《后端架构设计·分布式与数据一致性》10.1 分布式 ID:Snowflake、号段与时钟回拨

订单号生成器看似只需时间戳加机器号,真实故障却来自时钟回拨、机器号重复和单毫秒序列溢出。本节用可运行的 Go 实现拆解 Snowflake 的位分配与回拨策略,再给出号段模式的双缓冲与数据库故障恢复,并用并发测试验证唯一性、单调性与重启后的续号行为。

订单号在支付回调时重复了

大促零点,订单服务从 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 边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查