高并发分布式系统中生成全局唯一ID汇总
一、概述
在分布式系统中,特别是在分库分表场景下,生成全局唯一ID是一个关键的技术挑战。单纯的生成全局ID并不困难,但生成的ID需要满足分布式系统的特定要求:
- 无单点故障:ID生成服务必须具备高可用性
- 时间有序性:ID应包含时间信息或按时间排序,便于索引优化和冷热数据分离
- 分片可控性:能够控制ShardingId,使相关数据位于同一分片,提高查询和修改效率
- 长度适中:最好为64bit,便于使用long类型操作,避免组件兼容性问题
本文将详细介绍几种主流的全局唯一ID生成方案。
二、Snowflake算法(Twitter方案)
2.1 背景
Twitter在将存储系统从MySQL迁移到Cassandra时,由于Cassandra没有内置的顺序ID生成机制,开发了Snowflake全局唯一ID生成服务。
2.2 算法结构
Snowflake生成的ID为64位,结构如下:
1 | 0 | 41位时间戳 | 10位机器标识 | 12位序列号 |
各字段说明:
- 符号位:1位,始终为0
- 时间戳:41位,精确到毫秒,可使用69年(从自定义起始时间开始)
- 机器标识:10位,最多支持1024个节点
- 序列号:12位,每个节点每毫秒可生成4096个ID
2.3 核心原理
![[Snowflake算法原理图.png]]
2.4 Java实现示例
1 | public class IdWorker { |
2.5 优缺点分析
优点:
- 高性能,低延迟
- 独立应用,不依赖外部服务
- ID按时间有序
缺点:
- 需要独立开发和部署
- 时钟回拨问题需要处理
- 机器标识需要管理
三、Flickr数据库方案
3.1 方案概述
Flickr利用MySQL的自增ID特性,通过auto_increment、replace into和MyISAM引擎实现全局ID生成。
3.2 实现步骤
创建专用表
1 | CREATE TABLE Tickets64 ( |
生成ID操作
在事务会话中执行:
1 | REPLACE INTO Tickets64 (stub) VALUES ('a'); |
高可用配置
通过配置两台MySQL服务器,设置不同的起始值和步长来生成奇偶数ID:
1 | # TicketServer1配置 |
客户端通过轮询方式获取ID。
3.3 优缺点分析
优点:
- 利用数据库自增ID机制,可靠性高
- 生成的ID有序
- 实现相对简单
缺点:
- 需要独立的MySQL实例,资源消耗大
- 性能受数据库限制
- 存在单点故障风险(需额外配置高可用)
四、UUID方案
4.1 基本概念
UUID(Universally Unique Identifier)生成的是32位16进制格式的字符串,转换为byte数组为16个字节,即128bit。
4.2 生成原理
UUID算法的核心思想是结合机器的网卡地址、当地时间和一个随机数来生成唯一标识符。
4.3 唯一性保证
理论上,如果一台机器每秒产生1000万个GUID,可以保证(概率意义上)3240年不重复。
4.4 优缺点分析
优点:
- 本地生成,无需远程调用,延迟低
- 扩展性好,基本无性能上限
- 全球唯一性保证
缺点:
- 无法保证趋势递增
- 长度过长(128bit),作为主键时索引效率低
- 常见优化方案存在局限性:
- 转化为两个uint64整数存储
- 折半存储(可能影响唯一性)
五、基于Redis的分布式ID生成器
5.1 实现原理
利用Redis的Lua脚本执行功能,在每个节点上通过Lua脚本生成唯一ID。
5.2 ID结构
生成的ID为64位,结构如下:
- 41位时间戳:精确到毫秒,可使用41年
- 12位逻辑分片ID:最大分片ID为4095
- 10位自增长ID:每个节点每毫秒最多生成1024个ID
5.3 生成示例
假设:
- GTM时间:Fri Mar 13 10:00:00 CST 2015
- 毫秒数:1426212000000
- 分片ID:53
- 自增长序列:4
生成ID计算:
1 | 5981966696448054276 = 1426212000000 << 22 + 53 << 10 + 4 |
5.4 Redis命令使用
Redis提供TIME命令获取服务器上的秒数和微秒数。Lua脚本返回四元组:(second, microSecond, partition, seq)
客户端处理逻辑:
1 | long id = ((second * 1000 + microSecond / 1000) << (12 + 10)) |
六、MongoDB ObjectId方案
6.1 设计考虑
MongoDB的_id字段需要满足分布式环境下的全局唯一性要求,因此不能使用自增主键,而是采用ObjectId对象。
6.2 ObjectId结构
ObjectId使用12字节存储空间,结构如下:
| 字节位置 | 0-3 | 4-6 | 7-8 | 9-11 |
|---|---|---|---|---|
| 内容 | 时间戳 | 机器ID | 进程ID | 计数器 |
各字段说明:
- 时间戳(4字节):从标准纪元开始的秒数
- 机器ID(3字节):服务器主机标识,通常是主机名的散列值
- 进程ID(2字节):mongod进程标识符
- 计数器(3字节):自动增加的计数器,每个进程独立
6.3 唯一性保证机制
- 时间戳:保证秒级唯一性
- 机器ID:考虑分布式环境,避免时钟同步问题
- 进程ID:保证同一服务器上多个mongod实例的唯一性
- 计数器:保证同一秒内的唯一性(最多16777216个)
6.4 生成位置
_id可以在服务器端生成,也可以在客户端生成。客户端生成可以降低服务器端压力。
七、方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Snowflake | 高性能、有序、独立 | 需独立部署、时钟问题 | 大规模分布式系统 |
| Flickr数据库 | 简单、可靠、有序 | 性能受限、资源消耗大 | 中小规模系统 |
| UUID | 全球唯一、无中心化 | 无序、存储效率低 | 需要全局唯一标识 |
| Redis方案 | 性能好、可扩展 | 依赖Redis、配置复杂 | Redis环境下的系统 |
| MongoDB ObjectId | 内置支持、分布式友好 | MongoDB专用 | MongoDB数据库系统 |
八、选择建议
8.1 考虑因素
- 系统规模:大规模系统推荐Snowflake,中小规模可考虑数据库方案
- 性能要求:高并发场景选择Snowflake或Redis方案
- 有序性需求:需要有序ID时选择Snowflake或数据库方案
- 技术栈:现有技术栈影响方案选择(如已使用MongoDB)
8.2 最佳实践
- Snowflake:适合需要高性能、有序ID的大规模分布式系统
- 数据库方案:适合对数据库依赖较强的系统,实现简单
- 混合方案:可根据业务场景组合使用不同方案
九、总结
全局唯一ID生成是分布式系统中的基础且重要的技术。不同的方案各有优劣,选择时需要综合考虑系统规模、性能要求、有序性需求和技术栈等因素。
在实际应用中,建议:
- 明确业务需求,选择最适合的方案
- 考虑系统的扩展性和维护成本
- 做好异常处理,特别是时钟回拨等问题
- 定期评估和优化ID生成策略
随着技术发展,新的ID生成方案不断涌现,但核心原则不变:在保证唯一性的前提下,追求更高的性能和更好的扩展性。
参考资源:
- Twitter Snowflake官方文档
- Flickr技术博客
- MongoDB官方文档
- Redis官方文档
如何在高并发分布式系统中生成全局唯一Id - 滴答的雨 - 博客园
Excerpt
如何在高并发分布式系统中生成全局唯一Id。
1、使用数据库自增Id
2、单独开一个数据库,获取全局唯一的自增序列号或各表的MaxId
3、Sequence特性
4、通过数据库集群编号+集群内的自增类型两个字段共同组成唯一主键
5、通过设置每个集群中自增 ID 起始点
6、GU
最近公司用到,并且在找最合适的方案,希望大家多参与讨论和提出新方案。我和我的小伙伴们也讨论了这个主题,我受益匪浅啊……
博文示例:
今天分享的主题是:如何在高并发分布式系统中生成全局唯一Id。
但这篇博文实际上是“半分享半讨论”的博文:
1) 半分享是我将说下我所了解到的关于今天主题所涉及的几种方案。
2) 半讨论是我希望大家对各个方案都说说自己的见解,更加希望大家能提出更好的方案。(我还另外提问在此:http://q.cnblogs.com/q/53552/)
我了解的方案如下……………………………………………………………………
1、 使用数据库自增Id
优势:编码简单,无需考虑记录唯一标识的问题。
缺陷:
1) 在大表做水平分表时,就不能使用自增Id,因为Insert的记录插入到哪个分表依分表规则判定决定,若是自增Id,各个分表中Id各自增长就会重复
2) 在业务上操作父、子表(即关联表)插入时,需要在插入数据库之前获取max(id)用于标识父表和子表关系,若存在并发获取max(id)的情况,max(id)会同时被别的线程获取到。
3) DB数据记录都是可以根据ID号进行推测出来,对于一些数据敏感的场景,不建议采用
结论:适合小应用,无需分表,低并发。
2、 单独开一个数据库,获取全局唯一的自增序列号或各表的MaxId
使用MaxId表存储各表的MaxId值
专门一个数据库,记录各个表的MaxId值,建一个存储过程来取Id,逻辑大致为:开启事物,对于在表中不存在记录,直接返回一个默认值为1的键值,同时插入该条记录到table_key表中。而对于已存在的记录,key值直接在原来的key基础上加1更新到MaxId表中并返回key。(给table_key中为每个表初始化一条key为1的记录,这样就不用每次if来判断了—@辉_辉 提议)
使用此方案的问题是:每次的查询MaxId是一个性能损耗;
详细可参考:《使用MaxId表存储各表的MaxId值,以获取全局唯一Id》
我截取此文中的sql语法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
|
2. (@乐活的CodeMonkey)提醒提高获取ID时存储过程的隔离级别,避免读取到未提交事务导致并发ID重复的问题。(MSSQL事务隔离级别详解)
1 2 3 4 5 6 7 |
|
3. (@土豆烤肉)存储过程中不使用事物,一旦使用到事物性能就急剧下滑。直接使用UPDATE获取到的更新锁,即SQL SERVER会保证UPDATE的顺序执行。(已在用户过千万的并发系统中使用)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
结论:适用中型应用,此方案解决了分表,关联表插入记录的问题。但是无法满足高并发性能要求。存在单点问题
改进方案:时间信息 + 缓存总的maxid (@wee616 提议)
从redis中用lpop指令取指定key值的数据。(lpop:移除并返回列表的头元素)
如果将指定key值的数据取完了,会触发初始化。
初次初始化:
1)用for update锁表,存储最小值1和最大值50到数据库中。
2)将这50个数字放入redis中。
下次初始化:
1)用for update锁表,存储最小值51和最大值100到数据库中。
2)将这50个数字放入redis中。
数据库每天有脚本定时清理这个表,每天都将最小值归0,避免最大值过大。
结论:适合大型应用,生成Id顺序性,可读性比较好。
3、 Sequence特性
这个特性在SQL Server 2012、Oracle中可用。这个特性是数据库级别的,允许在多个表之间共享序列号。它可以解决分表在同一个数据库的情况,但倘若分表放在不同数据库,那将共享不到此序列号。(eg:Sequence使用场景:你需要在多个表之间公用一个流水号。以往的做法是额外建立一个表,然后存储流水号)
相关Sequence特性资料:
SQL Server2012中的SequenceNumber尝试
SQL Server 2012 开发新功能——序列对象(Sequence)
Difference between Identity and Sequence in SQL Server 2012
结论:适用中型应用,此方案不能完全解决分表问题。
4、 通过数据库集群编号+集群内的自增类型两个字段共同组成唯一主键
优点:实现简单,维护也比较简单。
缺点:关联表操作相对比较复杂,需要两个字段。并且业务逻辑必须是一开始就设计为处理复合主键的逻辑,倘若是到了后期,由单主键转为复合主键那改动成本就太大了。
结论:适合大型应用,但需要业务逻辑配合处理复合主键。
5、 通过设置每个集群中自增 ID 起始点(auto_increment_offset),将各个集群的ID进行绝对的分段来实现全局唯一。当遇到某个集群数据增长过快后,通过命令调整下一个 ID 起始位置跳过可能存在的冲突。
优点:实现简单,且比较容易根据 ID 大小直接判断出数据处在哪个集群,对应用透明。缺点:维护相对较复杂,需要高度关注各个集群 ID 增长状况。
结论:适合大型应用,但需要高度关注各个集群 ID 增长状况。
6、 GUID(Globally Unique Identifier,全局唯一标识符)
GUID通常表示成32个16进制数字(0-9,A-F)组成的字符串,如:{21EC2020-3AEA-1069-A2DD-08002B30309D},它实质上是一个128位长的二进制整数。
GUID制定的算法中使用到用户的网卡MAC地址,以保证在计算机集群中生成唯一GUID;在相同计算机上随机生成两个相同GUID的可能性是非常小的,但并不为0。所以,用于生成GUID的算法通常都加入了非随机的参数(如时间),以保证这种重复的情况不会发生。
优点:GUID是最简单的方案,跨平台,跨语言,跨业务逻辑,全局唯一的Id,数据间同步、迁移都能简单实现。
缺点:
1) 存储占了32位,且无可读性;
2) 插入时因为GUID是无需的,在聚集索引的排序规则下可能移动大量的记录。
有两位园友主推GUID,无须顺序GUID方案原因如下:
@徐少侠 GUID无序在并发下效率高,并且一个数据页内添加新行,是在B树内增加,本质没有什么数据被移动,唯一可能的,是页填充因子满了,需要拆页。而GUID方案导致的拆页比顺序ID要低太多了
@无色 我们要明白id是什么,是身份标识,标识身份是id最大的业务逻辑,不要引入什么时间,什么用户业务逻辑,那是另外一个字段干的事,使用base64(guid,uuid),是通盘考虑,完全可以更好的兼容nosql,key-value存储。
结论:适合大型应用;生成的Id不够友好;占据了32位;
改进:
1) (@dudu告知)在SQL Server 2005中新增了NEWSEQUENTIALID函数。
详细请看:《理解newid()和newsequentialid()》
在指定计算机上创建大于先前通过该函数生成的任何 GUID 的 GUID。 newsequentialid 产生的新的值是有规律的,则索引B+树的变化是有规律的,就不会导致索引列插入时移动大量记录的问题。
但一旦服务器重新启动,其再次生成的GUID可能反而变小(但仍然保持唯一)。这在很大程度上提高了索引的性能,但并不能保证所生成的GUID一直增大。SQL的这个函数产生的GUID很简单就可以预测,因此不适合用于安全目的。
a) 只能做为数据库列的DEFAULT VALUE,不能执行类似SELECT NEWSEQUENTIALID()的语句.
b) 如何获得生成的GUID.
如果生成的GUID所在字段做为外键要被其他表使用,我们就需要得到这个生成的值。通常,PK是一个IDENTITY字段,我们可以在INSERT之后执行 SELECT SCOPE_IDENTITY()来获得新生成的ID,但是由于NEWSEQUENTIALID()不是一个INDETITY类型,这个办法是做不到了,而他本身又只能在默认值中使用,不可以事先SELECT好再插入,那么我们如何得到呢?有以下两种方法:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
结论:适合大型应用,解决了GUID无序特性导致索引列插入移动大量记录的问题。但是在关联表插入时需要返回数据库中生成的GUID;生成的Id不够友好;占据了32位。
2) “COMB”(combined guid/timestamp,意思是:组合GUID/时间截)
(感谢:@ ethan-luo ,@lcs-帅 )
COMB数据类型的基本设计思路是这样的:既然GUID数据因毫无规律可言造成索引效率低下,影响了系统的性能,那么能不能通过组合的方式,保留GUID的10个字节,用另6个字节表示GUID生成的时间(DateTime),这样我们将时间信息与GUID组合起来,在保留GUID的唯一性的同时增加了有序性,以此来提高索引效率。
在NHibernate中,COMB型主键的生成代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
结论:适合大型应用。即保留GUID的唯一性的同时增加了GUID有序性,提高了索引效率;解决了关联表业务问题;生成的Id不够友好;占据了32位。
3) 长度问题,使用Base64或Ascii85编码解决。(要注意的是上述有序性方案在进行编码后也会变得无序)
如:
GUID:{3F2504E0-4F89-11D3-9A0C-0305E82C3301}
当需要使用更少的字符表示GUID时,可能会使用Base64或Ascii85编码。Base64编码的GUID有22-24个字符,如:
7QDBkvCA1+B9K/U0vrQx1A
7QDBkvCA1+B9K/U0vrQx1A==
Ascii85编码后是20个字符,如:
5:$Hj:Pf\4RLB9%kU\Lj
代码如:
Guid guid = Guid.NewGuid();
byte[] buffer = guid.ToByteArray();
var shortGuid = Convert.ToBase64String(buffer);
结论:适合大型应用,缩短GUID的长度。生成的Id不够友好;
7、 GUID TO Int64
对于GUID的可读性,有园友给出如下方案:(感谢:@黑色的羽翼)
即将GUID转为了19位数字,数字反馈给客户可以一定程度上缓解友好性问题。EG:
GUID: cfdab168-211d-41e6-8634-ef5ba6502a22 (不友好)
Int64: 5717212979449746068 (友好性还行)
不过我的小伙伴说ToInt64后就不唯一了。因此我专门写了个并发测试程序,后文将给出测试结果截图及代码简单说明。
(唯一性、业务适合性是可以权衡的,这个唯一性肯定比不过GUID的,一般程序上都会安排错误处理机制,比如异常后执行一次重插的方案……)
结论:适合大型应用,生成相对友好的Id****(纯数字)。****
8、 自己写编码规则
优点:全局唯一Id,符合业务后续长远的发展(可能具体业务需要自己的编码规则等等)。
缺陷:根据具体编码规则实现而不同;还要考虑倘若主键在业务上允许改变的,会带来外键同步的麻烦。
我这边写两个编码规则方案:(可能不唯一,只是个人方案,也请大家提出自己的编码规则)
1) 12位年月日时分秒+5位随机码+3位服务器编码 (这样就完全单机完成生成全局唯一编码)-–共20位
缺陷:因为附带随机码,所以编码缺少一定的顺序感。(生成高唯一性随机码的方案稍后给给出程序)
2) 12位年月日时分秒+5位流水码+3位服务器编码 (这样流水码就需要结合数据库和缓存)-–共20位 (将影响顺序权重大的“流水码”放前面,影响顺序权重小的服务器编码放后)
缺陷:因为使用到流水码,流水码的生成必然会遇到和MaxId、序列表、Sequence方案中类似的问题
(为什么没有毫秒?毫秒也不具备业务可读性,我改用5位随机码、流水码代替,推测1秒内应该不会下99999[五位]条语法)
结论:适合大型应用,从业务上来说,有一个规则的编码能体现产品的专业成度。
GUID生成Int64值后是否还具有唯一性测试
测试环境
主要测试思路:
1. 根据内核数使用多线程并发生成Guid后再转为Int64位值,放入集合A、B、…N,多少个线程就有多少个集合。
2. 再使用Dictionary字典高效查key的特性,将步骤1中生成的多个集合全部加到Dictionary中,看是否有重复值。
示例注解:测了 Dictionary<long,bool> 最大容量就在5999470左右,所以每次并发生成的唯一值总数控制在此范围内,让测试达到最有效话。
主要代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
测试数据截图:
数据一(循环1000次,测试数:1000*5999470)
数据二(循环5000次,测试数:5000*5999470)--跑了一个晚上……
**感谢@Justany_WhiteSnow****的专业回答:(**大家分析下,我数学比较差,稍后再说自己的理解)
GUID桶数量:(2 ^ 4) ^ 32 = 2 ^ 128
Int64桶数量: 2 ^ 64
倘若每个桶的机会是均等的,则每个桶的GUID数量为:
(2 ^ 128) / (2 ^ 64) = 2 ^ 64 = 18446744073709551616
也就是说,其实重复的机会是有的,只是概率问题。
楼主测试数是29997350000,发生重复的概率是:
1 - ((1 - (1 / (2 ^ 64))) ^ 29997350000) ≈ 1 - ((1 - 1 / (2 ^ 64)) ^ (2 ^ 32)) < 1 - 1 + 1 / (2 ^ 32) = 1 / (2 ^ 32) ≈ 2.3283064e-10
(唯一性、业务适合性是可以权衡的,这个唯一性肯定比不过GUID的,一般程序上都会安排错误处理机制,比如异常后执行一次重插的方案……)
(唯一性、业务适合性是可以权衡的,这个唯一性肯定比不过GUID的,一般程序上都会安排错误处理机制,比如异常后执行一次重插的方案……)
结论:GUID转为Int64值后,也具有高唯一性,可以使用与项目中。
Random生成高唯一性随机码
我使用了五种Random生成方案,要Random生成唯一主要因素就是种子参数要唯一。
不过该测试是在单线程下的,多线程应使用不同的Random实例,所以对结果影响不会太大。
1. 使用Environment.TickCount做为Random参数(即Random的默认参数),重复性最大。
2. 使用DateTime.Now.Ticks做为Random参数,存在重复。
3. 使用unchecked((int)DateTime.Now.Ticks)做为Random参数,存在重复。
4. 使用Guid.NewGuid().GetHashCode()做为random参数,测试不存在重复(或存在性极小)。
5. 使用RNGCryptoServiceProvider做为random参数,测试不存在重复(或存在性极小)。
即:
static int GetRandomSeed()
{
byte[] bytes = new byte[4];
System.Security.Cryptography.RNGCryptoServiceProvider rng
= new System.Security.Cryptography.RNGCryptoServiceProvider();
rng.GetBytes(bytes);
return BitConverter.ToInt32(bytes, 0);
}
测试结果:
*结论:随机码使用RNGCryptoServiceProvider***或Guid.NewGuid().GetHashCode()**生成的唯一性较高。
一些精彩评论(部分更新到原博文对应的地方)
一、
数据库文件体积只是一个参考值,可水平扩展系统性能(如nosql,缓存系统)并不和文件体积有高指数的线性相关。
如taobao/qq的系统比拼byte系统慢,关键在于索引的命中率,缓存,系统的水平扩展。
如果数据库很少,你搞这么多byte能提高性能?
如果数据库很大,你搞这么多byte不兼容索引不兼容缓存,不是害自已吗?
如果数据库要求伸缩性,你搞这么多byte,需要不断改程序,不是自找苦吗?
如果数据库要求移植性,你搞这么多byte,移植起来不如重新设计,这是不是很多公司不断加班的原因?
不依赖于数据存储系统是分层设计思想的精华,实现战略性能最大化,而不是追求战术单机性能最大化。
不要迷信数据库性能,不要迷信三范式,不要使用外键,不要使用byte,不要使用自增id,不要使用存储过程,不要使用内部函数,不要使用非标准sql,存储系统只做存储系统的事。当出现系统性能时,如此设计的数据库可以更好的实现迁移数据库(如mysql->oracle),实现nosql改造((mongodb/hadoop),实现key-value缓存(redis,memcache)。
二、
很多程序员有对性能认识有误区,如使用存储过程代替正常程序,其实使用存储过程只是追求单服务器的高性能,当需要服务器水平扩展时,存储过程中的业务逻辑就是你的噩运。(web服务器可以简单伸缩,但是数据库伸缩比较复杂)
三、
除数字日期,能用字符串存储的字段尽量使用字符串存储,不要为节省那不值钱的1个g的硬盘而使用类似字节之类的字段,进而大幅牺牲系统可伸缩性和可扩展性。
不要为了追求所谓的性能,引入byte,使用byte注定是短命和难于移植,想想为什么html,email一直流行,因为它们使用的是字符串表示法,只要有人类永远都能解析,如email把二进制转成base64存储。除了实时系统,视频外,建议使用字符串来存储数据,系统性能的关键在于分布式,在于水平扩展。
本次博文到此结束,希望大家对本次主题“如何在高并发分布式系统中生成全局唯一Id”多提出自己宝贵的意见。另外看着感觉舒服,还请多帮推荐…推荐……




