你的团队用了某个分布式数据库。官方文档写着”支持强一致性”。你信了,把关键业务逻辑(比如扣款、库存扣减)建在这个假设上。上线半年,没出过问题。
然后某天,一次网络抖动——两个机房之间的链路中断了 47 秒。恢复之后,运营发现有三笔订单扣款成功了但库存没扣,还有两笔订单的余额对不上。你去查日志,发现数据库在网络分区(network partition)期间,两个副本各自接受了写入,恢复连接后只做了”最后写入胜出”(last-write-wins)的合并,静默丢弃了一侧的数据。
所谓的”强一致性”,在网络分区面前形同虚设。
这不是假想的场景。MongoDB 2.x 在网络分区下允许从 Secondary 节点读取过期数据。Redis 在使用 Sentinel 做主从切换时可能出现脑裂,两个节点同时认为自己是主节点,各自接受写入。Elasticsearch 在早期版本中,网络分区可以导致已确认的写入永久丢失。这些都不是理论推演,而是被实际测试验证过的行为。
做这些测试的人叫 Kyle Kingsbury,他写的工具叫 Jepsen(https://jepsen.io)。
本文拆解 Jepsen 的方法论:它用什么方式制造故障,怎样并发操作数据库,如何从操作历史中检查一致性是否被违反。然后逐个回顾 Jepsen 揭露的经典问题,分析为什么这些 bug 存在以及厂商如何修复。最后讨论 Jepsen 方法论的局限性。
一、从一次数据库”丢数据”说起
先看一个具体的故障场景。假设你用一个三副本的分布式 KV
存储,配置为”写入需要多数派确认”。客户端发送
write(x, 1),收到成功响应。紧接着另一个客户端发送
read(x),期望读到 1。
正常情况下没问题。但如果在 write 返回成功之后、read
发出之前,包含最新数据的两个副本被网络分区隔开了呢?read
请求被路由到了落后的那个副本,读到了旧值
null。
这就是一致性违反(consistency violation)。数据库声称提供线性一致性(Linearizability),但实际行为违反了这个承诺。
问题在于:厂商怎么知道自己的一致性实现是正确的? 单元测试能测到这种情况吗?几乎不可能。这类 bug 只在特定的并发时序和故障组合下才会触发。手动测试更不靠谱——你不可能穷举所有的网络分区模式、进程崩溃时机和并发操作交错。
传统的测试方法有三个根本缺陷:
故障覆盖不足。单元测试和集成测试通常在无故障环境中运行,最多模拟单节点崩溃。真正的网络分区、时钟漂移、磁盘慢写这些故障模式很少被覆盖。
并发不够真实。测试往往是串行的:先写后读,检查结果。但生产环境是多客户端并发操作,操作之间的交错会产生指数级的执行路径。
正确性标准模糊。什么叫”正确”?“数据没丢”只是最低标准。线性一致性、可串行化(Serializability)、快照隔离(Snapshot Isolation)——每种一致性模型对”正确”有精确的数学定义,而大多数测试根本不检查这些。
Jepsen 的核心思路是把这三个问题系统化地解决:用程序自动注入各种故障,同时用多线程并发地对数据库执行操作,记录下每一次操作的调用和返回,最后用数学上严格的检查器(checker)验证操作历史是否符合声称的一致性模型。
二、Kyle Kingsbury 与 Jepsen 的历史
起源:Call Me Maybe 博客系列
2013 年,Kyle Kingsbury(网名 aphyr)在个人博客上开始发表一系列题为 “Call Me Maybe” 的文章。Kingsbury 当时是一名基础设施工程师,对分布式系统的正确性保证持怀疑态度。他的出发点很直接:厂商声称的一致性保证,到底能不能经受住网络分区的考验?
第一批被测试的系统包括:
- PostgreSQL(2013 年 4 月):测试了流复制(streaming replication)在网络分区下的行为
- Redis(2013 年 5 月):测试了 Sentinel 主从切换,发现脑裂场景下的数据丢失
- MongoDB(2013 年 5 月):测试了副本集在分区下的读一致性,发现可以从 Secondary 读到过期数据
- RabbitMQ(2014 年 6 月):测试了镜像队列在网络分区下的消息丢失
这些文章引起了巨大反响。因为 Kingsbury 不是在做理论分析,而是写代码、搭集群、真的把网线拔了(或者用 iptables 模拟分区),然后把操作历史喂给检查器,用数学方法证明一致性被违反了。
从博客到方法论
早期的 Call Me Maybe 文章更接近探索性测试(exploratory testing)。Kingsbury 逐渐将这套做法形式化为一个可复用的测试框架——Jepsen。Jepsen 用 Clojure 编写,基于几个核心抽象:
- Client:封装了对被测系统的操作(读、写、CAS 等)
- Generator:产生操作序列,控制并发度和操作分布
- Nemesis:故障注入器,可以制造网络分区、杀进程、篡改时钟
- Checker:接收操作历史,验证是否符合特定的一致性模型
到 2015 年左右,Jepsen 已经成为分布式系统领域事实上的正确性测试标准。厂商开始主动邀请 Kingsbury 对他们的产品进行测试,因为通过 Jepsen 测试已经成为一种市场信誉背书。
重要时间线
| 时间 | 事件 |
|---|---|
| 2013 | Call Me Maybe 系列启动,测试 PostgreSQL、Redis、MongoDB |
| 2014 | 测试 RabbitMQ、Elasticsearch、NuoDB |
| 2015 | 测试 etcd、Consul、MongoDB 3.x |
| 2016 | 测试 CockroachDB(beta),发现可串行化违反 |
| 2017 | 引入 Elle 检查器的前身工作,测试 Dgraph、TiDB |
| 2018 | 测试 TiDB、YugabyteDB、Redis Raft |
| 2019 | 测试 PostgreSQL 12(可串行化快照隔离)、CockroachDB 19.x |
| 2020 | Elle 论文发表(VLDB 2020),测试 MongoDB 4.2.6 事务 |
| 2021-2023 | 持续测试新版本数据库,包括 Redpanda、FoundationDB 等 |
三、Jepsen 测试方法论
Jepsen 的测试流程可以分解为五个阶段:集群搭建、操作生成、故障注入、历史记录、一致性检查。每个阶段对应框架中的一个核心组件。
3.1 集群搭建
Jepsen 通过 SSH 连接到一组预先准备好的节点(通常是 5 个
Linux
容器或虚拟机),在这些节点上安装和配置被测系统。节点命名遵循
n1、n2、n3、n4、n5
的约定。
搭建阶段由 db 协议(protocol)定义:
(defrecord MyDB []
db/DB
(setup! [this test node]
;; 在节点上安装和启动被测系统
(install-my-database! node)
(start-my-database! node))
(teardown! [this test node]
;; 停止并清理被测系统
(stop-my-database! node)
(wipe-data! node)))每次测试开始时,setup!
在所有节点上并行执行;测试结束后,teardown!
负责清理。这保证了每次测试从干净的状态开始。
3.2 操作生成(Generator)
生成器(Generator)是一个惰性序列,按需产生操作(operation)。每个操作是一个 Clojure map,包含以下字段:
{:type :invoke ;; 操作类型::invoke, :ok, :fail, :info
:f :write ;; 操作函数::read, :write, :cas 等
:value [3 "hello"] ;; 操作参数
:time 1234567890 ;; 时间戳(纳秒)
:process 2} ;; 执行该操作的工作线程编号生成器的组合是 Jepsen 的一个设计亮点。你可以用函数式风格组合多种操作模式:
(gen/mix [(gen/repeat {:f :read})
(gen/repeat {:f :write, :value (rand-int 100)})])上面的代码将读操作和写操作按均匀分布随机混合。你也可以用
gen/stagger 控制操作频率,用
gen/phases 定义多阶段测试:
(gen/phases
;; 第一阶段:只有写入,持续 30 秒
(gen/time-limit 30
(gen/repeat {:f :write, :value (rand-int 100)}))
;; 第二阶段:读写混合 + 故障注入,持续 60 秒
(gen/time-limit 60
(gen/nemesis
(gen/seq [{:type :info, :f :start}
(gen/sleep 10)
{:type :info, :f :stop}])
(gen/mix [(gen/repeat {:f :read})
(gen/repeat {:f :write, :value (rand-int 100)})])))
;; 第三阶段:恢复后的最终读取
(gen/clients (gen/each-thread {:f :read})))3.3 客户端(Client)
客户端将抽象操作转化为对被测系统的实际调用。每个工作线程(worker)持有一个客户端实例,这些实例并发地执行操作:
(defrecord MyClient [conn]
client/Client
(open! [this test node]
;; 建立到节点的连接
(assoc this :conn (connect node)))
(invoke! [this test op]
(case (:f op)
:read (let [v (db-read conn (:key op))]
(assoc op :type :ok, :value v))
:write (do (db-write conn (:key op) (:value op))
(assoc op :type :ok))
:cas (let [[old new] (:value op)]
(if (db-cas conn (:key op) old new)
(assoc op :type :ok)
(assoc op :type :fail)))))
(close! [this test]
(disconnect conn)))invoke! 方法是核心:它接收一个
{:type :invoke}
的操作,执行后返回带有结果的操作。如果操作成功,:type
设为 :ok;如果因为前置条件不满足而失败(比如
CAS 的旧值不匹配),设为
:fail;如果无法确定结果(比如连接超时),设为
:info。
:info
类型至关重要。在分布式系统中,操作超时不等于操作失败——写操作可能已经在服务端执行成功了,只是响应没有返回。检查器必须考虑这种不确定性。
3.4 故障注入(Nemesis)
Nemesis 是 Jepsen 中最有威力的组件。它不操作数据库的数据接口,而是直接操纵基础设施来制造故障。常见的故障类型:
网络分区:用 iptables
规则阻断节点间的网络通信。Jepsen 内置了多种分区策略:
partition-halves:将 5 个节点分成 2+3 两组partition-majorities-ring:每个节点看到的多数派不同,形成环状分区partition-random-node:随机隔离一个节点partition-random-halves:随机将节点分成两半
进程故障:
kill:用SIGKILL杀死数据库进程(模拟进程崩溃)pause:用SIGSTOP暂停进程(模拟长时间 GC 或 CPU 饥饿)
时钟故障:
clock-skew:用ntpdate或直接修改系统时钟,偏移几百毫秒到几十秒clock-strobe:在快进和回拨之间快速交替,模拟时钟不稳定
Nemesis 的实现也是一个协议:
(defrecord PartitionNemesis []
nemesis/Nemesis
(setup! [this test] this)
(invoke! [this test op]
(case (:f op)
:start (do (partition-network! test)
(assoc op :value "network partitioned"))
:stop (do (heal-network! test)
(assoc op :value "network healed"))))
(teardown! [this test]
(heal-network! test)))3.5 历史记录与一致性检查
测试执行过程中,每一次操作的调用和返回都被记录到一个有序的操作历史(history)中。历史是一个由操作 map 组成的向量:
[{:type :invoke, :f :write, :value 1, :process 0, :time 100}
{:type :invoke, :f :read, :value nil, :process 1, :time 110}
{:type :ok, :f :write, :value 1, :process 0, :time 150}
{:type :ok, :f :read, :value 1, :process 1, :time 160}]这段历史描述了两个并发操作:进程 0 在写入 1,进程 1 在读取。进程 0 的写操作在时间 100 调用、150 返回成功;进程 1 的读操作在时间 110 调用、160 返回,读到了值 1。
检查器的任务是:给定这段历史,判断是否存在一种合法的全序排列(total order),使得每个操作的返回值都能被解释为在该全序中某个时间点的正确结果。如果存在,历史是线性一致的;如果不存在,就发现了一致性违反。
3.6 完整测试链路追踪:以 etcd 线性一致性检验为例
为了更直观地理解 Generator、Nemesis、Checker 三者如何协同工作,我们以一个具体场景为例:使用 Jepsen 测试 etcd 集群的线性一致性。整个测试的架构可以用以下流程图概括:
flowchart LR
G[Generator<br/>生成 append/read 操作] --> C1[Client 线程 1]
G --> C2[Client 线程 2]
G --> C3[Client 线程 3]
C1 --> DB[(etcd 集群<br/>3-5 节点)]
C2 --> DB
C3 --> DB
N[Nemesis<br/>网络分区/进程杀死] -->|故障注入| DB
C1 --> H[History<br/>操作历史]
C2 --> H
C3 --> H
N --> H
H --> CK[Checker<br/>Knossos 线性一致性]
CK --> P{结果}
P -->|合法线性化序列存在| PASS[Pass]
P -->|无法线性化| FAIL[Fail + 反例]
上图展示了 Jepsen 测试的核心数据流:Generator 向多个 Client 线程分发操作,Client 线程并发地向 etcd 集群发送请求,同时 Nemesis 独立地对集群注入故障。所有操作(包括 Nemesis 的故障事件)都被记录到 History 中,最终由 Checker 进行一致性验证。
场景设定。 我们测试一个 5 节点的 etcd 集群,验证其对单个寄存器的读写操作是否满足线性一致性。Generator 产生两种操作:向某个 key 写入一个整数值(append),以及读取该 key 的当前值(read)。Nemesis 在测试中段注入网络分区,将 5 个节点分成 2+3 两组。
;; etcd 线性一致性测试的 Generator 配置
(gen/phases
;; 阶段一:纯写入,建立初始数据(持续 10 秒)
(gen/time-limit 10
(gen/clients
(gen/repeat {:f :write, :value (rand-int 100)})))
;; 阶段二:读写混合 + 网络分区注入(持续 60 秒)
(gen/time-limit 60
(gen/nemesis-during
(gen/phases
(gen/sleep 30) ;; t=30s 注入分区
{:type :info, :f :start, :value :partition-halves}
(gen/sleep 30) ;; t=60s 恢复分区
{:type :info, :f :stop})
(gen/clients
(gen/mix [(gen/repeat {:f :read})
(gen/repeat {:f :write, :value (rand-int 100)})]))))
;; 阶段三:分区恢复后的最终一致性读取
(gen/sleep 10)
(gen/clients (gen/each-thread {:f :read})))下面的时序图展示了故障注入的完整时间线:
sequenceDiagram
participant G as Generator
participant C as Client 线程
participant DB as etcd 集群
participant N as Nemesis
participant H as History
Note over G,H: 阶段一:初始写入(t=0s ~ t=10s)
G->>C: write(key, 42)
C->>DB: PUT /v3/kv/put
DB-->>C: OK
C->>H: {:type :ok, :f :write, :value 42}
Note over G,H: 阶段二:读写混合 + 故障注入(t=10s ~ t=70s)
G->>C: read(key)
C->>DB: POST /v3/kv/range
DB-->>C: value=42
C->>H: {:type :ok, :f :read, :value 42}
Note over N,DB: t=40s:Nemesis 注入网络分区
N->>DB: iptables 隔离节点 n4, n5
N->>H: {:type :info, :f :start, :value :partition-halves}
G->>C: write(key, 87)
C->>DB: PUT /v3/kv/put(路由到多数派 n1-n3)
DB-->>C: OK
C->>H: {:type :ok, :f :write, :value 87}
G->>C: read(key)(路由到少数派 n4)
C->>DB: POST /v3/kv/range
DB-->>C: timeout
C->>H: {:type :info, :f :read, :value nil}
Note over N,DB: t=70s:Nemesis 恢复网络
N->>DB: iptables 清除规则
N->>H: {:type :info, :f :stop}
Note over G,H: 阶段三:恢复后最终读取
G->>C: read(key)
C->>DB: POST /v3/kv/range
DB-->>C: value=87
C->>H: {:type :ok, :f :read, :value 87}
H->>H: Checker 开始验证历史
时序图清晰地展示了三个阶段的边界:初始写入阶段建立数据基线,故障注入阶段是测试的核心区间,恢复后的读取阶段用于验证集群是否最终收敛到正确状态。注意
:info 类型操作的关键作用——当 Client
连接到被隔离的少数派节点时,请求超时的结果被记录为
:info 而非
:fail,因为我们无法确定操作是否在服务端生效。
Checker 的验证流程。 测试结束后,Checker 接收完整的操作历史,逐步构建状态模型并尝试线性化:
stateDiagram-v2
[*] --> CollectHistory: 测试结束
CollectHistory --> ParseOperations: 提取 invoke/ok/fail/info 事件
ParseOperations --> BuildModel: 根据操作类型构建寄存器模型
BuildModel --> CheckConsistency: Knossos 搜索合法线性化序列
CheckConsistency --> Pass: 找到合法排列
CheckConsistency --> Fail: 穷举后无合法排列
Fail --> OutputCounterexample: 输出最小反例路径
OutputCounterexample --> [*]
Pass --> [*]
Checker 的状态流转体现了线性一致性检验的核心算法逻辑:首先收集历史并解析出所有操作对,然后基于寄存器语义构建初始状态模型,最后由 Knossos 在所有可能的线性化排列中搜索。如果搜索成功,测试通过;如果穷举所有排列后仍无法找到合法序列,则输出一条最小反例路径。
失败场景分析。 假设 etcd 在网络分区恢复后存在一个 Bug:少数派节点在重新加入集群时,短暂地返回了过期的数据。具体来说,操作历史中出现了以下序列:
;; 失败的操作历史片段
[{:type :invoke, :f :write, :value 87, :process 0, :time 4500}
{:type :ok, :f :write, :value 87, :process 0, :time 4600}
;; 写入 87 已确认成功
{:type :invoke, :f :write, :value 93, :process 1, :time 4700}
{:type :ok, :f :write, :value 93, :process 1, :time 4800}
;; 写入 93 已确认成功(覆盖了 87)
{:type :invoke, :f :read, :value nil, :process 2, :time 4900}
{:type :ok, :f :read, :value 87, :process 2, :time 5000}]
;; 读到了 87——但最新值应该是 93!这段历史无法被线性化:写入 93 在写入 87 之后完成且两者没有时间重叠,因此在任何合法的全序中 93 必须排在 87 之后。但随后的读操作返回了 87,这意味着读操作”看到了”一个已经被覆盖的旧值。Knossos 会报告类似以下的失败信息:
Analysis invalid! (╯°□°)╯︵ ┻━┻
Found inconsistency at operation:
{:type :ok, :f :read, :value 87, :process 2, :time 5000}
The register model expected value 93 (written by process 1 at time 4800),
but process 2 read stale value 87.
No linearization found after exploring 1,247 configurations.
这正是 Jepsen 在实际测试中发现数据库 Bug 的典型模式:在故障恢复的边界条件下,一致性保证被短暂违反。Checker 通过穷举搜索精确定位了违反点,为开发者提供了可直接复现的反例。
四、一致性模型与检查器
4.1 线性一致性
线性一致性(Linearizability)是 Herlihy 和 Wing 在 1990 年的论文中正式定义的。直觉上,线性一致性要求:
- 每个操作看起来在其调用和返回之间的某个时刻原子地生效
- 所有操作形成一个与实际时间兼容的全序
更形式化的说法:给定一个并发操作历史 H,如果存在一个合法的顺序历史 S(即所有操作按顺序执行的历史),使得 S 中每个操作的返回值与其在顺序规约中的语义一致,且 S 保持了 H 中所有非并发操作的先后顺序(实时约束),那么 H 就是线性一致的。
线性一致性是单对象模型——它对每个独立的寄存器(register)分别检查。对于多对象事务,需要更强的一致性模型,如严格可串行化(Strict Serializability)。
4.2 Knossos:原始线性一致性检查器
Jepsen 最初使用 Knossos 作为线性一致性检查器。Knossos 实现了 Wing 和 Gong 在 1993 年提出的算法(WGL 算法)的一个变体。
WGL 算法的核心思想是搜索:给定操作历史,尝试找到一种合法的线性化排列。算法维护一个状态模型(比如一个寄存器,当前值为 v),然后尝试在每个时间点从可线性化的操作集合中选择一个操作来执行。如果某个选择导致后续操作无法被合法地排列,就回溯。
问题在于:这个搜索空间的大小随操作数量指数增长。线性一致性检查是 NP 完全(NP-Complete)问题——Gibbons 和 Korach 在 1997 年证明了这一点。
实际测试中,Knossos 通过几种优化来控制搜索空间:
- 缓存已访问状态:避免重复搜索
- 剪枝:如果当前状态已经不可能满足后续操作的返回值,提前终止
- 限制并发窗口:只考虑时间上重叠的操作之间的排列
但即便如此,Knossos 处理几百个操作就可能需要很长时间,几千个操作可能直接超时。这限制了 Jepsen 早期测试的操作数量和持续时间。
4.3 Elle:面向事务的检查器
2020 年,Kingsbury 和 Alvaro 在 VLDB 上发表了 Elle 检查器的论文。Elle 解决了两个问题:
- 可扩展性:Elle 的时间复杂度是多项式级别的,而非指数级
- 事务一致性:Elle 可以检查事务隔离级别,而非仅限于单对象线性一致性
Elle 的核心方法基于 Adya 在 1999 年博士论文中定义的异常(anomaly)分类。Adya 将事务隔离级别的违反定义为特定类型的依赖环(dependency cycle):
- G0(脏写 / Dirty Write):两个事务的写操作交错,形成写-写依赖环
- G1a(脏读 / Dirty Read):事务读到了另一个未提交事务的写入
- G1b(中间读 / Intermediate Read):事务读到了另一个事务的中间状态
- G1c(环形信息流 / Circular Information Flow):已提交事务之间形成写-读依赖和写-写依赖的环
- G2(反依赖环 / Anti-dependency Cycle):涉及反依赖(anti-dependency)的环
不同的隔离级别禁止不同的异常:
| 隔离级别 | 禁止的异常 |
|---|---|
| 读已提交(Read Committed) | G0, G1a, G1b, G1c |
| 快照隔离(Snapshot Isolation) | G0, G1a, G1b, G1c, G-SIa |
| 可串行化(Serializable) | G0, G1a, G1b, G1c, G2 |
Elle 的工作流程:
使用精心设计的事务工作负载(workload),使操作之间的依赖关系可以从数据本身推断出来。比如,对列表(list)执行 append 操作,通过读取列表的最终状态就能推断出写入的顺序。
从操作历史中构建依赖图(dependency graph):节点是事务,边是事务之间的依赖关系(写-读、写-写、读-写反依赖)。
在依赖图中搜索禁止的环。如果找到了某个隔离级别禁止的环类型,就证明该隔离级别被违反了。
Elle 的关键创新是工作负载设计。以 list-append 工作负载为例:
;; 事务 T1: 向 key x 追加 1,读 key y
{:type :invoke, :f :txn,
:value [[:append :x 1] [:r :y nil]]}
;; 事务 T2: 向 key y 追加 2,读 key x
{:type :invoke, :f :txn,
:value [[:append :y 2] [:r :x nil]]}如果 T1 读到 y = [2],说明 T1 看到了 T2
的写入,因此 T2 的 append 发生在 T1 的 read
之前,形成写-读依赖 T2 -> T1。如果同时 T2 读到
x = [1],形成写-读依赖 T1 ->
T2。两条边构成环 T1 -> T2 -> T1,这是一个 G1c
异常,违反了读已提交隔离级别。
4.4 性能对比
| 检查器 | 支持的一致性模型 | 复杂度 | 典型处理量 |
|---|---|---|---|
| Knossos | 线性一致性 | 指数级(NP-Complete) | 数百个操作 |
| Elle | 事务隔离级别 | 多项式级别 | 数百万个操作 |
Elle 的引入使 Jepsen 可以运行更长时间的测试、产生更大的操作历史,从而发现更隐蔽的 bug。
五、经典发现:Jepsen 揭露的重大问题
Jepsen 自 2013 年以来测试了数十个分布式系统。以下是一些影响最大的发现,按系统分类。
5.1 MongoDB:从脏读到事务异常
MongoDB 2.4(2013 年)
早期 MongoDB 的副本集(Replica Set)允许客户端从 Secondary 节点读取数据。当发生网络分区时,如果 Primary 被隔离到少数派分区中,少数派分区中的 Primary 会降级,多数派分区会选举出新 Primary。但在降级完成之前,旧 Primary 仍然可以接受写入(这些写入最终会被回滚),而 Secondary 可能返回尚未复制到的旧数据。
Jepsen 测试发现:使用默认配置时,MongoDB 在网络分区下无法保证读操作返回最新的已提交数据。客户端可能读到已经被回滚的”脏数据”。
根本原因:MongoDB 的读偏好(Read Preference)默认允许从 Secondary 读取,但 Secondary 的复制是异步的。网络分区会放大这种延迟,导致客户端观察到一致性违反。
MongoDB 4.2.6(2020 年)
多年后,Jepsen 用 Elle 检查器再次测试了 MongoDB 的多文档事务(multi-document transaction)功能。发现了多种事务隔离级别违反,包括在声称的”快照隔离”下出现的脏读和非可重复读。MongoDB 随后在 4.2.8 和后续版本中修复了部分问题。
5.2 Redis:Sentinel 脑裂
Redis Sentinel(2013 年)
Redis 的 Sentinel 系统负责在主节点故障时自动进行主从切换(failover)。Jepsen 测试揭示了一个根本性设计问题:在网络分区期间,旧主节点和新主节点可以同时存在,形成脑裂(split-brain)。
场景:
- 主节点 M 与 Sentinel 集群断开
- Sentinel 选举 Slave S 为新主节点
- 此时 M 和 S 同时接受写入
- 网络恢复后,M 发现自己已经不是主节点,降级为 Slave,用 S 的数据覆盖自己
- 在 M 上写入的所有数据永久丢失
这不是 bug,而是 Redis 设计层面的权衡:Redis 是 AP 系统(可用性优先),不保证强一致性。但当时 Redis 的文档没有明确说明这一点,许多用户误以为 Sentinel 能提供无数据丢失的故障切换。
Redis Raft(2020 年)
Redis 后来实现了基于 Raft 的复制模块(RedisRaft),目标是提供线性一致性。Jepsen 测试该模块时发现了实现中的多个 bug,包括在特定的成员变更(membership change)场景下出现的线性一致性违反。
5.3 Elasticsearch:已确认写入丢失
Elasticsearch 1.1(2014 年)
Jepsen 对 Elasticsearch 的测试发现了严重的数据丢失问题。在网络分区场景下,已经被客户端确认的写入可以永久丢失。
根本原因在于 Elasticsearch 早期版本的选主(master
election)机制存在缺陷。Elasticsearch 使用了一种基于
discovery.zen.minimum_master_nodes
配置的仲裁(quorum)机制,但这个机制在某些分区模式下会失效,导致两个分区各自选出自己的
Master 节点。分区恢复后,一侧的数据被丢弃。
Elasticsearch 团队在后续版本中逐步修复了这些问题,最终在 7.0 版本中用新的集群协调机制替换了旧的 Zen Discovery。
5.4 CockroachDB:可串行化违反
CockroachDB beta-20160829(2016 年)
CockroachDB 声称提供可串行化(Serializable)隔离级别。Jepsen 测试发现,在特定条件下,CockroachDB 实际提供的是快照隔离(Snapshot Isolation),存在写偏序(write skew)异常。
写偏序的经典例子:两个医生值班,规则是”至少保留一人值班”。两个医生同时检查值班人数,都看到有两人,于是各自请假。结果没人值班了。
-- 事务 T1 -- 事务 T2
BEGIN; BEGIN;
SELECT count(*) FROM oncall; SELECT count(*) FROM oncall;
-- 返回 2 -- 返回 2
DELETE FROM oncall WHERE doc='A'; DELETE FROM oncall WHERE doc='B';
COMMIT; COMMIT;
-- 两个事务都提交成功,但违反了不变量CockroachDB 的实现基于多版本并发控制(MVCC),使用时间戳排序(timestamp ordering)来实现可串行化。Jepsen 发现的 bug 与时间戳推进(timestamp pushing)逻辑中的竞态条件有关。CockroachDB 团队在后续版本中修复了这些问题。
5.5 etcd:线性一致性违反
etcd 3.4.3(2020 年)
etcd 是 Kubernetes 的核心存储组件,声称提供线性一致性。Jepsen 测试发现 etcd 在特定配置下存在线性一致性违反。
具体来说,etcd 的
--experimental-initial-corrupt-check 功能与
Watch 机制之间存在竞态条件。在 Leader
切换期间,客户端可能观察到不一致的状态——先读到新值,再读到旧值(所谓的”时间旅行”)。
etcd 团队在 3.4.x 和 3.5.x 系列中修复了多个 Jepsen 发现的问题。
5.6 RabbitMQ:消息丢失
RabbitMQ 3.x(2014 年)
RabbitMQ 的镜像队列(Mirrored
Queue)功能声称通过跨节点复制来防止消息丢失。Jepsen
测试发现,在网络分区期间,即使使用了
ha-mode: all(所有节点复制)配置,已确认发布的消息仍然可能丢失。
原因涉及 RabbitMQ 的分区处理策略。默认的
ignore
策略在分区期间允许两个分区各自运行,分区恢复后需要手动介入。autoheal
策略会自动选择一个分区的数据,丢弃另一个分区在分区期间的所有变更。无论哪种策略,都存在已确认消息丢失的窗口。
5.7 Cassandra:轻量级事务违反
Apache Cassandra 2.0(2013 年)
Cassandra 的轻量级事务(Lightweight Transaction, LWT)基于 Paxos 协议实现,声称提供线性一致性的比较并交换(Compare-And-Swap)操作。Jepsen 测试发现了几个问题:
- 在网络分区和节点重启组合的场景下,LWT 的 Paxos 实现存在正确性问题
- 数据版本冲突的处理逻辑有误,导致某些已提交的 CAS 操作被静默覆盖
- 性能退化情况下(大量超时),操作结果的不确定性比预期更高
5.8 PostgreSQL:可串行化快照隔离边界
PostgreSQL 9.2+(2013 年,后续多次复测)
PostgreSQL 的可串行化快照隔离(Serializable Snapshot Isolation, SSI)实现总体上是正确的。Jepsen 测试确认了 SSI 能够正确检测和中止形成依赖环的事务。
但 Jepsen 也指出了几个边界情况:
- 只读事务在某些情况下可以观察到与可串行化不一致的状态(PostgreSQL 的 SSI 实现对只读事务有特殊优化,这些优化在极端情况下可能导致异常)
- 流复制(Streaming Replication)与 SSI 的交互在分区场景下的行为文档不足
PostgreSQL 在 Jepsen 测试中的表现总体优于大多数被测系统,这与其成熟的 MVCC 实现和保守的设计理念有关。
5.9 其他重要发现
| 系统 | 版本 | 发现的问题 |
|---|---|---|
| Dgraph | 1.0.2(2018) | 快照隔离违反,谓词级别的丢失更新 |
| TiDB | 2.1.7(2019) | 快照隔离下出现不可重复读,多版本时间戳处理异常 |
| YugabyteDB | 1.1.9(2019) | 读已提交和可串行化违反,Raft 日志与 MVCC 交互 bug |
| Redpanda | 21.10.1(2021) | 在领导者选举期间的消息重复和顺序违反 |
| VoltDB | 6.3(2016) | 在节点故障恢复时丢失已提交的事务 |
六、如何编写 Jepsen 测试
6.1 前置知识
编写 Jepsen 测试需要了解 Clojure 的基本语法。Clojure 是一种运行在 JVM 上的 Lisp 方言,Jepsen 大量使用了 Clojure 的不可变数据结构和惰性序列。你不需要精通 Clojure,但需要理解以下概念:
- 数据字面量:
{}是 map,[]是 vector,#{}是 set,()是 list - 关键字:
:read、:write这样以冒号开头的标识符 - 协议(protocol)和记录(record):类似 Java 的接口和实现
- 函数定义:
(defn name [args] body)
6.2 一个完整的 Jepsen 测试示例
下面是一个针对假想的 KV 存储的完整 Jepsen 测试。这个测试检查单个寄存器的线性一致性:
(ns my-db.core
(:require [jepsen [cli :as cli]
[client :as client]
[checker :as checker]
[control :as c]
[db :as db]
[generator :as gen]
[nemesis :as nemesis]
[tests :as tests]]
[jepsen.checker.timeline :as timeline]
[jepsen.os.debian :as debian]
[knossos.model :as model]
[clojure.tools.logging :refer [info warn]]))
;; ---------- 数据库安装与管理 ----------
(defn install-db!
"在节点上安装被测数据库。"
[node version]
(c/su
(c/exec :apt-get :install :-y (str "my-db=" version))))
(defn start-db!
"启动数据库进程。"
[node]
(c/su
(c/exec :systemctl :start :my-db)))
(defn stop-db!
"停止数据库进程。"
[node]
(c/su
(c/exec :systemctl :stop :my-db)))
(defn db
"构造数据库对象。"
[version]
(reify db/DB
(setup! [_ test node]
(install-db! node version)
(start-db! node))
(teardown! [_ test node]
(stop-db! node))))
;; ---------- 客户端 ----------
(defrecord KVClient [conn]
client/Client
(open! [this test node]
(assoc this :conn (my-db-driver/connect node 3000)))
(setup! [this test])
(invoke! [this test op]
(case (:f op)
:read (let [value (my-db-driver/get conn "test-key")]
(assoc op :type :ok, :value value))
:write (do (my-db-driver/put conn "test-key" (:value op))
(assoc op :type :ok))
:cas (let [[expected new-val] (:value op)
current (my-db-driver/get conn "test-key")]
(if (= current expected)
(do (my-db-driver/put conn "test-key" new-val)
(assoc op :type :ok))
(assoc op :type :fail,
:error :value-mismatch)))))
(teardown! [this test])
(close! [this test]
(my-db-driver/disconnect conn)))
;; ---------- 测试定义 ----------
(defn register-test
"线性一致性寄存器测试。"
[opts]
(merge tests/noop-test
opts
{:name "my-db-register"
:os debian/os
:db (db "1.0.0")
:client (KVClient. nil)
:nemesis (nemesis/partition-random-halves)
:checker (checker/compose
{:linear (checker/linearizable
{:model (model/register)
:algorithm :wgl})
:timeline (timeline/html)})
:generator (gen/phases
;; 正常操作阶段
(->> (gen/mix
[{:f :read}
{:f :write, :value (rand-int 100)}
{:f :cas, :value [(rand-int 100)
(rand-int 100)]}])
(gen/stagger 1/50)
(gen/nemesis
(cycle [(gen/sleep 5)
{:type :info, :f :start}
(gen/sleep 10)
{:type :info, :f :stop}]))
(gen/time-limit 60))
;; 恢复后的最终读取
(gen/log "Waiting for recovery...")
(gen/sleep 10)
(gen/clients
(gen/each-thread
{:f :read})))}))
;; ---------- 命令行入口 ----------
(defn -main
"命令行入口。"
[& args]
(cli/run! (cli/single-test-cmd {:test-fn register-test})
args))6.3 运行测试
测试通过 Leiningen(Clojure 的构建工具)运行:
lein run test --nodes n1,n2,n3,n4,n5 \
--ssh-private-key ~/.ssh/id_rsa \
--time-limit 120 \
--concurrency 10关键参数:
--nodes:被测节点列表--time-limit:测试持续时间(秒)--concurrency:并发工作线程数--nemesis:指定故障注入策略(如果测试代码支持参数化)
测试完成后,Jepsen 会在 store/
目录下生成结果,包括:
history.edn:完整的操作历史results.edn:检查结果timeline.html:操作的可视化时间线latency-raw.png:操作延迟分布图jepsen.log:测试运行日志
如果检查器发现一致性违反,results.edn
中会包含 :valid? false
和具体的反例——即一组无法被合法线性化的操作。
6.4 定义 Nemesis
一个更复杂的 Nemesis 示例,组合多种故障:
(defn combined-nemesis
"组合网络分区和进程崩溃的 Nemesis。"
[]
(nemesis/compose
{#{:partition-start :partition-stop}
(nemesis/partition-random-halves)
#{:kill :revive}
(nemesis/node-start-stopper
(fn [test nodes]
(rand-nth nodes))
(fn [test node]
(c/su (c/exec :systemctl :stop :my-db)))
(fn [test node]
(c/su (c/exec :systemctl :start :my-db))))}))对应的生成器需要产生匹配的事件:
(gen/nemesis
(gen/seq (cycle [{:type :info, :f :partition-start}
(gen/sleep 10)
{:type :info, :f :partition-stop}
(gen/sleep 5)
{:type :info, :f :kill}
(gen/sleep 15)
{:type :info, :f :revive}
(gen/sleep 10)])))6.5 使用 Elle 检查事务
如果被测系统支持事务,可以用 Elle 做隔离级别检查:
(ns my-db.txn
(:require [jepsen.tests.cycle.append :as append]))
(defn txn-test
[opts]
(append/test
(merge opts
{:key-count 10
:min-txn-length 2
:max-txn-length 4
:consistency-models [:strict-serializable]})))append/test 会自动生成 list-append
工作负载,并用 Elle
检查事务历史。你只需要提供一个能执行事务的 Client 实现。
七、对工业界的影响
7.1 改变了数据库的营销话术
Jepsen 最直接的影响是迫使数据库厂商更诚实地描述自己的一致性保证。在 Jepsen 出现之前,数据库的市场材料中充斥着模糊的一致性声明:“强一致”“ACID 兼容”“企业级可靠性”。这些说法没有精确的技术含义,也没有被独立验证。
Jepsen 测试报告用数学上严格的方法证明了哪些声明是成立的、哪些是虚假的。这使得厂商不得不:
- 明确说明在何种条件下(故障模式、配置选项)提供何种一致性级别
- 区分不同的一致性模型(线性一致性 vs. 最终一致性 vs. 因果一致性)
- 在文档中详细说明已知的限制和边界条件
比如,MongoDB 在被 Jepsen 测试后明确了
readConcern 和 writeConcern
的语义;CockroachDB 修正了关于可串行化的技术描述。
7.2 Jepsen 测试成为行业标杆
到 2018 年前后,通过 Jepsen 测试已经成为分布式数据库的一种市场准入门槛。厂商主动寻求 Jepsen 测试,原因包括:
- 信誉背书:通过 Jepsen 测试意味着产品经受住了独立的、严格的正确性审查
- 发现 bug:即使测试发现了问题,修复后也证明了厂商对正确性的重视
- 竞争差异化:在产品同质化的市场中,通过 Jepsen 测试是一个差异化优势
一些案例:
- TiDB 多次委托 Jepsen 测试,并将测试结果作为产品成熟度的证据
- CockroachDB 在修复 Jepsen 发现的问题后,将 Jepsen 测试集成到了持续集成(CI)流水线中
- YugabyteDB、Redpanda 等新兴数据库在早期版本就主动进行 Jepsen 测试
7.3 影响数据库开发实践
Jepsen 推动了几个开发实践的演变:
正确性优先的文化。在 Jepsen 之前,数据库开发团队的测试重点通常是性能(吞吐量、延迟)和功能(SQL 兼容性、索引类型)。Jepsen 证明了性能优秀但一致性有缺陷的数据库在生产中是危险的,促使团队将正确性测试提升为第一优先级。
形式化规约。为了通过 Jepsen 测试,数据库团队需要精确地定义自己的一致性语义——使用 Adya 的隔离级别定义,或者用 TLA+(时序逻辑)对协议进行形式化规约。这推动了形式化方法(formal methods)在工业界的普及。
混沌工程(Chaos Engineering)的先驱。Jepsen 的故障注入方法论启发了更广泛的混沌工程实践。Netflix 的 Chaos Monkey 关注的是”服务在节点故障下能否继续提供服务”,而 Jepsen 关注的是”服务在故障下的行为是否正确”。两者互补。
7.4 与其他测试方法的对比
| 方法 | 目标 | 故障模型 | 正确性标准 |
|---|---|---|---|
| Jepsen | 一致性验证 | 真实故障注入 | 形式化一致性模型 |
| TLA+ / 形式化验证 | 协议正确性证明 | 模型层面穷举 | 形式化规约 |
| 混沌工程 | 系统韧性 | 真实故障注入 | 可用性指标 |
| 确定性模拟测试 | 协议实现正确性 | 确定性调度 | 断言和不变量 |
| 传统集成测试 | 功能正确性 | 无故障 | 预期输出 |
Jepsen 的独特优势在于它将真实故障注入与形式化正确性标准结合——既不是纯理论的模型检查,也不是只看”系统有没有挂”的韧性测试,而是回答”系统在故障下的行为是否符合其声称的语义”。
八、局限性与未来
8.1 Jepsen 不能测什么
性能异常。Jepsen 关注的是正确性,不是性能。一个数据库在分区恢复后正确但延迟飙升到 30 秒,Jepsen 不会报告这是问题。
持久性的完整验证。Jepsen 可以检测”已确认写入是否丢失”,但不能完全验证持久性(durability),因为它不模拟磁盘故障、存储层错误或硬件损坏。
活性(Liveness)。Jepsen 主要检查安全性(safety)——“坏事不会发生”。活性——“好事最终会发生”——更难测试。Jepsen 能检测到”系统在故障后一直不可用”这种明显的活性问题,但不能系统性地证明活性保证。
大规模集群。Jepsen 通常使用 5 个节点的集群。对于几十甚至上百个节点的大规模部署,Jepsen 的测试结果不一定能外推。某些 bug 可能只在大规模集群中出现。
应用层语义。Jepsen 测试的是数据库提供的一致性保证,不是应用层面的业务正确性。即使数据库通过了 Jepsen 测试,应用代码仍然可能因为错误地使用数据库 API 而产生一致性问题。
8.2 可扩展性限制
Knossos 的指数级复杂度限制了线性一致性检查的操作数量。虽然 Elle 解决了事务检查的可扩展性问题,但单对象线性一致性检查仍然受限于 NP-Complete 的理论下界。
实践中,Jepsen 通过以下方式缓解这个问题:
- 将长历史切分为多个短窗口分别检查
- 使用概率性检查(只检查历史的一个随机子集)
- 设置超时:如果检查器在一定时间内找不到合法的线性化,报告为”可能违反”
8.3 测试环境与生产环境的差距
Jepsen 使用 Docker 容器或虚拟机搭建测试集群,用 iptables 模拟网络分区。这与生产环境存在几个差距:
- 网络故障模式:真实的网络故障比二元的”通/断”复杂得多。部分丢包、延迟尖峰、带宽下降、MTU 不匹配等故障模式在 Jepsen 中不容易模拟。
- 硬件差异:Jepsen 测试通常在同一物理机上运行所有容器,磁盘 I/O 和网络延迟特征与真实的多机房部署不同。
- 规模效应:5 节点集群中不出现的 bug 可能在 50 节点集群中出现,因为更多的节点意味着更多的状态组合和更复杂的故障模式。
- 长时间运行:Jepsen 测试通常持续几分钟到几小时。生产系统运行数月甚至数年,某些 bug 可能需要很长时间才会被触发。
8.4 未来方向
自动化测试生成。当前的 Jepsen 测试需要人工编写 Client 和 Nemesis。未来可能通过分析数据库的 API 定义自动生成测试代码。
更丰富的故障模型。模拟更接近真实的网络故障(部分连通性、灰色故障),以及存储层故障(fsync 失败、部分写入)。
与形式化方法的结合。将 TLA+ 规约与 Jepsen 测试对接:用 TLA+ 定义正确性规约,用 Jepsen 验证实现是否符合规约。一些学术团队已经在探索这个方向。
覆盖率指标。开发度量”故障空间覆盖率”的方法,帮助用户理解 Jepsen 测试了故障空间中的多大比例。
云原生环境。适配 Kubernetes 等云原生基础设施,模拟 Pod 驱逐、节点扩缩容等云环境特有的故障模式。
参考文献
- Kingsbury, K. Jepsen 项目主页. https://jepsen.io/
- Kingsbury, K. “Call Me Maybe” 系列博文. https://aphyr.com/tags/jepsen
- Kingsbury, K., Alvaro, P. “Elle: Inferring Isolation Anomalies from Experimental Observations.” Proceedings of the VLDB Endowment, 14(3), 2020. https://jepsen.io/analyses
- Herlihy, M., Wing, J. “Linearizability: A Correctness Condition for Concurrent Objects.” ACM Transactions on Programming Languages and Systems, 12(3):463-492, 1990.
- Wing, J., Gong, C. “Testing and Verifying Concurrent Objects.” Journal of Parallel and Distributed Computing, 17(1-2):164-182, 1993.
- Gibbons, P., Korach, E. “Testing Shared Memories.” SIAM Journal on Computing, 26(4):1208-1244, 1997.
- Adya, A. “Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions.” PhD thesis, MIT, 1999.
- Kingsbury, K. Jepsen: MongoDB. https://jepsen.io/analyses/mongodb-4.2.6
- Kingsbury, K. Jepsen: Redis. https://aphyr.com/posts/283-jepsen-redis
- Kingsbury, K. Jepsen: Elasticsearch. https://aphyr.com/posts/317-jepsen-elasticsearch
- Kingsbury, K. Jepsen: CockroachDB. https://jepsen.io/analyses/cockroachdb-beta-20160829
- Kingsbury, K. Jepsen: etcd 3.4.3. https://jepsen.io/analyses/etcd-3.4.3
- Kingsbury, K. Jepsen: RabbitMQ. https://aphyr.com/posts/315-jepsen-rabbitmq
- Kingsbury, K. “Jepsen 系列演讲”, Strange Loop / GOTO / QCon 等会议. https://jepsen.io/talks
- Jepsen 源代码. https://github.com/jepsen-io/jepsen
- Knossos 线性一致性检查器. https://github.com/jepsen-io/knossos
- Elle 事务检查器. https://github.com/jepsen-io/elle
prev: 成员协议:SWIM 与 Gossip next: 确定性模拟测试
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】混沌工程:在生产环境中主动寻找系统弱点
从 Netflix Chaos Monkey 到 Chaos Mesh,系统讲解混沌工程的方法论、实验设计、工具链与实践经验,以及与故障注入和确定性模拟的本质区别。
【分布式系统百科】大规模故障复盘:从真实事故中学习分布式系统设计
精选 8 个真实大规模分布式系统故障案例,逐一分析根因、传播路径、恢复过程与事后改进,提炼分布式系统可靠性设计的共性教训。
【分布式系统百科】形式化验证:用数学证明分布式协议的正确性
从 TLA+ 到 P 语言,解析形式化验证在分布式系统中的应用,包含 Amazon、Azure 等工业实践以及 Two-Phase Commit 的完整 TLA+ 规范。
【分布式系统百科】确定性模拟测试:让分布式系统的 Bug 无处遁形
从 FoundationDB 到 TigerBeetle 再到 Antithesis,解析确定性模拟测试如何通过控制所有非确定性源实现完全可重放的分布式系统测试。