订单号在支付回调时重复了
大促零点,订单服务从 8 个 Pod 扩到 24 个。新 Pod 用的是同一份镜像,NODE_ID 从 ConfigMap 读取,而 ConfigMap 里只有一个默认值 0。前 8 个节点恰好分布在 0 到 7,扩容后 16 个新节点全部认为自己也是 0。Snowflake 仍然按毫秒递增,序列号也在递增,于是两个节点在同一毫秒、同一序列位置生成了完全相同的订单号。支付回调按订单号幂等,第二次回调被当成重复请求丢弃,一笔真实付款没有落到订单上。
常见直觉是“Snowflake 有时间戳,所以天然唯一”。时间戳只解决同一节点内的时间前进问题;它不解决机器号冲突,也不解决时钟回拨。另一个直觉是“回拨了就让线程睡到时间追上”,如果回拨是 10 分钟,请求会挂 10 分钟,靠超时兜底时订单已经失败。
站内 分布式 ID 生成方案 已经比较过 UUID、数据库自增与 Snowflake 的取舍,本节不重复选型百科,只把位分配、回拨处理和号段双缓冲写成可以跑测试的代码,再用真实输出说明每条不变量的边界。
41 + 10 + 12 位各管什么
arch-lab 的订单号、支付单号、库存预占单号共用一套 64 位布局:
| 区段 | 位数 | 含义 | 容量与边界 |
|---|---|---|---|
| 符号位 | 1 | 恒为 0 | 保证 ID 为正数 |
| 时间戳 | 41 | 相对自定义 epoch 的毫秒数 | 约 69.7 年 |
| 机器号 | 10 | 节点编号 | 0–1023,必须全局唯一 |
| 序列号 | 12 | 同一毫秒内的序号 | 0–4095,每毫秒每节点 4096 个 |
唯一性依赖两个条件同时成立。第一,任意两个节点在同一时刻的机器号不同;第二,同一节点内 (毫秒, 序列号) 对不会重复。第二个条件由“同一毫秒内序列递增、跨毫秒归零”保证,但它有一个前提:时钟不能倒退。时钟一旦回拨到已经用过的毫秒,序列号会从 0 重新开始,旧 ID 就会被重新生成。
这里的 4096 是单节点单毫秒的上限,不是系统吞吐承诺。真实 QPS 还受锁竞争、网络和下游写入影响;超过上限时生成器必须等到下一毫秒,不能把序列号回绕后继续发号。
可运行的 Snowflake
下面的实现把时钟与 sleep 都抽成接口,测试可以精确制造回拨和序列耗尽,不依赖真实等待。小回拨(默认 5ms 内)通过等待吸收;超过阈值直接返回错误,让调用方快速失败,而不是悄悄发出可能重复的号。
// file: internal/platform/idgen/snowflake.go
// tier: L1
// verify: go test ./idgen/ -run TestSnowflake -count=1
package idgen
import (
"errors"
"fmt"
"sync"
"time"
)
const (
epochMillis int64 = 1704067200000 // 2024-01-01T00:00:00Z
nodeBits = 10
stepBits = 12
maxNode int64 = (1 << nodeBits) - 1
maxStep int64 = (1 << stepBits) - 1
maxBackward = 5 * time.Millisecond
)
var (
// ErrClockBackwards means the wall clock jumped back too far to absorb.
ErrClockBackwards = errors.New("idgen: clock moved backwards")
// ErrInvalidNode means the node id does not fit in nodeBits.
ErrInvalidNode = errors.New("idgen: node id out of range")
)
// Clock is the wall clock used by Snowflake, injectable for tests.
type Clock interface {
Now() time.Time
Sleep(time.Duration)
}
// SystemClock is the production clock.
type SystemClock struct{}
// Now returns the current wall time.
func (SystemClock) Now() time.Time { return time.Now() }
// Sleep pauses the caller for d.
func (SystemClock) Sleep(d time.Duration) { time.Sleep(d) }
// Snowflake packs millis|node|step into one int64.
type Snowflake struct {
mu sync.Mutex
node int64
clock Clock
lastMS int64
step int64
}
// NewSnowflake validates node and returns a generator.
func NewSnowflake(node int64, clock Clock) (*Snowflake, error) {
if node < 0 || node > maxNode {
return nil, fmt.Errorf("snowflake: node %d: %w", node, ErrInvalidNode)
}
return &Snowflake{node: node, clock: clock, lastMS: -1}, nil
}
// Next returns the next id. A backwards jump up to maxBackward is absorbed by
// waiting; a larger jump fails rather than risking duplicate ids.
func (s *Snowflake) Next() (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
nowMS := s.clock.Now().UnixMilli()
if nowMS < s.lastMS {
drift := s.lastMS - nowMS
if drift > maxBackward.Milliseconds() {
return 0, fmt.Errorf("snowflake: now=%d last=%d drift=%dms: %w", nowMS, s.lastMS, drift, ErrClockBackwards)
}
s.clock.Sleep(time.Duration(drift) * time.Millisecond)
nowMS = s.lastMS
}
if nowMS == s.lastMS {
s.step++
if s.step > maxStep {
for nowMS <= s.lastMS {
s.clock.Sleep(time.Millisecond)
nowMS = s.clock.Now().UnixMilli()
}
s.step = 0
}
} else {
s.step = 0
}
s.lastMS = nowMS
return ((nowMS - epochMillis) << (nodeBits + stepBits)) | (s.node << stepBits) | s.step, nil
}
代码里有三个决定行为的细节。lastMS 初始为 -1,保证第一次调用一定走“新毫秒”分支;序列耗尽时在循环里等待时钟真正越过 lastMS,而不是简单地把序列归零;回拨被吸收后把 nowMS 强制设为 lastMS,即使真实时钟已经多走了一点,也继续在同一毫秒内递增序列,避免生成更小的 ID。
回拨不是“睡一下”就完事
回拨的处理策略取决于回拨幅度和业务对失败的态度:
| 回拨幅度 | 推荐动作 | 代价 | 不适用场景 |
|---|---|---|---|
| 小于 5ms | 等待追平,继续发号 | 少量延迟 | 对尾延迟极敏感且无法排队 |
| 5ms 到数秒 | 快速失败并告警 | 当前请求失败,可重试 | 不能失败的同步下单 |
| 大于数秒 | 停止发号,切换备用节点 | 需要机器号与路由配合 | 单节点部署 |
| 未来时间被借用过 | 禁止再借,等真实时间追上 | 期间吞吐为 0 | 高频发号 |
“借用未来时间”是另一种常见做法:回拨时直接把时间戳设为 lastMS+1。它能让 ID 继续单调,但会把未来一段时间内的毫秒提前消费掉;如果借得太多,真实时间追上之前,每一毫秒的序列都会很快耗尽,吞吐骤降。更危险的是,多个节点各自借用未来时间,回拨结束后同一毫秒的序列分布会被打乱,排障时很难解释。
还有一个边界:检测到回拨时,当前毫秒可能已经发出去过 ID。此时不能只比较“当前时间小于上次时间”,还要确认回拨后的毫秒是否真的被使用过;如果节点刚启动、lastMS 还没有值,回拨本身不构成重复风险。实现上把 lastMS 当作“本节点用过的最大毫秒”即可,重启后的空洞由机器号与持久化状态处理。
号段模式:把数据库从每单一次变成每千单一次
Snowflake 依赖时钟,号段模式依赖数据库。每次向数据库申请一段 [Start, End),在内存里递增分配;快用完时预取下一段,形成双缓冲。数据库只在换段时被访问,正常情况下每千个 ID 一次。
// file: internal/platform/idgen/segment.go
// tier: L1
// verify: go test ./idgen/ -run TestSegment -count=1
package idgen
import (
"errors"
"fmt"
"sync"
)
const prefetchAt int64 = 200
// ErrStoreUnavailable means the segment store could not be reached.
var ErrStoreUnavailable = errors.New("idgen: segment store unavailable")
// Segment is a half-open range [Start, End).
type Segment struct {
Start int64
End int64
}
// SegmentStore hands out monotonically increasing segments. The production
// implementation is one UPDATE ... RETURNING transaction per call.
type SegmentStore interface {
NextSegment(name string, step int64) (Segment, error)
}
// MemorySegmentStore is a deterministic in-process SegmentStore.
type MemorySegmentStore struct {
mu sync.Mutex
next int64
fail bool
calls int
}
// NewMemorySegmentStore starts allocating at start.
func NewMemorySegmentStore(start int64) *MemorySegmentStore {
return &MemorySegmentStore{next: start}
}
// NextSegment returns the next range or fails when injected.
func (s *MemorySegmentStore) NextSegment(_ string, step int64) (Segment, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
if s.fail {
return Segment{}, ErrStoreUnavailable
}
seg := Segment{Start: s.next, End: s.next + step}
s.next = seg.End
return seg, nil
}
// SetFail toggles the injected store failure.
func (s *MemorySegmentStore) SetFail(fail bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.fail = fail
}
// Calls reports how many segment requests were made, including failures.
func (s *MemorySegmentStore) Calls() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
// SegmentAllocator serves ids from a current segment and keeps one prefetched
// segment as a buffer.
type SegmentAllocator struct {
mu sync.Mutex
store SegmentStore
name string
step int64
cur *Segment
next *Segment
curID int64
fetches int
}
// NewSegmentAllocator returns an allocator with no buffered segment.
func NewSegmentAllocator(store SegmentStore, name string, step int64) *SegmentAllocator {
return &SegmentAllocator{store: store, name: name, step: step}
}
// Next returns the next id, fetching or switching segments as needed.
func (a *SegmentAllocator) Next() (int64, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.cur == nil || a.curID >= a.cur.End {
if a.next != nil {
a.cur, a.next = a.next, nil
} else {
seg, err := a.store.NextSegment(a.name, a.step)
if err != nil {
return 0, fmt.Errorf("idgen: allocate %q: %w", a.name, err)
}
a.fetches++
a.cur = &seg
}
a.curID = a.cur.Start
}
id := a.curID
a.curID++
if a.next == nil && a.cur.End-a.curID <= prefetchAt {
// Prefetch failure is recorded by the store and retried on the next
// call; the current segment keeps serving ids in the meantime.
if seg, err := a.store.NextSegment(a.name, a.step); err == nil {
a.fetches++
a.next = &seg
}
}
return id, nil
}
// Fetches reports how many segment ranges were obtained.
func (a *SegmentAllocator) Fetches() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.fetches
}
双缓冲的关键不是“多拿一段”,而是失败时的行为。预取失败被吞掉,当前段继续服务;等到当前段耗尽、缓冲区为空时,下一次取号才向调用方返回错误。这样数据库抖动几毫秒不会立刻打断发号,但也不会掩盖持续故障。生产实现还要把 NextSegment 的数据库更新做成单条原子语句,例如 UPDATE id_alloc SET max_id = max_id + ? WHERE name = ? RETURNING max_id,否则两个实例会拿到重叠的区间。
号段 ID 只在单个实例内单调,跨实例不保证顺序。用 ORDER BY id 做跨实例时间排序会出错;需要全局顺序时应使用时间戳或逻辑时钟,而不是号段。
本机实测:唯一性、回拨与故障恢复
在 .local/checks/be-arch-dist-ch10 的独立 module 中运行,Go 1.26.0、darwin/arm64,全部为进程内测试:
$ go test ./idgen/ -v -count=1
=== RUN TestSnowflakeLayout
--- PASS: TestSnowflakeLayout (0.00s)
=== RUN TestSnowflakeInvalidNode
--- PASS: TestSnowflakeInvalidNode (0.00s)
=== RUN TestSnowflakeMonotonic
--- PASS: TestSnowflakeMonotonic (0.00s)
=== RUN TestSnowflakeConcurrentUnique
--- PASS: TestSnowflakeConcurrentUnique (0.01s)
=== RUN TestSnowflakeRollbackAbsorbed
--- PASS: TestSnowflakeRollbackAbsorbed (0.00s)
=== RUN TestSnowflakeLargeRollback
--- PASS: TestSnowflakeLargeRollback (0.00s)
=== RUN TestSnowflakeSequenceExhaustion
--- PASS: TestSnowflakeSequenceExhaustion (0.00s)
=== RUN TestSegmentUniqueAndMonotonic
--- PASS: TestSegmentUniqueAndMonotonic (0.00s)
=== RUN TestSegmentRecoversAfterStoreFailure
--- PASS: TestSegmentRecoversAfterStoreFailure (0.00s)
=== RUN TestSegmentConcurrentUnique
--- PASS: TestSegmentConcurrentUnique (0.00s)
PASS
ok archlab/idgen 0.295s
$ go test ./idgen/ -race -count=1
ok archlab/idgen 1.354s
读数要结合测试内容。TestSnowflakeMonotonic 连续取 5000 个号,每 97 个推进 2ms,验证单节点内严格递增;TestSnowflakeConcurrentUnique 用 8 个 goroutine 各取 2000 个,共 16000 个 ID,验证唯一性,同一毫秒内序列耗尽会触发等待。TestSnowflakeRollbackAbsorbed 把时钟回拨 2ms,生成器等待后继续,第三个 ID 仍大于第二个;TestSnowflakeLargeRollback 回拨 20ms,返回 ErrClockBackwards。TestSnowflakeSequenceExhaustion 在同一毫秒连续取 4105 个号,确认序列回绕时会推进时钟而不是重复。
号段测试用 step=100 取 10000 个号,严格连续,Fetches() 为 101:100 个区间,加上最后一段触发的 1 次预取。故障恢复测试先正常取 100 个,注入数据库不可用后再取 100 个(当前段仍可服务),第 101 次返回 ErrStoreUnavailable;恢复后第一个号是 201,说明失败区间没有被重复发放。8 个 goroutine 各取 2000 个号段 ID 也没有重复。
这些数字是单进程内存实现的验证,不是生产数据库的分配吞吐,也没有模拟网络分区和主从切换。真实环境需要验证数据库事务的原子性、连接池耗尽时的超时行为,以及实例重启后未用完的号段如何处理(通常直接丢弃,允许空洞)。
选型判据
| 方案 | 依赖 | 单调性 | 时钟敏感 | 主要风险 |
|---|---|---|---|---|
| Snowflake | 机器号分配 | 单节点严格递增 | 是,回拨需处理 | 机器号重复、回拨、序列耗尽 |
| 号段模式 | 数据库 | 单实例递增,跨实例无序 | 否 | 数据库故障、段空洞、ID 泄露量 |
| UUIDv7 | 无 | 近似时间有序 | 是,但冲突概率极低 | 128 位存储与索引成本 |
| 数据库自增 | 单库 | 全局递增 | 否 | 单点瓶颈、分库后不唯一 |
判据可以压缩成三个问题。第一,ID 是否要暴露给用户并参与排序?如果是,Snowflake 或 UUIDv7 更合适,号段会出现跨实例乱序。第二,能否接受数据库在换段时短暂不可用?如果能,号段的实现和运维成本最低。第三,机器号由谁分配、如何防止重复?如果回答不了,Snowflake 不适合直接上线。
常见错误与边界
第一,所有实例共用默认机器号。镜像、Helm chart 或 ConfigMap 里的默认值最容易复制;必须在启动时校验机器号唯一,例如从分配服务获取并持久化,冲突时拒绝启动,而不是回退到 0。
第二,把回拨处理写成无限等待。回拨 10 分钟时 sleep 10 分钟,请求早已超时;正确做法是设置吸收上限,超过就失败并告警,由上层决定重试或切换节点。
第三,号段 step 固定不变。step 太小会让数据库成为瓶颈,太大则实例重启时浪费大量 ID,且故障后空洞更明显。应按发号速率动态调整,并给 step 设上限。
第四,把 Snowflake 当成全局单调时钟。跨节点只能保证大致按时间递增,不能保证任意两个 ID 的先后就是事件发生的先后;需要严格顺序时使用逻辑时钟或版本号,不能靠 ID 大小推断因果关系。
第五,号段故障时降级到 UUID。ID 类型和排序语义会立刻分叉,下游按数值比较、分片路由或数据库列类型都会出问题;降级方案必须保持同一类型和同一单调语义。
小结
分布式 ID 的唯一性由“机器号不重复”和“时钟不回拨”共同保证,缺一不可。Snowflake 用 41/10/12 位换取了本地发号性能和大致时间有序,代价是必须处理回拨与序列耗尽;号段模式把数据库访问降到每段一次,代价是跨实例无序和段空洞。本机的 16000 个并发 ID 与 10000 个号段 ID 验证了实现层面的唯一性和故障恢复,但没有验证生产数据库与真实网络。ID 解决的是“谁是谁”,下一节要解决的是“同一时刻谁有权改”,也就是租约锁与 fencing token。
阅读导航:上一节:冲正、补偿与资金类操作的最终一致 · 下一节:分布式锁:租约、fencing token 与 Redis 边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。