唯一ID有很多种方法,但都各有利弊。
MySQL的AUTO_INCREMENT
主键
- 优点:
- 对于小应用/单机应用来说速度足够快,直接插入数据,数据库CAS一下就可以获取新ID;
- 依赖少,不需要额外的IT设施;
- ID完全严格递增,方便数据库建立索引,维持插入数据的高性能;(数字ID一般都是BTree,索引不需重建的情况下插入性能很高)
- 方便统计,看看ID值基本就知道业务量;
- 缺点:
- 依赖特定数据库,如果要更换数据库便需要仔细重写建表语句;(但业务代码可能并不需要重写)
- 如果ID泄露到外部,会被别有用心的人猜出我方的业务量;
- 也有可能被枚举拖取数据;
- 集群式应用如果每个微服务实例都有自己的数据库的话,就需要提前设定不同数据库实例、不同表的自增ID的初始值和步长,步长太大浪费ID的逻辑空间、步长太小在扩容的时候可能就会ID重叠,这对运维人员来说是个很大的挑战;
- 集群式应用如果每个微服务实例都共用同一个数据库的话,大规模应用、请求量很大的情况下,性能可能就不能满足要求,存在单点失效、整个集群崩溃的可能性;
- 如果数据库是集群式的,每个表自增的主键需要主动记录之前自增的最大ID是什么,然后通过冷热备份复制到不同数据库实例,虽然MySQL集群都支持这些功能,但是也是要留一个心眼才可以,否则出了问题都没意识到,搜索都不知道查什么关键字。
AUTO_INCREMENT
配合REPLACE INTO
使用
这个方案实际上对于小规模的分布式应用还是能满足需求的。假设每个微服务实例都有自己的数据库,为了有效地产生唯一ID,我们可以在一个公共的MySQL实例上建立一个这样的表:
CREATE TABLE `id_generator` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`type` VARCHAR(30) NOT NULL,
UNIQUE KEY `type`
);
然后使用REPLACE INTO
代替INSERT INTO,就可以方便地按类型获取最新的唯一递增ID了:
REPLACE INTO `id_generator`(`type`) VALUES ('order'); -- 产生新的订单ID
SELECT LAST_INSERT_ID(); -- 返回刚生成的订单ID
它的优缺点与前面的一致,因为本质上是一样的,只是形式上从被动生成ID变成主动生成ID。
不过要注意这个方法产生的ID是不同类型之间共用递增序列的,所以如果之前生成的订单ID为1
,下次生成的快递ID便会变成2
,如此类推。这种特性可能会对理解上造成一点困扰。
Hibernate/JPA的ID生成器有点儿类似这个方案,不过实际上是按不同实体/表分开了ID生成器,所以不同ID表之间不是公用递增序列的。
随机ID
- 优点:
- 依赖少,不需要额外的IT设施,一个
Random
类甚至SecureRandom
类就可以满足任务,而且ID类型不局限在数字,还可以自己拼字符串,可长可短; - 即便ID泄露到外部,也不会被别有用心的人猜出我方的业务量;
- 也不可能被枚举拖取数据;
- 不需要依赖特定的数据库功能,迁移到其它数据库业务代码不用做适配;
- 依赖少,不需要额外的IT设施,一个
- 缺点:
- 如果使用锁机制来保证唯一性,那么可能要用到表锁(因为被插入的数据还不在数据中),这必然会影响数据库乃至业务的吞吐性能;
- 如果不使用锁机制来保证唯一性,那么便要在插入数据的时候捕捉插入失败的异常,通过重试的方式不断插入数据来保证ID唯一;
- ID没了单调递增性,数据库插入数据时需要重排索引,导致插入性能低下;
- ID没了单调递增性,也没了业务量、业务先后的信息,难于直观统计;
- 集群式应用如果每个微服务实例都有自己的数据库的话,就无法使用这个方案了,因为ID的唯一性无法保证了,即便通过其它消息机制来确定唯一性也得不偿失——性能太差;
- 集群式应用如果每个微服务实例都共用同一个数据库的话,大规模应用、请求量很大的情况下,性能可能就不能满足要求(锁表与插入重试都不是高性能的方案)。
UUID
- 优点:
- 依赖少,不需要额外的IT设施,一个UUID类就可以满足任务,几乎所有编程语言都有这个工具;
- 即便ID泄露到外部,也不会被别有用心的人猜出我方的业务量;
- 也不可能被枚举拖取数据;
- 不需要依赖特定的数据库功能,迁移到其它数据库业务代码不用做适配;
- 天生具有唯一性,不用担心数据插入失败;
- FasterXML的UUID实现能够恢复出时间信息,大致能过够知道业务的先后次序;
- 高性能、高吞吐量,以目前的JVM和CPU的性能一秒钟产生上百万个UUID;
- 缺点:
- UUID有很多实现,不同的实现使用不同的方案来保证唯一性,而FasterXML的实现是基于时间戳和Mac地址的,所以除了要保证参与系统的所有机器的时间同步之外、还要求Mac地址不可以重复,否则就有可能会出现重复的UUID,这要求运维人员必须是十分靠谱的;(但靠谱不是基本要求嚒)
- ID没了单调递增性,数据库插入数据时需要重排索引,导致插入性能低下;
- ID没了单调递增性,也没了业务量、业务先后的信息,难于直观统计;
- 如果用字符串存放UUID,需要三十多个字符,而使用数字,也要占用128bit,数据库基本没有直接对应的数据类型,所以最终只能使用字符串,这显得十分浪费数据库的空间。
Redis的INCR
和INCRBY
命令
Redis天生就是单线程、高性能、高吞吐量的(单线程就不需要加锁,所以吞吐量肯定高),所以可以考虑使用它的INCR
和INCRBY
命令来产生唯一ID。
> set order_id 10
OK
> incr order_id
(integer) 11
> incrby order_id 5
(integer) 16
- 优点:
- 高性能、高吞吐量,这方面肯定比MySQL好;
- ID严格递增,方便数据库建立索引,维持插入数据的高性能;
- 方便统计,看看ID值基本就知道业务量;
- 缺点:
- 依赖多了,需要额外的Reids服务器;
- 如果ID泄露到外部,会被别有用心的人猜出我方的业务量;
- 也有可能被枚举拖取数据;
- 对于集群式Reids,除了要考虑ID的初始值和步长,还要考虑Redis的分区/预分区之类的运维;
- 还要考虑Reids集群的可用性——容错恢复和数据持久化机制,例如哨兵模式。
(Redis6.0发布了,有多线程了)
雪花(SnowFlake)算法
这是Twitter提出的集群全局递增唯一ID算法。
看了几个方案之后,就明白全局唯一ID的几个基本需求:
- 全局唯一:这是废话;
- 高性能、高吞吐量:这个是基本需求;
- 数字类型:方便建立BTree索引,而且不能是太长的数字,最好控制在64bit内,这样所有数据库和编程语言都可以方便地读写这个ID;
- 宏观上单调递增:某个微观瞬间的两个ID顺序不递增其实不十分会影响数据库的插入性能,最终宏观上递增就可以保证有统计上的方便了;
- 最好像UUID那样内涵时间戳和机器信息:不一定需要Mac地址,因为这个无法保证靠谱,包含一些自己编码的机房/机器号码也是可以的,这样方便跟踪调试;
- 没有外部依赖:最好就是像UUID那样一个算法、一段代码就可以保证快速生成大量ID,不需要依赖数据库或者缓存系统,要易于维护;
- 安全性:引入一定的随机量,使得ID即便被泄露出去之后,也无法通过枚举来拖取数据。
安全性和单调性貌似是互相矛盾的,但是计算机是十分快的,1ms对于它来说已经足够说完一个故事了,所以雪花ID在1ms内是严格递增的,如果递增部分溢出了,则自旋繁忙等待至下一毫秒,这样并不会十分影响性能。而大于毫秒的粒度,由于时间戳未必是连续的,所以便有一定的随机性,外部人员便无法通过枚举拖取信息了,即便枚举了也要按毫秒枚举,这个代价是十分庞大的。
于是雪花算法就应运而生了,感谢Twitter。
首先,它是64bit的,下面二进制64位数字高位在左地位在右,逐位看看它是怎样组成的:
63 62 21 16 11 0 0 _ 0 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 _ 0 0000 _ 0 0000 _ 0000 0000 0000
- 最高位:一直为0,不使用;
- 第22至62位,合共41位:精度为毫秒的时间戳,可以存跨度约为69年的时间戳;
- 第17至21位,合共5位:机房号,可以存2^5=32个不同的机房;
- 第12至16位,合共5位:主机号,可以存2^5=32个不同的主机;
- 第0至11位,合共12位:顺序号,即一毫秒内、同一个机房的同一个主机可以顺序产生2^12=4096个顺序的ID。
也就是说,任何理论上时间跨度小于69年、并发吞吐量小于四百万QPS的IT系统都可以使用雪花算法来生成唯一ID。
如果机房号与主机号都确定的情况下,那么就是同一个操作系统实例。一个操作系统实例很少会出现时间倒着走的情况,所以同一台机器内实际上是通过时间戳区分了不同毫秒之间的请求,而毫秒内的请求就直接用序号区分。所以即便集群系统内的各个操作系统实例时间不同步也没关系,只需要获取到的时间戳保证只往前走便不会有重复的ID。
时间戳理论上是相对1970-01-01
往后的2^41/1000/60/60/24/365.25=69年,也就是到了2039年就不能用了,对于目前很多IT系统来说都够用。但很多雪花算法的实现都会使用自定义时间戳起点,写死在代码中,这样实际上就会变成从目前时间往后算69年,那便意味着几乎100%的IT系统都够用了。
机房号、主机号、顺序号的位长安排不是绝对的,甚至乎如果你觉得时间戳占的位置太大,还可以考虑再挪一两位来用也是可以的,只要保证时间戳的最小单位是毫秒就可以了,例如我可以这样魔改:
63 62 22 18 14 10 9 0 0 _ 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 _ 0000 _ 0000 _ 0 0000 _ 00 0000 0000
- 最高位:一直为0,不使用;
- 第23至62位,合共40位:精度为毫秒的时间戳,可以存跨度约为34年的时间戳;
- 第19至22位,合共4位:业务类型,可以存2^4=16种不同业务;
- 第15至18位,合共4位:机房号,可以存2^4=16个不同的机房;
- 第10至14位,合共5位:主机号,可以存2^5=32个不同的主机;
- 第0至9位,合共10位:顺序号,即一毫秒内、同一个业务、同一个机房的同一个主机可以顺序产生2^10=1024个顺序的ID。
这样设计对于生存跨度不超过34年、并发量一秒不超过一百万QPS的系统来说也是绰绰有余的,而且还多了一个业务类型段留作其它用途。不过一毫秒内1024个ID其实很容易就会成为瓶颈,导致算法老是进入忙等待/自旋的状态。
实际上在我的JDK13+Xeon E3-123v3机器上,雪花算法的QPS两个配置都能够达到它们理论上的最大值,所以完全不需要担心性能。但不知道是JVM还是什么别的原因,这个性能表现并不稳定:
defaultSetting: 973709 QPS defaultSetting: 4098360 QPS defaultSetting: 3144654 QPS defaultSetting: 2754820 QPS withBussSetting: 399361 QPS withBussSetting: 282246 QPS withBussSetting: 279485 QPS withBussSetting: 211059 QPS
缺点:
- 需要调用方预先配置好机房ID、机器ID、业务类型之类的配置。但在类似SpringBoot/Cloud这样的环境下,这种配置的问题都十分好解决,所以在有配置中心功能的云环境种使用雪花算法难度并不大;
- 整个64bit的配置在启用前必须深思熟虑,例如前面的配置就有1024太小导致性能不如默认配置的问题,确定位长配置后就不能改了,永远也不能改了。