如何优雅精巧地使线上数据定时生效

1. 背景

商业化服务中我们经常需要对数据进行定期更新,如在神秘商店活动中,因为用户等级、付费等信息会的变化,给用户推荐的道具和折扣也需要每天都进行更新,进而就产生了如何使线上数据定时生效的问题。在之前的架构中,我们采取的做法是业务同学周期性地将数据导入到hdfs中,采用打tag的形式告知后台开发同学线上服务该读取哪份数据。现在我们把数据存储从hdfs迁移到redis,利用哈希的数据结构,实现了一套自动删除和更新的生效方式,无需人工打tag区分生效版本。这里将实现的思路简要介绍一下,给其他需要线上数据定时生效的场景提供参考。

2. 老版数据生效方式

在旧版的实现方式中,线下数据生成和线上数据生效是分离的,由业务同学和后台开发同学定好统一的接口。由于某些原因,后台开发同学认为应该每次单独出一份文件,并且用tag标识线上服务该取的文件。基于此,旧版的数据生效方式针对每一个业务都需要如下的配置文件,该配置以mysql数据库的形式存储:

business表示活动code;version_tag dateTime是需要加载的数据的版本。这里的版本号是201712110000。

因为这样一个接口的设定,在周期性的商业化服务中,数据出库之后,业务同学还需要将每个周期产生的数据单独命名,并标记tag和修改mysql配置。如果任务是每天进行的话,那么每天都要进行重复操作,多个业务的话就需要每天多个业务操作,繁琐费时。而且业务同学各自处理的方式都不同,没有一个统一出库的标准,逐渐这一步就成为了一个灰色地带。这时候不得不提下已离职到pdd的前同事纪老师,感谢他在空闲时间给业务同学写了JLS出库工具,让该部分统一化。

JLS工具的处理流程主要是:

  1. 在lz例行化将数据存储到对应的hdfs上后,把数据拉取到内部的hdfs上;
  2. 同时生成对应的dateTime文件和md5(用于文件检查);
  3. 修改mysql配置。

整个流程听上去比较简单,纪老师也是写了三个500+行的python代码,考虑了各种情况,如多个用户同时写,数据未能按时加载等。而相应的,后台开发同学会不断检查dateTime是否有更新,有的话就查找对应tag为dateTime的数据,并且检查md5文件确定是同一文件,然后加载到服务器内存中提供线上服务。

因为数据需要周期性地加载到hdfs中,JLS工具需要一直在后台执行,当该进程“不小心”被kill掉(确实也会经常莫名挂掉…),线上服务便不能读取到正确的数据。在2018年中秋节期间,因为集群升级,导致nohup命令不能执行(新的程序无法挂在后台运行),之前后台运行的程序也全部挂掉,导致了大规模的线上事故。

3. 新版数据生效方式

新版的数据生效方式将hdfs和tcaplus统一起来,把数据统一存储到redis中,业务同学可以直接进行线下的离线数据存储和线上的在线服务读取。

3.1 Redis存储简介

Redis(REmote DIctionary Server)是一个开源的使用ANSI C语言编写、遵守BSD协议、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。

具备以下特性:

  • Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  • Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。
  • Redis支持数据的备份,即master-slave模式的数据备份。

和具有以下优势:

  • 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。
  • 丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
  • 原子 – Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
  • 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性。

Redis常用数据结构

Redis提供五种数据类型:string,hash,list,set及zset(sorted set)。

  • String(字符串)

    string 类型是 Redis 最基本的数据类型,一个 key 对应一个 value。string 类型的值最大能存储 512MB。

  • Hash(哈希)

    Redis hash 是一个键值(key-value)对集合,和数学中的集合概念相似,对集合的操作有添加删除元素,有对多个集合求交并差等操作。操作中key理解为集合的名字。它是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。

  • List(列表)

    Redis 列表是简单的字符串列表,按照插入顺序排序,可以添加一个元素到列表的头部(左边)或者尾部(右边)。主要功能是push、pop、获取一个范围的所有值等等。操作中key理解为链表的名字。

  • Set(集合)

    Redis的Set是string类型的无序集合。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。

  • Zset(sorted set:有序集合)

    Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。zset的成员是唯一的,但分数(score)却可以重复。

3.2 数据结构设计

3.2.1 生效数据结构

回顾定时生效数据的使用场景,我们的目的是在现有生效数据服务的情况下,仍然知道哪份数据会在下个周期生效,这样才能不影响线上服务。因此,其实我们最多只需保存两份数据即可,一份是正在生效的数据,一份是即将生效的数据,按照这种思路,我们基于redis的hash数据结构设计了如下用于数据存储的数据结构:

appid_domain_userid -> (
    (enabletime1 -> content1)
    (enabletime2 -> content2)
)

其中appid用于区分线上业务,如王者荣耀神秘商店、LOL周边商城等,domain用于区分数据产生方式,如白名单、默认等,userid是唯一识别用户的标识,enabletime表示数据生效的时间。

对于一个用户(即key appid_domain_userid),存储在数据库中的至多有两份数据(对应两个field,enabletime1和enabletime2)。如

appid_domain_user1 -> (
    (20181228120000) -> (item1:20|item2:30|...)
    (20181229120000) -> (item2:35|item5:40|...)
)

表示用户user1有两份数据,一份内容为“(item1:20|item2:30|…)”,数据在2018年12月28日12点0分0秒生效,另一份内容为“(item2:35|item5:40|…)”,数据在2018年12月29日12点0分0秒生效。

3.2.2 元信息

此外,在redis中我们还维护了一些meta信息,用于生效时间的校验和生效数据的检查,都为string类型。

appid_domain_meta_enabletime_with_data -> (writetime1_enabletime1, writetime2_enabletime2)

存储在redis中的key为appid_domain_meta_enabletime_with_data,appid和domain的含义与前文相同,value是正在redis的用户的数据的写入时间(writetime)和生效时间(enabletime),和前文提到的appid_domain_userid内的field保持一致,最多有两个值。

通过该meta信息我们可以得到现在生效和即将生效(如果已经写入redis的话)的数据时间:比较enabletime与当前获取的时间,在当前时间之前的enabletime为正在生效的数据时间,之后的为下一份要生效的数据时间。

存储写入时间writetime最直接的目的在于用户合法生效时间enabletime的检验,写入时间在生效时间之前的数据才能被认为是合法的。

3.2.3 tdw备份表结构

redis本质是一个key-value数据库,只能用key去查询value,不能像之前hdfs存储的文件一样知道appid_domain下的全部数据,而且因为一般集群上存储的数据量都比较大,也不支持“keys appid_domain*“命令。因此, 为了方便数据查询和检查,我们还在tdw表里维护了两个备份表,其结构如下

create table $appid_$domain_$period_userid(
    statis_date string comment '写入数据时间_数据生效时间',
    uin string comment '用户id'
)
partition by list(statis_date) (partition default);

create table $appid_$domain_$period_success(
    statis_date string comment '写入数据时间_数据生效时间',
    success string comment '是否写入成功'
)
partition by list(statis_date) (partition default);

userid表用于记录写入redis的用户,考虑到同一个enabletime的数据可能会多次写入(在写入中断或者发现数据错误重新写入时都会发生),用writetime_enabletime作为分区,可以将每次写入的用户id都记录下来,记录了所有的key,便可进行检索。success表的作用是用来标识每次写入是否成功。

3.3 实现细节

3.3.1 数据逻辑

前文中提到我们在redis中只需保存两份数据即可,这个是可解释的:下一个周期生效的数据的产生必然是在当前周期时间内。

如果记一个周期为period,在period0产生period1生效的数据,period1产生period2生效的数据,而且两份数据已经存储到了redis中,生效时间记为enabletime1和enabletime2,那么当有period3的数据即enabletime3产生时,现在的时间必然已经到了period2。此时enabletime1的数据已经过期,没有必要存储在redis中。因此redis中只需要记录两份数据就可以了,在写入第三份数据时将第一份数据删除,redis存储数据的中间状态将一直在【一份生效数据,一份即将生效数据$\rightarrow$一份过期数据,一份正在生效的数据】两者之间不断转换。

3.3.2 锁的加入

为了防止多人同时写相同appid和domain的数据时产生的数据不一致性,我们在代码中还引入了锁的机制。

实现时是通过维护一个外部能够共同访问的数据来控制。我们在redis中针对一个业务(appid和domain)维护一个appid_domain_lock变量,设置其过期时间为60秒,并在代码中开启一个线程每60秒写一次。这样当程序结束一分钟后该变量便会自动失效,不影响下次调用程序进行写入。

也是基于以上设置,程序正式执行前会先sleep一分钟,再去检测appid_domain_lock的存在和其值,判断现在是否可写。如果程序sleep一分钟后appid_domain_lock存在并且为已锁状态,则程序直接退出。但此时仍然不能直接写入数据,因为不能保证有份相同的代码同时执行,下一步是让程序随机睡眠1~2分钟,再次判断appid_domain_lock的情况。通过这种方式,可以说是基本解决了写冲突的问题。

3.3.3 meta_enabletime_with_data的判断

meta_enabletime_with_data记录着已经存储在redis里的数据情况,由这个字段判断新的数据是否可以写入到redis中。因为最多只能有两份数据在redis中,meta_enabletime_with_data里最多也只有两条记录,下面分meta_enabletime_with_data有0条、1条和2条数据三种情况进行讨论:

  • meta_enabletime_with_data有0条记录

    此时说明redis里没有合法数据,新的数据可以写入。

  • meta_enabletime_with_data有1条记录

    由前文讨论可知,如果meta_enabletime_with_data里的字段合法的话(即writetime在enabletime之前)此时在redis里的数据必然正在生效。新写入的enabletime要么是和现在数据的enabletime相同(即更新正在生效的数据),要么在其后(插入即将生效的数据),其他的都是不合理的请求,不可以写入。 在对meta_enabletime_with_data进行判断的时候,也同时会把不合法的字段(即writetime在enabletime之前的字段)进行删除。

  • meta_enabletime_with_data有2条记录

    在这种情况下,首先需要根据当前时间判断出当前是哪份数据 即哪个enabletime的数据正在生效。在得到该字段后,新写入数据的enabletime必须和生效数据的相同或者在其后面。同时要根据新写入数据的enabletime判断此时需要删除的数据的enaletime。原则为只需要保证不删除正在生效的数据,而且写入的数据时间等于现在生效的数据时间或者在其后就可以了。

3.3.4 数据写入

前面进行完了数据检查,下一步就是数据写入,分为以下三部分:

  • tdw备份表写入

    tdw备份表\$appid_\$domain_\$period_userid用于记录写入redis的key。为了防止在数据写入redis过程中程序就意外中断退出,从而导致把redis中的key记录在tdw表中,在程序即将写入redis之前就首先写入表\$appid_\$domain_\$period_userid。这种方式保证了所有在redis中的key一定会记录在\$appid_\$domain_\$period_userid内。

    \$appid_\$domain_\$period_success用于标注数据写入redis是否成功,在程序一开始执行时,写对应writetime_enabletime分区的值为false,待程序正常执行完毕后,更新对应分区的值为true。

  • Redis数据更新

    将数据写入时可以分为以下几种情况:

    ① redis里没有数据;

    ② redis中只有一份数据,此次写入下次enabletime的数据;

    ③ redis中有两份enabletime的数据,此次写入下次enabletime的数据;

    ④ 上次写入redis时程序异常中断,此次写入同样enabletime的数据;

    ⑤ 上次写入redis中的数据有问题,此次写入同样enabletime的数据进行更新;

    情况①②③都是正常情况,④⑤是考虑数据更新的情况,在实现时可以划分①②为不需要删除数据,③④⑤需要删除数据 ,具体删除信息可从tdw备份表中得到。

    通过对tdw备份表里的数据的判断,可以将上述五种情况统一成如下的处理流程:

    1. 首先根据meta_enabletime_with_data判断出需要保存在redis里的数据,即对应的enabletime,宗旨是除非是更新正在生效的数据 否则正在生效的enabletime的数据肯定不能删,此次插入的数据对应的enabletime也需要保留。

    2. 然后从tdw备份表里拿到需要删除的key(即用户),具体步骤如下:

    • 根据meta_enabletime_with_data判断需要删除的enabletime的数据。
    • 得到需要删除的数据的enabletime后,从tdw表 $appid_\$domain_\$period_success中按照writetime倒序读对应enabletime的分区。如果对应分区的值为false,说明对应分区插入的数据可能在redis中有残留,而是true就说明插入成功,再之前的数据肯定已经被清理了,所以会一直读到第一个为true的分区,将这些分区记录下来。
    • 再从\$appid_\$domain_\$period_userid表中读出这些分区的userid,这些都是可能在redis中的key。
    1. 通过前两步得到了需要保留的用户和需要删除的对应用户的field,下一步需要考虑的是如何高效地根据这些信息更新redis中的数据。考虑到可能会更新正在生效的数据,如果直接将数据先删除,那么线上必然会有段时间无法提供服务,会造成线上事故,所以不能先删除再插入。我们采用如下方式处理:

      将需要保留的用户与需要删除field的用户进行outer full join操作,得到所有用户(在redis中的和此次需要写入的用户)的状态,有如下几种

    • 如果用户在此次需要插入的数据中,则执行插入此次enabletime的操作。
    • 如果用户在此次需要插入的数据中且之前就在redis中,需要进一步判断是否需要删除某个enabletime。
    • 如果用户不在此次需要插入的数据中,那么必然已经在redis中,属于需要删除的数据,需要进一步判断是需要删除某个enabletime还是删除整个用户:如果该用户有两个field,即两个enabletime,那么删除其中需要删除的;如果只有一个enabletime,那必为此次要删除的,这个用户也就没有了数据,可以直接删除。
    • 由前几步判断得到每个用户对应的操作,批量执行,在线上看来就是对用户增量更新。
  • meta信息更新

    meta_enabletime_with_data记录着所有存在redis里数据的情况,所以它必然是在数据插入完成后才会更新,是在最后进行的。

    值得注意的是,如果meta_enabletime_with_data为空,不能够说明redis中没有数据,有可能之前数据插入全部失败,没有执行到更新meta_enabletime_with_data这一步。所以在数据写入那一步,即使meta_enabletime_with_data为空,也需要将\$appid_\$domain_\$period_success前面分区全部读出(按照writetime倒序读出直到第一个为true的所有分区)。

  • tdw表更新

    在前面全部执行完毕后,数据已经正常插入到redis,meta信息也已经更新,此次数据插入成功,需将\$appid_\$domain_\$period_success对应的writetime_enabletime分区的值改为true。

3.3.5 其他细节

前面较为详细地讲述了如何写入定时生效的数据,基于redis写入,还有以下几点考虑:

  • 考虑到并发控制,我们在代码里面限制每次写入时最多调用5个节点,最多5台机器同时写redis。
  • 考虑流量控制,我们使用pipeline的方式去写,通过sleep与设置同步速率,控制速度为1s写5000条数据到redis中。

3.4 使用方法

val redis_config = new RedisConfig()
redis_config.host = host
redis_config.port = port
redis_config.password = pwd
redis_config.expire_time = expire_time
val redis_rec = new RedisPeriodData(appid, domain, redis_config, period_type, period_time, backup_db, group)
redis_rec.uploadData(data_rdd, enable_datatime)

redis_config为需要写入的redis的一系列配置,包括地址(host)、端口(port)、密码(pwd)和默认写入数据的过期时间(expire_time)。

redis_rec为类接口,backup_db为tdw表所在的数据库,group为集群所在位置,默认是国内集群“tl”。period_type为周期类型,有RedisPeriodData.day、RedisPeriodData.week和RedisPeriodData.month三种类型,分别是日周期、周周期和月周期的数据。period_time是生效时间,是一个数组类型,由冒号分隔该周期的第几个时间片和具体到几点,负数为该周期的倒数第几个时间段,日周期没有冒号 分隔:

  • 针对RedisPeriodData.day(日)周期,period_time为-1表示每日的 23 点,合法数据参数为0~23,-1~-24;
  • 针对RedisPeriodData.week(周)周期,period_time为-1表示每周的周日,合法数据参数为1~7,-1~-7;
  • 针对RedisPeriodData.month(月)周期,period_time为-1表示每月的最后一天,合法数据参数为1-28,-1~-28。

例如,如果一个数据是每月的第十天的下午两点和最后一天的上午十点生效:

period_type = RedisPeriodBusinessData.month
period_time = Array(“10:14”, “-1:10”)

如果一个数据是每天的十二点生效:

period_type = RedisPeriodBusinessData.day
period_time = Array(“12”)

4. 总结

这种数据生效的方式其实是很少见的:当天用的是昨天根据前天的数据计算出的结果。实时性很差,比较适合于按照规则出的结果。

老版的数据生效方式和JLS工具是业务和后台开发分离的产物。业务同学负责出数据,后台开发同学负责提供线上服务,如何把数据按照接口存放和怎样对存放后的数据进行查验 就成为了两不管地带,因此也发生了很多因数据加载失败产生的事故:两方需要配置和读取的内容不一致,可能缺少某个配置就会产生事故,而这其间没有明确归哪一方负责查验。现在线下数据的产生和线上数据的服务都由业务同学统一负责,就能够统一规划,规避这种风险。

从制定方案到代码开发完毕,历时约四周,技术评审一周半,代码开发两周半,scala代码写了1000行+,感谢gotoli在整个过程中对技术方案和代码思路的指导。写完的感受是即使功能不是很烦多的工具,为了能够应对各种可能的bug,也是需要花费较大的精力的,东西小也能做的很美。

此条目发表在未分类分类目录。将固定链接加入收藏夹。

发表评论

电子邮件地址不会被公开。