系统架构 · 时间顺序与因果

分布式系统没有统一的"现在": 物理钟会说谎 (漂移/回拨), 逻辑钟才讲因果 — 计时用单调钟, 排序用因果, 展示才用墙钟

① 物理钟不可信 · 同一真实时刻, 两台机器各说各话 skew 是常态: 晶振漂移 + NTP 改写 真实同一时刻 T Server A · 与 NTP 同步, 偏差 ≈0 10:00:00 10:00:05 Server B · 慢 3s (漂移未校) 09:59:57 10:00:02 A 读出 10:00:01 B 读出 09:59:58 ① 创建订单 · ts=10:00:03 ② 支付订单 · ts=09:59:59 按各自墙钟排序: 09:59:59 (支付) < 10:00:03 (创建) ⇒ 支付排在创建之前 — 因果颠倒 同一对事件, 单机内永远正确, 跨机后才颠倒 — 这类 bug 只出现在多机环境 ② 时钟回拨事故链 · 雪花 ID 重复发号 (rose) 回拨源 chrony makestep 阶跃纠偏 运维 date -s / 虚机快照回滚 墙钟倒退 time.Now() 读数变小 10:00:05 → 10:00:00 雪花 ID 时间段重复 ID 源 = wall 毫秒时间戳 回拨 5s → 5 万毫秒段重发 唯一键冲突 同一 ID 第二次入库 Duplicate entry '7266…' 阶跃 倒退 重发 java.sql.SQLIntegrityConstraintViolationException: Duplicate entry '726618496314245120' for key 'order.PRIMARY' 定位: 两实例 workerId 相同 + 双双时钟回拨 → 同一段毫秒被第二次发出 · 兜底见场景 1 与必知必会 · Snowflake ③ Lamport 逻辑钟 · 本地事件 +1 · 收消息取 max 再 +1 — 只记因果不记几点 msg{c=2} msg{c=5} P1 P2 c=1 c=2 c=6 c=3 c=4 c=5 推进规则 (三条) ① 本地/发送事件: c = c + 1 ② 接收消息: c = max(c, msg.c) + 1 ③ a → b ⇒ c(a) < c(b) (必要条件) c 相等只说明并发, 先后由 nodeID 打破平局 ④ 双轨时钟 · 计时用单调钟 (只进不退) · 排序展示才用墙钟 wall clock · 会被 NTP/人工改写 适合展示 · 严禁算耗时 回拨 -120ms → elapsed = -83ms 变负 monotonic · 只进不退 (Go 1.9+ 双读数) NTP 怎么跳都不影响 适合: 耗时/超时/限流窗口 — 数值仅本机有效 ● 事故链: makestep 阶跃回拨 → 雪花 ID 重复 → Duplicate entry '726618496314245120' → 幂等表误判重付 → 资损工单 ● 破局链: 计时 time.Since (单调) · 排序 HLC/逻辑钟 · 存储 UTC+时区 · deadline 传相对时长 · makestep 限启动窗口 · 偏移告警

机制视角 — 三种钟各管一件事

  • • 物理钟 (wall): 回答"现在几点", 供人看, 受 NTP 改写, 会漂移会跳变
  • • 单调钟 (monotonic): 回答"过了多久", 不受 NTP 影响, 只在本机有效
  • • 逻辑钟 (Lamport/Vector/HLC): 回答"谁先谁后", 只记因果不记时刻
  • • 混用 = 最贵的 bug: 墙钟算耗时得负数, 墙钟跨机排序因果颠倒

行为视角 — happens-before 是唯一的序

  • • a → b 仅两种来源: 同进程程序序, 或 b 看见了 a 发的消息
  • • 互相看不见的两个事件是"并发", 谈先后无意义, 只能定打破平局规则
  • • Lamport 给全序, Vector 能识别并发, HLC 兼得墙钟可读性
  • • NTP 是必要的恶: makestep 快但会回拨, slew 平滑但收敛慢

生产价值 — 哪里最容易翻车

  • • 雪花 ID: 时钟回拨兜底 (等待/拒绝/借位) 是发号器的入场券
  • • 日志按 @timestamp 排序丢因果, 要带 trace_id/序列号
  • • 存储一律 UTC + 时区标注; deadline 传相对时长不传绝对时刻
  • • 偏移监控 (chronyc tracking) + 回拨告警是底线设施

💡 一句话理解

每台服务器都戴一块自己的表, 走得快慢不一, 还时不时被人 (NTP) 拨一下。你说"我先到的"凭的是你的表, 我说"我先到的"凭的是我的表 — 两块表对不上, "谁先谁后"就成了罗生门。唯一的出路是不比表, 比"谁看见了谁的动作": 支付看见了订单, 支付必然在后, 这就是 happens-before。

于是三种钟分工: 计时 (耗时/超时) 用单调钟, 它只进不退; 排序 (因果) 用逻辑钟, Lamport/Vector/HLC 都是"看见消息就推进"的不同记账法; 给人看的时间戳用墙钟, 但别拿它做任何正确性判断。NTP 负责让墙钟别太离谱 — makestep 纠得快但会回拨, slew 平滑但慢, 选哪个取决于业务怕不怕回拨。

🧠 必知必会 必考 & 必会

Clock Skew 时钟偏移
每台机器的晶振频率有偏差, 一天漂几十毫秒到几秒不等; 两台机器"同一时刻"读出的时间差就是 skew。skew 不可消除只能驯服 (NTP), 所以任何"跨机器直接比较墙钟"的设计都是赌博。
a := time.Now().UnixNano() // 在机器 A 上执行
b := time.Now().UnixNano() // 在机器 B 上"同一瞬间"执行
_ = b - a                  // 关键: 差值 = skew, ms 级常态, 断 NTP 可到秒级
NTP / chrony 时间同步
层级式对时协议, 客户端与服务器交换时间戳估计偏移与延迟。chrony 是 Linux 主流实现: makestep 在偏移过大时直接阶跃 (快但回拨), 小偏移默认用 slew 渐变 (不回拨但慢)。
$ chronyc tracking
System time     : 0.0000231 seconds fast of NTP time
Leap status     : Normal      # 关键: 偏移常驻监控, 阶跃必须有告警
Monotonic Clock 单调钟
只会前进的钟 (Go 的 time.Now 底层同时取墙钟+单调钟两个读数)。适合一切"测量时长"的场景; 但数值只在本机本次开机内有意义, 不能落库、不能跨进程比较。
start := time.Now()        // 同时记下墙钟+单调钟两份读数
time.Sleep(3 * time.Second)
el := time.Since(start)    // 关键: 用单调读数, NTP 回拨也不影响
// → 3s (用墙钟相减, 回拨时会得到 -2s)
Wall Clock 墙钟
time.Now().UnixNano() / System.currentTimeMillis(), 对应日历时间, 会被 NTP 阶跃、闰秒、人工修改改写, 甚至倒退。只配两件事: 展示给人和粗略日志筛选, 不配做任何正确性判断。
t0 := time.Now().UnixNano()
t1 := time.Now().UnixNano()
// 关键: t1 < t0 完全可能 — NTP 阶跃回拨的瞬间就发生
Happens-before 因果序
分布式里唯一的"先后": 同一进程内程序序 a 先于 b, 或跨进程时"发送 a 的消息被 b 接收"。它是偏序 — 两个互相看不见的事件是并发的, 不可比较。
// a → b 仅两种来源: 同进程程序序; 消息 send → recv 链
// 关键: 从不通信的两个事件是并发 (concurrent), 判先后=玄学
// → 排序前先问: 这两个事件有因果吗? 没有就别排
Lamport Clock 逻辑钟
每节点维护计数器: 本地事件 +1, 发消息随信携带, 收消息取 max(本地, 信中)+1。保证 a→b 则 c(a)<c(b)。代价: 反之不成立 (钟号小不代表发生在先), 且它给的是"序"不是"时刻"。
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); 反之不成立, 钟号小未必发生在先
Vector Clock 向量钟
每节点维护全集群计数器向量, 收消息逐分量取 max 再加自己的。能判断"并发": 两个向量互不支配即 concurrent — 这是 Lamport 做不到的。代价: 向量随节点数膨胀, 大集群要剪枝。
// A:[1,0] B:[0,1] → 互不支配 = 并发 (Dynamo 冲突, 交客户端合并)
// A:[2,1] B:[1,1] → B 被 A 支配, B 可被安全覆盖
// 关键: 向量钟是唯一能"发现并发"的廉价手段
HLC 混合逻辑钟
(wall, counter) 二元组: 物理部分取 max(本地墙钟, 消息墙钟), 不增才推逻辑部分。既有物理可读性 (接近真实时刻) 又有因果单调性, TiDB 的 TSO 思路 / CockroachDB 的 HLC 都基于此。
// 收消息: l = max(l, m.l, physicalNow); 若 l 未增则 c++ 否则 c=0
// 排序: 先比 l 再比 c 再比 nodeID → 全序且保因果
// 关键: 墙钟回拨时 l 不跟着缩, HLC 依旧单调 — 这就是"混合"的意义
Timestamp Ordering 时间戳排序
用时间戳给事务/事件定全序 (TiDB TSO 中心发号 / Spanner TrueTime 区间保证 / 各类 MVCC)。前提是时间戳可信: 要么中心化发号, 要么给误差区间+commit wait, 裸墙钟谁都不敢直接用。
-- 错: ORDER BY created_at      -- 各机墙钟, skew 排乱因果
--   → 支付(09:59:59) 排在 创建(10:00:03) 前面
-- 对: ORDER BY hlc, node_id    -- 逻辑序定先后, 墙钟只给人看
全序 vs 偏序
偏序: 有因果的事件才可比, 并发的不可比; 全序: 任意两个都可比。分布式系统天然只有偏序; "全序"要靠额外规则人为制造 (Lamport+nodeID 打破平局 / 中心发号 / 共识日志)。
// 偏序: a→b, c→d, 但 b 与 c 谁先? 无因果, 不可比
// 全序: 追加打破平局键 (nodeID) → b#2 与 c#1 也能比出先后
// 关键: 日志系统要全序, 业务正确性只要偏序别排错
Snowflake 雪花 ID 与回拨
64 位 = 时间戳(41b) + 机器(10b) + 序列(12b), 趋势递增可排序。命门: 以墙钟为源, 回拨即重复发号。兜底三招: 小回拨自旋等待、借未来时间位 (有争议)、超阈值直接拒绝并告警。
if now < s.lastTS {
    wait := time.Duration(s.lastTS-now) * time.Millisecond
    if wait <= 5*time.Second { time.Sleep(wait) } // 小回拨: 等追平
    return 0, ErrClockBackwards // 关键: 大回拨拒绝发号, 宁断勿重
}
Kafka 时间戳两种语义
每条消息带 timestamp: CreateTime (生产者设置, 反映业务钟) 或 LogAppendTime (broker 落盘时刻), topic 级配置 message.timestamp.type。两种语义混存的流按 timestamp 排序必乱。
# topic 级配置: 二选一, 全链路统一语义
bin/kafka-topics.sh --create --topic orders --config message.timestamp.type=CreateTime
# 关键: CreateTime=业务钟(生产者机器), LogAppendTime=broker 钟, 不可混用
Causality 并发的业务含义
并发 (concurrent) 不等于同时: 只要互不见因果就是并发。业务上"并发写"必须定义合并/覆盖规则 (版本向量 / CRDT / 单点写), 而不是猜先后; 误判并发为先后就会出现"后写的被先写的覆盖"。
// 两个请求几乎同时改同一 key, 互相看不见 = 并发写
// LWW 按墙钟挑"新值": 两机 skew 3s 时, 真正的新值反而会输
// 关键: 并发写用逻辑版本合并, 或让同一 key 永远单点写

🏭 生产实战 real world

场景 1 · 凌晨 NTP 阶跃, 唯一键炸出 Duplicate entry — 雪花 ID 回拨兜底

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
}

兜底上线后, 阶跃回拨从"资损事故"降级为一条拒绝发号的告警。

场景 2 · 300 台机器一个月漂出 1.8 秒, 定时任务双跑 — chrony 配置

内网机器没配 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 看源状态

场景 3 · 订单事件流跨 3 个服务, 排查像看抽帧错位的视频 — Lamport 排序

日志聚合按 @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 })

场景 4 · App 和 Web 同时改购物车, 偶发整字段被旧值覆盖 — Vector Clock 冲突检测

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 交上层合并
// 关键: 把"悄悄丢更新"变成"显式冲突", 合并策略业务自己定

场景 5 · 压测报告出现"平均耗时 -120ms" — Go/Java 单调钟计时

监控图出现负耗时: 计时用了墙钟差值, 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;

场景 6 · 分库分表后要"按时间粗排"的全局 ID — HLC 混合时钟落地

中心 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 仍单调

场景 7 · 海外用户看到"订单是昨天下的" — UTC 存储 + 本地时区展示

库里存的是部署机本地时区的字符串, 海外节点写入偏 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)
// 关键: 库里没有任何本地时区数据, 谁展示谁转换

场景 8 · 手动拨表 2 小时, 支付对账卡死 — NTP 阶跃 vs 渐变选择

业务运行中的机器, 阶跃校正就是"时间原子弹": 定时器被跳过或重放, 雪花 ID 直接重复。运行期只允许 slew 渐变。

# /etc/chrony.conf — 已承载业务的机器: 防运行期阶跃
makestep 1 3      # 只允许开机前 3 次采样阶跃; 运行期一律 slew 渐变
maxslewrate 1000  # 渐变上限 1000ppm: 每秒最多纠 ~1ms, 不惊动业务
rtcsync
# 新装机器偏移 2 小时? 先停业务手动粗校, 再起 chrony 收尾
# 关键: makestep 的阶跃对"运行中的定时器/雪花 ID"是原子弹, 业务期禁用

场景 9 · 风控要求同一用户操作严格有序 — 乱序事件的窗口重排

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
}

场景 10 · 批处理总在 NTP 校时那天"假死" — 用单调钟实现超时

自研调度器用墙钟判 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 同样走单调钟, 语义与墙钟彻底解耦

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 耗时统计偶发负数, 监控曲线"时间倒流" — 症状: elapsed = -120ms. 原因: 墙钟差值, NTP 回拨. 正解: time.Since / nanoTime。
// 错: end.UnixNano() - start.UnixNano()
//   → 回拨 120ms 时 elapsed = -120000000, 图表倒挂
// 对: elapsed := time.Since(start)  // 单调钟, 永不为负
坑 2 · 时钟回拨后, 雪花 ID 报 Duplicate entry — 症状: 唯一键冲突. 原因: 发号器以墙钟为源, 回拨后同一段毫秒重新发号. 正解: 回拨等待/拒绝 + 发号器启动时钟预检。
// 错: id := (now - epoch) << 22 | seq   // 回拨后整段重发
//   → Duplicate entry '726618496314245120' for key 'PRIMARY'
// 对: now < lastTS 先等待追平, 超 2s 拒绝发号并告警
坑 3 · "支付在创建之前" — 直接比较两台机器的时间戳 — 症状: 按时间戳排序事件, 顺序偶发颠倒. 原因: 两机 skew, 谁的钟都不权威. 正解: 逻辑钟 (Lamport/HLC) 或中心发号, 墙钟只展示。
// 错: if a.Ts < b.Ts { order = [a, b] } // 两机 skew 3s 直接排反
//   → 支付排在创建前面, 状态机错乱
// 对: 按 (HLC.wall, HLC.log, nodeID) 全序, 不信裸墙钟
坑 4 · 同一订单时间在报表里差 8 小时 — 症状: 海外订单显示"昨天". 原因: 应用服务器本地时区写库, 各地机器时区不同. 正解: 存 UTC + 展示转时区。
-- 错: INSERT ... VALUES (NOW())  -- 跟着服务器时区走
--   → 上海机房写 10:00, 法兰克福机房写 02:00, 同一时刻
-- 对: UTC_TIMESTAMP() 统一存, 展示层按用户时区转
坑 5 · NTP 阶跃那一秒, cron 没跑或跑了两遍 — 症状: 每次校时后定时任务缺失或重复. 原因: 阶跃跳过区间不触发、回拨区间重复触发. 正解: makestep 限制在启动窗口 + 任务幂等。
# 错: 业务运行期 makestep 生效, 时钟阶跃 30s
#   → 该窗口 cron 没跑, 上一窗口跑了两遍
# 对: makestep 1 3 只放开机前 3 次采样; 任务按窗口 ID 幂等
坑 6 · 千节点集群 vector clock 消息头 8KB — 症状: 写 100B 数据, 元数据比数据大 80 倍. 原因: 向量长度=节点数, 不裁剪只会膨胀. 正解: 只保留活跃 writer 分量 (Dynamo 剪枝) 或换 HLC/版本号。
// 错: VClock 里塞全集群 1000 个节点  → 每条消息白扛 8KB
//   → 元数据比 100B 的 value 大 80 倍
// 对: 只留活跃 writer 分量, 长期不写的衰减清零
坑 7 · 把并发误判为先后, 后提交的修改被覆盖 — 症状: 用户第二次修改被"更早"的数据盖掉. 原因: 用墙钟/单计数器跨机定先后, skew 必然误判. 正解: 承认并发, 用版本向量发现并发并显式合并。
// 错: LWW 用两台机器的墙钟比"新旧" — skew 3s 必然误判
//   → 用户后提交的修改被先提交的覆盖
// 对: 向量钟判并发, 并发的交给业务合并, 有因果的才覆盖
坑 8 · 一次回拨, 缓存整批"多活几分钟" — 症状: 改了数据缓存迟迟不失效, 脏读横行. 原因: 应用用 EXPIREAT+本机墙钟, 回拨后过期时刻被整体推迟. 正解: 用相对 TTL (EXPIRE/PX), 过期交给 Redis 服务器钟。
# 错: SET k v EXAT <本机算的绝对时间戳>  # 回拨 5s → 过期推迟 5s
#   → 回拨 5 分钟, 整批 key 多活 5 分钟, 脏读横行
# 对: SET k v PX 1800000  # 相对 TTL 由 Redis 服务器钟裁决
坑 9 · 分布式 deadline: B 说"没超时", A 早已 abort — 症状: 上游掐断下游还在跑. 原因: A 用自己的钟生成绝对 deadline 传给 B, B 拿自己的钟比较, skew 双重标准. 正解: 传相对时长, 各自以本地单调钟起算。
// 错: RPC 头里传绝对 deadline (A 机的墙钟时刻)
//   → B 机钟慢 2s: A 已判超时, B 还在"没超时"地跑
// 对: 传剩余时长, B 用 context.WithTimeout 本地单调起算
坑 10 · ELK 按时间排序排查事故, 因果全是反的 — 症状: "响应"日志排在"请求"前面. 原因: 跨机日志按 @timestamp 排, skew 大于事件间隔. 正解: 排序键用 trace_id+span 序号或逻辑钟。
# 错: Kibana 按 @timestamp 排跨机日志
#   → 响应日志排在请求前面, 排查越排越糊涂
# 对: trace_id + span 序号定因果, timestamp 只作粗筛
坑 11 · 容器重启后雪花 ID 又撞了 — workerId 冲突 — 症状: 无回拨仍出重复 ID. 原因: workerId 按 IP 分配, 容器重建 IP 复用, 两实例拿到同一 id. 正解: workerId 经 ZK/etcd 注册分配, 抢不到拒绝启动。
# 错: workerId := int(ip[14])<<8 | int(ip[15])  # 容器 IP 会复用
#   → 新旧两实例同 workerId, ID 成对撞
# 对: 启动时在 etcd 用 lease 抢占 worker 序号, 抢不到拒绝启动
坑 12 · Go 服务内存缓涨, pprof 全是 time.After — 症状: 高频循环里 timer 堆积. 原因: 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)
坑 13 · Go 传的纳秒时间戳, JS 端解析出五万年后 — 症状: 跨语言字段时间全错. 原因: 精度约定不一 (ns/ms/s 混用). 正解: 契约写死单位或用 protobuf Timestamp。
// 错: Go 传 UnixNano, JS 端 new Date(ts) 当毫秒
//   → 显示日期变成 56xxx 年
// 对: 契约写死 ts_ms, 或跨语言一律 protobuf Timestamp
坑 14 · 闰秒那天半夜, 一批机器 CPU 100% — 症状: 闰秒插入后部分服务卡死. 原因: 老内核闰秒处理 hrtimer 空转 (2012/2017 两次大规模事故), Java 老版本时钟类挂起. 正解: 升级内核 + NTP 用 leap smear。
# 错: 2012/2017 闰秒, 老内核 hrtimer 死循环
#   → 半夜一批机器 CPU 100%, 老版本 Java 服务集体挂起
# 对: 内核升级到含闰秒修复的版本, NTP 用 leap smear 摊平
坑 15 · 单调钟数值跨进程比较 — 症状: 服务重启后"已运行时长"显示荒谬值. 原因: monotonic 读数语义是"本次开机以来", 无跨进程意义. 正解: 跨进程持久化一律 epoch/墙钟, 单调差值只在本进程算。
// 错: 把 time.Now() 序列化进 MQ, 消费端拿去比先后
//   → 跨进程单调读数无意义, 结果随机
// 对: 跨进程传 epoch/逻辑钟, 单调差值只在进程内算
坑 16 · 用户改手机时间, 会员"永久免费" — 症状: 客户端判 VIP 过期被绕过. 原因: 过期判定用了客户端本地钟. 正解: 一切有效期判定以服务端时间为准, 客户端只展示。
// 错: 客户端 System.currentTimeMillis() 判会员到期
//   → 手机时间调回去年, 会员"永久有效"
// 对: 过期判定只在服务端 (JWT exp / DB expire_at)
坑 17 · 同一 topic 两种时间戳, 重放排序乱成一锅粥 — 症状: 按 timestamp 重放, 业务顺序错乱. 原因: CreateTime (生产者钟) 与 LogAppendTime (broker 钟) 混存, 语义不同. 正解: 全链路统一 message.timestamp.type, 业务序自带逻辑钟字段。
# 错: 按 timestamp 重放 topic — CreateTime 与 LogAppendTime 混存
#   → 生产者钟和 broker 钟差 2s, 顺序乱掉
# 对: 统一 message.timestamp.type; 关键序靠消息体里的逻辑钟字段
坑 18 · API 返回 "10:00:00", 前端按 UTC 又减了 8 小时 — 症状: 页面时间集体偏移. 原因: 字符串不带时区, 每端自行假设. 正解: 对外一律 ISO8601 带偏移或 epoch ms + 约定。
// 错: {"createdAt": "2026-09-26 10:00:00"}   // 无时区标注
//   → 浏览器按 UTC 解析, 页面显示 18:00, 工单又来了
// 对: {"createdAt": "2026-09-26T10:00:00+08:00"}
坑 19 · 回拨已经发生三天, 没有任何人知道 — 症状: ID 重复告警才发现时钟上周就回拨过. 原因: 没有偏移/回拨监控. 正解: 采 chronyc tracking 偏移入库, 偏移超阈值与阶跃事件双告警。
# 错: 只监控业务, 不监控时钟
#   → 回拨 3 天后才因 ID 重复被发现, 倒查无门
# 对: node_exporter/chronyc 采偏移, |offset| > 100ms 即时告警
坑 20 · 幂等键带时间戳, 重试落在下一秒就失效 — 症状: 网络重试产生两笔扣款. 原因: 幂等键掺秒级时间, 重试跨秒后键变化, 幂等表查不到. 正解: 幂等键用业务 ID (订单号/请求号), 与时间无关。
// 错: idem := fmt.Sprintf("%s-%d", uid, time.Now().Unix())
//   → 重试落在下一秒, 键变了, 幂等表形同虚设
// 对: idem := req.RequestID  // 与时间无关, 重试恒定