土法炼钢兴趣小组的算法知识备份

【分布式系统百科】Jepsen 方法论:如何科学地证明分布式系统有 Bug

文章导航

分类入口
分布式系统
标签入口
#Jepsen#一致性测试#线性一致性#故障注入#分布式数据库

目录

你的团队用了某个分布式数据库。官方文档写着”支持强一致性”。你信了,把关键业务逻辑(比如扣款、库存扣减)建在这个假设上。上线半年,没出过问题。

然后某天,一次网络抖动——两个机房之间的链路中断了 47 秒。恢复之后,运营发现有三笔订单扣款成功了但库存没扣,还有两笔订单的余额对不上。你去查日志,发现数据库在网络分区(network partition)期间,两个副本各自接受了写入,恢复连接后只做了”最后写入胜出”(last-write-wins)的合并,静默丢弃了一侧的数据。

所谓的”强一致性”,在网络分区面前形同虚设。

这不是假想的场景。MongoDB 2.x 在网络分区下允许从 Secondary 节点读取过期数据。Redis 在使用 Sentinel 做主从切换时可能出现脑裂,两个节点同时认为自己是主节点,各自接受写入。Elasticsearch 在早期版本中,网络分区可以导致已确认的写入永久丢失。这些都不是理论推演,而是被实际测试验证过的行为。

做这些测试的人叫 Kyle Kingsbury,他写的工具叫 Jepsen(https://jepsen.io)。

本文拆解 Jepsen 的方法论:它用什么方式制造故障,怎样并发操作数据库,如何从操作历史中检查一致性是否被违反。然后逐个回顾 Jepsen 揭露的经典问题,分析为什么这些 bug 存在以及厂商如何修复。最后讨论 Jepsen 方法论的局限性。

Jepsen 测试架构

一、从一次数据库”丢数据”说起

先看一个具体的故障场景。假设你用一个三副本的分布式 KV 存储,配置为”写入需要多数派确认”。客户端发送 write(x, 1),收到成功响应。紧接着另一个客户端发送 read(x),期望读到 1

正常情况下没问题。但如果在 write 返回成功之后、read 发出之前,包含最新数据的两个副本被网络分区隔开了呢?read 请求被路由到了落后的那个副本,读到了旧值 null

这就是一致性违反(consistency violation)。数据库声称提供线性一致性(Linearizability),但实际行为违反了这个承诺。

问题在于:厂商怎么知道自己的一致性实现是正确的? 单元测试能测到这种情况吗?几乎不可能。这类 bug 只在特定的并发时序和故障组合下才会触发。手动测试更不靠谱——你不可能穷举所有的网络分区模式、进程崩溃时机和并发操作交错。

传统的测试方法有三个根本缺陷:

  1. 故障覆盖不足。单元测试和集成测试通常在无故障环境中运行,最多模拟单节点崩溃。真正的网络分区、时钟漂移、磁盘慢写这些故障模式很少被覆盖。

  2. 并发不够真实。测试往往是串行的:先写后读,检查结果。但生产环境是多客户端并发操作,操作之间的交错会产生指数级的执行路径。

  3. 正确性标准模糊。什么叫”正确”?“数据没丢”只是最低标准。线性一致性、可串行化(Serializability)、快照隔离(Snapshot Isolation)——每种一致性模型对”正确”有精确的数学定义,而大多数测试根本不检查这些。

Jepsen 的核心思路是把这三个问题系统化地解决:用程序自动注入各种故障,同时用多线程并发地对数据库执行操作,记录下每一次操作的调用和返回,最后用数学上严格的检查器(checker)验证操作历史是否符合声称的一致性模型。

二、Kyle Kingsbury 与 Jepsen 的历史

起源:Call Me Maybe 博客系列

2013 年,Kyle Kingsbury(网名 aphyr)在个人博客上开始发表一系列题为 “Call Me Maybe” 的文章。Kingsbury 当时是一名基础设施工程师,对分布式系统的正确性保证持怀疑态度。他的出发点很直接:厂商声称的一致性保证,到底能不能经受住网络分区的考验?

第一批被测试的系统包括:

这些文章引起了巨大反响。因为 Kingsbury 不是在做理论分析,而是写代码、搭集群、真的把网线拔了(或者用 iptables 模拟分区),然后把操作历史喂给检查器,用数学方法证明一致性被违反了。

从博客到方法论

早期的 Call Me Maybe 文章更接近探索性测试(exploratory testing)。Kingsbury 逐渐将这套做法形式化为一个可复用的测试框架——Jepsen。Jepsen 用 Clojure 编写,基于几个核心抽象:

到 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 容器或虚拟机),在这些节点上安装和配置被测系统。节点命名遵循 n1n2n3n4n5 的约定。

搭建阶段由 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 内置了多种分区策略:

进程故障

时钟故障

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 年的论文中正式定义的。直觉上,线性一致性要求:

  1. 每个操作看起来在其调用和返回之间的某个时刻原子地生效
  2. 所有操作形成一个与实际时间兼容的全序

更形式化的说法:给定一个并发操作历史 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 解决了两个问题:

  1. 可扩展性:Elle 的时间复杂度是多项式级别的,而非指数级
  2. 事务一致性:Elle 可以检查事务隔离级别,而非仅限于单对象线性一致性

Elle 的核心方法基于 Adya 在 1999 年博士论文中定义的异常(anomaly)分类。Adya 将事务隔离级别的违反定义为特定类型的依赖环(dependency cycle):

不同的隔离级别禁止不同的异常:

隔离级别 禁止的异常
读已提交(Read Committed) G0, G1a, G1b, G1c
快照隔离(Snapshot Isolation) G0, G1a, G1b, G1c, G-SIa
可串行化(Serializable) G0, G1a, G1b, G1c, G2

Elle 的工作流程:

  1. 使用精心设计的事务工作负载(workload),使操作之间的依赖关系可以从数据本身推断出来。比如,对列表(list)执行 append 操作,通过读取列表的最终状态就能推断出写入的顺序。

  2. 从操作历史中构建依赖图(dependency graph):节点是事务,边是事务之间的依赖关系(写-读、写-写、读-写反依赖)。

  3. 在依赖图中搜索禁止的环。如果找到了某个隔离级别禁止的环类型,就证明该隔离级别被违反了。

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)。

场景:

  1. 主节点 M 与 Sentinel 集群断开
  2. Sentinel 选举 Slave S 为新主节点
  3. 此时 M 和 S 同时接受写入
  4. 网络恢复后,M 发现自己已经不是主节点,降级为 Slave,用 S 的数据覆盖自己
  5. 在 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 测试发现了几个问题:

5.8 PostgreSQL:可串行化快照隔离边界

PostgreSQL 9.2+(2013 年,后续多次复测)

PostgreSQL 的可串行化快照隔离(Serializable Snapshot Isolation, SSI)实现总体上是正确的。Jepsen 测试确认了 SSI 能够正确检测和中止形成依赖环的事务。

但 Jepsen 也指出了几个边界情况:

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,但需要理解以下概念:

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

关键参数:

测试完成后,Jepsen 会在 store/ 目录下生成结果,包括:

如果检查器发现一致性违反,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 测试报告用数学上严格的方法证明了哪些声明是成立的、哪些是虚假的。这使得厂商不得不:

比如,MongoDB 在被 Jepsen 测试后明确了 readConcernwriteConcern 的语义;CockroachDB 修正了关于可串行化的技术描述。

7.2 Jepsen 测试成为行业标杆

到 2018 年前后,通过 Jepsen 测试已经成为分布式数据库的一种市场准入门槛。厂商主动寻求 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 模拟网络分区。这与生产环境存在几个差距:

8.4 未来方向

自动化测试生成。当前的 Jepsen 测试需要人工编写 Client 和 Nemesis。未来可能通过分析数据库的 API 定义自动生成测试代码。

更丰富的故障模型。模拟更接近真实的网络故障(部分连通性、灰色故障),以及存储层故障(fsync 失败、部分写入)。

与形式化方法的结合。将 TLA+ 规约与 Jepsen 测试对接:用 TLA+ 定义正确性规约,用 Jepsen 验证实现是否符合规约。一些学术团队已经在探索这个方向。

覆盖率指标。开发度量”故障空间覆盖率”的方法,帮助用户理解 Jepsen 测试了故障空间中的多大比例。

云原生环境。适配 Kubernetes 等云原生基础设施,模拟 Pod 驱逐、节点扩缩容等云环境特有的故障模式。

参考文献

  1. Kingsbury, K. Jepsen 项目主页. https://jepsen.io/
  2. Kingsbury, K. “Call Me Maybe” 系列博文. https://aphyr.com/tags/jepsen
  3. Kingsbury, K., Alvaro, P. “Elle: Inferring Isolation Anomalies from Experimental Observations.” Proceedings of the VLDB Endowment, 14(3), 2020. https://jepsen.io/analyses
  4. Herlihy, M., Wing, J. “Linearizability: A Correctness Condition for Concurrent Objects.” ACM Transactions on Programming Languages and Systems, 12(3):463-492, 1990.
  5. Wing, J., Gong, C. “Testing and Verifying Concurrent Objects.” Journal of Parallel and Distributed Computing, 17(1-2):164-182, 1993.
  6. Gibbons, P., Korach, E. “Testing Shared Memories.” SIAM Journal on Computing, 26(4):1208-1244, 1997.
  7. Adya, A. “Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions.” PhD thesis, MIT, 1999.
  8. Kingsbury, K. Jepsen: MongoDB. https://jepsen.io/analyses/mongodb-4.2.6
  9. Kingsbury, K. Jepsen: Redis. https://aphyr.com/posts/283-jepsen-redis
  10. Kingsbury, K. Jepsen: Elasticsearch. https://aphyr.com/posts/317-jepsen-elasticsearch
  11. Kingsbury, K. Jepsen: CockroachDB. https://jepsen.io/analyses/cockroachdb-beta-20160829
  12. Kingsbury, K. Jepsen: etcd 3.4.3. https://jepsen.io/analyses/etcd-3.4.3
  13. Kingsbury, K. Jepsen: RabbitMQ. https://aphyr.com/posts/315-jepsen-rabbitmq
  14. Kingsbury, K. “Jepsen 系列演讲”, Strange Loop / GOTO / QCon 等会议. https://jepsen.io/talks
  15. Jepsen 源代码. https://github.com/jepsen-io/jepsen
  16. Knossos 线性一致性检查器. https://github.com/jepsen-io/knossos
  17. Elle 事务检查器. https://github.com/jepsen-io/elle

prev: 成员协议:SWIM 与 Gossip next: 确定性模拟测试

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .