分布式系统里没有「现在」这种统一概念
单体应用里时间很简单: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)的核心思想,三个关键设计:
- 唯一持有者标识:每次获取锁生成一个全局唯一的 token(UUID / 单调递增序列号)。
- 所有写操作都要带 token:释放锁时必须校验 token 匹配。
- 校验发生在服务端,用服务端自己的时钟和状态,不依赖客户端的判断。
-- 获取: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 过期判断、日志排序全出问题——而且不报错,只表现为「偶发」。
七、一句话总结
时间问题浓缩成三条:
- 测间隔用单调时钟,不要用墙上时钟做减法。
- 跨机器判断顺序用逻辑时钟或令牌,不要用时间戳大小。
- 超时是「不确定」,不是「失败」;重试必须幂等,写操作必须带 fencing token。
把「时间」从判断依据降级为参考信息,是分布式系统里最省心的思维转变。难缠的竞态问题,换成「我是第几个」之后忽然都变得可解。
至于那些说「我们集群在同一机房,NTP 肯定准」的团队——祝你们的时钟永远别跳变。等到那天凌晨三点全量任务集体提前触发,你就会想起这篇文章。