5.分区

故障引入

nemesis是一个不绑定到任何特定节点的特殊客户端,用于引入整个集群内运行过程中可能遇到的故障。我们需要导入jepsen.nemesis来提供数个内置的故障模式。

(ns jepsen.etcdemo
  (:require [clojure.tools.logging :refer :all]
            [clojure.string :as str]
            [jepsen [checker :as checker]
                    [cli :as cli]
                    [client :as client]
                    [control :as c]
                    [db :as db]
                    [generator :as gen]
                    [nemesis :as nemesis]
                    [tests :as tests]]
            [jepsen.checker.timeline :as timeline]
            [jepsen.control.util :as cu]
            [jepsen.os.debian :as debian]
            [knossos.model :as model]
            [slingshot.slingshot :refer [try+]]
            [verschlimmbesserung.core :as v]))

我们将选取一个简单的nemesis进行介绍,并在测试中添加名为:nemesis的主键。当它收到:start操作指令时,它会将网络分成两部分并随机选择其中一个。当收到:stop指令时则恢复网络分区。

(defn etcd-test
  "Given an options map from the command line runner (e.g. :nodes, :ssh,
  :concurrency ...), constructs a test map."
  [opts]
  (merge tests/noop-test
         opts
         {:pure-generators true
          :name            "etcd"
          :os              debian/os
          :db              (db "v3.1.5")
          :client          (Client. nil)
          :nemesis         (nemesis/partition-random-halves)
          :checker         (checker/compose
                             {:perf   (checker/perf)
                              :linear (checker/linearizable
                                        {:model     (model/cas-register)
                                         :algorithm :linear})
                              :timeline (timeline/html)})
          :generator       (->> (gen/mix [r w cas])
                                (gen/stagger 1)
                                (gen/nemesis nil)
                                (gen/time-limit 15))}))

像常规的客户端一样,nemesis从生成器中获取操作。现在我们的生成器会将操作分发给常规的客户端——而nemesis只会收到nil,即什么都不用做。我们将专门用于nemesis操作的生成器来替换它。我们也准备增加时间限制,那样就有足够的时间等着nemesis发挥作用了。

          :generator (->> (gen/mix [r w cas])
                          (gen/stagger 1)
                          (gen/nemesis
                            (cycle [(gen/sleep 5)
                              {:type :info, :f :start}
                              (gen/sleep 5)
                              {:type :info, :f :stop}]))
                          (gen/time-limit 30))

Clojure sequence数据结构可以扮演生成器的角色,因此我们可以使用Clojure自带的函数来构建它们。这里,我们使用cycle来构建一个无限的睡眠、启动、睡眠、停止循环,直至超时。

网络分区造成一些操作出现崩溃:

WARN [2018-02-02 15:54:53,380] jepsen worker 1 - jepsen.core Process 1 crashed
java.net.SocketTimeoutException: Read timed out

如果我们知道一个操作没有触发,我们可以通过返回带有:type :fail代替client/invoke!抛出异常让checker更有效率(也能发现更多的bugs!),但每个错误引发程序崩溃依旧是安全的:jepsen的checkers知道一个已经崩溃的操作可能触发也可能没触发。

发现bug

我们已经在测试中写死了超时时间为30s,但是如果能够在命令行中控制它就好了。Jepsen的cli工具箱提供了一个--time-limit开关,在参数列表中,它作为:time-limit传给etcd-test。现在我们把它的使用方法展示出来。

          :generator (->> (gen/mix [r w cas])
                          (gen/stagger 1)
                          (gen/nemesis
                            (gen/seq (cycle [(gen/sleep 5)
                                             {:type :info, :f :start}
                                             (gen/sleep 5)
                                             {:type :info, :f :stop}])))
                          (gen/time-limit (:time-limit opts)))}
$ lein run test --time-limit 60
...

现在我们的测试时间可长可短,让我们加速请求访问速率。如果两次请求时间间隔太长,那么我们就看不到一些有趣的行为。我们将两次请求的时间间隔设置为1/10s。

          :generator (->> (gen/mix [r w cas])
                          (gen/stagger 1/50)
                          (gen/nemesis
                           (cycle [(gen/sleep 5)
                            {:type :info, :f :start}
                            (gen/sleep 5)
                            {:type :info, :f :stop}]))
                          (gen/time-limit (:time-limit opts)))

如果你多次运行这个测试,你会注意到一个有趣的结果。有些时候它会失败!

$ lein run test --test-count 10
...
     :model {:msg "can't read 3 from register 4"}}]
...
Analysis invalid! (ノಥ益ಥ)ノ ┻━┻

Knossos参数有误:它认为寄存器需要的合法参数个数是4,但是程序成功读取到的是3。当出现线性验证失败时,Knossos将绘制一个SVG图展示错误——我们可以读取历史记录来查看更详细的操作信息。

$ open store/latest/linear.svg
$ open store/latest/history.txt

这是读取操作常见的脏读问题:尽管最近得一些写操作已完成了,我们依然获取了一个过去值。这种情况出现是因为etcd允许我们读取任何副本的局部状态,而不需要经过共识特性来确保我们拥有最新的状态。

线性一致读

etcd文档宣称"默认情况下etcd确保所有的操作都是线性一致性",但是显然事实并非如此,在第二版api文档隐藏着这么一条不引人注意的注释:

如果你想让一次读取是完全的线性一致,可以使用quorum=true。读取和写入的操作路径会因而变得非常相似,并且具有相近的速度(译者注:暗指速率变慢)。如果不确定是否需要此功能,请随时向etcd开发者发送电子邮件以获取建议。

啊哈!所以我们需要使用quorum读取,Verschlimmbesserung中有这样的案例:

    (invoke! [this test op]
      (case (:f op)
        :read (let [value (-> conn
                              (v/get "foo" {:quorum? true})
                              parse-long)]
                (assoc op :type :ok, :value value))
      ...

引入quorum读取后测试通过。

$ lein run test
...
Everything looks good! ヽ(‘ー`)ノ

恭喜!你已经成功写完了第一个Jepsen测试,我在2014年提出了这个issue,并且联系了etcd开发团队请他们介绍quorum读机制。

休息一下吧,这是你应得的!如果你喜欢的话,可以继续完善Jepsen测试

Last updated