id 生成算法大杂烩

97 阅读12分钟

分布式ID大合集

包含:雪花算法&seata的雪花 [ link] — 大众点评订单系统分库分表实践

别的分布式id的算法分库分表一站式通关

分布式情况下,id 一般就不是自增了

雪花算法

基本介绍

雪花算法是一个64位的二进制整数

  1. 符号位,固定为0。确保id是整数

  2. 时间戳位(41位)

    1. 存储毫秒时间戳,表示当前时间与某个固定起始时间的差值

    41位大约表示69年

  3. 10位的机器标识,10位的长度最多支持部署1024个节点。

  4. 12位的计数序列号,序列号即一系列的自增ID,可以支持同一节点同一毫秒生成多个ID序号,12位的计数序列号支持每个节点每毫秒产生4096个ID序号。

也就是说,最多并发4096/ms。 但是是耍了一个文字游戏,一毫秒最多4096个并发,并不是整个一秒钟的并发。可能有些毫秒大于4096,那么就会不行

优缺点

特性:

  • 全局唯一性:雪花算法可以保证集群系统的ID全局唯一
  • 趋势递增:由于强依赖时间戳,所以整体趋势会随着时间递增
  • 单调递增(×):不满足单调递增,在不考虑时间回拨的情况下,虽然在单机中可以保持单调递增,但在分布式集群中无法做到单调递增,只能保证总体趋势递增
  • 信息安全指的是ID生成不规则,无法猜测下一个

📌

缺点:

依赖与系统时间的一致性,如果系统时间被回调,或者改变,可能会造成 ID 冲突或者重复

时钟回拨解决方案

  1. 对于此问题,Seata的解决策略是记录上一次的时间戳,若发现当前时间戳小于记录值(意味着出现了时钟回拨),则拒绝服务, 等待时间戳追上记录值。 但这也意味着这段时间内该TC将处于不可用状态。
  2. 把雪花中十位id拿三位作为冲突位,冲突了进行+1,这样一个id可以冲突8(2^3)次

Seata改良版本雪花

为什么改良

主要是防止时间回拨提高并发

改进的核心思想是解除与操作系统时间戳的强绑定,生成器仅在初始化时获取一次系统时间戳作为初始值,之后不再同步系统时间;

所谓‘超前时间’是指生成ID的时间戳(由序列号驱动递增)可能暂时超前于物理时间,当物理时间追上后才会进入阻塞逻辑。它之后的递增, 只由序列号的递增来驱动。 比如序列号当前值是4095,下一个请求进来, 序列号+1溢出12位空间,序列号重新归零,而溢出的进位则加到时间戳上, 从而让时间戳+1。 至此,时间戳和序列号实际可视为一个整体了。实际上我们也是这样做的,为了方便这种溢出进位,我们调整了64位ID的位分配策略, 由原版的:

原版位分配策略

改成(即时间戳和节点ID换个位置):

改进版位分配策略

📌

这个需要在多节点的时候使用,因为你并发量不大的话,那么id就是递增的

但好像递增也没问题

全局递增的问题

问题:

新版算法在单节点内部确实是单调递增的,但是在多实例部署时,它就不再是全局单调递增了啊!因为显而易见,节点ID排在高位,那么节点ID大的,生成的ID一定大于节点ID小的,不管时间上谁先谁后。而原版算法,时间戳在高位,并且始终追随系统时钟,可以保证早生成的ID小于晚生成的ID,只有当2个节点恰好在同一时间戳生成ID时,2个ID的大小才由节点ID决定。这样看来,新版算法是不是错的?

📌

新版算法的确不具备全局的单调递增性,但这不影响我们的初衷(减少数据库的页分裂)。

剖析

如果主键ID由标准版雪花算法生成,最好的情况下,是每个时间戳内只有一个节点在生成ID,这时候算法的效果等同于理想情况的顺序递增,即跟auto_increment无差。最坏的情况下,是每个时间戳内所有节点都在生成ID,这时候算法的效果接近于无序(但仍比UUID的完全无序要好得多,因为workerId只有10位决定了最多只有1024个节点)。实际生产中,算法的效果取决于业务流量,并发度越低,算法越接近理想情况。

那么,换成新版算法又会如何呢?

新版算法从全局角度来看,ID是无序的,但对于每一个workerId,它生成的ID都是严格单调递增的,又因为workerId是有限的,所以最多可划分出1024个子序列,每个子序列都是单调递增的。

对于数据库而言,也许它初期接收的ID都是无序的,来自各个子序列的ID都混在一起,就像这样:

初期

如果这时候来了个worker1-seq2,显然会造成页分裂:

首次分裂

但分裂之后,有趣的事情发生了,对于worker1而言,后续的seq3,seq4不会再造成页分裂(因为还装得下),seq5也只需要像顺序增长那样新建页进行链接(区别是这个新页不是在双向链表的尾部)。注意,worker1的后续ID,不会排到worker2及之后的任意节点(因而不会造成后边节点的页分裂),因为它们总比worker2的ID小;也不会排到worker1当前节点的前边(因而不会造成前边节点的页分裂),因为worker1的子序列总是单调递增的。在这里,我们称worker1这样的子序列达到了稳态,意为这条子序列已经"稳定"了,它的后续增长只会出现在子序列的尾部,而不会造成其它节点的页分裂。

同样的事情,可以推广到各个子序列上。无论前期数据库接收到的ID有多乱,经过有限次的页分裂后,双向链表总能达到这样一个稳定的终态:

终态

到达终态后,后续的ID只会在该ID所属的子序列上进行顺序增长,而不会造成页分裂。该状态下的顺序增长与auto_increment的顺序增长的区别是,前者有1024个增长位点(各个子序列的尾部),后者只有尾部一个。

到这里,我们可以回答开头所提出的问题了:新算法从全局来看的确不是全局递增的,但该算法是收敛的,达到稳态后,新算法同样能达成像全局顺序递增一样的效果。

美团Leaf

Leaf-segment数据库方案

第一种Leaf-segment方案,在使用数据库的方案上,做了如下改变:

  • 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。
  • 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。

📌

使用数据库作为发号中心,只不过一次就获取step个号码(1000)

把号码存到当前系统中,用完了再去数据库进行写操作

这样数据库的写频率就降为了1/step

优缺点

优点:

  • Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。
  • ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
  • 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。
  • 可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。

缺点:

  • ID号码不够随机,能够泄露发号数量的信息,不太安全。
  • TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。
  • DB宕机会造成整个系统不可用。

双buffer优化

使用两个变量,在剩余10%的号段可以使用 之后,异步去数据进行更新,然后两个号段进行复用

Leaf-snowflake方案

介绍:

主体使用雪花算法,但是使用了Zookeeper,如果没注册,就注册一个节点,然后作为自己的workID

同时,也在本地缓存workID,保证SLA

幸运码设计

[[🔗 link] — 号码生成系统的创新实践:游戏周周乐幸运码设计本文以游戏周周乐的幸运码为切入点,针对其生成过程中涉及的随机性、唯一性及高并 - 掘金](www.notion.so/link-ef7bc8…)

背景/要求

  1. 每期一百万唯一无重复的 6 位数字幸运码
  2. 人数到达上限的时候,可以追加 100 万

特征:

  • 随机性,发给每个用户的幸运码都是随机的,同时每个用户领取的多个幸运码也是随机的。
  • 唯一性,每一组的幸运码中,各幸运码都是唯一的。
  • 范围性,幸运码严格限定在000000到999999区间内。
  • 高并发,幸运码的生成和发放需要支持高并发,能够至少达到300QPS。
  • 可追加,在当期活动非常火爆时,需要可临时追加一组幸运码库存。

方案设计

实时随机生成

随机生成,然后看数据有没有,有再生成一个

已经不像人类的方案了

预生成库存方案

离线生成一百万个幸运码,随机打乱,写入数据库,每个幸运码对应一个从 1 自增的序列号,并使用 redis 记录幸运码序列号索引,初始值为 1

数据库表结构

id code

1 ddj2

这种就是提前生成了,存在数据库里面,然后在 redis 记录一个当前用到了哪里的索引

号段+子码模式

子码串为什么是一千位?

因为你还要进行二级随机,所以在这里生成了一个0-1000 的随机数,然后去这个 01 串(也就是 bitmap 中)查看有没有被使用,如果被使用,再进行一次随机。

这里应该还有设计,就是用一次之后看看是不是已经全部用完了,然后 redis 继续进行增加,文章并没有说明

采用号段+子码机制:

  • 号段管理:将10^6号码划分为1000个号段(号段值:0-999)
  • 子码管理:每个号段维护1000个可用子码(子码值:0-999)
  • 生成规则:幸运码=随机号段1000+随机子码(示例:129358=1291000+358)

具体设计

将 100 万注幸运码分为了 1000 个号段(每段 1000 注)

  • 号段 id:唯一且不重复,范围介于 0-999
  • 子码串:1000 位字符串,采用01标记,0 表示 未使用,1 表示使用

幸运码生成公式:号段 id*1000+ 子码位置

保留了幸运码的随机性(号段 id 随机+子码随机)又通过子码的类比特存储方式提升了存储效率

4.2.1 多级缓存策略

Redis存储可用号段集合,若号段的子码使用完,该号段会从Redis集合中剔除,同时本地缓存也会预加载可用号段,确保发码时能更高效地获取候选号段。

4.2.2 高效锁抢占策略

系统为每个号段分配了分布式锁,当执行发放幸运码时,会从本地缓存随机获取15个候选号段。然后在遍历获取号段时,将等待锁的超时时间设置成30ms,确保号段被占用的情况下能够快速遍历到下一个号段(根据实际场景统计,等待锁的情况很少发生,一般最多遍历到第二个号段即可成功抢占)。一旦成功获得号段的分布式锁后,便可进一步随机获取该号段下的可用子码。

4.2.3 动态库存策略

要追加库存,只需再创建一组幸运码号段,并写入Redis,后续发放时获取该组的可用号段生成幸运码即可。从性能和存储空间上远优于预生成方式。

幸运码发放流程

发放步骤

  1. 随机获取至多15个可用号段
  2. 遍历号段
  3. 抢占号段的分布式锁
  4. 若号段的分布式锁抢占成功,则随机获取号段中可用的子码,再根据号段和子码生成幸运码
  5. 若号段的分布式锁抢占失败,则遍历下一个号段,并重复上述步骤