分布式ID
分布式 ID 是在分布式环境中生成全局唯一标识符的技术,确保在不同节点生成的 ID 不会冲突。常用于数据库主键、订单号等场景。
分布式 ID 是分布式系统中不可或缺的一环,其设计直接影响系统的性能和稳定性。在实际应用中,可以根据业务需求选择合适的生成方案,如高并发场景优先选择雪花算法,有序性需求可选 Redis 或号段模式。
分布式 ID 需要满足下面几个特性:
-
全局唯一:每个ID在整个分布式系统中必须是全局唯一的,不能出现重复。
-
高性能&高可用:在高并发的情况下,分布式ID生成系统需要具备高可用性和低延迟,能够快速生成ID。
-
有序性:ID 的有序性可以提升数据库的写入效率,以便排序或提高数据库性能。
-
安全性:需要保证 ID 无法被轻易猜测,避免数据库信息被泄露。
数据库自增主键
数据库的自增主键(AUTO_INCREMENT
)是数据库层面提供的一种功能,能够自动为新插入的记录生成一个递增的整数主键。利用这种特性,应用程序可以通过插入一条记录获取唯一的 ID。
实现方法
1. 创建数据库表
CREATE TABLE `sequence_id` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`stub` char(10) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `stub` (`stub`)
);
id
字段:作为主键,保证id
字段全局唯一,并通过AUTO_INCREMENT
,自增生成唯一的分布式 ID。stub
字段:设为唯一索引(UNIQUE KEY
),无实际业务意义,仅用于支持REPLACE INTO
操作。强制插入数据时不能有重复值。
2. 执行事务生成并获取唯一ID
BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT LAST_INSERT_ID();
COMMIT;
- 开启事务:利用事务的隔离性,防止其他线程获取到当前的分布式ID,破坏分布式ID的唯一性。
REPLACE INTO
语义:如果stub
值已经存在,则删除该行并插入新行;如果stub
值不存在,则直接插入新行。通过REPLACE INTO
插入元素,生成自增主键ID。SELECT LAST_INSERT_ID()
作用:返回当前会话最后一次生成的自增主键值。
通过执行事务,可以获取到数据库生成的唯一ID:
此时 sequence_id
表的数据如下:
优缺点
使用数据库自增主键生成分布式ID的方案实现简单,适合中小型系统,以下是优缺点:
优点:
- 唯一性:在同一个数据库实例内是唯一的,天然保证了 ID 不会重复;在数据库集群模式下,需要额外设计步长+偏移量的方案,保证唯一性。
- 有序性:在数据库存储时,自增 ID 的有序性可以减少 B+ 树索引的分裂,提高写入性能;但在数据库集群模式下,不能保证分布式ID严格有序。
缺点:
- 性能问题:频繁的
INSERT
操作会引发锁的竞争,降低数据库性能;每次获取分布式 ID 都要访问数据库,查询受到磁盘I/O的限制。 - 可用性问题:存在单点故障问题,如果负责生成 ID 的数据库实例宕机,整个系统将无法生成新 ID。
- 数据膨胀问题:频繁的写操作会导致数据库的二进制日志快速膨胀,影响复制和备份效率。
- 信息泄露问题:自增 ID 直接暴露数据插入的顺序,可能导致业务敏感信息(如用户量、交易量)被推测。
数据库号段模式
号段模式是一种高效生成分布式唯一ID的方案,适用于分布式系统中需要高性能、全局唯一ID的场景。号段模式通常依赖于数据库,通过将分配到的号段缓存到内存中,直接从号段中取值生成ID,避免频繁访问数据库。
号段:将一批连续的数字ID作为一个号段,例如
[1001, 2000]
。客户端从号段中依次分配ID,直到用完,再申请新的号段。
实现方法
1. 创建数据库表
CREATE TABLE sequence_id_generator (
biz_type VARCHAR(50) NOT NULL, -- 业务类型,如订单ID、用户ID等
max_id BIGINT NOT NULL, -- 当前号段的最大值
step INT NOT NULL, -- 步长(每次分配的号段大小)
version INT NOT NULL, -- 乐观锁版本号,防止并发问题
PRIMARY KEY (biz_type)
);
表中各个字段的含义如下:
字段 | 含义 |
---|---|
biz_type | 业务类型,用于区分不同业务的ID生成规则 |
max_id | 当前号段的最大值,表示分配的ID范围上限 |
step | 每次申请号段时的步长,为实际可用的ID数量 |
version | 版本号,申请新的号段时根据版本号进行并发控制 |
2. 初始化数据
在使用号段模式分配ID之前,需要先在数据库中初始化数据,方便后续客户端的取用号段。
这里将初始的号段最大值 max_id
设置为 0,步长 step
设置为 100,版本号 version
设置为 0。
INSERT INTO `sequence_id_generator`(`biz_type`, `max_id`, `step`, `version`)
VALUES (101, 0, 100, 0);
3. 获取分布式ID
SELECT `max_id`, `step`,`version`
FROM `sequence_id_generator`
WHERE `biz_type` = 101;
返回结果如下:
分配的号段 [max_id + 1, max_id + step]
为分布式ID的可用范围。申请到号段的线程会将该号段缓存起来,直接在内存中取用号段中的数据作为分布式ID,从而避免访问数据库。(如果使用的是数据库自增主键的方案,就需要查询 step
次数据库,影响性能)
4. 生成分布式ID
如果某一业务类型的号段用尽时,需要从数据库中申请新的号段。在应用层执行 UPDATE
语句时需要使用乐观锁,防止并发情况下多个线程申请到相同的号段。
UPDATE sequence_id_generator
SET max_id = max_id + step, version = version + 1
WHERE biz_type = 101 AND version = #{version};
数据库更新结果:
优缺点
优点:
- 唯一性:依靠
MySQL
数据库的行级锁和应用层的乐观锁,保证相同业务类型biz_type
下号段分配的唯一性。 - 有序性:每个号段分配的 ID 都是单调递增的,这对于某些需要有序 ID 的场景(如订单号、时间戳排序等)非常适用。
- 高性能:相较于数据库自增主键的方案,ID 分配主要在内存中完成,只有在号段用尽时才访问数据库申请新的号段,大大提高了生成效率。
缺点:
- 乐观锁重试问题:在高并发情况下,客户端申请号段时,乐观锁可能频繁失败,需要多次重试才能成功获取号段,增加了分配延迟。
- 可用性问题:存在单点故障问题,如果负责生成号段的数据库实例宕机,整个系统将无法生成新 ID。
- 信息泄露问题:自增 ID 直接暴露数据插入的顺序,可能导致业务敏感信息(如用户量、交易量)被推测。
Redis的incr命令
Redis
的 INCR
命令是一种简单高效的分布式 ID 生成方案。通过 Redis
的单线程模型来保证分布式 ID 的唯一性,而 INCR
命令则保证了分布式 ID 的有序性。
单线程模型:
Redis
的单线程模型保证多个客户端执行INCR
命令时,同一时刻只会有一个线程执行INCR
命令,因此避免了并发安全问题。
实现方法
1. 初始化数据
首先设置分布式 ID 的初始结构,后续直接取用 seq_id
对应的 value
值作为分布式 ID。
SET seq_id 1
2. 生成分布式ID
对于单机模式下的 Redis
,由于只存在一个 Redis
节点,直接使用 INCR
命令即可保证有序性。
对于集群模式下的 Redis
,由于存在多个 Redis
节点,因此需要给每个节点设置不同的初始偏移量,并使用 INCRBY
命令指定集群中所有节点数量的步长来保证全局唯一性。(也可以使用哈希插槽将生成的分布式 ID 的键固定到同一个节点,将问题转化单机模式,Redis
集群模式讲解详见此处)
比如集群中共存在3个节点A、B、C,可以指定A的初始值为0,B的初始值为1,C的初始值为2,步长设置为3。这样每个节点生成的分布式ID为 初始偏移量 + 访问次数 * 步长
,防止不同节点生成重复的分布式ID,保证 ID 的全局唯一性。
# 自增命令
INCR seq_id
# 指定步长自增
INCRBY seq_id 3
3. 获取分布式ID
GET seq_id
优缺点
优点:
- 唯一性:单机模式下依赖于
Redis
的单线程模型,可以保证 ID的唯一性;在集群模式,可以通过哈希插槽固定键到某个节点,或者通过步长+偏移量实现分布式 ID 的生成。 - 有序性:在
Redis
单机模式或者在集群模式使用哈希插槽固定键到某个节点时,可以保证 ID 是严格有序的。 - 高性能:
Redis
是内存数据库,INCR
操作非常快,延迟一般在微秒级,单节点 QPS 可达十万级别。 - 高可用:通过部署
Redis
分片集群,可以解决单点故障问题,保证某些节点故障后分布式 ID 生成服务依旧可用。
缺点:
- 非严格有序:在分布式场景下,为了保证ID 的唯一性,通常通过步长+偏移量生成 ID,但这导致生成的 ID 不是严格有序的。
- 信息泄露问题:自增 ID 直接暴露数据插入的顺序,可能导致业务敏感信息(如用户量、交易量)被推测。
UUID
UUID (Universally Unique Identifier) 是一种通用唯一标识符,其目的是为分布式系统中的所有元素生成唯一的标识符,在大多数情况下具有全球唯一性。UUID 的长度为 128
位(16
字节 ),通常用 32
个十六进制数字表示。
按照十六进制的数字个数划分,UUID的长度构成为 8-4-4-4-12
,一共 32 个数字。以下为 UUID 的示例:
550e8400-e29b-41d4-a716-446655440000
UUID 的内部结构分为 5 个主要部分,每部分基于不同的位数:
- 时间低位(前 8 个字符):共 32 位,表示时间戳的地位部分。
- 时间中位(占 4 个字符):共 16 位,表示时间戳的中间部分。
- 时间高位及版本号(占 4 个字符):共 16 位,由前 4 位版本号和后 12 位的时间戳高位部分组成。
- 变体及序列号(占 4 个字符):共 16 位,前 3 位表示变体,后 13 位表示序列号。
- 节点(占 12 个字符):共 48 位,表示节点信息,通常是设备的 MAC 地址或随机数。
UUID 的生成方式由其 版本号(Version) 和 变体(Variant) 决定,本文主要关注不同版本下 UUID。
UUID版本
UUID 的五个版本各有用途,但最常用的是版本一和版本四。版本三和版本五更适合生成基于命名空间的确定性标识符,而版本二因适用范围窄,在现代系统中已很少使用。
-
版本一:基于时间戳和节点地址的 UUID
通过计算当前时间戳、时钟序列号和机器的 MAC 地址生成。
- 由于使用了 MAC 地址,可以保证在全球范围内唯一性。
- 包含生成时间信息,时间戳可以用作生成时间的标识。
- 依赖硬件 MAC 地址,可能会暴露设备信息,存在隐私风险。
-
版本二:DCE 安全的 UUID
类似于版本一,但是依赖于早期的 DCE (分布式计算环境)环境,增加了一些安全特性,在现代系统中很少使用。
-
版本三:基于命名空间的UUID(MD5)
通过对名字(如字符串)和命名空间(如 URL)的组合计算 MD5 哈希值,并将 MD5 哈希值的部分位替换为版本号和变体标志生成。
- 相同命名空间中相同名字生成的 UUID 保持一致。
- 哈希算法确定性强,适用于需要固定标识符的场景。
- 使用 MD5 算法,安全性较弱。
-
版本四:基于随机数的UUID
UUID 的 128 位数据大部分由随机数生成。
- 无需依赖硬件或时间信息,生成速度快。
- 碰撞概率极低,即使生成 10¹² 个 UUID,也几乎不会发生冲突。
- 不包含设备或时间信息,隐私性较好。
-
版本五:基于命名空间的UUID(SHA1)
与版本三类似,但使用更安全的 SHA-1 哈希算法代替 MD5。
-
同样的命名空间和名字生成的 UUID 一致。
-
SHA-1 比 MD5 更安全,但仍然存在一定的碰撞风险(对于 UUID 应用影响有限)。
-
UUID版本 | 重复条件 | 重复概率 |
---|---|---|
版本1 | 如果两个设备具有相同的 MAC 地址、相同的时钟序列,并在同一时刻生成,则可能重复。 | 几乎不可能 |
版本2 | 如果两个设备使用相同的本地标识符(UID/GID)和相同时间戳,可能重复(需要运行在 DCE 环境)。 | 几乎不可能 |
版本3 | 相同命名空间和相同名字才会重复;不同命名空间或名字不会重复。 | 确定性重复 |
版本5 | 相同命名空间和相同名字才会重复;不同命名空间或名字不会重复。 | 确定性重复 |
版本4 | 由于随机数生成碰撞(在生成量极大时,如接近 2 64 2^{64} 264 时可能发生)。 | 极低概率 |
优缺点
优点:
- 唯一性:使用不同的算法生成,几乎可以保证在全球范围内唯一,避免了多台机器之间主键冲突的问题。
- 无需网络请求:UUID 生成过程完全依赖本地算法,无需访问数据库或其他第三方服务,不依赖于网络请求,降低了延迟。
- 高可用:UID 生成过程完全依赖本地算法,无需依赖于第三方服务。
- 不可预测:UUID 没有明确的序列信息,能够在一定程度上保护数据隐私,不易被外界推测业务数据规模或增长趋势。
缺点:
- 存储开销大:UUID的长度为128位(16字节),存储时会占用较多空间,尤其是在数据库中作为主键时,会增加索引的存储成本。
- 不具体有序性:UUID生成通常是随机的(如版本四),插入数据库时会导致索引频繁分裂或重排,降低写入性能。
雪花算法
雪花算法(Snowflake 算法) 是 Twitter 开源的分布式唯一 ID 生成算法,主要用于分布式系统中生成全球唯一的、有序的 64 位长整型 ID。它能够在高并发环境下快速生成高效、唯一的 ID,并确保生成的 ID 大致有序。
- 符号位(1bit):预留的符号位,始终为0,占用1位。
- 时间戳(41bit):记录生成 ID 时的时间戳,精确到毫秒级别,可以容纳的毫秒数是2的41次幂,可以支持大约 69 年的时间跨度。
- 数据中心标识(5bit):可以用来区分不同的数据中心。
- 机器标识(5bit):可以用来区分不同的机器。
- 序列号(12bit):表示同一毫秒内生成的 ID 的序号,单台机器每毫秒可以生成 4096 个唯一ID。
默认的雪花算法是 64 bit,具体的长度可以自行配置。如果希望运行更久,增加时间戳的位数;如果需要支持更多节点部署,增加标识位的长度;如果并发很高,增加序列号的位数。
防止时钟回拨
雪花算法生成的唯一ID依赖于当前系统时间戳。如果发生时钟回拨(系统时间向后调整),导致新的时间戳与之前生成 ID 时的时间戳重复,可能会造成分布式 ID 不唯一的问题。
- 等待策略:使用一个变量
lastTimestamp
记录上次生成 ID 的时间戳。当检测到时钟回拨时,服务可以暂时拒绝生成新的ID,等待系统时钟恢复到正常状态(至少大于或等于上次生成ID时的时间戳)。这种策略可以确保ID的严格递增性,但可能会在时钟调整期间暂停服务,对系统性能造成一定的影响。 - 序列号持久化:雪花算法原本设计中序列号是在内存中自增的,为了避免时钟回拨导致的序列号重置问题,可以将序列号持久化存储到磁盘上。这样即使服务重启或时钟回拨,服务也能继续从上次持久化的序列号开始自增,从而避免ID重复。这种策略可以在一定程度上容忍时钟回拨,但可能会影响ID的绝对时间戳准确性。
保证标识位唯一
雪花算法生成的唯一ID依赖于标识位。在集群模式下产生重复 ID,需要以下两个前提条件:
- 服务通过集群的方式部署,其中部分机器标识位一致。(比如在 K8s 中,多个 Pod 使用相同的配置镜像启动,而镜像中未动态生成唯一标识)
- 在高并发场景下,不同实例或业务在同一毫秒内生成了相同的序列号,由于机器标识位一致,导致生成的 ID 出现冲突。
由此可知,只要能够保证标识位不重复,那么雪花算法生成的分布式ID也不会重复。
预分配
在应用启动之前,统计当前服务的节点数量,人工去申请标识位。
这种方案适合服务节点数量基本不变或者服务节点数量少的情况,但是如果服务节点较多,或者经常需要扩缩容时,预分配是无法满足ID不重复的要求的。
动态分配
将标识位存放在 Redis、Zookeeper、MySQL等中间件中,在服务启动时动态的请求标识位。
如果要保证分布式ID在服务内唯一,将标识位直接存放在服务所使用的 Redis 节点即可;如果要保证分布式ID的全局唯一,则多个服务需要使用同一个 Redis 节点保证唯一性。
数据中心标识和机器标识的组合为 32 * 32
,即可以存储 1024
个服务节点,如果节点数超过了这个数量,则可以扩展标识位,或者选用其他开源的分布式ID框架。
具体方案
下面将用 Redis
展示服务内唯一的标识号分配实现方案
1. 设计键值结构
使用 Redis
中的 Hash
结构,KEY
为,键值对为 datacenterId
、workerId
及其对应生成的标识号。
2.标识号自增逻辑
通过 Lua
脚本从 Redis
中获取标识位,具体的执行逻辑为:
- 检查 Hash 是否存在:如果 Redis 中不存在
id_key
这个 Hash,则初始化id_key
,并将dataCenterId
和workerId
设置为 0。 - 判断 Hash 是否已存在:
- 如果
id_key
已存在,检查dataCenterId
和workerId
是否都达到最大值(31)。 - 若条件满足,则将
dataCenterId
和workerId
都重置为 0,并返回。
- 如果
- 分配策略:
dataCenterId
和workerId
的排列组合一共有 1024 种可能性,在分配时优先分配workerId
。 - 分配逻辑:
- 检查
workerId
是否小于 31。若满足条件,则对workerId
自增并返回。 - 如果
workerId
等于 31,则将dataCenterId
自增,同时将workerId
重置为 0,并返回。
- 检查
3.标识号循环递增
在当前自增逻辑中,采用了 dataCenterId
和 workerId
循环递增的方案。将 dataCenterId
视为高位,workerId
等于最大值 31 时向 dataCenterId
进一位;dataCenterId
和 workerId
都达到最大值 31 时数据重置为 0-0
。数据展示如下图:
因此该方案最多只能保证 1024 个服务节点分配的标识位不重复。如果当前系统的服务节点超过了 1024 个,会出现标识位分配重复的情况,进而增加分布式ID重复的风险。
优缺点
优点:
- 高性能高可用:生成时不依赖于数据库,完全在内存中生成,每秒钟能生成数百万的自增ID。
- ID自增:在单个进程中,生成的ID是自增的,可以用作数据库主键做范围查询。但是需要注意的是,在集群中是没办法保证顺序一定是递增的。
缺点:
- 重复ID问题:雪花算法依赖于时间戳,在获取时间的时候,如果出现时间回拨问题,服务器上的时间突然倒退到之前的时间,会造成 ID 重复的问题。
- 依赖机器ID不灵活:每个节点的机器ID和数据中心ID都是硬编码在代码中的,在分布式环境如果节点出现故障或者需要扩容,更改机器ID和数据中心ID需要重新编译代码。