分布式系统没有统一的"现在": 物理钟会说谎 (漂移/回拨), 逻辑钟才讲因果 — 计时用单调钟, 排序用因果, 展示才用墙钟
每台服务器都戴一块自己的表, 走得快慢不一, 还时不时被人 (NTP) 拨一下。你说"我先到的"凭的是你的表, 我说"我先到的"凭的是我的表 — 两块表对不上, "谁先谁后"就成了罗生门。唯一的出路是不比表, 比"谁看见了谁的动作": 支付看见了订单, 支付必然在后, 这就是 happens-before。
于是三种钟分工: 计时 (耗时/超时) 用单调钟, 它只进不退; 排序 (因果) 用逻辑钟, Lamport/Vector/HLC 都是"看见消息就推进"的不同记账法; 给人看的时间戳用墙钟, 但别拿它做任何正确性判断。NTP 负责让墙钟别太离谱 — makestep 纠得快但会回拨, slew 平滑但慢, 选哪个取决于业务怕不怕回拨。
a := time.Now().UnixNano() // 在机器 A 上执行 b := time.Now().UnixNano() // 在机器 B 上"同一瞬间"执行 _ = b - a // 关键: 差值 = skew, ms 级常态, 断 NTP 可到秒级
makestep 在偏移过大时直接阶跃 (快但回拨), 小偏移默认用 slew 渐变 (不回拨但慢)。 $ chronyc tracking
System time : 0.0000231 seconds fast of NTP time
Leap status : Normal # 关键: 偏移常驻监控, 阶跃必须有告警time.Now 底层同时取墙钟+单调钟两个读数)。适合一切"测量时长"的场景; 但数值只在本机本次开机内有意义, 不能落库、不能跨进程比较。 start := time.Now() // 同时记下墙钟+单调钟两份读数 time.Sleep(3 * time.Second) el := time.Since(start) // 关键: 用单调读数, NTP 回拨也不影响 // → 3s (用墙钟相减, 回拨时会得到 -2s)
time.Now().UnixNano() / System.currentTimeMillis(), 对应日历时间, 会被 NTP 阶跃、闰秒、人工修改改写, 甚至倒退。只配两件事: 展示给人和粗略日志筛选, 不配做任何正确性判断。 t0 := time.Now().UnixNano()
t1 := time.Now().UnixNano()
// 关键: t1 < t0 完全可能 — NTP 阶跃回拨的瞬间就发生// a → b 仅两种来源: 同进程程序序; 消息 send → recv 链 // 关键: 从不通信的两个事件是并发 (concurrent), 判先后=玄学 // → 排序前先问: 这两个事件有因果吗? 没有就别排
func (n *Node) Recv(m Msg) { n.lc = max(n.lc, m.LC) + 1 } // Go 1.21+ max // 本地 lc=2, 收到 m.LC=5 → n.lc 变 6, 对端的因果被记住 // 关键: a→b ⇒ c(a) < c(b); 反之不成立, 钟号小未必发生在先
// A:[1,0] B:[0,1] → 互不支配 = 并发 (Dynamo 冲突, 交客户端合并) // A:[2,1] B:[1,1] → B 被 A 支配, B 可被安全覆盖 // 关键: 向量钟是唯一能"发现并发"的廉价手段
// 收消息: l = max(l, m.l, physicalNow); 若 l 未增则 c++ 否则 c=0 // 排序: 先比 l 再比 c 再比 nodeID → 全序且保因果 // 关键: 墙钟回拨时 l 不跟着缩, HLC 依旧单调 — 这就是"混合"的意义
-- 错: ORDER BY created_at -- 各机墙钟, skew 排乱因果 -- → 支付(09:59:59) 排在 创建(10:00:03) 前面 -- 对: ORDER BY hlc, node_id -- 逻辑序定先后, 墙钟只给人看
// 偏序: a→b, c→d, 但 b 与 c 谁先? 无因果, 不可比 // 全序: 追加打破平局键 (nodeID) → b#2 与 c#1 也能比出先后 // 关键: 日志系统要全序, 业务正确性只要偏序别排错
if now < s.lastTS { wait := time.Duration(s.lastTS-now) * time.Millisecond if wait <= 5*time.Second { time.Sleep(wait) } // 小回拨: 等追平 return 0, ErrClockBackwards // 关键: 大回拨拒绝发号, 宁断勿重 }
CreateTime (生产者设置, 反映业务钟) 或 LogAppendTime (broker 落盘时刻), topic 级配置 message.timestamp.type。两种语义混存的流按 timestamp 排序必乱。 # topic 级配置: 二选一, 全链路统一语义 bin/kafka-topics.sh --create --topic orders --config message.timestamp.type=CreateTime # 关键: CreateTime=业务钟(生产者机器), LogAppendTime=broker 钟, 不可混用
// 两个请求几乎同时改同一 key, 互相看不见 = 并发写 // LWW 按墙钟挑"新值": 两机 skew 3s 时, 真正的新值反而会输 // 关键: 并发写用逻辑版本合并, 或让同一 key 永远单点写
chrony 阶跃回拨几百毫秒, 发号器立刻产出重复 ID, 订单表唯一键报 Duplicate entry。发号器必须内置回拨兜底。
func (s *Snowflake) Next() (int64, error) { now := time.Now().UnixMilli() // Go 1.17+ if now < s.lastTS { // 检测到回拨 drift := time.Duration(s.lastTS-now) * time.Millisecond if drift <= 2*time.Second { time.Sleep(drift + time.Millisecond) // 关键: 小回拨等钟追平再发 now = time.Now().UnixMilli() } else { alertClockBackwards(drift) // 大回拨: 拒绝发号 + 告警 return 0, fmt.Errorf("clock backwards %v, refuse to issue", drift) } } if now == s.lastTS { s.seq = (s.seq + 1) & 0xFFF // 同毫秒内序列号递增 (12bit) if s.seq == 0 { // 4096 用尽: 自旋等下一毫秒 for time.Now().UnixMilli() <= s.lastTS { time.Sleep(100 * time.Microsecond) } } } else { s.seq = 0 } s.lastTS = now return (now-epoch)<<22 | s.machine<<12 | s.seq, nil }
兜底上线后, 阶跃回拨从"资损事故"降级为一条拒绝发号的告警。
内网机器没配 NTP, 晶振自由漂移, cron 和业务窗口全部失准。指向机房自建源 + 明确 makestep 策略一次配平。
# /etc/chrony.conf — 内网客户端: 指向机房自建源 server ntp1.corp.internal iburst minpoll 4 maxpoll 10 server ntp2.corp.internal iburst driftfile /var/lib/chrony/drift makestep 1 3 # 关键: 前 3 次采样偏移 >1s 才阶跃, 之后只 slew 不回拨 rtcsync # 系统钟写回硬件钟, 重启后不裸奔 # 验证: chronyc tracking 看 "System time" 偏移; chronyc sources -v 看源状态
日志聚合按 @timestamp 排, 跨机事件顺序颠倒。给每个服务嵌 Lamport 计数, 事件带 (lc, nodeID), 聚合端按逻辑序排。
type Node struct { id string lc uint64 } func (n *Node) Emit(event string) Envelope { // 本地事件/发送 n.lc++ return Envelope{LC: n.lc, Node: n.id, Event: event} } func (n *Node) Recv(e Envelope) { // 收到外部事件 if e.LC > n.lc { n.lc = e.LC } n.lc++ // 关键: max + 1, 因果被固化进序号 } // 聚合端排序: 先比 LC, 相同用 Node 打破平局 // sort.Slice(envs, func(i, j int) bool { // if envs[i].LC != envs[j].LC { return envs[i].LC < envs[j].LC } // return envs[i].Node < envs[j].Node })
LWW 按墙钟裁决新旧, 两端机器钟有偏差时"新值"会输。Dynamo 风格: 向量钟识别并发写, 并发就保留双版本显式合并。
type VClock map[string]uint64 func merge(a, b VClock) VClock { // 写入时合并双方向量 m := VClock{} for k, v := range a { m[k] = v } for k, v := range b { if v > m[k] { m[k] = v } } return m } // happens(a, b): a 每个分量 >= b 且至少一个 > → a 支配 b, 可覆盖 // 互不支配 → 并发: 不能覆盖, 保留 siblings 交上层合并 // 关键: 把"悄悄丢更新"变成"显式冲突", 合并策略业务自己定
监控图出现负耗时: 计时用了墙钟差值, NTP 阶跃回拨 120ms。统一改成单调钟计时, 一劳永逸。
// 错法: start.UnixNano() 差值 — 回拨时得负数 // 对法: time.Now() 在 Go 里同时携带 monotonic 读数 start := time.Now() doWork() elapsed := time.Since(start) // 关键: 内部只用单调读数, 回拨免疫 log.Info("cost", "ms", elapsed.Milliseconds()) // → 永远 >= 0; NTP 阶跃/闰秒都不影响 // Java 同理: System.nanoTime() 单调, currentTimeMillis() 会跳 // long s = System.nanoTime(); ... (System.nanoTime() - s) / 1_000_000;
中心 TSO 每次取号多一跳, 各机墙钟又不可信。HLC: 墙钟部分保可读与粗排, 逻辑部分保因果单调。
type HLC struct{ Wall, Log int64 } // max64: 多值取大, 实现为普通 if 比较 func (h *HLC) Emit(nowMs int64) HLC { // 本地事件 if nowMs > h.Wall { return HLC{nowMs, 0} // 物理钟推进, 逻辑归零 } return HLC{h.Wall, h.Log + 1} // 同毫秒并发事件, 逻辑位递增 } func (h *HLC) Recv(m HLC, nowMs int64) HLC { w := max64(h.Wall, m.Wall, nowMs) switch { case w == h.Wall && w == m.Wall: return HLC{w, max64(h.Log, m.Log) + 1} case w == h.Wall: return HLC{w, h.Log + 1} case w == m.Wall: return HLC{w, m.Log + 1} default: return HLC{w, 0} } } // 关键: 墙钟回拨时 nowMs 变小, w 不缩水 — HLC 仍单调
库里存的是部署机本地时区的字符串, 海外节点写入偏 8 小时。规范: 存 UTC, 展示时转用户时区, 谁展示谁转换。
// 写: 一律 UTC 落库 (DATETIME 或 BIGINT epoch ms 均可) _, err := db.Exec("INSERT INTO orders(id, created_at) VALUES(?, ?)", id, time.Now().UTC().Format("2006-01-02 15:04:05")) // 读: 库里拿到 UTC, 按用户 profile 的时区渲染 loc, _ := time.LoadLocation("Asia/Shanghai") t, _ := time.ParseInLocation("2006-01-02 15:04:05", utcStr, time.UTC) fmt.Println(t.In(loc).Format("2006-01-02 15:04:05 MST")) // → 2026-09-26 14:30:00 CST (UTC 06:30 + 8h) // 关键: 库里没有任何本地时区数据, 谁展示谁转换
业务运行中的机器, 阶跃校正就是"时间原子弹": 定时器被跳过或重放, 雪花 ID 直接重复。运行期只允许 slew 渐变。
# /etc/chrony.conf — 已承载业务的机器: 防运行期阶跃 makestep 1 3 # 只允许开机前 3 次采样阶跃; 运行期一律 slew 渐变 maxslewrate 1000 # 渐变上限 1000ppm: 每秒最多纠 ~1ms, 不惊动业务 rtcsync # 新装机器偏移 2 小时? 先停业务手动粗校, 再起 chrony 收尾 # 关键: makestep 的阶跃对"运行中的定时器/雪花 ID"是原子弹, 业务期禁用
Kafka 分区内有序, 跨分区/跨源无序; 风控引擎要按因果序处理。事件打 HLC 标 + watermark 窗口缓冲重排。
const windowMs = 500 // 重排窗口: 给乱序留 500ms 裕量 type Reorderer struct { buf map[string][]Event // 按 userID 缓冲 } func (r *Reorderer) Push(e Event) { r.buf[e.User] = append(r.buf[e.User], e) } func (r *Reorderer) Drain(nowMs int64) []Event { // 定时调用 cut := nowMs - windowMs // watermark 前的事件不再等 var out []Event for u, evs := range r.buf { sort.Slice(evs, func(i, j int) bool { // 用户内按 HLC 全序 if evs[i].Wall != evs[j].Wall { return evs[i].Wall < evs[j].Wall } return evs[i].Log < evs[j].Log }) k := 0 for k < len(evs) && evs[k].Wall <= cut { out = append(out, evs[k]); k++ } r.buf[u] = evs[k:] // 没到 watermark 的留下轮 } return out }
自研调度器用墙钟判 deadline, 每次校时阶跃就少跑/多跑一轮。切到单调钟, 与墙钟彻底解耦。
// 错法: deadline 用 UnixNano 存下再比 — 墙钟阶跃直接欺骗判断 deadline := time.Now().Add(3 * time.Second) // time.Time 内含单调读数 for batch := range jobs { process(batch) if time.Now().After(deadline) { // 关键: After 优先比较单调读数 return ErrDeadline // 校时阶跃不再影响判断 } } // 或直接用标准库: ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // → 内部 timer 同样走单调钟, 语义与墙钟彻底解耦
time.Since / nanoTime。 // 错: end.UnixNano() - start.UnixNano() // → 回拨 120ms 时 elapsed = -120000000, 图表倒挂 // 对: elapsed := time.Since(start) // 单调钟, 永不为负
// 错: id := (now - epoch) << 22 | seq // 回拨后整段重发 // → Duplicate entry '726618496314245120' for key 'PRIMARY' // 对: now < lastTS 先等待追平, 超 2s 拒绝发号并告警
// 错: if a.Ts < b.Ts { order = [a, b] } // 两机 skew 3s 直接排反 // → 支付排在创建前面, 状态机错乱 // 对: 按 (HLC.wall, HLC.log, nodeID) 全序, 不信裸墙钟
-- 错: INSERT ... VALUES (NOW()) -- 跟着服务器时区走 -- → 上海机房写 10:00, 法兰克福机房写 02:00, 同一时刻 -- 对: UTC_TIMESTAMP() 统一存, 展示层按用户时区转
makestep 限制在启动窗口 + 任务幂等。 # 错: 业务运行期 makestep 生效, 时钟阶跃 30s # → 该窗口 cron 没跑, 上一窗口跑了两遍 # 对: makestep 1 3 只放开机前 3 次采样; 任务按窗口 ID 幂等
// 错: VClock 里塞全集群 1000 个节点 → 每条消息白扛 8KB // → 元数据比 100B 的 value 大 80 倍 // 对: 只留活跃 writer 分量, 长期不写的衰减清零
// 错: LWW 用两台机器的墙钟比"新旧" — skew 3s 必然误判 // → 用户后提交的修改被先提交的覆盖 // 对: 向量钟判并发, 并发的交给业务合并, 有因果的才覆盖
EXPIREAT+本机墙钟, 回拨后过期时刻被整体推迟. 正解: 用相对 TTL (EXPIRE/PX), 过期交给 Redis 服务器钟。 # 错: SET k v EXAT <本机算的绝对时间戳> # 回拨 5s → 过期推迟 5s # → 回拨 5 分钟, 整批 key 多活 5 分钟, 脏读横行 # 对: SET k v PX 1800000 # 相对 TTL 由 Redis 服务器钟裁决
// 错: RPC 头里传绝对 deadline (A 机的墙钟时刻) // → B 机钟慢 2s: A 已判超时, B 还在"没超时"地跑 // 对: 传剩余时长, B 用 context.WithTimeout 本地单调起算
# 错: Kibana 按 @timestamp 排跨机日志 # → 响应日志排在请求前面, 排查越排越糊涂 # 对: trace_id + span 序号定因果, timestamp 只作粗筛
# 错: workerId := int(ip[14])<<8 | int(ip[15]) # 容器 IP 会复用 # → 新旧两实例同 workerId, ID 成对撞 # 对: 启动时在 etcd 用 lease 抢占 worker 序号, 抢不到拒绝启动
time.After 每轮新建 timer, 到期前不回收. 正解: time.NewTimer + Reset 复用。 // 错: for { select { case <-time.After(time.Minute): ... } } // → 每轮一个新 timer 堆在堆上, pprof 一片 time.After // 对: tm := time.NewTimer(d); defer tm.Stop(); 每轮 tm.Reset(d)
// 错: Go 传 UnixNano, JS 端 new Date(ts) 当毫秒 // → 显示日期变成 56xxx 年 // 对: 契约写死 ts_ms, 或跨语言一律 protobuf Timestamp
# 错: 2012/2017 闰秒, 老内核 hrtimer 死循环 # → 半夜一批机器 CPU 100%, 老版本 Java 服务集体挂起 # 对: 内核升级到含闰秒修复的版本, NTP 用 leap smear 摊平
// 错: 把 time.Now() 序列化进 MQ, 消费端拿去比先后 // → 跨进程单调读数无意义, 结果随机 // 对: 跨进程传 epoch/逻辑钟, 单调差值只在进程内算
// 错: 客户端 System.currentTimeMillis() 判会员到期 // → 手机时间调回去年, 会员"永久有效" // 对: 过期判定只在服务端 (JWT exp / DB expire_at)
CreateTime (生产者钟) 与 LogAppendTime (broker 钟) 混存, 语义不同. 正解: 全链路统一 message.timestamp.type, 业务序自带逻辑钟字段。 # 错: 按 timestamp 重放 topic — CreateTime 与 LogAppendTime 混存 # → 生产者钟和 broker 钟差 2s, 顺序乱掉 # 对: 统一 message.timestamp.type; 关键序靠消息体里的逻辑钟字段
// 错: {"createdAt": "2026-09-26 10:00:00"} // 无时区标注 // → 浏览器按 UTC 解析, 页面显示 18:00, 工单又来了 // 对: {"createdAt": "2026-09-26T10:00:00+08:00"}
# 错: 只监控业务, 不监控时钟 # → 回拨 3 天后才因 ID 重复被发现, 倒查无门 # 对: node_exporter/chronyc 采偏移, |offset| > 100ms 即时告警
// 错: idem := fmt.Sprintf("%s-%d", uid, time.Now().Unix()) // → 重试落在下一秒, 键变了, 幂等表形同虚设 // 对: idem := req.RequestID // 与时间无关, 重试恒定