CosId 通用、灵活、高性能的分布式ID生成器
更新内容(v1.3.19) 🎉 🎉 🎉
- 性能优化:使用
AtomicLongFieldUpdater 替换 AtomicLong 进一步降低 频繁创建 DefaultIdSegment new AtomicLong 内存分配、GC 压力。(段模式ID生成器)
- 增强:
CacheClock 支持响应线程中断信号
- 支持自定义时区(
SnowflakeFriendlyId)
- 变更
SnowflakeFriendlyId.generateAsString 默认返回 friendlyId
-
增强:增加号段分发器NextMaxId回滚检测。
简介
CosId 旨在提供通用、灵活、高性能的分布式 ID 生成器。 目前提供了俩类 ID 生成器:
SnowflakeId : 单机 TPS 性能:409W/s JMH 基准测试 , 主要解决 时钟回拨问题 、机器号分配问题 并且提供更加友好、灵活的使用体验。
SegmentId: 每次获取一段 (Step) ID,来降低号段分发器的网络IO请求频次提升性能。
IdSegmentDistributor: 号段分发器(号段存储器)
RedisIdSegmentDistributor: 基于 Redis 的号段分发器。
JdbcIdSegmentDistributor: 基于 Jdbc 的号段分发器,支持各种关系型数据库。
SegmentChainId(推荐):SegmentChainId (lock-free) 是对 SegmentId 的增强。性能可达到近似 AtomicLong 的 TPS 性能:12743W+/s JMH 基准测试 。
PrefetchWorker 维护安全距离(safeDistance), 并且支持基于饥饿状态的动态safeDistance扩容/收缩。
背景(为什么需要分布式ID)
在软件系统演进过程中,随着业务规模的增长,我们需要进行集群化部署来分摊计算、存储压力,应用服务我们可以很轻松做到无状态、弹性伸缩。 但是仅仅增加服务副本数就够了吗?显然不够,因为性能瓶颈往往是在数据库层面,那么这个时候我们就需要考虑如何进行数据库的扩容、伸缩、集群化,通常使用分库、分表的方式来处理。 那么我如何分片(水平分片,当然还有垂直分片不过不是本文需要讨论的内容)呢,分片得前提是我们得先有一个ID,然后才能根据分片算法来分片。(比如比较简单常用的ID取模分片算法,这个跟Hash算法的概念类似,我们得先有key才能进行Hash取得插入槽位。)
当然还有很多分布式场景需要分布式ID,这里不再一一列举。
分布式ID方案的核心指标
不同分布式ID方案核心指标对比
| 分布式ID |
全局唯一性 |
有序性 |
吞吐量 |
稳定性(1s=1000,000us) |
自治性 |
可用性 |
适应性 |
存储空间 |
| UUID/GUID |
是 |
完全无序 |
3078638(ops/s) |
P9999=0.325(us/op) |
完全自治 |
100% |
否 |
128-bit |
| SnowflakeId |
是 |
本地单调递增,全局趋势递增(受全局时钟影响) |
4096000(ops/s) |
P9999=0.244(us/op) |
依赖时钟 |
时钟回拨会导致短暂不可用 |
否 |
64-bit |
| SegmentId |
是 |
本地单调递增,全局趋势递增(受Step影响) |
29506073(ops/s) |
P9999=46.624(us/op) |
依赖第三方号段分发器 |
受号段分发器可用性影响 |
否 |
64-bit |
| SegmentChainId |
是 |
本地单调递增,全局趋势递增(受Step、安全距离影响) |
127439148(ops/s) |
P9999=0.208(us/op) |
依赖第三方号段分发器 |
受号段分发器可用性影响,但因安全距离存在,预留ID段,所以高于SegmentId |
是 |
64-bit |
有序性(要想分而治之·二分查找法,必须要维护我)
刚刚我们已经讨论了ID有序性的重要性,所以我们设计ID算法时应该尽可能地让ID是单调递增的,比如像表的自增主键那样。但是很遗憾,因全局时钟、性能等分布式系统问题,我们通常只能选择局部单调递增、全局趋势递增的组合(就像我们在分布式系统中不得不的选择最终一致性那样)以获得多方面的权衡。下面我们来看一下什么是单调递增与趋势递增。
有序性之单调递增
![单调递增]()
单调递增:T表示全局绝对时点,假设有Tn+1>Tn(绝对时间总是往前进的,这里不考虑相对论、时间机器等),那么必然有F(Tn+1)>F(Tn),数据库自增主键就属于这一类。 另外需要特别说明的是单调递增跟连续性递增是不同的概念。 连续性递增:F(n+1)=(F(n)+step)即下一次获取的ID一定等于当前ID+Step,当Step=1时类似于这样一个序列:1->2->3->4->5。
扩展小知识:数据库的自增主键也不是连续性递增的,相信你一定遇到过这种情况,请思考一下数据库为什么这样设计?
有序性之趋势递增
![趋势递增]()
趋势递增:Tn>Tn-s,那么大概率有F(Tn)>F(Tn-s)。虽然在一段时间间隔内有乱序,但是整体趋势是递增。从上图上看,是有上升趋势的(趋势线)。
- 在SnowflakeId中n-s受到全局时钟同步影响。
- 在号段模式(SegmentId)中n-s受到号段可用区间(
Step)影响。
分布式ID分配方案
UUID/GUID
不依赖任何第三方中间件
性能高
完全无序
空间占用大,需要占用128位存储空间。
UUID最大的缺陷是随机的、无序的,当用于主键时会导致数据库的主键索引效率低下(为了维护索引树,频繁的索引中间位置插入数据,而不是追加写)。这也是UUID不适用于数据库主键的最为重要的原因。
SnowflakeId
![Snowflake]()
SnowflakeId使用Long(64-bit)位分区来生成ID的一种分布式ID算法。 通用的位分配方案为:timestamp(41-bit)+machineId(10-bit)+sequence(12-bit)=63-bit。
- 41-bit
timestamp=(1L<<41)/(1000/3600/365),约可以存储69年的时间戳,即可以使用的绝对时间为EPOCH+69年,一般我们需要自定义EPOCH为产品开发时间,另外还可以通过压缩其他区域的分配位数,来增加时间戳位数来延长可用时间。
- 10-bit
machineId=(1L<<10)=1024,即相同业务可以部署1024个副本(在Kubernetes概念里没有主从副本之分,这里直接沿用Kubernetes的定义)。一般情况下没有必要使用这么多位,所以会根据部署规模需要重新定义。
- 12-bit
sequence=(1L<<12)*1000=4096000,即单机每秒可生成约409W的ID,全局同业务集群可产生4096000*1024=419430W=41.9亿(TPS)。
从 SnowflakeId 设计上可以看出:
![👍]()
timestamp在高位,单实例SnowflakeId是会保证时钟总是向前的(校验本机时钟回拨),所以是本机单调递增的。受全局时钟同步/时钟回拨影响SnowflakeId是全局趋势递增的。
SnowflakeId不对任何第三方中间件有强依赖关系,并且性能也非常高。
位分配方案可以按照业务系统需要灵活配置,来达到最优使用效果。
强依赖本机时钟,潜在的时钟回拨问题会导致ID重复、处于短暂的不可用状态。
![👎]()
machineId需要手动设置,实际部署时如果采用手动分配machineId,会非常低效。
SnowflakeId之机器号分配问题
在SnowflakeId中根据业务设计的位分配方案确定了基本上就不再有变更了,也很少需要维护。但是machineId总是需要配置的,而且集群中是不能重复的,否则分区原则就会被破坏而导致ID唯一性原则破坏,当集群规模较大时machineId的维护工作是非常繁琐,低效的。
有一点需要特别说明的,SnowflakeId的MachineId是逻辑上的概念,而不是物理概念。 想象一下假设MachineId是物理上的,那么意味着一台机器拥有只能拥有一个MachineId,那会产生什么问题呢?
目前 CosId 提供了以下三种 MachineId 分配器。
- ManualMachineIdDistributor: 手动配置
machineId,一般只有在集群规模非常小的时候才有可能使用,不推荐。
- StatefulSetMachineIdDistributor: 使用
Kubernetes的StatefulSet提供的稳定的标识ID(HOSTNAME=service-01)作为机器号。
- RedisMachineIdDistributor: 使用Redis作为机器号的分发存储,同时还会存储
MachineId的上一次时间戳,用于启动时时钟回拨的检查。
![RedisMachineIdDistributor]()
SnowflakeId之时钟回拨问题
时钟回拨的致命问题是会导致ID重复、冲突(这一点不难理解),ID重复显然是不能被容忍的。 在SnowflakeId算法中,按照MachineId分区ID,我们不难理解的是不同MachineId是不可能产生相同ID的。所以我们解决的时钟回拨问题是指当前MachineId的时钟回拨问题,而不是所有集群节点的时钟回拨问题。
MachineId时钟回拨问题大体可以分为俩种情况:
SnowflakeId之JavaScript数值溢出问题
JavaScript的Number.MAX_SAFE_INTEGER只有53-bit,如果直接将63位的SnowflakeId返回给前端,那么会产生值溢出的情况(所以这里我们应该知道后端传给前端的long值溢出问题,迟早会出现,只不过SnowflakeId出现得更快而已)。 很显然溢出是不能被接受的,一般可以使用以下俩种处理方案:
号段模式(SegmentId)
![SegmentId]()
从上面的设计图中,不难看出号段模式基本设计思路是通过每次获取一定长度(Step)的可用ID(Id段/号段),来降低网络IO请求次数,提升性能。
强依赖第三方号段分发器,可用性受到第三方分发器影响。
每次号段用完时获取NextMaxId需要进行网络IO请求,此时的性能会比较低。
- 单实例ID单调递增,全局趋势递增。
- 从设计图中不难看出Instance 1每次获取的
NextMaxId,一定比上一次大,意味着下一次的号段一定比上一次大,所以从单实例上来看是单调递增的。
- 多实例各自持有的不同的号段,意味着同一时刻不同实例生成的ID是乱序的,但是整体趋势的递增的,所以全局趋势递增。
- ID乱序程度受到Step长度以及集群规模影响(从趋势递增图中不难看出)。
- 假设集群中只有一个实例时号段模式就是单调递增的。
Step越小,乱序程度越小。当Step=1时,将无限接近单调递增。需要注意的是这里是无限接近而非等于单调递增,具体原因你可以思考一下这样一个场景:
- 号段分发器T1时刻给Instance 1分发了
ID=1,T2时刻给Instance 2分发了ID=2。因为机器性能、网络等原因,Instance 2网络IO写请求先于Instance 1到达。那么这个时候对于数据库来说,ID依然是乱序的。
号段链模式(SegmentChainId)
分布式ID(CosId)之号段链模式性能(1.2亿/s)解析
![SegmentChainId]()
SegmentChainId是SegmentId增强版,相比于SegmentId有以下优势:
SegmentChainId-吞吐量 (ops/s)
RedisChainIdBenchmark-Throughput
![RedisChainIdBenchmark-Throughput]()
MySqlChainIdBenchmark-Throughput
![MySqlChainIdBenchmark-Throughput]()
SegmentChainId-每次操作耗时的百分位数(us/op)
RedisChainIdBenchmark-Percentile
![RedisChainIdBenchmark-Sample]()
MySqlChainIdBenchmark-Percentile
![MySqlChainIdBenchmark-Sample]()
基准测试报告运行环境说明
- 基准测试运行环境:笔记本开发机(MacBook-Pro-(M1))
- 所有基准测试都在开发笔记本上执行。