分布式 ID 生成方案
分库分表之后数据库自增主键不再全局唯一——每个分片都从 1 开始,订单号会撞车。即使没分库,也需要一个能对外暴露、不泄露业务量、趋势递增的订单号。分布式 ID 解决的就是"多节点如何生成全局唯一 ID"的问题。
一个好 ID 的标准
| 要求 | 说明 | 不满足的后果 |
|---|---|---|
| 全局唯一 | 任何节点、任何时刻都不重复 | 数据覆盖、主键冲突 |
| 趋势递增 | 整体随时间增大 | B+ 树随机插入,页分裂,写入变慢 |
| 高性能 | 满足峰值写入 QPS | ID 生成成为写入瓶颈 |
| 高可用 | 不因单点故障停止发号 | 下单链路整体不可用 |
| 信息安全 | 外部不可推算业务量 | 竞对能估算你的日订单量 |
"趋势递增"不等于"严格单调递增":只要求整体随时间变大,允许同毫秒内乱序,这对 B+ 树已经足够友好。
方案一:数据库自增与步长
最省事的做法是直接用数据库自增,缺点是强依赖数据库、每次取号一次数据库往返:
CREATE TABLE id_seq (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
stub CHAR(1) NOT NULL DEFAULT 'x',
PRIMARY KEY (id), UNIQUE KEY uk_stub (stub)
) ENGINE=InnoDB;
REPLACE INTO id_seq (stub) VALUES ('x');
SELECT LAST_INSERT_ID(); -- 输出:1024
单点问题可以靠"多实例不同起始值 + 步长"错开:
-- 实例 1(得到 1, 4, 7 ...):SET @@auto_increment_offset = 1; SET @@auto_increment_increment = 3;
-- 实例 2(得到 2, 5, 8 ...):SET @@auto_increment_offset = 2; SET @@auto_increment_increment = 3;
方案二:UUID
// 片段,需放入工程
String id = UUID.randomUUID().toString();
// 输出示例:f47ac10b-58cc-4372-a567-0e02b2c3d479(36 个字符)
优点是本地生成、无网络与中心依赖、性能极高、几乎不会冲突;缺点是完全无序,作主键会让 B+ 树频繁页分裂,且 36 个字符比 BIGINT 大数倍,索引膨胀明显。折中做法:UUID 作为对外唯一标识(如 traceId),不要作为 InnoDB 聚簇索引主键。
方案三:雪花算法(Snowflake)
把 64 位长整型切成三段:时间戳保证趋势递增,机器位保证多节点不冲突,序列号保证同毫秒内不重复。
0 | 41 bit 毫秒时间戳 | 10 bit 机器 ID | 12 bit 序列号
符号位 约可用 69 年 最多 1024 台 每毫秒 4096 个 → 单机峰值约 409.6 万 ID/秒
nextId():
now = currentTimeMillis()
if now < lastTimestamp: return handleClockBackwards(lastTimestamp - now) # 时钟回拨
if now == lastTimestamp:
sequence = (sequence + 1) & 4095
if sequence == 0: now = waitNextMillis(lastTimestamp) # 本毫秒 4096 个用完
else: sequence = 0
lastTimestamp = now
return (now - epoch) << 22 | (workerId << 12) | sequence
时钟回拨的处理策略:
| 回拨幅度 | 处理方式 |
|---|---|
| 几毫秒 | 自旋等待时钟追上 lastTimestamp 再发号 |
| 中等及以上 | 抛异常拒绝发号由上层重试,或切换备用位、降级到号段模式 |
优点是本地生成、性能极高、趋势递增;限制是强依赖系统时钟、机器 ID 需统一分配、时间戳位数决定可用年限约 69 年。生产环境务必部署 NTP 时钟同步并监控回拨事件。
方案四:号段模式
应用启动或号段用尽时,从数据库领取一个区间(如 1~1000),之后在内存里自增,用完再领下一段。
CREATE TABLE id_segment (
biz_tag VARCHAR(32) NOT NULL COMMENT '业务标识,如 order',
max_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前已分配到的最大值',
step INT NOT NULL DEFAULT 1000 COMMENT '每次领取的号段长度',
PRIMARY KEY (biz_tag)
) ENGINE=InnoDB;
-- UPDATE 原子累加,再读回本段的边界值
UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM id_segment WHERE biz_tag = 'order';
nextId():
if next > end: # 当前段用尽,领取新段
新段 = 从数据库领取(max_id += step)
next = 新段.max - step + 1; end = 新段.max
return next++
关键优化是双缓冲:当前段用到 10%~20% 时异步预取下一段,避免段用尽瞬间的取号延迟尖刺。服务重启会浪费未用完的号段,属于可接受代价。号段模式在多种开源分布式 ID 组件中都有实现(部分组件同时提供雪花与号段两种模式),具体行为以对应项目官方文档为准。
方案五:Redis 自增
利用 Redis 单线程的原子自增命令:
INCR order:id # 输出:1001
INCRBY order:id 1000 # 输出:11001,一次领一段可减少网络往返
优点:命令简单、原子、性能高,天然全局递增。缺点:强依赖 Redis,需考虑持久化与故障切换;必须接受 ID 不连续——未开持久化时故障重启可能从更早的值继续甚至回退,因此生产上推荐用 INCRBY 批量取号并开启合适的持久化策略。
方案对比与选型
| 方案 | 趋势递增 | 性能 | 依赖组件 | 适用场景 |
|---|---|---|---|---|
| 数据库自增 | 严格递增 | 低 | 数据库 | 单库、量小的内部系统 |
| 数据库步长 | 趋势递增 | 中 | 数据库 | 少量实例的中小系统 |
| UUID | 无序 | 极高 | 无 | 非主键的唯一标识 |
| 雪花算法 | 趋势递增 | 极高 | 无 | 高并发、分库分表主键 |
| 号段模式 | 趋势递增 | 高 | 数据库 | 需要好看且有序的业务 ID |
| Redis 自增 | 严格递增 | 高 | Redis | 已有 Redis 且能接受跳号 |
选型建议:
- 单库小系统:直接用数据库自增,不要过度设计;
- 分库分表 + 高并发:雪花算法(无中心依赖)或号段模式(对外 ID 更连续);
- 对外展示订单号:号段模式或"日期 + 号段"组合,避免被推算业务量;
- 已有 Redis 且能接受跳号用
INCRBY;需要不可猜测的标识用 UUID,但仅作业务唯一键。
常见坑
- 机器位分配冲突:手工配置 workerId 极易撞号,应由配置中心统一分配;
- 时钟回拨未处理:这是雪花算法唯一但致命的缺陷,必须显式处理并告警;
- 前端精度丢失:雪花 ID 超出 JavaScript 安全整数范围(2^53-1),接口层应序列化为字符串返回;
- 号段浪费:服务频繁重启会浪费大量号段,步长不宜设置过大。
小结:分布式 ID 按"是否依赖中心组件"分两类——雪花算法与 UUID 本地生成、性能最高,其中雪花趋势递增适合做主键;数据库自增、号段模式与 Redis 自增依赖中心组件,但 ID 更连续易读。选型时优先考虑趋势递增与不可猜测两项要求,务必处理时钟回拨,并在接口层把长整型 ID 转为字符串返回前端。