分布式唯一 ID
背景
日常开发中,我们需要对系统中的各种数据使用 ID 唯一表示。
比如用户 ID 对应且仅对应一个人,商品 ID 对应且仅对应一件商品,订单 ID 对应且仅对应一个订单。我们现实生活中也有各种 ID,比如身份证 ID 对应且仅对应一个人。简单来说,ID 就是数据的唯一标识。
一般情况下,会使用数据库的自增主键作为数据 ID。但在大数据量场景下,往往会引入分布式、分库分表等手段来应对。很明显,对数据分库分表后我们依然需要有一个唯一 ID 来标识一条数据或消息,数据库的自增 ID 已经无法满足需求。此时,一个能够生成全局唯一 ID 的系统是非常必要的。
业务系统对 ID 的要求
| 要求 | 说明 |
|---|---|
| 全局唯一性 | 不能出现重复的 ID 号,这是最基本的要求 |
| 趋势递增 / 单调递增 | 保证下一个 ID 一定大于上一个 ID |
| 信息安全 | 若 ID 连续,恶意用户可按顺序扒取;若是订单号,竞对可推算日单量。部分场景需要 ID 无规则、不规则 |
| 可用性 | 平均延迟和 TP999 尽可能低;可用性 5 个 9;高 QPS。ID 生成系统不稳定时,订单生成等关键动作将无法执行 |
常见方法介绍
UUID
UUID(Universally Unique Identifier)的标准形式包含 32 个 16 进制数字,以连字号分为五段,形式为 8-4-4-4-12 的 36 个字符。
示例:
text
550e8400-e29b-41d4-a716-446655440000
优缺点
优点:
- 性能非常高:本地生成,没有网络消耗
缺点:
- 不易于存储:UUID 太长,16 字节 128 位,通常以 36 长度的字符串表示,很多场景不适用
- 信息不安全:基于 MAC 地址生成 UUID 的算法可能造成 MAC 地址泄露(该漏洞曾被用于寻找梅丽莎病毒制作者位置)
ID 作为主键时,在特定环境下会存在一些问题。例如做 DB 主键时,UUID 非常不适用:
- MySQL 官方明确建议主键尽量越短越好,36 个字符长度的 UUID 不符合要求
- 对 MySQL 索引不利:若作为数据库主键,在 InnoDB 引擎下,UUID 的无序性可能引起数据位置频繁变动,严重影响性能。InnoDB 使用聚集索引,多数 RDBMS 用 B-tree 存储索引数据,主键应尽量有序以保证写入性能
雪花算法及其衍生
这是一种以划分命名空间来生成 ID 的算法(UUID 也可算作此类,因较常见单独分析)。Snowflake 是 Twitter 开源的分布式 ID 生成算法,把 64-bit 分别划分成多段,分开来标识机器、时间等。
生成规则
| 位段 | 位数 | 含义 |
|---|---|---|
| 第 0 位 | 1 | 符号位(标识正负),始终为 0 |
| 第 1~41 位 | 41 | 时间戳(毫秒),可支撑 (2^{41}) 毫秒(约 69 年) |
| 第 42~52 位 | 10 | 一般前 5 位表示机房 ID,后 5 位表示机器 ID(可按项目调整),即可表示 32 个 IDC,每个 IDC 下 32 台机器 |
| 第 53~64 位 | 12 | 序列号(自增),单机每毫秒最多生成 (2^{12} = 4096) 个唯一 ID |
理论上 Snowflake 方案的 QPS 约为 409.6w/s。这种分配方式可以保证:在任何一个 IDC 的任何一台机器、任意毫秒内生成的 ID 都是不同的。
优缺点
优点:
- 毫秒数在高位,自增序列在低位,整个 ID 趋势递增
- 不依赖数据库等第三方系统,以服务方式部署,稳定性更高,生成 ID 性能也很高
- 可根据自身业务特性分配 bit 位,非常灵活
缺点:
- 强依赖机器时钟:若机器时钟回拨,会导致发号重复,或服务处于不可用状态
相关实现
若项目不想自行实现唯一 ID,还可利用外部中间件,例如:
- MongoDB ObjectId :与 Snowflake 类似,通过「时间 + 机器码 + pid + inc」共 12 个字节,以
4+3+2+3方式最终标识成 24 长度的十六进制字符 - Seata 内置分布式 UUID 生成器 :用于辅助生成全局事务 ID 和分支事务 ID,完整类名为
io.seata.common.util.IdWorker
数据库生成(MySQL)
1)创建数据库表
sql
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 字段无业务意义,只为占位,便于插入或修改数据;并对其创建唯一索引,保证唯一性。
2)通过 REPLACE INTO 插入数据
sql
BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT 7558616;
COMMIT;
这里没有使用 INSERT INTO,而是使用 REPLACE INTO:
REPLACE 是 INSERT 的增强版,会先尝试插入数据:
- 若发现表中已有此行数据(根据主键或唯一索引判断),则先删除此行,再插入新数据
- 否则,直接插入新数据
优缺点
优点:
- 非常简单,利用现有数据库系统功能实现,成本小,有 DBA 专业维护
- ID 号单调自增,存储消耗空间小
缺点:
- 支持的并发量不大
- 存在数据库单点问题(可用集群解决,但增加复杂度)
- ID 没有具体业务含义
- 安全问题(如根据订单 ID 递增规律可推算日订单量)
- 每次获取 ID 都要访问一次数据库(增加压力,获取也慢)
性能优化思路
可在分布式系统中多部署几台机器,每台机器设置不同初始值,且步长与机器数相等。
该架构看似能满足性能需求,但仍有以下缺点:
- ID 失去严格单调递增,只能趋势递增(一般业务可容忍)
- 数据库压力仍很大,每次获取 ID 都得读写一次数据库,只能靠堆机器提升性能
- 系统水平扩展方案复杂,难以实现
Redis
通过 Redis 的 INCR 命令即可实现对 ID 的原子顺序递增,例如:
bash
INCR sequence_id_biz_type
为提高可用性和并发,可使用 Redis Cluster。
Redis 基于内存,性能很好,且生成的 ID 有序递增;但需要持久化,避免重启或故障后数据丢失。
即使开启持久化------无论是快照(RDB)、只追加文件(AOF),还是 RDB + AOF 混合持久化------仍存在丢失数据的可能,也就意味着生成的 ID 存在重复的概率。
分布式 ID 微服务
美团 Leaf 方案实现
Leaf 这个名字来自德国哲学家、数学家莱布尼茨的一句话:
There are no two identical leaves in the world
(世界上没有两片相同的树叶)
Leaf 分别在 MySQL 和雪花算法上做了相应优化,实现了两套方案:
- Leaf-segment
- Leaf-snowflake
Leaf-segment 数据库方案
原 MySQL 方案每次获取 ID 都得读写一次数据库,压力大。Leaf-segment 改为批量获取 :每次获取一个 segment(由 step 决定大小)号段,用完后再去数据库获取新号段,可大幅减轻数据库压力。
各业务不同的发号需求用 biz_tag 字段区分,每个 biz_tag 的 ID 获取相互隔离、互不影响。若有性能需求需要对数据库扩容,无需复杂操作,只需对 biz_tag 分库分表即可。
表设计要点
| 字段 | 说明 |
|---|---|
biz_tag |
区分业务 |
max_id |
该 biz_tag 当前已分配号段的最大值 |
step |
每次分配的号段长度 |
原来每次获取 ID 都需要写数据库;现在只要把 step 设得足够大(例如 1000),只有当 1000 个号消耗完后才会重新读写一次数据库,读写频率从 1 降到 1/step。
举例说明
假设有 3 台机器,每台各取 1000 个号:
- 第一台 Leaf 机器上是
1~1000号段 - 该号段用完后,会加载另一个长度为
step=1000的号段 - 假设另外两台号段都未更新,则第一台新加载的号段应为
3001~4000 - 同时数据库中对应
biz_tag的max_id会从3000更新成4000
更新号段的 SQL:
sql
BEGIN;
UPDATE table SET max_id = max_id + step WHERE biz_tag = xxx;
SELECT tag, max_id, step FROM table WHERE biz_tag = xxx;
COMMIT;
优缺点
优点:
- Leaf 服务可方便线性扩展,性能可支撑大多数业务场景
- ID 趋势递增,为 8 byte / 64 位数字,满足数据库主键要求
- 容灾性高:Leaf 内部有号段缓存,即使 DB 宕机,短时间内仍能正常对外提供服务
- 可自定义
max_id大小,便于从原有 ID 方式迁移
缺点:
- ID 不够随机,可能泄露发号数量信息,不够安全
- TP999 波动大:号段用完后获取新号段时,更新数据库的 I/O 仍可能造成等待,出现偶尔尖刺
- DB 宕机会造成整个系统不可用
双 Buffer 优化
针对「TP999 波动」这一缺点,Leaf-segment 做了优化。
原逻辑是:号段消耗完时才取下一段。这意味着临界点的 ID 下发时间取决于下次从 DB 取回号段的时间;期间进来的请求会因号段未取回而线程阻塞。若取 DB 时网络抖动或出现慢查询,整个系统响应会变慢。
期望 DB 取号段过程无阻塞:当号段消费到某个点时,异步把下一个号段加载到内存,而不必等号段用尽。这样可大幅降低 TP999。
双 Buffer 方式:
- Leaf 服务内部有两个号段缓存区 segment
- 当前号段已下发 10% 时,若下一个号段未更新,则另启更新线程去更新下一个号段
- 当前号段全部下发完后,若下个号段已准备好,则切换为当前 segment 继续下发,循环往复
建议:
segment长度设置为服务高峰期发号 QPS 的 600 倍(约 10 分钟)。这样即使 DB 宕机,Leaf 仍能持续发号 10~20 分钟。每次请求都会判断下个号段状态并更新,因此偶尔的网络抖动一般不影响下个号段更新。
Leaf 高可用容灾
针对「DB 可用性」问题,可采用一主两从,同时分机房部署;Master 与 Slave 之间采用半同步方式同步数据。
美团内部使用了奇虎 360 的 Atlas 数据库中间件(已开源,改名为 DBProxy)做主从切换。
该方案在某些情况下会退化成异步模式,极端情况下仍可能造成数据不一致,但概率非常小。若要保证 100% 强一致,可选择「类 Paxos 算法」实现的强一致 MySQL 方案,如 MySQL 5.7 的 MySQL Group Replication;运维成本和精力会相应增加,按实际情况选型即可。
Leaf-snowflake 方案
背景
Leaf-segment 可生成趋势递增的 ID,同时 ID 号可计算,不适用于订单 ID 等场景。例如竞对在两天中午 12 点分别下单,通过订单 ID 相减就能大致推算公司一天的订单量,这是不能忍受的。面对这一问题,美团提供了 Leaf-snowflake 方案。
Leaf-snowflake 完全沿用 Snowflake 的 bit 位设计,即 1 + 41 + 10 + 12 方式组装 ID。
对于 workerID 的分配:
- 服务集群较小:可完全手动配置
- Leaf 服务规模较大:手动配置成本太高,因此使用 Zookeeper 持久顺序节点 自动为 snowflake 节点配置
workerID
启动步骤
- 启动 Leaf-snowflake 服务,连接 Zookeeper,在
leaf_forever父节点下检查自己是否已注册(是否有该顺序子节点) - 若已注册 :直接取回自己的
workerID(ZK 顺序节点生成的 int 类型 ID),启动服务 - 若未注册 :在该父节点下创建持久顺序节点,创建成功后取回顺序号作为自己的
workerID,启动服务
弱依赖 ZooKeeper
除了每次去 ZK 拿数据外,也会在本机文件系统上缓存一个 workerID 文件。当 ZooKeeper 出现问题,且机器恰好需要重启时,仍能保证服务正常启动,从而做到对第三方组件的弱依赖。
解决时钟问题
该方案依赖时间。若机器时钟发生回拨,可能生成重复 ID,因此需要解决时钟回退问题。
启动时检查:
- 新节点 :通过对比其余 Leaf 节点系统时间判断自身时间是否准确------取所有运行中 Leaf-snowflake 节点的 IP:Port,经 RPC 得到各节点系统时间,计算
sum(time) / nodeSize,看本机时间与该平均值是否在阈值内:- 准确 → 正常启动
- 不准确 → 认为本机系统时间发生大步长偏移,启动失败并报警
- 已在 ZooKeeper 登记的老节点:同样比较自身系统时间、ZooKeeper 上本节点曾记录时间,以及所有运行中 Leaf-snowflake 节点时间;不准确同样启动失败并报警
另外,运行过程中每隔一段时间,节点都会上报自身系统时间写入 ZooKeeper。
服务运行中,机器 NTP 同步也可能造成秒级回退;由于强依赖时钟,对时间较敏感。
美团建议的三种解决方案:
- 直接关闭 NTP 同步
- 时钟回拨时不提供服务,直接返回
ERROR_CODE,等时钟追上即可 - 做一层重试并上报报警系统;或发现时钟回拨后自动摘除本节点并报警
从美团实际运行情况看,在 2017 年闰秒那一次曾出现部分机器回拨;由于 Leaf-snowflake 的策略保证,成功避免了对业务造成影响。
美团 Leaf 现状
Leaf 在美团点评内部覆盖金融、支付交易、餐饮、外卖、酒店旅游、猫眼电影等众多业务线。
| 指标 | 数据 |
|---|---|
| 压测机器 | 4C8G |
| QPS | 近 5 万/s |
| TP999 | 1ms |
| 日调用量 | 亿级 |
已能够满足大部分业务需求。