分布式 ID 与防重:雪花算法实战

订单号要有序、消息 ID 要全局唯一、防重要有幂等键——分布式系统的第一行代码往往是 ID 生成。雪花算法(Snowflake)是应用最广的方案,这篇文章讲透它的原理和落地坑。

一、为什么需要分布式 ID

单 U 需 机 U 求 自 I : 全 趋 高 可 增 D 局 势 性 反 : 唯 递 能 解 I 全 一 增 ( ( D 局 ( ( 高 从 : 唯 必 数 并 数 一 须 据 发 I 据 , ) 库 下 D 库 但 索 单 保 无 引 , 能 证 序 友 I 看 唯 ( 好 D 出 一 索 、 时 , 引 业 生 间 但 碎 务 成 / 分 片 上 不 机 库 化 " 能 器 分 ) 最 成 , 表 、 近 为 排 后 太 的 瓶 查 ( 长 单 颈 方 多 ( 号 ) 便 库 3 更 ) 多 6 大 表 " ) 字 ) 自 符 增 ) 冲 突

二、雪花算法原理

6 ┌ │ └ 时 机 序 单 4 ─ ─ 间 器 列 机 ─ 1 ─ 戳 位 号 每 位 ─ ─ : : : 秒 ─ b ─ 毫 5 同 : l ─ i ─ 秒 一 1 o ─ t ─ 级 位 毫 0 n ─ ─ ( 机 秒 0 g ─ 符 ─ 可 房 内 0 : ─ 号 ─ 用 m ─ 位 ─ + 4 s ─ ─ 6 0 ─ │ ─ 9 5 9 × ─ ─ 6 ┬ 4 ┴ 年 位 4 ─ 1 ─ ) 机 个 0 ─ ─ 器 9 ─ b ─ ( 6 ─ i ─ 1 ─ t ─ 0 = ─ ─ 2 ─ 时 ─ 4 4 ─ 间 ─ 0 ─ 戳 ─ 台 9 ─ ─ ) . ─ │ ─ 6 ─ ─ ─ 1 ─ 万 ─ 0 ─ 个 ┬ ┴ ─ b ─ I ─ i ─ D ─ t ─ ( ─ ─ 绰 ─ 机 ─ 绰 ─ 器 ─ 有 ─ ─ 余 ─ │ ─ ) ─ ─ ─ 1 ─ ─ 2 ─ ─ ─ ┬ b ┴ ─ i ─ ─ t ─ ─ ─ ─ 序 ─ ─ 列 ─ ─ 号 ─ ─ ─ ─ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┘

三、Go 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Snowflake struct {
    mu        sync.Mutex
    lastTs    int64
    machineId int64   // 机器位(10 bit)
    sequence  int64   // 序列号(12 bit)
}

func (s *Snowflake) NextID() int64 {
    s.mu.Lock()
    defer s.mu.Unlock()

    now := time.Now().UnixMilli()
    if now == s.lastTs {
        s.sequence = (s.sequence + 1) & 0xFFF   // 4096 上限
        if s.sequence == 0 {                    // 用完了,等下一毫秒
            for now <= s.lastTs {
                now = time.Now().UnixMilli()
            }
        }
    } else {
        s.sequence = 0
    }
    s.lastTs = now
    return (now << 22) | (s.machineId << 12) | s.sequence
}

四、核心坑:时钟回拨

最经典的坑:NTP 校时导致系统时钟回拨,now < lastTs → 生成的 ID 小于已生成的 → ID 重复或乱序。

方案对比

方案 做法 适用
抛异常 回拨超过阈值直接报错 强一致场景
等待 回拨小(< 5ms)等待追上 大多数场景
备用位 记录 lastTs,回拨用"回拨期序列号" 复杂,少用
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 实践:小回拨等待,大回拨报警+降级
func (s *Snowflake) waitClock(now int64) int64 {
    if now < s.lastTs {
        diff := s.lastTs - now
        if diff > 500 {                 // 回拨 > 500ms:报警 + 用备用序列
            log.Error("时钟回拨过大", diff)
            s.sequence = (s.sequence + 1) & 0xFFF  // 保底防重
            return s.lastTs
        }
        time.Sleep(time.Duration(diff) * time.Millisecond)
        now = time.Now().UnixMilli()
    }
    return now
}

五、机器位分配

机 分 器 配 位 方 配 注 主 式 置 册 机 1 : 文 中 名 0 件 心 / ( 分 I b 每 配 P i 台 ( t 机 启 哈 器 动 希 = 写 时 死 注 — 5 ) 册 — — 拿 位 — 冲 机 I 突 房 简 D 风 单 ) 险 + , — 怕 — 5 配 错 自 位 动 机 , 器 推 ( 荐 最 多 3 2 机 房 × 3 2 机 器 )

我的实践:启动时从配置中心(Nacos)注册拿 machineId,重复自动重试——避免手配导致两台机器同 ID。

六、方案对比

方案 性能 有序 依赖 适用
雪花 40万/s 趋势递增 时钟 绝大多数场景
DB 号段 万级 递增 DB 订单号(严格递增需求)
Redis INCR 万级 递增 Redis 简单场景
UUID 快 无序 无 不追求有序
美团 Leaf 雪花增强 递增 ZK/DB 大厂规模化

选型建议:默认雪花;要"严格递增且可回看"(订单)用 DB 号段;大数据量分布式部署用 Leaf 类增强方案。

七、防重的组合拳

ID 生成是防重的地基:

防 方 实 I → 重 案 现 D 场 : : 幂 景 幂 R 的 等 : 等 e 作 键 用 键 d 用 户 i : = 重 = s 幂 复 等 业 提 业 S 键 务 交 务 E 要 语 订 唯 T 有 义 单 一 N " 组 、 键 X 确 合 消 ( 定 ( 息 如 / 性 不 重 生 是 复 u D 成 随 投 s B " 机 递 e ( r 唯 重 I I 一 试 D d 索 时 ) + 引 用 订 同 单 / 一 号 个 ) 状 键 或 态 ) 分 机 布 式 I D

八、踩坑记录

  1. 时钟回拨没处理:NTP 校时 → 重复 ID → 订单串号 → 等待/兜底必做
  2. 机器位配错:两台机器同 machineId → 同毫秒序列冲突 → 注册中心分配
  3. ID 反解滥用:从 ID 能看出时间戳 → 不要暴露业务敏感信息在 ID 里(可混淆)
  4. 性能误判:单机 40 万/s 够用,别为"更高性能"过度设计(除非真有单机百万级)

总结

分布式 ID 的核心认知:

  1. 雪花算法:时间戳 + 机器位 + 序列号,40 万/s,趋势递增
  2. 时钟回拨是头号坑:小回拨等待、大回拨报警兜底
  3. 机器位要自动分配:别手配,避免冲突
  4. ID 是防重的地基:幂等键要确定性生成,业务语义组合

ID 生成看似小事,崩起来是串号、错单级别的灾难。 把雪花算法的坑都踩平了,分布式系统的第一行代码才稳。