跳到正文

分布式 ID ​

一句话:分布式 ID 用「时间 + 机器 + 序列」在本地算出全局唯一且趋势递增的 64 位整数,换来写入性能和索引友好;代价是对时钟和机器编号的强依赖。

适用:Snowflake 同族方案(Twitter Snowflake、UidGenerator、Leaf-snowflake)。SnowflakeIdGenerator 为示例命名;号段、ULID 等方案在对比表中单列。

为什么需要 ​

单库自增在主从切换、分库分表后不再全局唯一:两个库都从 1 开始,订单号会撞。UUID 无中心依赖,但随机无序,作为 InnoDB 主键会频繁页分裂,36 个字符也比 8 字节的 bigint 更占索引空间。

业务还要求 ID 能排序、能按时间范围定位、能直接落库做主键。所以目标不是「唯一」两个字,而是:全局唯一、趋势递增、足够短、生成不成为写入瓶颈。

核心机制 ​

方案对比 ​

方案有序性外部依赖单节点吞吐主要成本
数据库自增/号段严格递增数据库号段模式万级/秒(经验值)号段用尽要等库;重启可能产生空洞
Redis INCR严格递增Redis十万级/秒(受网络往返限制)多一次 RTT;持久化配置决定故障后是否重号
UUID v4 / ULIDv4 无序;ULID 按时间有序无百万级/秒v4 索引差;ULID 26 字符,仍比 bigint 长
Snowflake趋势递增时钟 + workerId 分配单机百万级/秒(经验值)时钟回拨、机器位有限

选型看约束:不能接受外部依赖、写入量大、需要趋势递增,选 Snowflake;只要唯一、不关心顺序,UUID 足够;已有可靠数据库且量不大,号段最省事。分库分表后的主键与跨库写入取舍见 07-分布式事务。

Snowflake 的位结构 ​

64 位 long 拆成四段:

段位数容量耗尽后的后果
符号位1恒为 0保证 ID 为正数
时间戳41约 69.7 年(2^41 ms)溢出到符号位或与旧时间区段重叠,只能换起点或换方案
机器位101024 个节点超过要扩位,或拆成数据中心位 + 机器位
序列号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−1Snowflake 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: 2m
java
// 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 丢精度。不保证连续,也不能当安全随机数;对唯一性零容忍时,号段模式更稳。

把一门技术讲透到既懂也会用