← 返回笔记列表

一个系统里两种主键:雪花 ID 与 UUID v7 的分工边界

发布于 来自 Kite

  • 工程笔记
  • Mini-IM
  • Go
  • 分布式ID

为什么一个系统里会有两种主键

写 mini-im 的时候给两类表选了不同的主键:用户与好友申请用雪花 ID,消息表用 UUID v7。 看起来像选型摇摆,其实是两个约束碰在一起的结果:

  • 消息表是增长最快、只增不改的一张,而且将来最可能分库分表;
  • 雪花 ID 要求每个实例拿到全局唯一的节点号,多实例部署时这是一件要人去协调的事。

消息表换成 UUID v7 之后,snowflake.node_id 只影响用户与好友申请两张表,多实例可以直接水平扩容。 代价也清楚:主键宽一倍(8 字节 → 16 字节),二级索引里也要各带一份。

雪花:节点号从哪来

节点号不写在配置文件里,而是启动时推导 —— 取本机非回环 IPv4 的后两段:

const (
    epoch     int64 = 1704067200000 // 2024-01-01 00:00:00 UTC(毫秒)
    NodeBits  uint8 = 10            // NodeMax = 1023
    stepBits  uint8 = 12            // 单节点每毫秒 4096 个
    timeShift       = NodeBits + stepBits // 22
)
// 节点号:(ip[2]<<8|ip[3]) & 1023;取不到网卡就按主机名做 FNV-1a 哈希

同一网段的多台机器天然错开(第三、四段不同),不需要额外的发号服务。 配置文件里仍然留着 node_id 让人显式指定,但越界的值一律视为「自动推导」 —— 越界的节点号会被位移直接丢弃,反而引入重复 ID 的风险,这比自动推导糟糕得多。

时钟回拨:不等待,也不抛错

大多数雪花实现遇到时钟回拨会「等到时钟追上」或者直接报错。这里选了第三条路:

now := time.Now().UnixMilli()
if now < g.lastMs {
    now = g.lastMs // 回拨:沿用上次时间戳,靠序列号继续区分
}
if now == g.lastMs {
    g.step = (g.step + 1) & stepMax
    if g.step == 0 { // 这一毫秒用完了,才真的等下一毫秒
        for now <= g.lastMs { now = time.Now().UnixMilli() }
    }
} else {
    g.step = 0
}

理由很实际:NTP 校正导致的几毫秒回拨非常常见,为它让一次 ID 生成阻塞或失败, 会在最不可预期的时刻把一次注册请求打挂。沿用上次时间戳 + 序列号自增, 唯一性依然成立(同一毫秒内序号不重复),牺牲的只是「ID 严格单调递增」。

UUID v7:同一毫秒用序列号,而不是纯随机

v7 的前 48 位是毫秒时间戳,但后 74 位怎么做是有选择的:

b[6] = 0x70 | byte(s>>8) // 版本号 0111 + 序列号高 4 位
b[7] = byte(s)           // 序列号低 8 位
_, _ = rand.Read(rnd[:]) // 随机位只用于跨进程区分
b[8] = 0x80 | rnd[0]&0x3f

同一毫秒内用序列号递增而不是纯随机,是为了保留「按 ID 排序即按时间排序」这条性质 —— 这正是它比 v4 更适合做主键的原因:新行总是追加到 B-tree 的最右侧,不会像 v4 那样随机插页、 把索引写放大成随机 I/O。

一个必须一起做的决定:对外一律字符串

雪花 ID 是 18~19 位,超过 JS 的 Number.MAX_SAFE_INTEGER(2^53)。 如果直接序列化成数字,前端 JSON.parse 之后末尾几位就已经被静默改掉了, 而这种 bug 只在「某个用户突然看不到自己的数据」时才暴露。 因此所有 ID 出参都带 json:"id,string"。

什么时候该选哪个

雪花 ID UUID v7
主键宽度 8 字节 16 字节
多实例 需要协调节点号 不需要
排序 严格单调 毫秒级单调(同毫秒按序列号)
插入前可知 是 是
适合 量小、要短主键的表 增长快、可能分片的 append-only 表

顺带一提:这张表的时间列是 timestamptz,而「按创建时间生成主键」这件事本身 也值得钉住 —— 测试里断言了主键前 48 位等于 created_at,改一边忘了另一边会直接失败。