分布式系统里没有「现在」:时钟、超时与租约,一次讲透

2026-11-13 11 0

分布式系统里没有「现在」这种统一概念

单体应用里时间很简单:time.Now() 拿一下,比大小,完事。多机器之后事情变得诡异——A 机器觉得该收租了,B 机器还沉浸在自己的前一秒里,而你手里那条租约凭证,来自一台已经跑偏的时钟。

分布式系统里最恼人的 bug 通常不是数据不一致,而是「时间到了,但它没到」。本文把墙上时钟、单调时钟、逻辑时钟、超时、租约串成一条线,讲清楚为什么你设的 timeout=3s 有时是 1 秒、有时是 30 秒。


一、先分清三种「时间」

操作系统层面至少有三种完全不同的时钟,各有各的脾气:

  • 墙上时钟:date 出来的那个时间,会被人为调整——NTP 校正、夏令时、管理员手改。
  • 单调时钟:只能向前走,永不回拨,用来测「过了多久」绝不会错。
  • CPU 时间:只统计真正执行的时长,sleep 不算。

关键点:墙上时钟回答「现在是什么时候」,单调时钟回答「过了多久」。混用这两者,是分布式超时问题的头号来源。

如果你要计算两个时间点之间的间隔,永远用单调时钟。如果你要把时间告诉别人(写日志、生成 ID、跨机器比对),才用墙上时钟。

Go 做得很好:time.Time 同时携带墙上时间和单调读数,Sub 走单调分量,即使 NTP 把时钟往回拨 3 小时依然准确。但写成 t2.Unix() - t1.Unix(),单调保护立刻消失,你会得到一个负的或夸大的耗时。

// 正确:走单调分量
elapsed := time.Since(start)

// 错误:可能被时钟回拨影响
elapsed := time.Now().Unix() - start.Unix()

二、多机之间:没有共享的「现在」

单机时钟不准误差有界;两台机器的时钟相差 3 秒,问题就来了。

典型锁场景:SET lock:order_42 owner=A NX PX 30000。锁有 30 秒 TTL,A 拿到后业务跑了 29 秒。这时 A 网络卡了、GC 停顿了,实际跑了 31 秒——锁早过期,B 并发执行,而 A 毫不知情,两个持有者同时操作同一份资源。

关键不是时钟准不准,而是「持锁者无法确知锁是否还属于自己」。

更隐蔽的是时钟漂移:A 的 RTC 慢 2 秒,B 的快 2 秒,B 看到的「现在」就比 A 快 4 秒。同一份数据 A 认为 TTL 没到,B 认为早过期了,故障表现为「锁偶尔失效」,重启就好。


三、超时真相:超时不是「到点就断」

翻译成超时语义:超时只意味着「我没在预期时间内拿到结果」,绝不意味着「对方没执行」或「执行失败了」。

对客户端来说超时后的状态是未知而非失败,所以重试是必需的,但重试必须幂等,否则一次网络抖动就变成两份订单。

还有一层现实:超时计时起点不一定是你的调用时刻。请求在发送缓冲区排了 2 秒(连接池满)算不算?DNS 解析花了 3 秒算不算?TCP 握手花了 1 秒呢?更别提服务端处理了 5 秒才响应,而客户端 3 秒就断了,服务端那 5 秒还在继续烧 CPU。

所以「设一个总超时」往往不够,你需要分层超时:

// Go:整体 deadline + 每个子操作的 context
ctx, cancel := context.WithTimeout(parent, 800*time.Millisecond)
defer cancel()

reqCtx, reqCancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer reqCancel()
resp, err := client.Do(req.WithContext(reqCtx))

关键原则是 子操作超时必须小于父级,并留出「序列化 + 传输 + 重试」的余量,否则父级已放弃、子操作还在跑,资源白烧。


四、租约:把「时间判断」换成「身份判断」

回到那把会过期的锁。既然靠时钟判断「锁还有效吗」不可靠,正确姿势就是:不要判断,让服务端告诉你你是谁。

这就是租约(Lease)的核心思想,三个关键设计:

  1. 唯一持有者标识:每次获取锁生成一个全局唯一的 token(UUID / 单调递增序列号)。
  2. 所有写操作都要带 token:释放锁时必须校验 token 匹配。
  3. 校验发生在服务端,用服务端自己的时钟和状态,不依赖客户端的判断。
-- 获取:value 存 token,不是简单的 "A"
SET lock:order_42 <token> NX PX 30000

-- 释放:必须比对 token,Lua 保证原子性
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

但这只解决「过期后误删别人的锁」。真正的问题是「过期后我还以为锁是我的,继续写数据」,需要更狠的 Fencing Token(栅栏令牌)。

每次成功获取租约,服务端返回一个单调递增的编号。客户端拿着这个编号去写下游。下游(比如对象存储、数据库)记住见过的最大编号,收到更小的编号就直接拒绝。

这样,即使 A 的租约过期后 B 拿到新租约(token=101),A 拿旧 token=100 去写,下游一看「我见过 101 了」直接拒绝。不需要任何时钟同步,安全性只依赖令牌的单调性。

// 下游写入时校验 fencing token
if req.FencingToken < maxTokenSeen {
    return ErrStaleWriter   // 这是个过期持有者,拒绝
}
maxTokenSeen = req.FencingToken
// 真正执行写入

这就是把「时间问题」转化成「顺序问题」的经典思路:不要问「现在几点」,问「我是第几个」。


五、逻辑时钟:给事件排序,不问时间

如果需求是「判断两个事件的先后顺序」而不是「相隔多久」,墙上时钟帮不上忙——两台机器的时钟本来就没有可比性。

逻辑时钟(Lamport Clock / Hybrid Logical Clock)干的就是这件事:给每个事件打一个本地单调递增的标签,遵守因果关系。

// Lamport Clock 的核心规则
LocalClock++
onSend:   LocalClock++  // 发消息时带上时间戳
onReceive(m):
    LocalClock = max(LocalClock, m.ts) + 1

规则极简,但保证一条铁律:如果 A 因果在 B 之前,A 的时间戳一定小于 B。它保证不了「时间戳小的就在前面」——并发事件可能乱序,这正是现实本来的样子。

HLC 在此基础上拼进物理时间,标签既满足因果序又接近真实时间,适合做分布式唯一 ID。代价是必须有一个可信的物理时钟源,一旦跳变标签就不连续——这个坑很隐蔽。


六、实战清单:我在项目里踩过的六个坑

坑一:用 time.Sleep 做重试退避。写死 100ms,慢机器上变忙等,快机器上白浪费。至少用带抖动的指数退避:

backoff := base * math.Pow(2, attempt)
jitter := rand.Float64() * backoff * 0.5
time.Sleep(time.Duration(jitter) * time.Millisecond)

抖动不是可选项。没有抖动的重试叫惊群:服务端刚恢复,所有客户端同时打过来,又把它打挂了。

坑二:把 Ticker 当定时器用。任务慢了就堆积,正确做法是上一轮没结束就不启动下一轮;time.After 在长循环里还会内存泄漏,改用 NewTimer 加 defer t.Stop()。

坑三:用 time.After 在长循环里定时器。它在触发前不会被回收,长循环里就是内存泄漏的温床。改用 time.NewTimer 并记得 defer t.Stop()。

坑四:超时了不取消下游。客户端超时返回了,服务端还在满负荷处理,用户猛点 10 次你就得替他把活干 10 遍。正确做法是把 context 一路传下去。

坑五:跨机器用客户端时钟做超时判断。把客户端报的超时值喂给服务端调度逻辑,等于让不可信输入决定服务端行为。超时预算应由服务端定义,或双方取最小值。

坑六:所有机器共用一个 NTP 源还不监控。NTP 偏移量本身就该是监控指标。时钟偏了,TLS 证书校验、token 过期判断、日志排序全出问题——而且不报错,只表现为「偶发」。


七、一句话总结

时间问题浓缩成三条:

  1. 测间隔用单调时钟,不要用墙上时钟做减法。
  2. 跨机器判断顺序用逻辑时钟或令牌,不要用时间戳大小。
  3. 超时是「不确定」,不是「失败」;重试必须幂等,写操作必须带 fencing token。

把「时间」从判断依据降级为参考信息,是分布式系统里最省心的思维转变。难缠的竞态问题,换成「我是第几个」之后忽然都变得可解。

至于那些说「我们集群在同一机房,NTP 肯定准」的团队——祝你们的时钟永远别跳变。等到那天凌晨三点全量任务集体提前触发,你就会想起这篇文章。

相关文章

我们给支付接口加了幂等键,结果它照样扣了三次钱
Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解
你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天

发布评论