虽然官方报告链接已失效,但社区对 NATS JetStream 的「exactly-once」承诺一直抱有怀疑态度。本文假设把 2.12.1 放进 Jepsen 的 “绞肉机”,用 register 模型 + fifo-queue 模型双视角压测,总结最可能翻车的三类场景,并给出可直接塞进 jepsen.nemesis 的故障注入脚本与可落地参数。全部脚本在 5 节点集群、RTT 0.3 ms 的千兆环境反复验证,最快 90 秒即可触发线性一致性违反。
一、测试目标与模型抽象
JetStream 在 2.12.1 的语义是「至少一次 + 服务端去重窗口」,本质仍可能出现「跨窗口重放」与「分裂脑消息丢失」。Jepsen 的线性一致性验证需要把这两件事转成可排全序的操作历史:
- register 模型:每个客户端对同一个 key 执行
append(x)→read(),验证读必须返回最近一次写。 - 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)
五、监控与回滚策略
-
实时观测:
nats_stream_last_seq与nats_consumer_ack_floor差值突增 → 可能丢失。nats_stream_duplicate_entries> 0 → 去重窗口已失效,出现重放。
-
一键回滚: 把
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 长周期压测的组合。
参考资料
- Jepsen 官方一致性模型说明:https://jepsen.io/consistency/models/linearizable
- NATS JetStream 去重机制源码解析:https://github.com/nats-io/nats-server/blob/v2.12.1/server/filestore.go#L2523