存储拆分后,如何将请求路由至正确的处理单元? 业界比较知名的单元化实践案例采用用户号段的方式,其处理方式类似于 Redis cluster 集群的 slot 机制,客户端初次请求(路由模块间转发)后,会将 uid 和其对应的处理单元编号存于 cookie。会议作为业务的核心,绝大多数的存储 key 和会议 ID 有关,很自然想到根据会议 ID 请求路由:相同会议的写请求只会路由到相同区域,业务层面数据区域自治,不同区域相互同步互为灾备;考虑到客户端改造成本高(新老版本兼容,各种客户端类型),且涉及到多域名改造,故从后台接入层开始着手。 那么根据会议 ID 路由能否满足异地多活延时要求?我们的目标是一次请求的跨城调用次数可控,整体延时在可接受范围内。梳理了下会控现有的Key情况: 对于无会议 ID 的场景,需要具体分析测试验证对业务的影响,不重要的场景,则可直接降级掉。小写请求量且一致性要求高的场景可考虑并行多写。在接入层做流量转发(现在上海用户本身就会跨城访问),各 SET 流量最大程度区域自治。
5.3 拆分策略
▶︎ 身份维度:个人和企业数据隔离存储;▶︎ 会议信息:根据会议创建者账号类型隔离,如创建者是企业用户,则存储于企业实例;如创建者是个人用户,则存储于个人实例;▶︎ 成员列表:根据会议 ID 隔离,每个会议都对应一个成员列表;▶︎ 成员信息:根据成员自身账号类型隔离,企业账号存储于企业实例,个人用户存储于个人实例;存储 key 和会议 ID 无关;对于成员信息的个人企业隔离,不会影响到下一步的异地多活;若成员信息中心写的方式,则中心实例为广州个人和广州企业;由于成员进退会、会中操作请求都会带会议 ID,亦可采用会议 ID 路由方式,则最终广州、上海的个人实例都拥有全量个人数据,企业实例都拥有全量企业数据;多份数据并存,若使用不当则为脏数据,有一定的风险。 ▶︎ 地域维度:广州上海异地多活,互为灾备,按地域划分 SET,流量区域自治,数据互备;前面提到,我们的目标是两种拆分维度能够统一处理。
5.4 方案选型
会议的路由规则比较复杂,不能简单地按会议 ID 号段分配映射。接入层流量调度策略不仅有按环境变量 env 规则路由,也有根据会议本身的业务信息调度(如网络研讨会);业务逻辑层各 SET 仅是机器运维层面的隔离,请求层面不做隔离,如企业 SET 可以调度个人会议,大盘 SET 也可以调度企业会议,这些路由规则都会用到会议业务信息,无法单纯根据会议 ID 决策,现状是接入层并行双(多)查。 个人和企业会议是通过会议创建者是否为企业账号区分,而创建者账号本身又存储于会议信息 VAL 结构中;加之异地多活后,会议信息进一步拆分为广州上海两地存储,接入层现在的处理方式显然不具备扩展性,因此要有一个中心化的第三方数据库专门存储路由信息,当然可以通过回源读,本地缓存的方式优化。我们这里需要将会议 ID 对应的所有路由信息:区域(gz,sh,tj,sg等),身份(个人或企业),后续可能还会有某个 SET 单独一套存储系统等路由信息都保存起来。 经过讨论,最终有如下两种方案:路由表和会议 ID 编码路由信息,并且最终选择了会议 ID 编码路由信息的方案。
会议 ID 路由编码 参考 QQ 看点和浏览器内容中心的 rowkey 设计,其将区域和时间戳等信息预埋至 rowkey,那么能否在会议 ID 这里采用类似的思路? 会议 ID 是一个 uint64 位的整数,较之字符串编码有一定的难度。前面提到会议业务路由规则复杂,涉及拆分维度多,我们的思路是将各种隔离维度策略下的存储进行编排,编排后分配一个唯一的编号,然后将按编号信息编码到会议 ID。收到请求的时候根据会议 ID 解码出存储位置。
6.1 存储编排
编排方案支持 SET 粒度的存储隔离,不过业务暂无此需求。
6.2 会议 ID 编码
会议后台已经运行了3年,已经有一套自己的 ID 生成规则;加之复杂的业务应用场景也对会议 ID 强加了各种隐形规则,梳理了现在会议 ID 的各种限制条件: 业务整改难度大,我们还是希望尽量兼容;我们的目的就是在不影响业务逻辑的情况下,重塑会议 ID 生成规则,平滑迁移。旧编码规则确认:幸运的是,没有业务利用旧会议 ID 的编码规则做逻辑,都是当作一个随机数处理;存储编码位数确认:X 位二进制,0~(2^X - 1)共 2^X 套存储; 分组会议:分组会议将会议 ID 作为 ZSet 的 score,score 是一个浮点数,浮点数有什么问题了?(网传 Go 语言之父曾吐槽:不懂浮点数不配当码农,哈哈哈,我赶快恶补下)。浮点数表示的范围很大,但精度有限,如按规范化表示,双精度尾数为53位,对应十进制9007199254740992,16位十进制数有效数字,因此分组会议 ID 的值不能超过此值。 考虑到分组会议的量级较小,号段分配范围过大浪费空间,最终决定0~48共49位二进制用于分组会议 ID,如不够用后续可考虑回收复用(分组会议的业务场景如此)。普通会议范围(0x0001ffffffffffff, 0xffffffffffffffff]和分组会议的号段范围[0, 0x0001ffffffffffff]不交叉。 成本最小化,增量会议 ID 需要全量长期存储用于去重,成本较高。我们的做法是减少增量会议的存储容量: 在64位整数中选取T位用来表示相对时间(20230302项目启动时间),T位二进制可表示2^T天,超过后回绕;因为每个 Redis 实例对应一个唯一的编号,故对于增量会议来说,各实例间必不会重复; 对于同一个实例,每天生成的会议 ID 必不相同(由相对时间保证);对于同一个实例同一天新生成的会议 ID,由于会议 ID 有一定的过期时间(大于3天),而且会控每次写 Redis 都采用 lua 原子方式,实现类似于 SETNX 的能力,这就保证了同实例同天会议 ID 的唯一性,以及时钟回拨导致的问题; 相对时间位置:由于分组会议的号段范围限制,为统一处理,存储编码和相对时间位置被限定在0~48位;存量会议 ID 也需要用于去重,全量存储的话需要 300+G 的 Redis 内存,扫描存量会议 ID,发现用第 e 和 f 位的话,相对时间为50年内的所有数据只占 3G 空间。最终形成了上图的编码格式。
6.2.1 编码规则
编码规则主要用于生成会议 ID 的服务,部署区域由 TKEX_REGION 环境变量获取,新增部署区域需要强制开发同学感知存储,即需要配置区域对应的编码规则;现考虑广州上海多活,现在需要在北京部署服务逻辑层,那么北京是使用广州的存储还是上海的存储?由开发同学指定。配置兜底编码规则*;*;*,如果 TKEX_REGION 出问题则兜底至26号(企业)实例,仍然可以正常路由,只不过流量不均衡;SET 暂时全部配置为通配*,后续若要支持某个 SET 单独一套存储,可以简单扩展;最后提一下,上层业务可以自己定制策略决定会议的存储,如将付费用户路由至企业编号(不建议),以解燃眉之急。
会议 ID 的随机数我采用 C++11 的梅森旋转19937_64和均匀分布生成;为什么不采用全局唯一 ID 生成服务? 冲突的根本原因是每次生成的会议 ID 是完全随机的独立事件,对于分布式 ID 生成,业界也有比较成熟的方案,如百度基于雪花算法的 UidGenerator,美团基于 DB/雪花算法的 Leaf。 会议没有现成可用的全局唯一 ID 生成服务,做为引入的关键依赖,分布式 ID 生成服务的降级、性能、容灾、异地和高可用等需要较多的开发工作量和生产流量验证;基于质量和每天的期望冲突次数考虑,我们采用冲突重试的方案,尽量减少依赖。
会议 ID 的全局唯一性是整个会议数据的基础,会议 ID 重复可能会看到其他人的会议,导致严重的安全越权。存储拆分后如何保证会议 ID 的全局唯一性?加之在会议 ID 中进行了编码,进一步导致重复的概率增大。由容量分析可知冲突的概率较小,但对业务来说却是无法容忍的。前面讲到增量会议的冲突很容易解决,但存量会议无任何规则,新生成的会议 ID 肯定有概率与其冲突,如何处理? 首先看我们的方案: 新增了去重 Redis,去重 Redis 仅导入全部存量数据的会议 ID,那么问题来了去重 Redis 网络调用失败如何处理?从安全层面来讲这应该是一个关键依赖,如果重试最终调用失败则请求返回失败,但如果去重 Redis 异常会导致整个会议 app 不可创建会议,是存在一定风险的,不过 Redis 的 SLA 是有保证的。 对于异地集群需要跨城调用,当然上海或者北京调度到广州其实距离不是问题。
6.4.1 shadow key 方案优化
作为项目 owner,说实话对上述方案并不是很满意,因为新增了存储依赖(还有逻辑层面的风险),并且有跨城调用,很不优雅,我给出了一种新方案: 因为会议信息是必定要存储的,会议 ID 编号划分后,每个集群只可能和(按新规则解析)相同编号的存量会议 ID 冲突,为不引入依赖组件,我们就利用会议信息 Redis 本身的存储,将存量会议按编号进行迁移。 基于成本大小考虑,我们只迁移会议 ID 用于去重(会议 ID 为 hash tag),VAL 为 empty。预订会议时,在 lua 中先查下会议 ID 是否和当前实例 shadow key 冲突。 那要迁移多少数据了?很容易估算,XBits 一共2^X套存储,存量会议 ID 完全随机,则每个实例需要迁移的期望数据量为:300G(总存量 ID 大小)* (1/2^X),约等于10G;如果再考虑相对时间为50年内的数据,则占用空间更少。 如此一来,就去掉了跨城调用以及每次请求去重的 RPC。
6.5 存量会议
一个比较棘手的问题:按 X 个 Bits 划分号段后,存量会议如何处理?因为不同号段的 ID 会存储于不同的 Redis 实例,我的做法是按编号规则解析,将存量数据按编号进行迁移(现在看起来可能比较容易,不过当时想到这的时候确实兴奋不已)。如此一来就将问题转化为 DB 分库了,只不过分库策略是会议 ID 的存储编号,具体分库方案将在第8章仔细讨论。那么号码不在[0~2^X)区间内的 ID 如何处理?用旧实例兜底承接即可(也可用新实例兜底,需要数据迁移)。 按照新的解析规则,存量的企业会议可能会被判定存储于个人实例,个人会议也可能被判定为企业实例,增量会议无影响。新实例也可用于企业数据的灾备。
6.6 异地多活
▶︎ 搭建上海 SET,北极星权重初为0;▶︎ 将广州个人企业实例数据分别跨地域复制到上海: 考虑新增上海,将流量切换到上海的情况,此时手动更改配置; 上海 SET 放量过程中,由于存量会议(编号新规则之前的存量,新规则之后且放量之前全部为广州)和接入层机器无法做到转发配置同时生效,可能会在灰度生效过程中出现广州和上海并发更改同一个会议的情况,不过这种场景出现概率较小,要同时满足如下条件:▶︎ 配置生效过程中,修改存量会议(会议 ID 编号新规则之前的数据);▶︎ 并发修改▶︎ 请求恰好落到配置生效和未生效的机器; 本身概率极小,另外可以在晚上低峰操作。 再考虑广州挂掉,流量切换到上海的情况,同样手动更改配置,直接将接入层所有流量路由到上海,此时配置的路由规则强制覆盖会议 ID 路由规则,因为广州和上海的 Redis 实例通过跨地域复制双向同步,切换后仍可以正常服务,由于跨城同步延迟,在切换瞬间服务是有损的,不过对于会控业务是可以接受的:比如用户开麦,流量切换到上海后,开麦信息可能又丢失了,用户重试操作下即可。上海多活放量时,需要预先把广州生成的编号为上海的存量数据迁移到上海,比如存量预订的会议;如果广州挂掉所有的流量都将切至上海,因此灾难切换前需要把全部有效的存量数据迁移至上海。
6.7 会议 ID 路由
6.7.1 路由规则
由上述分析,加上存量数据,会议 ID 总的解析规则如下: cowork 上已经提供会议 ID 解析小工具。
▶︎ 会议 ID 设计规则复杂,增加了冲突概率;▶︎ 无会议 ID 请求无法支持,需要单独处理;▶︎ 支持的存储套数有限,最多 2^X 套;▶︎ 会议 ID 编码后就固定了路由规则,后续只有通过配置来控制路由优先级(配置路由 > 会议 ID 路由)。
6.11 小插曲之成员列表
成员列表在 Redis 中存储的为一个 Hash 结构,和会议信息、成员信息不同的是,memberlist 的 value 并无 SEQ 序列号(val 前8个字节的一个无符号数,通过 lua 做 CAS 并发控制)。 因为担心通用的分库方式(第8章)在数据迁移过程中,由于成员列表的并发请求导致的不一致难以收敛,也讨论了一个备用方案:即在预订会议时,在会议信息中存储成员列表的 Redis 实例。 显然此方案对业务耦合较重,业务还需要关注路由逻辑;另外,在做存储访问收拢时发现调用成员列表的场景较多,此方案需要先查询会议信息,获取到成员列表路由信息,然后将其层级透传至成员列表查询接口。 另外,这将导致成员列表的路由规则和会议信息的路由规则不一致。
07
平行扩展 特殊时间期间的请求量已达 Redis 单实例的处理极限,很自然的想法就是水平扩展为多个实例,拆分策略为按会议 ID 取模,如此一来问题又被转化为 DB 分库的问题了,哈哈哈,神奇吧,具体我们在下一章节讨论。 特殊时间之后 PCU 下降了不少,评估 ROI 不高,结论是暂不用支持多实例。
7.1 成倍扩容
按会议ID取模实现起来很简单,那么后续如何扩容了?答案是采用成倍扩容的方式: 假设已经做了多实例扩展,%2=0的路由至实例11,%2=1的路由至实例21;现在简单分析下扩容步骤。▶︎ 新增实例12和实例22;▶︎ %2=0的流量拆分为%4=0和%4=2的两部分,分别由实例11和实例12承接;▶︎ %2=1的流量拆分为%4=1和%4=3的两部分,分别由实例21和实例22承接;▶︎ 存量数据迁移。 给大家分析一下:一个正数 a%2=0,若 a < 4,则 a 为2,则 a%4=2;若a >= 4,则 a 可以写为2m,m>=2,因此a=2m=2(2*n+r)=4n + 2r => a%4=0或者2,其中 r 为0或者1;一个正数 a%2=1,若a < 4,则 a 为1或3,则 a%4=1 或3;若a >= 4,则a可以写为2m+1(奇数),m>=2,因此a=2m+1=2(2*n+r)+1=4n+2r+1,其中 r 为0或者1。
08
分库 前面提到,无论是个人企业隔离,异地多活,还是水平扩展,本质上都是一个分库操作,只不过采用的是不同的拆分策略。内部多个团队都做过或者正在做 DB 拆分,大家使用的方案都不太相同,并且体量也没有会控业务大,不能完全参考。这里总结了会控业务的存储分库成功实践,目的是形成一套方法论,沉淀下来一套可以复用的工具,以供后续业务参考。
目的是保证存量数据的一致性。 存量数据迁移可以全量 SCAN 旧 Redis,将满足条件的 Key 迁移至新 Redis。因为此时已经有双写,故数据迁移和双写可能存在并发时序问题。不容质疑地,双写优先级更高,故数据迁移应该遵循 set If Not Exist 语义:set key val nx/hsetnx/zadd key nx score member 等,如不支持 nx 语义,直接插入即可,一般来说系统不会有那么大的并发,或者选择流量低峰操作。
前面已经讲过,双写是先写旧实例再写新实例。实时对账的目的是尽量减小对业务的影响,因为双写写新实例失败,会直接返回成功,我们应该在用户发起下一次操作前修复好数据。触发条件:▶︎1)写旧实例失败,存在很多场景写旧实例SDK返回失败,但是 Redis 实际成功;▶︎2)写旧实例成功,写新实例失败;▶︎3)写操作全量对账;如果对一致性要求很高,则可以采用此种方式,一般的业务1)和2)足矣;如成员列表的 VAL 无 SEQ,并发就会有时序问题,但实际操作过程中发现少有由于并发时序导致的数据不一致。 对账步骤:▶︎新旧实例双查,对比数据是否一致;▶︎一致,则返回;▶︎不一致,则以旧实例为基准,修复新实例的数据。 如果希望对生产影响尽量小,修复新实例的数据可以采用 Lua CAS 的方式;SET 之前先和双查出来的数据比较,若相同则修复,否则说明有其他服务更新放弃修复;如果数据有 SEQ,用 SEQ 做比较值; 如果数据没有 SEQ,可以直接将 VAL 作为比较值;此时极限并发下可能存在 ABA(了解 C++无锁的同学应该知道)的问题:假设某时刻旧实例数据为 a,新实例为 b,a 为正确的数据,现在实时对账需要 CAS 修复新实例 b 为 a(还未修复),但此时业务并发请求恰好双写将双方的数据都修改为b,b是最终正确的数据,由于 CAS 修复和业务请求不是在同一个事务中,导致 CAS 又将新实例的数据修复为 a(时序发生在业务请求后修复),a 在此时已经是错误的数据。当然这种情况出现的概率较小,即便是出现我们通过离线对账来发现。▶︎修复失败,则重试1~3步骤;▶︎重试失败,则置于延迟队列,定时执行1~3步骤;▶︎延迟修复失败,则告警,人工介入,工具修复。
8.3.3.2 定时对账
定时对账的目的是发现新旧实例数据的不一致,可以认为是兜底。根据经验对账很容易发现业务本身的设计缺陷,这也是对账的价值所在,会控的对账过程中发现了很多模块设计缺陷。 由于 Redis SCAN 的有限保证,SCAN 过程中只有新增和删除的 key 是 undefined,这部分是增量数据由双写保证一致性。 ▶︎ 双向对账:定时对账需要正向对账和反向对账结合使用:正向对账:遍历旧实例,和新实例对比;一般来说这个是全量对账,数据量较大,遍历一次耗时较长,可以发现包括双写(写切换至新实例)是否遗漏、业务缺陷等所有问题;执行频率可以稍长,比如2小时;反向对账:遍历新实例,和旧实例对比;一般来说新实例数据量较小,执行时间短,可以快速发现问题;执行频率可以稍快,比如30分钟;▶︎结果播报:对账的结果需要及时播报知会。 ▶︎延迟对账:业务在双写,对账在遍历读取,由于不是同一个事务,很可能对账读取的时刻,业务双写才写完旧实例,新实例还没有写完,这也会导致不一致造成干扰。我们的解决方案是,对于对账不一致的数据延迟30秒再对账,甚至可以根据具体情况多延迟对账几次,完美解决。▶︎数据修复:对账出来不一致的数据需要立即修复,可以在对账完成后调用修复工具自动化完成;自动修复的前提是对账已经没有未知问题了,所有问题基本上都已经很清楚了,否则程序自动化修复有一定风险;自动化之前还是要靠人工排查和修复。由于需要修复多种不同 KEY 对应的 VAL 和 TTL,经常用配置来操作比较容易出错,我们的解决方案是每一种修复类型的工具预先代码写死编译好,使用的时候直接执行就可以了,数据治理我一共准备了20+个工具,哈哈哈。 实时对账和修复工具采用的是大 SEQ 覆盖小 SEQ;对于无 SEQ 的数据,采用 VAL 作为 CAS 更新,这和实时对账面临同样的问题;不过不用担心,有定时对账兜底,其实很快就收敛了(用实践说话)。▶︎并行对账:我们需要遍历的数据有20亿+,需要128个分片并行对账,否则时延不可接受。