一个系统里两种主键:雪花 ID 与 UUID v7 的分工边界
为什么一个系统里会有两种主键
写 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,改一边忘了另一边会直接失败。