分布式环境下的核心挑战:
- 多节点间同时请求获取的 ID,不可重复;
- 唯一 ID 生成器需要承载极高的并发量,而不会影响 生成ID 的速度,并且需要保证服务的高可用性;
- 可能需要递增且不连续的 ID,递增是为了顺序性(如使用 b+Tree 作为存储索引结构时,避免随机读写造成性能下降,LSM 没有这个问题),不连续是为了安全性,避免被预测 ID;
- 如果使用时间戳作为 唯一ID 生成的基础,需要解决不同节点间时钟不一致的问题;
UUID
本地生成,8个16进制数 + 一个连字符 + 4个16进制数 + 一个连字符 + 4个16进制数 + 1个连字符 + 12个16进制数;
优点:
- 本地生成,性能高;
缺点:
- 太长;
- 无序性,如在 mysql 写入场景下,追加写(如原本的自增主键)变成随机的 io,性能大幅下降;
Snowflake
按照最高位锁0(防负)+ 时间 + 机器码 + 递增序列号,可保证任何机器生成的 id 均唯一;
优点:
- 递增趋势;
- 仅依赖于时钟; 缺点:
- 因为强依赖于时钟,但是服务器的时钟可能会发生飘逸(包括人工误操作,即 clock drift,可考虑使用 NTP 服务平滑调整时钟)(也许可以考虑逻辑时钟如 lamport?但它在高并发环境下重复问题,需要引入全序关系广播类策略解决,也麻烦)
Leaf
1、Leaf-segment;
基于数据库,通过 biz 区分业务场景,max_id 为当前的发号上限,做了一个优化:快到上限值时,会起异步线程提前更新上限值(降低 TP999),后面设计个人目前看下来类似于 seqsvr,不赘述了(见下文 seqsvr 分析);
2、Leaf-snowflake:
看下来,核心是两步:1、使用一个分布式的 KV 存储保持各服务的时钟(心跳维护);2、服务会通过 KV 的时钟和当前时钟进行对比,防止出现大步长回拨,新服务启动时,也会和 KV 中所有时钟对比,防止多个节点的时钟偏差过大;
seqsvr
策略设计
唯一 ID 视为一个序列号,是唯一和递增的 int64,且每个用户都有自己独立的 64位 ID 的空间;
即每个用户都独占 8bytes 空间,空间内存放已经分配出去的最后一个 序列号 K;
最简单的方法就是,用户申请一个序列号 K+1
,服务持久化并返回;
但很明显在高并发场景下不可能做到如此频繁的持久化,第一个服务的优化策略:
- 非连续递增的序列号持久化:
- 业务场景并不会要求 ID 严格递增,只需要保持递增趋势,用于分辨 ID 顺序即可;
初始化状态:当前最后一个被分配的 ID:K
,递增上限步长:N
,分配 ID 的上限值:M=K+N
;
核心逻辑:用户请求 ID 时,执行 K+1
,并将其与上限值对比,如果大于,则需要持久化上限值到磁盘,并执行 M += N
,再返回当前 ID:K+1
;
由此可见,当 ID 生成器服务宕机时,此时磁盘中存储的上限值,一定大于当前最后一个被分配的 ID,将其赋值到 K 中,重新初始化,依然能保证 ID 唯一性、递增性;
总结:抽象点就是 聚合
一批数据,一次持久化,本处聚合的数据集 就是在一个 N
中,被分配出去的多个不同时刻的 K;
解决了用户请求的并发问题(频繁磁盘IO),新问题接踵而至,单用户使用 8 字节的空间,所有用户总占用的空间也相当庞大,故障恢复的时间会被拖长,故第二个服务优化策略:
- 分 Section 合并持久化:
再次聚合 上限值,相邻 uid 组成一个 section,共用一个 上限值;(唯一缺点貌似是一个 section 中有一个用户发送消息的频繁远高于其他用户,导致一个累积,只要发生一次宕机,那么其他用户的 ID 值会暴涨)
优点显而易见,合并多少 uid 到一个 section 中,就能减少多少次磁盘 IO;
工程设计
设计原则:保持自身架构简单、避免对外部服务的强依赖;
- 分为存储层和缓存中间层,StoreSvr 为存储层 通过 NRW 保证数据持久化后不丢失、AllocSvr(分布式) 负责一组 section 的序列号分配;
- 整个发号器根据 uid 进行分组,每个组有单独的 StoreSvr 和 AllocSvr,主要为了限制故障的范围;
(这里为个人思考,可能有误区,原文的图5感觉有点问题,既然是同一个 uid 的序列号,不同 AllocSvr 上最后一个被分配的序列号也不应该会相差到 一个上限步长,否则正常分配序列号肯定会重复,最新的上限值会被持久化,问题应该是:不同 AllocSvr 本地缓存的 序列号可能会有冲突,因为同步序列号明显不适合,解决方案为加分布式锁等,但也明显性能下降,所以此处的设计应该是 AllocSvr 的管理 uid 的集合不应该有交集)
- AllocSvr 明显存在单点故障的问题,故引入一个仲裁服务,负责 AllockSvr 的探活,故障转移通过 仲裁服务更新
故障范围内 section
的迁移位置
到 Store 并将其持久化,AllocSvr 会定期访问 StoreSvr 获取最新的配置信息; - 为防止多个 AllocSvr 操作 序列号,引入租约概念,当一个 AllocSvr 与 StoreSvr 发生网络分区时,无法进行续约,当前 AllocSvr 将会在剩余有效期内停止服务,被分配到当前 section 的 AllocSvr 则会在更新配置信息后一个完整租期后才开始提供服务;
- 隐藏条件:
- 所有 AllocSvr 从 StoreSvr 同步配置信息的时间必须尽可能相等,或者说租约的有效期必须远长于 StoreSvr 的同步周期;
- 还需要避免 AllocSvr 和 仲裁服务 在同一网络分区内,否则当发生网络分区时,仲裁服务也无法更新 StoreSvr 的配置信息;也不能出现:AllocSvr 与 StoreSvr 断链,但是没有和仲裁服务断链的情况;
- 隐藏条件:
多机 NRW 策略:Non-delay Right-shift、Weighted,用于多机调度问题的优化策略;
- 无延迟调度:即所有节点均等分配任务,即每个节点的权重值相同(非异构机器);
- 右移调整:为减少任务间冲突、平衡负载,向右移动任务的开始时间优化调度计划;
- 加权优化:对任务加权;
容灾策略
个人感觉没什么特别值得聊的,原文提到的嵌入式路由表本质上就是在请求和响应的过程中 实时更新路由表,即更新 uid 对应的 AllocSvr 的地址(他这里只讨论数据迁移后如何寻找的问题);
至于区分路由表的新旧,也是常规版本号可以轻易解决;