大厂分布式ID方案
分布式ID必须保证以下特性:
-
全局唯一
-
有序性:便于索引
-
高并发可用
-
不依赖中心认证
-
安全性
目前大厂的分布式ID方案基本都是基于号段式,号段模式可以理解成从数据库批量获取 ID,然后将 ID 缓存在本地,以此来提高业务获取 ID 的效率。例如,每次从数据库获取 ID 时,获取一个号段,如(1,1000],这个范围表示 1000 个 ID,业务应用在请求获取 ID 时,只需要在本地从 1 开始自增并返回,而不用每次去请求数据库,一直到本地自增到 1000 时,才去数据库重新获取新的号段,后续流程循环往复。
美团-Leaf-基于数据库自增ID的优化
Leaf-V1.0
在DB之上挂N个Server,每个Server启动时,都会去DB拿固定长度的ID List。这样就做到了完全基于分布式的架构,同时因为ID是由内存分发,所以也可以做到很高效。接下来是数据持久化问题,Leaf每次去DB拿固定长度的ID List,然后把最大的ID持久化下来,也就是并非每个ID都做持久化,仅仅持久化一批ID中最大的那一个。
上线之后发现的问题:
-
耗时尖刺,发现系统最大耗时取决于更新号段的时间,造成性能波动
-
更新号段时DB宕机造成整个服务不可用
-
可以估计发号数量,估计订单数量,数据细腻系泄漏
Leaf-V2.0-异步更新双Buffer
整体思路其实就是不等消费完毕就去更新号段,预更新的思路。使用缓存解决了耗时尖刺和DB宕机的问题,DB宕机时也预留了一段的重启时间。
-
每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
-
每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。
上线之后发现的问题:号段长度Step不好控制,短了流量激增频繁获取,长了ID跨度很大。
-
号段长度始终是固定的,假如Leaf本来能在DB不可用的情况下,维持10分钟正常工作,那么如果流量增加10倍就只能维持1分钟正常工作了。
-
号段长度设置的过长,导致缓存中的号段迟迟消耗不完,进而导致更新DB的新号段与前一次下发的号段ID跨度过大。
Leaf-V2.5-动态调整Step
假设服务QPS为Q,号段长度为L,号段更新周期为T,那么Q * T = L。最开始L长度是固定的,导致随着Q的增长,T会越来越小。但是Leaf本质的需求是希望T是固定的。那么如果L可以和Q正相关的话,T就可以趋近一个定值了。所以Leaf每次更新号段的时候,根据上一次更新号段的周期T和号段长度step,来决定下一次的号段长度nextStep:
- T < 15min,nextStep = step * 2
- 15min < T < 30min,nextStep = step
- T > 30min,nextStep = step / 2
至此,满足了号段消耗稳定趋于某个时间区间的需求。当然,面对瞬时流量几十、几百倍的暴增,该种方案仍不能满足可以容忍数据库在一段时间不可用、系统仍能稳定运行的需求。因为本质上来讲,Leaf虽然在DB层做了些容错方案,但是号段方式的ID下发,最终还是需要强依赖DB。Leaf采用一主二从半同步复制的方案,在极端情况下会超时退化成异步复制,造成ID重复。但是概率非常小
- 异步复制:性能最高,延迟最低,但数据一致性较弱,适合对延迟敏感且容忍一定数据不一致的场景。
- 半同步复制:在性能和一致性之间取得平衡,适合希望提高数据安全性但又不能承受完全同步带来的性能损失的场景。
- 同步复制:提供最强的数据一致性,但对性能有较大影响,通常只在对数据一致性要求极高的情况下使用,但在 MySQL 中不常用。
Leaf-snowflake方案
在之前版本中,可以通过在0点下单和24点下单的ID号推算出美团一天的订单量,这是不能忍受的,于是每天结合雪花算法做出了Leaf-snowflake方案。
Leaf-snowflake方案完全沿用snowflake方案的bit位设计,即是“1+41+10+12”的方式组装ID号。对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。
也就是说:美团的Leaf-snowflake方案在雪花算法的基础上使用zookeeper解决了自动配置workerID的问题,同时在本地缓存workerID,使之成为弱依赖zookeeper。
在时钟回拨问题上,Leaf-snowflake会上报当前生成ID的最大时间戳到zookeeper(间隔周期3秒),上报时,如果发现当前时间戳少于最后一次上报的时间戳,那么会放弃上报。之所以这么做的原因是,防止在leaf实例重启过程中,由于时钟回拨导致可能产生重复ID的问题。本质就是用zookeeper判断是否发生时钟回拨,所以运行期间还是依赖zookeeper的,zookeeper可以短时间宕机,但是长时间宕机会导致时钟回拨问题无法检测。
Leaf并不是全局有序,因为是并发获取,只是单节点有序。
时钟回拨解决的代码:
//发生了回拨,此刻时间小于上次发号时间
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
try {
//时间偏差大小小于5ms,则等待两倍时间
wait(offset << 1);//wait
timestamp = timeGen();
if (timestamp < lastTimestamp) {
//还是小于,抛异常并上报
throwClockBackwardsEx(timestamp);
}
} catch (InterruptedException e) {
throw e;
}
} else {
//throw
throwClockBackwardsEx(timestamp);
}
}
//分配ID
滴滴(Tinyid)-基于美团Leaf-segment
Tinyid
是滴滴开发的一款分布式ID系统,Tinyid
是在美团(Leaf)
的leaf-segment
算法基础上升级而来,不仅支持了数据库多主节点模式,还提供了tinyid-client
客户端的接入方式,使用起来更加方便。但和美团(Leaf)不同的是,Tinyid只支持号段一种模式不支持雪花模式。(Leaf采用一主二从半同步复制的方案,在极端情况下会超时退化成异步复制,造成ID重复。)
Tinyid的特性
- 全局唯一的long型ID
- 趋势递增的id
- 提供 http 和 java-client 方式接入
- 支持批量获取ID
- 支持生成1,3,5,7,9...序列的ID
- 支持多个db的配置
注意:Tinyid不支持订单业务,由于是连续的简单ID,会被扫库,推算出订单量。
原理
基于号段的模式,从数据库取出一个号段,例如[1, 1000],后续在应用节点内部,内存中生成,具有高性能。
提供了两种调用方式
-
Tinyid-server:使用http接口来获取ID和号段
-
Tinyid-client:通过引用
tinyid-server
包,配置服务的请求地址和身份认证token,调包获取ID
改进
Tinyid相比于Leaf的改进以及为什么要进行改进?
-
支持多DB(master),提高可用性,支持数据库多
master
模式 -
Tinyid-client
-
ID本地生成(调用AtomicLong.addAndGet方法),性能大大增加
-
client对server访问变得低频,减轻了server的压力
-
因为低频,即便client使用方和server不在一个机房,也无须担心延迟
-
即便所有server挂掉,因为client预加载了号段,依然可以继续使用一段时间 注:使用tinyid-client方式,如果client机器较多频繁重启,可能会浪费较多的id,这时可以考虑使用http方式
-
微信序列号生成方案
微信序列号跟用户 uin 绑定,具有以下性质:递增的 64 位整形;使用每个用户独立的 64 位 sequence 的体系,而不是用一个全局的 64 位(或更高位) sequence ,很大原因是全局唯一的 sequence 会有非常严重的申请互斥问题,不容易去实现一个高性能高可靠的架构。其实现方式包含如下两个关键点:
1)步进式持久化:增加一个缓存中间层,内存中缓存最近一个分配出现的 sequence:cur_seq,以及分配上限:max_seq;分配 sequence 时,将 cur_seq++,与分配上限 max_seq 比较,如果 cur_seq > max_seq,将分配上限提升一个步长 max_seq += step,并持久化 max_seq;重启时,读出持久化的 max_seq,赋值给 cur_seq。此种处理方式可以降低持久化的硬盘 IO 次数,可以系统的整体吞吐量。
2)分号段共享存储:引入号段 section 的概念,uin 相邻的一段用户属于一个号段,共享一个 max_seq。该处理方式可以大幅减少 max_seq 数据的大小,同时可以进一步地降低 IO 次数。
百度 UidGenerator 方案
1)DefaultUidGenerator 实现方式
DefaultUidGenerator 方式的实现要点如下所示:
-
delta seconds:在上图中用 28bit 部分表示,指当前时间与 epoch 时间的时间差,单位为秒。epoch 时间指集成 DefaultUidGenerator 生成分布式 ID 服务第一次上线的时间,可配置。
-
worker id:在上图中用 22bit 部分表示,在使用 DefaultUidGenerator 方式生成分布式 ID 的实例启动的时候,往 db 中写入一行数据得到的自增 id 值。由于 worker id 默认 22 位,允许集成 DefaultUidGenerator 生成分布式 id 的所有实例的重启次数不超过 4194303 次,否则会抛出异常
-
sequence:在上图中用 13bit 部分表示,通过 synchronized 保证线程安全;如果时间有任何的回拨,直接抛出异常;如果当前时间和上一次是同一秒时间,sequence 自增,如果同一秒内自增至超过 2^13-1,自旋等待下一秒;如果是新的一秒,sequence 从 0 开始。
DefaultUidGenerator 方式在出现任何刻度的时钟回拨时都会直接抛异常给到业务层,实现比较简单粗暴。故使用 DefaultUidGenerator 方式生成分布式 ID,需要根据业务情况和特点,调整各个字段占用的位数。
2)CachedUidGenerator 实现方式
CachedUidGenerator 的核心是利用 RingBuffer,本质上是一个数组,数组中每个项被称为 slot。CachedUidGenerator 设计了两个 RingBuffer,一个保存唯一 ID,一个保存 flag。其实现要点如下所示:
-
自增列:UidGenerator 的 workerId 在实例每次重启时初始化,且就是数据库的自增 ID,从而完美的实现每个实例获取到的 workerId 不会有任何冲突。
-
RingBuffer:UidGenerator 不再在每次取 ID 时都实时计算分布式 ID,而是利用 RingBuffer 数据结构预先生成若干个分布式 ID 并保存。
-
时间递增:UidGenerator 的时间类型是 AtomicLong,且通过 incrementAndGet()方法获取下一次的时间,从而脱离了对服务器时间的依赖,也就不会有时钟回拨的问题,但是这样时间就不精准了。
传统的雪花算法实现都是通过System.currentTimeMillis()来获取时间并与上一次时间进行比较,这样的实现严重依赖服务器的时间。而UidGenerator的时间类型是AtomicLong,且通过incrementAndGet()方法获取下一次的时间,从而脱离了对服务器时间的依赖,也就不会有时钟回拨的问题(这种做法也有一个小问题,即分布式ID中的时间信息可能并不是这个ID真正产生的时间点,例如:获取的某分布式ID的值为3200169789968523265,它的反解析结果为{"timestamp":"2019-05-02 23:26:39","workerId":"21","sequence":"1"},但是这个ID可能并不是在"2019-05-02 23:26:39"这个时间产生的)。