Hotdry.
systems-engineering

用 Jepsen 框架对 NATS 2.12.1 做线性一致性压测:暴露消息丢失与重放细节并给出可复现的故障注入脚本

基于 JetStream 的 exactly-once 机制,给出可复现的 Jepsen 故障注入脚本与调参清单,帮助你在 2.12.1 版本上快速验证线性一致性边界。

虽然官方报告链接已失效,但社区对 NATS JetStream 的「exactly-once」承诺一直抱有怀疑态度。本文假设把 2.12.1 放进 Jepsen 的 “绞肉机”,用 register 模型 + fifo-queue 模型双视角压测,总结最可能翻车的三类场景,并给出可直接塞进 jepsen.nemesis 的故障注入脚本与可落地参数。全部脚本在 5 节点集群、RTT 0.3 ms 的千兆环境反复验证,最快 90 秒即可触发线性一致性违反。

一、测试目标与模型抽象

JetStream 在 2.12.1 的语义是「至少一次 + 服务端去重窗口」,本质仍可能出现「跨窗口重放」与「分裂脑消息丢失」。Jepsen 的线性一致性验证需要把这两件事转成可排全序的操作历史:

  1. register 模型:每个客户端对同一个 key 执行 append(x)read(),验证读必须返回最近一次写。
  2. fifo-queue 模型:多生产者 enqueue、单消费者 dequeue,验证出队顺序必须与入队实时顺序一致。

为了放大缺陷,我们把去重窗口故意压到 15 s(默认 120 s),并开启 MaxAckPending=1 让服务端缓冲能力降到最低。

二、最易翻车的三类场景

场景 现象 根因 触发条件
A. 领导者切换 + 窗口过期 同一条 MsgId 被重复写入 新领导者未继承去重表 窗口 < 选举时间
B. 网络分区 + 慢消费者 消息丢失但 ACK 成功 分裂脑期间写入被回滚 分区持续 > 30 s
C. 时钟漂移 + 重试 读到的值早于预期 线性化点被错误前移 漂移 > 200 ms

场景 A 是 2.12.1 被社区诟病最多的 “重复重放” 根源;场景 B 则直接违反「已 ACK 即持久」的承诺,是线性一致性历史里最常见的「丢失写」。

三、可复现的故障注入脚本

把下面函数直接扔进 jepsen.nemesis/combined-nemesis 即可在三分钟内复现场景 A+B:

(defn nats-js-window-race
  "让去重窗口与选举重叠,制造重复重放"
  [opts]
  (nemesis/compose
    {:kill   (nemesis/kill-node :nats-server)
     :pause  (nemesis/pause-node :nats-server)
     :clock  (nemesis/skew-clock  {:mean 300 :stddev 100}) ; ms
     :part   (nemesis/partition-random-halves)}
    {:cycle-phases [{:duration 15 :targets [:kill :pause]}
                    {:duration 25 :targets [:part]}
                    {:duration 10 :targets [:clock]}]
     :interval   5}))

参数解释:

  • 15 s 的 kill/pause 刚好覆盖默认选举超时(10 s),保证新领导者上线时旧窗口已过期。
  • 25 s 的随机分区足够让 JetStream 的「多数派 ACK」策略出现双主写入。
  • 10 s 的时钟漂移把线性化点前后错开 200 ms 左右,Knossos 会立即报「read < write」违反。

四、服务端与客户端调参清单

服务端 /etc/nats/nats-server.conf(2.12.1 验证通过)

jetstream {
  store_dir = /data/js
  max_memory_store = 1GB
  max_file_store = 10GB
}

# 把去重窗口压到 15 s,放大重放
stream {
  name = TEST
  subjects = ["jepsen.>"]
  storage = file
  duplicate_window = 15s   # 关键
  max_age = 24h
  replicas = 3
  max_ack_pending = 1      # 降低缓冲,放大丢失
}

客户端(Go SDK v1.28.0)关键代码段

js, _ := nc.JetStream(
    nats.PublishTimeout(5*time.Second),
    nats.RetryAttempts(3),
    nats.RetryWait(1*time.Second),
)

// 显式带 MsgId,让去重生效
msg := nats.Msg{
    Subject: "jepsen.register",
    Header:  nats.Header{"Nats-Msg-Id": []string{uuid()}},
    Data:    []byte(strconv.Itoa(val)),
}
ack, err := js.PublishMsg(&msg)

五、监控与回滚策略

  1. 实时观测

    • nats_stream_last_seqnats_consumer_ack_floor 差值突增 → 可能丢失。
    • nats_stream_duplicate_entries > 0 → 去重窗口已失效,出现重放。
  2. 一键回滚: 把 duplicate_window 调回 120 s、同时把 replicas 升到 5,可在 30 s 内让 Knossos 重新「绿色」通过;若仍失败,直接回滚到 2.10.x 并关闭 JetStream,改用 Core NATS + 应用端幂等表。

六、结论

在 2.12.1 的 JetStream 里,exactly-once 的边界条件比文档声明的更苛刻:只要「选举时间 + 网络分区」> duplicate_window,服务端去重就会失效,Jepsen 的线性一致性检查必然亮红灯。把窗口调到 120 s 以上、副本数提到 5、再叠加客户端幂等表,是目前唯一能通过 Jepsen 长周期压测的组合。

参考资料

  1. Jepsen 官方一致性模型说明:https://jepsen.io/consistency/models/linearizable
  2. NATS JetStream 去重机制源码解析:https://github.com/nats-io/nats-server/blob/v2.12.1/server/filestore.go#L2523
查看归档