主题
分布式 ID
一句话:分布式 ID 用「时间 + 机器 + 序列」在本地算出全局唯一且趋势递增的 64 位整数,换来写入性能和索引友好;代价是对时钟和机器编号的强依赖。
适用:Snowflake 同族方案(Twitter Snowflake、UidGenerator、Leaf-snowflake)。SnowflakeIdGenerator 为示例命名;号段、ULID 等方案在对比表中单列。
为什么需要
单库自增在主从切换、分库分表后不再全局唯一:两个库都从 1 开始,订单号会撞。UUID 无中心依赖,但随机无序,作为 InnoDB 主键会频繁页分裂,36 个字符也比 8 字节的 bigint 更占索引空间。
业务还要求 ID 能排序、能按时间范围定位、能直接落库做主键。所以目标不是「唯一」两个字,而是:全局唯一、趋势递增、足够短、生成不成为写入瓶颈。
核心机制
方案对比
| 方案 | 有序性 | 外部依赖 | 单节点吞吐 | 主要成本 |
|---|---|---|---|---|
| 数据库自增/号段 | 严格递增 | 数据库 | 号段模式万级/秒(经验值) | 号段用尽要等库;重启可能产生空洞 |
| Redis INCR | 严格递增 | Redis | 十万级/秒(受网络往返限制) | 多一次 RTT;持久化配置决定故障后是否重号 |
| UUID v4 / ULID | v4 无序;ULID 按时间有序 | 无 | 百万级/秒 | v4 索引差;ULID 26 字符,仍比 bigint 长 |
| Snowflake | 趋势递增 | 时钟 + workerId 分配 | 单机百万级/秒(经验值) | 时钟回拨、机器位有限 |
选型看约束:不能接受外部依赖、写入量大、需要趋势递增,选 Snowflake;只要唯一、不关心顺序,UUID 足够;已有可靠数据库且量不大,号段最省事。分库分表后的主键与跨库写入取舍见 07-分布式事务。
Snowflake 的位结构
64 位 long 拆成四段:
| 段 | 位数 | 容量 | 耗尽后的后果 |
|---|---|---|---|
| 符号位 | 1 | 恒为 0 | 保证 ID 为正数 |
| 时间戳 | 41 | 约 69.7 年(2^41 ms) | 溢出到符号位或与旧时间区段重叠,只能换起点或换方案 |
| 机器位 | 10 | 1024 个节点 | 超过要扩位,或拆成数据中心位 + 机器位 |
| 序列号 | 12 | 每毫秒 4096 个 | 同一毫秒超过 4096 次就自旋到下一毫秒 |
公式:id = ((now - epoch) << 22) | (workerId << 12) | sequence。时间在高位,所以 ID 随时间趋势递增;epoch 是自定义起点,选得越晚可用年限越长,但一旦上线不能再改,否则新旧 ID 空间会重叠。
时钟回拨
Snowflake 的唯一性依赖「时间只前进」。NTP 校时、虚拟机迁移、人工改时间都可能让时钟倒退,同一毫秒的序列号被重新使用就会重复。常见处理:
- 小回拨等待:回拨小于阈值(如 5–500ms,看实现)就睡到
lastTimestamp,牺牲这点延迟换正确性。 - 大回拨拒绝:超过阈值直接抛异常,服务短暂不可用,但不产生重复 ID。
- 备用 workerId:检测到回拨后切到另一个未使用的机器位继续发号,需要预留机器位并保证切换原子性。
- 持久化最后时间戳:重启后先读回上次时间,发现当前时间更早就拒绝启动;它防的是「重启 + 回拨」,防不了进程内回拨。
没有一种方案同时保住可用性和正确性:等待和拒绝丢可用性,换 workerId 依赖预留与协调,持久化只覆盖重启场景。
图里的分支对应上面的取舍:回拨小就等,回拨大就拒绝,序列号耗尽就自旋到下一毫秒。所有路径最终都回到同一个不变式:同一毫秒内序列号不重复。
workerId 怎么分配
- 配置文件:最简单,也最容易在扩容时复制出重复值。
- 数据库表:
worker_id + ip + 租约到期时间,启动时抢占并定期续约,过期可回收。 - ZK/etcd:创建顺序节点取序号,会话断开自动释放;依赖协调服务可用性。
- K8s StatefulSet:从 Pod 名
app-0、app-1解析序号,稳定且无需外部依赖;超过副本上限要改位宽。
重复 workerId 的后果是:同一毫秒、同一序列号会生成完全相同的 ID。检测手段是把 workerId 打进日志和监控,发布后比对实例列表;更稳的做法是分配时带租约和 fencing,续约失败就停止发号。
工程细节
- JS 精度:JavaScript
Number只能安全表示 2^53−1,Snowflake ID 早已超过;接口必须序列化为字符串,否则前端会丢精度。Jackson 可对 Long 统一转 String,或给 ID 字段加序列化注解。 - 时间起点:
epoch写进文档和代码常量,禁止运行期修改;换起点等于换 ID 空间。 - 序列耗尽:不要忙等空转,用
Thread.onSpinWait()或短睡眠到下一毫秒;4096/ms 是单节点理论上限。 - 监控:时钟偏移、回拨次数、workerId 冲突、每秒生成量;时钟偏移告警要早于 ID 重复发生。
- 存储:用
BIGINT UNSIGNED或bigint,不要用字符串主键;对外展示再转字符串。
实战场景
一、扩容复制配置,两个实例发出同一个 ID
现象:订单服务从 8 个实例扩到 12 个后,凌晨批量任务报 Duplicate entry '2051xxxxxxxxxxx' for key 'PRIMARY',同一天出现 4 次;插入被拒绝,业务侧表现为“下单系统繁忙”。查数据库并没有重复数据,说明是主键撞了而不是脏数据。
排查:重复 ID 一定能从位段反解出来源,先拆再猜。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| workerId 是否相同 | 把两个重复 ID 按位反解:(id >> 12) & 1023 | 相同且时间戳/序列也接近 → 机器位重复 |
| 分配方式 | 配置项、StatefulSet 序号解析、分配表租约记录 | 用配置文件手工填 → 扩容复制必撞 |
| 实例清单比对 | kubectl get pods -o name 与分配表逐一对齐 | 数量或序号重叠即命中 |
| 是否发生过回拨 | 同一时间窗的回拨日志与 lastMs 持久化值 | 时间倒退会让同毫秒序列复用 |
| 是否跨 epoch | 新旧实现的 EPOCH 常量与上线记录 | 换起点等于换 ID 空间,历史 ID 会被重生成 |
反解代码可以直接用在排障里:
java
long ts = (id >>> 22) + EPOCH; // 发号时间(毫秒)
long node = (id >>> 12) & 0x3FF; // workerId,10 位
long seq = id & 0xFFF; // 序列号,12 位
log.info("id={} ts={} node={} seq={}", id, Instant.ofEpochMilli(ts), node, seq);
// 两个“重复”的 ID 反解后三段完全一致 → 必然是同 workerId + 同毫秒 + 同序列本例是第一个分支:新扩容的 4 个 Pod 由同一份 ConfigMap 渲染,worker-id 写死成了 8,与老实例重叠。
对症做法:
java
// 1. 分配带租约与 fencing:拿不到租约就不发号,而不是照发
public long acquireWorkerId() {
for (int candidate : range) {
if (leaseTable.grant("worker-" + candidate, instanceId, Duration.ofSeconds(30))) {
return candidate; // 抢占成功
}
}
throw new IllegalStateException("no free workerId: stop issuing");
}
// 续约失败要立刻停止发号:继续发就是拿唯一性换可用性yaml
# 2. K8s 下优先用稳定序号,而不是复制配置
statefulset.yaml: # Pod 名 app-0 / app-1 解析 workerId,天然不重叠
worker-id-from: metadata.name # 例:order-id-3 → 3
# 副本数上限要小于机器位容量(10 位 = 1024),超过就提前扩位或拆数据中心位java
// 3. 启动自检:与注册中心/分配表核对,重叠就不上线
@PostConstruct void assertWorkerIdUnique() {
if (leaseTable.isHeldByOthers(workerId, instanceId)) throw new IllegalStateException("workerId conflict");
}复盘要点:workerId 冲突是可用配置但不可扩容的写法,判据是“实例数与分配表条目一一对应”,这条要放进扩容流程而不是发布流程。把 node 位打进 ID 指标维度(低基数,1024 以内),冲突能在第一次生成时被发现,而不是等主键冲突。对唯一性零容忍的下游(支付流水)应改用号段或数据库分配,见 方案对比。
二、前端看到的订单号尾数变了:JS 精度
现象:客服系统里用户报的订单号是 2051234567890123000,数据库里查到的是 2051234567890123021;运营反馈“搜索订单号搜不到”,每天十余次(经验值,需校准)。后端日志、数据库、MQ 里的值都正确,只有前端不对。
排查:这是传输层表示问题,不是生成问题。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 原始响应报文 | curl -i 看 body 里 ID 是不是裸数字 | 数字形式 → 客户端解析存在精度风险 |
| 客户端数值上限 | JS Number.MAX_SAFE_INTEGER = 2^53−1 | Snowflake ID 远超该值,尾数被舍入 |
| 复现方式 | 浏览器控制台 JSON.parse('[2051234567890123021]') | 输出 2051234567890123000 即确认 |
| 影响范围 | 所有以数字下发的 ID 字段(订单号、用户 ID、批次号) | 逐个字段核对,不只这一处 |
| 其他语言 | Go/Python 无此问题;移动端 JSON 库按 double 解析时同样丢 | 跨端契约要统一 |
对症做法:
java
// 1. 全局把 Long 序列化成字符串(最直接,覆盖所有接口)
@Configuration
class JacksonIdConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToString() {
return b -> b.serializerByType(Long.class, ToStringSerializer.instance)
.serializerByType(Long.TYPE, ToStringSerializer.instance);
}
}
// 2. 或按字段标注,保留其他数值字段的数字类型
public class OrderView {
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;
}text
3. 契约里写明“ID 一律按字符串处理”,并在接口测试里加一条断言:
响应体中 ID 必须是 JSON string,不允许 number
4. 入参方向同理:DTO 的 ID 字段接 String,服务端再转换,避免前端拼数字溢出javascript
// 前端侧兜底:不要用 Number 接 ID
const id = order.orderId; // string,不参与算术复盘要点:跨语言位宽不统一属于结构性风险,判据要写进接口契约与自动化测试(ID 必须是字符串),而不是靠前端“记得处理”。同一 ID 在多个系统间流转时(对账、导出、搜索),还要保证不做隐式数值转换——字符串比较与数值比较在边界上不等价。
三、宿主机校时把发号服务打成 500
现象:一次底层维护批量同步时钟后,发号服务(承载在订单与流水服务里)出现约 90 秒的 500 高峰,日志刷满 IllegalStateException: clock back 1830ms;恢复后一切正常,且没有产生重复 ID。另一台机器上的同类服务则完全没报错,但当天出现了少量重复流水号。
排查:两种表现是同一问题的两个方向——拒绝保正确、静默埋重复。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 是否发生过跳变 | node_timex_offset_seconds、chronyd/ntpd 日志(step 而非 slew) | 绝对值突然大幅变化即为强制校时 |
| 本地阈值行为 | 代码里 MAX_BACKWARD_MS;回拨幅度 | 1830ms > 5ms → 走拒绝分支,服务不可用 |
| 另一台为何不报错 | 是否持久化了 lastMs、是否在重启后读取 | 未持久化 + 重启 → 时间更早也能继续发号,产生重复 |
| 是否真重复 | 反解重复 ID 的 ts/node/seq 三段 | 三段一致即确认同实例同毫秒复用 |
| 影响面 | 拒绝期间的请求量与错误率、下游重试 | 无重试时用户可见失败,有重试时可能放大 |
结论要说全:等待和拒绝丢可用性,换 workerId 依赖预留与原子切换,持久化只覆盖“重启 + 回拨”,防不了进程内回拨——没有一种方案同时保住可用性和正确性。
对症做法:
bash
# 1. 校时用逐步调整(slew),禁止大幅 step;这一条能消掉绝大多数回拨事件
chronyd -a ... # 或 timeout/rtcslewslew 相关参数,按发行版配置;关键是避免跳变
# 监控绝对偏移,阈值远小于业务容忍值
- alert: ClockOffsetHigh
expr: abs(node_timex_offset_seconds) > 0.1 # 100ms,经验阈值
for: 2mjava
// 2. 拒绝要变成“短暂不可用 + 明确降级”,而不是把异常抛到用户面前
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastMs && lastMs - now > MAX_BACKWARD_MS) {
metrics.clockBack(lastMs - now);
standbyWorker.switchTo(reservePool.next()); // 切到预留机器位继续发号
return nextIdOnStandby(); // 前提:预留位由租约保证不冲突
}
...
}
// 3. 持久化 lastMs:重启后当前时间更早就不启动
@PostConstruct void guardAgainstRestartBackward() {
long stored = store.readLastMs();
if (System.currentTimeMillis() < stored) throw new IllegalStateException("clock behind stored ts");
}
// 4. 关键流水号(对账口径)不要单靠 Snowflake:加数据库唯一约束 + 幂等写入兜底序列号耗尽(同一毫秒超过 4096 次)走的是另一条路径:自旋到下一毫秒会带来可观测的延迟尖刺,压测时要看 sequence 耗尽次数,别把它和回拨混成一个指标。
复盘要点:发号服务要有“时间”这个一等公民指标:偏移、回拨次数、切换 workerId 次数、每秒生成量。时钟偏移告警必须早于 ID 重复发生——这条做到了,故障就从“事后查重复主键”变成“事前拦校时”。ID 只增不改,发出即不可复用,回滚和重投都靠幂等而不是重新生成;对外展示转字符串、存储用 bigint,见 分布式事务 里的幂等键设计。
最小上手
Java 17,单文件可直接运行;省略 workerId 分配、监控和序列号预热:
java
public final class SnowflakeIdGenerator {
private static final long EPOCH = 1704067200000L; // 2024-01-01,示例起点,上线后不可改
private static final long WORKER_BITS = 10L, SEQ_BITS = 12L;
private static final long SEQ_MASK = (1L << SEQ_BITS) - 1, MAX_BACKWARD_MS = 5L;
private final long workerId;
private long lastMs = -1L, seq = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId >= (1L << WORKER_BITS)) throw new IllegalArgumentException("workerId");
this.workerId = workerId;
}
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastMs) { // 时钟回拨
long back = lastMs - now;
if (back > MAX_BACKWARD_MS) throw new IllegalStateException("clock back " + back + "ms");
now = waitUntil(lastMs);
}
if (now == lastMs) {
seq = (seq + 1) & SEQ_MASK;
if (seq == 0) now = waitUntil(lastMs + 1); // 本毫秒 4096 个用尽
} else seq = 0;
lastMs = now;
return ((now - EPOCH) << 22) | (workerId << SEQ_BITS) | seq;
}
private static long waitUntil(long targetMs) {
long now = System.currentTimeMillis();
while (now < targetMs) { Thread.onSpinWait(); now = System.currentTimeMillis(); }
return now;
}
}验证:连续生成 10 万个 ID,确认严格递增且无重复;把系统时间往回调 10ms,观察是等待还是抛异常。
生产动作
- workerId 上线前核对:
kubectl get pods -o name与分配表比对,数量必须一一对应;扩容前先扩位宽或预留号段。 - 时钟同步:所有节点接同一 NTP 源,校时用逐步调整(slew)而不是直接跳变;监控
node_timex_offset_seconds,绝对值超过 100ms 告警(经验阈值)。 - 序列上限:单节点持续超过 4096/ms 会被限到约 409.6 万/秒(理论值),压测时看
sequence耗尽次数。 - ID 只增不改:发出即不可复用;数据库回滚、消息重投都只能靠幂等,不能靠「重新生成一个」。
- 排障路径:发现重复 ID → 先查两个实例的 workerId 是否相同 → 再查同一时间窗内是否有回拨日志 → 最后查是否跨了
epoch或换过实现。 - 容量:41 位时间戳约 69.7 年;
epoch选 2024 年,理论上限到 2093 年前后,换起点前先确认历史 ID 不会被重新生成。
边界
- 不保证连续:序列号可能跳号,业务不能把 ID 当计数器;对账要用业务单号。
- 强依赖时钟:回拨处理得再细也只能降低概率;对唯一性零容忍的场景,号段或数据库分配更稳。
- 机器位有限:10 位只够 1024 个并发发号节点,容器化环境动态扩缩容要提前规划分配方式。
- 不是安全随机数:ID 可预测,不能用作 token、邀请码或抽奖种子。
- 跨语言要统一位宽:Java long、Go int64、MySQL bigint 都是 64 位整数;JavaScript 必须按字符串处理,否则前后端拿到的不是同一个 ID。
讲清楚
分布式 ID 解决分库分表后自增不唯一、UUID 无序占空间的问题。Snowflake 把 64 位拆成 41 位毫秒时间、10 位机器号、12 位序列号,在本地拼出趋势递增的 ID,没有网络开销,吞吐高,索引友好。代价是依赖时钟和机器号:回拨会让同一毫秒的序列号复用,workerId 重复会生成相同 ID。要处理回拨(小回拨等待、大回拨拒绝)、分配 workerId(配置、租约、注册中心或 StatefulSet 序号),并把 ID 序列化成字符串避免 JS 丢精度。不保证连续,也不能当安全随机数;对唯一性零容忍时,号段模式更稳。