docs/distributed-system/distributed-id.md
日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示,比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。
我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人、地址 ID 对应且仅对应一个地址。
简单来说,ID 就是数据的唯一标识。
分布式 ID 是分布式系统下的 ID。分布式 ID 不存在与现实生活中,属于计算机系统中的一个概念。
我简单举一个分库分表的例子。
我司的一个项目,使用的是单机 MySQL 。但是,没想到的是,项目上线一个月之后,随着使用人数越来越多,整个系统的数据量将越来越大。单机 MySQL 已经没办法支撑了,需要进行分库分表(推荐 Sharding-JDBC)。
在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?
这个时候就需要生成分布式 ID了。
分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。
一个最基本的分布式 ID 需要满足下面这些要求:
除了这些之外,一个比较好的分布式 ID 还应保证:
这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。
以 MySQL 举例,我们通过下面的方式即可。
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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
stub 字段无意义,只是为了占位,便于我们插入或者修改数据。并且,给 stub 字段创建了唯一索引,保证其唯一性。
2.通过 replace into 来插入数据。
BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT LAST_INSERT_ID();
COMMIT;
⚠️ REPLACE INTO 的生产隐患:
REPLACE INTO 本质是 DELETE + INSERT 的组合操作:
替代方案:生产环境推荐使用号段模式(下面会介绍),或改用 INSERT ... ON DUPLICATE KEY UPDATE 减少索引震荡。
这种方式的优缺点也比较明显:
数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。
如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。
数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的Tinyid 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化。
以 MySQL 举例,我们通过下面的方式即可。
1. 创建一个数据库表。
CREATE TABLE `sequence_id_generator` (
`id` int(10) NOT NULL,
`current_max_id` bigint(20) NOT NULL COMMENT '当前最大id',
`step` int(10) NOT NULL COMMENT '号段的长度',
`version` int(20) NOT NULL COMMENT '版本号',
`biz_type` int(20) NOT NULL COMMENT '业务类型',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
current_max_id 字段和step字段主要用于获取批量 ID,获取的批量 id 为:current_max_id ~ current_max_id+step。
version 字段主要用于解决并发问题(乐观锁),完整流程如下:
-- 1. 读取当前值
SELECT current_max_id, step, version FROM sequence_id_generator WHERE biz_type = 101;
-- 2. CAS 更新(version 作为乐观锁版本号)
UPDATE sequence_id_generator
SET current_max_id = current_max_id + step, version = version + 1
WHERE version = {当前读取的version} AND biz_type = 101;
-- 3. 检查 affected_rows,为 1 表示成功,为 0 表示被其他线程抢先,需重试
⚠️ 高并发重试提醒:在号段耗尽瞬间,多个线程可能同时争抢新号段,CAS 更新可能失败。代码层面需要实现有限次数的重试循环(如 3 次),确保请求稳定性。若重试仍失败,应降级为阻塞等待或返回降级 ID。
biz_type 主要用于表示业务类型。
2. 先插入一行数据。
INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`)
VALUES
(1, 0, 100, 0, 101);
3. 通过 SELECT 获取指定业务下的批量唯一 ID
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101
结果:
id current_max_id step version biz_type
1 0 100 0 101
4. 不够用的话,更新之后重新 SELECT 即可。
UPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0 AND `biz_type` = 101
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101
结果:
id current_max_id step version biz_type
1 100 100 1 101
相比于数据库主键自增的方式,数据库的号段模式对于数据库的访问次数更少,数据库压力更小。
另外,为了避免单点问题,你可以从使用主从模式来提高可用性。
数据库号段模式的优缺点:
一般情况下,NoSQL 方案使用 Redis 多一些。我们通过 Redis 的 incr 命令即可实现对 id 原子顺序递增。
127.0.0.1:6379> set sequence_id_biz_type 1
OK
127.0.0.1:6379> incr sequence_id_biz_type
(integer) 2
127.0.0.1:6379> get sequence_id_biz_type
"2"
为了提高可用性和并发,我们可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 集群解决方案(3.0+版本)。
除了 Redis Cluster 之外,你也可以使用开源的 Redis 集群方案Codis (大规模集群比如上百个节点的时候比较推荐)。
除了高可用和并发之外,我们知道 Redis 基于内存,我们需要持久化数据,避免重启机器或者机器故障后数据丢失。Redis 支持两种不同的持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)。 并且,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。
关于 Redis 持久化,我这里就不过多介绍。不了解这部分内容的小伙伴,可以看看 Redis 持久化机制详解这篇文章。
虽然 Redis INCR 性能优异,但存在以下失败路径需要特别注意:
持久化延迟导致 ID 回退
INCR 后,Redis 在 RDB/AOF 刷盘前崩溃。AOF 重写导致短暂阻塞
生产配置建议:
# Redis 7.0+ 推荐配置
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes # 混合持久化,RDB+AOF 组合
SETNX 实现幂等检查,或对 ID 唯一性要求极高的场景使用数据库号段模式。Redis 方案的优缺点:
除了 Redis 之外,MongoDB ObjectId 经常也会被拿来当做分布式 ID 的解决方案。
MongoDB ObjectId 一共需要 12 个字节存储:
MongoDB 方案的优缺点:
UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。
JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa
UUID.randomUUID()
RFC 4122 定义了 UUID v1-v5,2024 年发布的 RFC 9562 新增了 v6、v7、v8。RFC 9562 中关于 UUID 的示例是这样的:
我们这里重点关注一下这个 Version(版本),不同的版本对应的 UUID 的生成规则是不同的。
8 种不同的 Version(版本)值分别对应的含义(参考维基百科对于 UUID 的介绍):
下面是 Version 1 版本下生成的 UUID 的示例:
JDK 中通过 UUID 的 randomUUID() 方法生成的 UUID 的版本默认为 4。
UUID uuid = UUID.randomUUID();
int version = uuid.version();// 4
另外,Variant(变体)也有 4 种不同的值,这种值分别对应不同的含义。这里就不介绍了,貌似平时也不怎么需要关注。
需要用到的时候,去看看维基百科对于 UUID 的 Variant(变体) 相关的介绍即可。
从上面的介绍中可以看出,UUID 可以保证唯一性,因为其生成规则包括 MAC 地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,计算机基于这些规则生成的 UUID 是肯定不会重复的。
虽然,UUID 可以做到全局唯一性,但是,我们一般很少会使用它。
比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:
UUID v7(RFC 9562)是目前替代 Snowflake 的最佳无中心化方案:
RFC 9562 官方推荐:实现应尽可能使用 UUID v7 替代 UUID v1/v6。
| 特性 | Snowflake | UUID v7 |
|---|---|---|
| Worker ID 管理 | 需要中心化分配(ZK/etcd) | 无需分配,开箱即用 |
| 时钟回拨风险 | 需要额外处理 | 毫秒内允许乱序,天然规避 |
| B+ 树友好 | 趋势递增 | 天然有序 |
| 标准化 | 各家实现不一 | RFC 标准,跨语言兼容 |
| 结构 | 64 位(自定义) | 128 位(48 位时间戳 + 74 位随机/单调) |
适用场景:中小规模分布式系统、无需 Snowflake 级性能的场景。
UUID v8(实验性用途):如果需要嵌入额外信息(如业务标识、集群信息)或有特殊应用限制,可考虑 UUID v8。但需注意:v8 的唯一性由实现保证,不可假设与其他实现兼容。
⚠️ 注意:部分数据库(MySQL 8.0.37 以下、PostgreSQL 15 以下)需通过函数生成 UUID v7,原生支持尚在普及中。
最后,我们再简单分析一下 UUID 的优缺点 (面试的时候可能会被问到的哦!) :
Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:
⚠️ 高并发警示:如果某一毫秒内的并发请求超过 4096 个,算法会阻塞等待直到下一毫秒。这可能导致在高并发瞬间(如秒杀、大促)出现响应延迟毛刺(Latency Spike)。生产环境需评估峰值 QPS,必要时采用多实例分片或改造算法增加 sequence 位数。
在实际项目中,我们一般也会对 Snowflake 算法进行改造,最常见的就是在 Snowflake 算法生成的 ID 中加入业务类型信息。
问题根因:NTP 同步、人工调整时间、硬件时钟漂移可能导致系统时间倒退。
解决方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 拒绝服务 | 实现简单 | 时钟回拨期间完全不可用 | 对可用性要求不高的场景 |
| 等待追回 | 保证 ID 唯一性 | 可能长时间阻塞 | 时钟稳定的内网环境 |
| 备用 Worker ID | 高可用 | 实现复杂,需考虑 ZK 脑裂 | 生产环境推荐 |
推荐:生产环境使用美团 Leaf 或 IdGenerator,它们已内置时钟回拨处理。
在容器化部署(Kubernetes) 环境下,Snowflake 的 Worker ID 分配成为最大痛点:
问题场景:
主流解决方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| ZooKeeper 注册 | 服务启动时在 ZK 创建临时节点,节点序号作为 Worker ID | 自动回收,崩溃后释放 | 依赖 ZK,增加运维复杂度 |
| Redis 注册 | 使用 SETNX + 过期时间实现 Worker ID 申领 | 轻量,无额外组件 | 需处理 Redis 宕机场景 |
| 数据库分配 | 启动时从数据库分配并持久化到本地文件 | 简单可靠 | 依赖数据库 |
| 动态 Worker ID | 使用 Pod IP 或 UID 哈希生成 | 无需中心化组件 | 可能产生哈希冲突 |
推荐:生产环境使用美团 Leaf(基于 ZooKeeper)或滴滴 Tinyid(基于数据库),它们已内置 Worker ID 自动管理。
我们再来看看 Snowflake 算法的优缺点:
如果你想要使用 Snowflake 算法的话,一般不需要你自己再造轮子。有很多基于 Snowflake 算法的开源实现比如美团 的 Leaf、百度的 UidGenerator(后面会提到),并且这些开源实现对原有的 Snowflake 算法进行了优化,性能更优秀,还解决了 Snowflake 算法的时间回拨问题和依赖机器 ID 的问题。
并且,Seata 还提出了“改良版雪花算法”,针对原版雪花算法进行了一定的优化改良,解决了时间回拨问题,大幅提高的 QPS。具体介绍和改进原理,可以参考下面这两篇文章:
UidGenerator 是百度开源的一款基于 Snowflake(雪花算法)的唯一 ID 生成器。
不过,UidGenerator 对 Snowflake(雪花算法)进行了改进,生成的唯一 ID 组成如下:
可以看出,和原始 Snowflake(雪花算法)生成的唯一 ID 的组成不太一样。并且,上面这些参数我们都可以自定义。
UidGenerator 官方文档中的介绍如下:
自 18 年后,UidGenerator 就基本没有再维护了,我这里也不过多介绍。想要进一步了解的朋友,可以看看 UidGenerator 的官方介绍。
Leaf 是美团开源的一个分布式 ID 解决方案 。这个项目的名字 Leaf(树叶) 起源于德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world”(世界上没有两片相同的树叶) 。这名字起得真心挺不错的,有点文艺青年那味了!
Leaf 提供了 号段模式 和 Snowflake(雪花算法) 这两种模式来生成分布式 ID。并且,它支持双号段,还解决了雪花 ID 系统时钟回拨问题。不过,时钟问题的解决需要弱依赖于 Zookeeper(使用 Zookeeper 作为注册中心,通过在特定路径下读取和创建子节点来管理 workId) 。
Leaf 的诞生主要是为了解决美团各个业务线生成分布式 ID 的方法多种多样以及不可靠的问题。
Leaf 对原有的号段模式进行了核心优化——双 Buffer 机制(Double Buffer Optimization):
设计原理:Leaf 不会在号段用尽时才去 DB 申请,而是在当前号段使用率达到一定阈值(如 10%~20%)时,异步线程提前去 DB 申请下一个号段并预加载到内存。这使得 ID 获取的 TP999 极其平稳,彻底消除了 DB 访问带来的延迟抖动。
(图片来自于美团官方文章:《Leaf——美团点评分布式 ID 生成系统》)
根据项目 README 介绍,在 4C8G VM 基础上,通过公司 RPC 方式调用,QPS 压测结果近 5w/s,TP999 1ms。
Tinyid 是滴滴开源的一款基于数据库号段模式的唯一 ID 生成器。
数据库号段模式的原理我们在上面已经介绍过了。Tinyid 有哪些亮点呢?
为了搞清楚这个问题,我们先来看看基于数据库号段模式的简单架构方案。(图片来自于 Tinyid 的官方 wiki:《Tinyid 原理介绍》)
在这种架构模式下,我们通过 HTTP 请求向发号器服务申请唯一 ID。负载均衡 router 会把我们的请求送往其中的一台 tinyid-server。
这种方案有什么问题呢?在我看来(Tinyid 官方 wiki 也有介绍到),主要由下面这 2 个问题:
除此之外,HTTP 调用也存在网络开销。
Tinyid 的原理比较简单,其架构如下图所示:
相比于基于数据库号段模式的简单架构方案,Tinyid 方案主要做了下面这些优化:
Tinyid 的优缺点这里就不分析了,结合数据库号段模式的优缺点和 Tinyid 的原理就能知道。
和 UidGenerator、Leaf 一样,IdGenerator 也是一款基于 Snowflake(雪花算法)的唯一 ID 生成器。
IdGenerator 有如下特点:
IdGenerator 生成的唯一 ID 组成如下:
WorkerIdBitLength(默认 6)限定。如果一台服务器部署多个独立服务,需要为每个服务指定不同的 WorkerId。SeqBitLength(默认 6)限定。增加 SeqBitLength 会让性能更高,但生成的 ID 也会更长。Java 语言使用示例:https://github.com/yitter/idgenerator/tree/master/Java。
通过这篇文章,我基本上已经把最常见的分布式 ID 生成方案都总结了一波。
除了上面介绍的方式之外,像 ZooKeeper 这类中间件也可以帮助我们生成唯一 ID。没有银弹,一定要结合实际项目来选择最适合自己的方案。
核心方案横向对比表:
| 方案 | 性能 | 有序性 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| 数据库自增 | 低 | 严格递增 | 低 | 业务量小、单机架构、后台系统 |
| 号段模式 | 高 | 趋势递增 | 中 | 高并发、追求极致吞吐量的互联网业务 |
| Redis 方案 | 很高 | 严格递增 | 中 | 已有 Redis 集群,能容忍极小概率 ID 回退 |
| Snowflake | 高 | 趋势递增 | 低/中 | 大中型分布式系统、Java 生态(最主流) |
| UUID v7 | 高 | 趋势递增 | 极低 | 云原生、无中心化集群、追求开箱即用 |
不过,本文主要介绍的是分布式 ID 的理论知识。在实际的面试中,面试官可能会结合具体的业务场景来考察你对分布式 ID 的设计,你可以参考这篇文章:分布式 ID 设计指南(对于实际工作中分布式 ID 的设计也非常有帮助)。
<!-- @include: @article-footer.snippet.md -->