7.3 时钟回拨与容错
7.1 的雪花算法把本地时间当成了唯一性的一部分:ID 的高 41 位就是「生成时的毫秒数」。这带来一个硬约束——时间只能往前走,绝不能往后退。一旦某台机器的时钟往回拨了哪怕一毫秒,它就可能重新生成出「刚才已经发过的那个时间戳」,如果序列也恰好撞上,就是一个重复 ID。
很多人的第一反应是「机器时钟怎么会往后退」。答案是:会,而且比你想的常见。这一节先把回拨的三种典型成因讲清楚,再给出三种应对策略以及它们各自的代价,最后写一个时钟可注入的容错生成器——这样回拨场景就不再是「等线上偶发」,而是一个能在本机 go test 里稳定复现的测试用例。
回拨的三种成因
| 成因 | 机制 | 典型幅度 | 可预测性 |
|---|---|---|---|
| NTP 校时 | 机器时间跑偏后,NTP 客户端把本地时钟直接拨回正确值 | 毫秒到秒级 | 低,随时可能 |
| 虚拟机迁移/挂起 | VM 在别的物理机上恢复,或从挂起态唤醒,CLOCK_MONOTONIC 被重置 | 秒到分钟级 | 低,但幅度大 |
| 闰秒 | 闰秒插入时部分系统把时间「回拨 1 秒」而不是「重复 1 秒」 | 1 秒 | 可提前预知 |
三种成因里,NTP 校时最常见。NTP 默认的行为是「步进(step)」——发现偏差超过阈值(通常 128ms)就直接把时钟设成正确值,而不是慢慢追。如果这台机器之前快了 300ms,一次 NTP 同步就会让时钟倒退 300ms。对雪花算法来说,这就是一次回拨。
虚拟机迁移的幅度更大。一台 VM 被迁移到另一台物理机,或者从快照恢复,它看到的「墙上时间」可能一下子退回到几分钟前。这在云上很常见,也是为什么「雪花算法跑在容器/VM 里」比「跑在物理机上」风险更高。
闰秒是可预知的,一年最多一两次。问题在于不同系统处理闰秒的方式不同:有的「重复最后一秒」(23:59:60 出现两次,时间不倒退),有的直接「把时钟回拨一秒」。后者会让雪花算法看到一次 1 秒的回拨。
一句话总结:时钟回拨不是异常,是常态。任何依赖本地时钟的 ID 方案,都必须对它有明确的态度。
三种应对
面对回拨,业界主流有三种态度,没有绝对的对错,只有取舍:
| 策略 | 做法 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 等待追平 | 自旋等到本地时钟追回 lastMs | ID 仍然单调、实现简单 | 回拨期间阻塞,回拨多久就卡多久 | 小幅回拨(NTP 级) |
| 换 worker id | 换一个 worker id 继续发,身份变了就不怕时间重叠 | 不阻塞,不停服 | 需要预留备用 worker 池;ID 来源会变 | 大幅回拨、不能等 |
| 抛错拒绝 | 直接返回错误,让上层决定 | 最安全,绝不产生重复 | 发号功能在回拨期间不可用 | 宁可停服也不出脏数据 |
实际生产里很少只用一种,常见的是组合策略:回拨小于阈值(比如 5ms)就用等待追平,超过阈值就尝试换 worker id,连备用 worker 也没有了才抛错。7.1 里 Snowflake.Next 遇到 now < lastMs 直接返回 ErrClockBackwards,其实是最保守的「抛错拒绝」;本节把它升级成一个可配置的容错版本。
需要特别提醒一点:等待追平的前提是「回拨一定会结束」。如果时钟被拨回 1 小时,自旋等待就是死循环。所以等待策略必须带一个容忍上限(maxBackward),超了就升级为报错——代码里的 WithMaxBackward 就是干这个的。
实现:可注入假时钟的容错生成器
下面这段是 L1,go test 可以直接跑。它复用 7.1 的 Clock 接口与位布局,只是在回拨处加了一个可配置的 recover:
// file: internal/idgen/clockback.go
// tier: L1
// verify: go test ./internal/idgen/
package idgen
import (
"fmt"
"sync"
"time"
)
// Strategy 是时钟回拨时的应对策略。
type Strategy int
const (
// StrategyWait 自旋等待本地时钟追平上次生成时刻,超过容忍上限则报错。
StrategyWait Strategy = iota
// StrategyBorrowWorker 从备用 worker 池借一个 worker id,换身份继续生成。
StrategyBorrowWorker
// StrategyReject 直接拒绝,返回包装了 ErrClockBackwards 的错误。
StrategyReject
)
// TolerantSnowflake 在雪花算法之上加了回拨容错。位布局与 Snowflake 完全一致,
// 因此两种实现生成的 ID 可以混在同一个空间里。
type TolerantSnowflake struct {
mu sync.Mutex
clock Clock
workerID int64
spareWorkers []int64
strategy Strategy
maxBackward int64 // 允许等待的最大回拨毫秒数
lastMs int64
sequence int64
borrowed int64 // 已借用的 worker 次数,供观测
}
// TolerantOption 是可选配置。
type TolerantOption func(*TolerantSnowflake)
// WithStrategy 设置回拨策略。
func WithStrategy(s Strategy) TolerantOption {
return func(g *TolerantSnowflake) { g.strategy = s }
}
// WithMaxBackward 设置等待策略能容忍的最大回拨毫秒数。
func WithMaxBackward(ms int64) TolerantOption {
return func(g *TolerantSnowflake) { g.maxBackward = ms }
}
// WithSpareWorkers 设置可借用的备用 worker id 池(仅 StrategyBorrowWorker 用)。
func WithSpareWorkers(ids ...int64) TolerantOption {
return func(g *TolerantSnowflake) { g.spareWorkers = append([]int64(nil), ids...) }
}
// NewTolerantSnowflake 构造带回拨容错的生成器。
func NewTolerantSnowflake(workerID int64, clock Clock, opts ...TolerantOption) (*TolerantSnowflake, error) {
if workerID < 0 || workerID > MaxWorkerID {
return nil, ErrWorkerRange
}
if clock == nil {
clock = RealClock{}
}
g := &TolerantSnowflake{clock: clock, workerID: workerID, strategy: StrategyWait, maxBackward: 5}
for _, o := range opts {
o(g)
}
return g, nil
}
// WorkerID 返回当前生效的 worker id(借位后可能已改变)。
func (g *TolerantSnowflake) WorkerID() int64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.workerID
}
// Borrowed 返回累计借位次数。
func (g *TolerantSnowflake) Borrowed() int64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.borrowed
}
// Next 生成下一个 ID,并在时钟回拨时按配置的策略恢复。
func (g *TolerantSnowflake) Next() (uint64, error) {
g.mu.Lock()
defer g.mu.Unlock()
now := g.clock.NowMillis()
if now < g.lastMs {
fixed, err := g.recover(now)
if err != nil {
return 0, err
}
now = fixed
}
if now == g.lastMs {
g.sequence = (g.sequence + 1) & MaxSequence
if g.sequence == 0 {
for now <= g.lastMs {
time.Sleep(100 * time.Microsecond)
now = g.clock.NowMillis()
}
}
} else {
g.sequence = 0
}
g.lastMs = now
return compose(now, g.workerID, g.sequence), nil
}
func (g *TolerantSnowflake) recover(now int64) (int64, error) {
switch g.strategy {
case StrategyReject:
return 0, fmt.Errorf("%w: last=%d now=%d", ErrClockBackwards, g.lastMs, now)
case StrategyWait:
back := g.lastMs - now
if back > g.maxBackward {
return 0, fmt.Errorf("%w: 回拨 %dms 超过容忍上限 %dms", ErrClockBackwards, back, g.maxBackward)
}
for now < g.lastMs {
time.Sleep(100 * time.Microsecond)
now = g.clock.NowMillis()
}
return now, nil
case StrategyBorrowWorker:
if len(g.spareWorkers) == 0 {
return 0, fmt.Errorf("%w: 备用 worker 池已空,无法借位", ErrClockBackwards)
}
g.workerID, g.spareWorkers = g.spareWorkers[0], g.spareWorkers[1:]
g.borrowed++
g.sequence = 0
return now, nil
}
return 0, ErrClockBackwards
}
关键点在 Next 里那一句 if now < g.lastMs:所有回拨处理都收敛到这一个分支,正常路径(时间前进)一行都没变,因此不会拖慢绝大多数调用。
真跑:假时钟复现回拨
回拨场景之所以难测,是因为你没法让真实时钟「倒退 5 秒再跑测试」。解法是把时钟抽象成接口——7.1 的 Clock 就是为此留的。测试里注入一个 fakeClock,Advance(-5000) 就能精确制造任意幅度的回拨。
下面这段是 L1,go test 可以直接跑,能真跑出回拨:
// file: internal/idgen/clockback_test.go
// tier: L1
// verify: go test ./internal/idgen/ -run TestBackward -v
package idgen
import (
"errors"
"sync"
"testing"
)
// fakeClock 是可注入的假时钟:可以任意前后拨动,用来复现回拨场景。
type fakeClock struct {
mu sync.Mutex
now int64
}
func (c *fakeClock) NowMillis() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Set(v int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = v
}
func (c *fakeClock) Advance(d int64) { c.Set(c.NowMillis() + d) }
// 成因一:NTP 校时——本地时钟被往回拨。拒绝策略直接报错。
func TestBackwardReject(t *testing.T) {
clk := &fakeClock{now: Epoch + 10_000}
g, err := NewTolerantSnowflake(9, clk, WithStrategy(StrategyReject))
if err != nil {
t.Fatalf("new: %v", err)
}
if _, err := g.Next(); err != nil {
t.Fatalf("first: %v", err)
}
clk.Advance(-5000) // NTP 把时钟往回拨 5 秒
if _, err := g.Next(); !errors.Is(err, ErrClockBackwards) {
t.Fatalf("回拨后应拒绝生成,got %v", err)
}
}
// 成因三:闰秒/大幅校时——回退很大且不能等。借位换 worker 继续生成。
func TestBackwardBorrowWorker(t *testing.T) {
clk := &fakeClock{now: Epoch + 10_000}
g, err := NewTolerantSnowflake(9, clk,
WithStrategy(StrategyBorrowWorker), WithSpareWorkers(11, 12))
if err != nil {
t.Fatalf("new: %v", err)
}
first, _ := g.Next()
if WorkerIDOf(first) != 9 {
t.Fatalf("初始 worker 应为 9,got %d", WorkerIDOf(first))
}
clk.Advance(-60_000) // 回退一分钟,借位策略不等待
second, err := g.Next()
if err != nil {
t.Fatalf("借位策略应成功:%v", err)
}
if got := WorkerIDOf(second); got != 11 {
t.Fatalf("借位后 worker 应为 11,got %d", got)
}
if second == first {
t.Fatal("借位后生成的 ID 不能与旧 ID 相同")
}
if g.Borrowed() != 1 {
t.Fatalf("借位次数应为 1,got %d", g.Borrowed())
}
// 第二次回拨,再借一个
clk.Advance(-60_000)
third, err := g.Next()
if err != nil {
t.Fatalf("第二次借位应成功:%v", err)
}
if WorkerIDOf(third) != 12 {
t.Fatalf("第二次借位后 worker 应为 12,got %d", WorkerIDOf(third))
}
}
本机实跑:
--- PASS: TestBackwardReject (0.00s)
--- PASS: TestBackwardBorrowWorker (0.00s)
--- PASS: TestBackwardWaitCatchesUp (0.02s)
--- PASS: TestBackwardWaitOverLimit (0.00s)
--- PASS: TestBackwardBorrowExhausted (0.00s)
五个回拨用例全绿,覆盖了三种策略各自的正常路径与降级路径:拒绝策略报错、借位策略换到 11 再换到 12、等待策略在 20ms 后追上(真时钟追平由 goroutine 模拟)、等待超限报错、备用池耗尽报错。注意 TestBackwardWaitCatchesUp 里没有 Sleep 出真实回拨——它只是用一个 goroutine 把假时钟往前推,把「时间继续走」这件事精确控制住。假时钟的价值就在这里:回拨的幅度、时机、持续时间全都可以是测试参数,而不是等线上偶发。
本节小结
- 时钟回拨是常态而非异常:NTP 步进校时、虚拟机迁移/挂起、闰秒回拨,三者都会让本地时间倒退,幅度从毫秒到分钟不等。
- 三种应对各有取舍:等待追平不阻塞但会卡住发号、换 worker id 不阻塞但需要备用池、抛错拒绝最安全但发号不可用。生产上通常是「小幅等待 + 大幅借位 + 兜底报错」的组合。
- 等待策略必须带容忍上限,否则遇到小时级回拨就是死循环;
WithMaxBackward就是这个上限。 - 把时钟抽象成
Clock接口,回拨就从「线上偶发」变成了可参数化的测试场景;本节五个 L1 用例全绿,覆盖三种策略的正常与降级路径。 - 7.1 的雪花算法、本节的容错生成器、7.2 的号段模式,位布局与接口完全一致,可以混用:热点主键走雪花,严格递增的序号走号段。
阅读导航:上一节:7.2 号段模式 · 下一节:8.1 Redis 使用与失效策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。