jepsen 官方文件的中文翻譯版本

恒温發表於2022-11-24

ZZFrom https://jaydenwen123.gitbook.io/zh_jepsen_doc/,感謝翻譯者,給我們介紹優秀的測試工具。

本教程將指引你從零開始編寫一個 Jepsen 測試。它是 Jespen 提供的訓練課的基礎知識。
如果你不熟悉 Clojure 語言,我們推薦你瀏覽 Clojure for the Brave and True 和 Clojure From the Ground Up 或其他任意可以幫助你瞭解 Clojure 的教程。

1.測試腳手架

在本新手教程中,我們打算為 etcd 編寫一個測試。etcd 是一個分散式共識系統。在此,我想建議各位在學習過程中能自己親手敲一下程式碼,即便一開始還不是特別理解所有內容。如此一來既能幫你學得更快,也不會在我們開始修改更復雜函式程式碼時而感到茫然。
我們首先在任意目錄下建立一個新的(音讀 ['laɪnɪŋən])專案。

$ lein new jepsen.etcdemo
Generating a project called jepsen.etcdemo based on the 'default' template.
The default template is intended for library projects, not applications.
To see other templates (app, plugin, etc), try `lein help new`.
$ cd jepsen.etcdemo
$ ls
CHANGELOG.md  doc/  LICENSE  project.clj  README.md  resources/  src/  test/

正如任何一個新建立的Clojure(音讀/ˈkloʊʒər/)專案那樣,我們會得到一個空白的變更日誌、一個用於建立文件的目錄、一個 Eclipse 公共許可證副本、一個 project.clj 檔案(該檔案告訴 leiningen 如何構建和執行我們的程式碼)以及一個名為 README 的自述檔案。resources 目錄是用於存放資料檔案的地方,比如我們想進行測試的資料庫的配置檔案。src 目錄存放著原始碼,並按照程式碼中名稱空間的結構被組織成一系列目錄和檔案。test 目錄是用於存放測試程式碼的目錄。值得一提的是,這整個目錄就是一個 “Jepsen 測試”;test 目錄是沿襲大多數 Clojure 庫的習慣生成,而在本文中,我們不會用到它。

我們將從編輯一個指定專案的依賴項和其他後設資料的 project.clj 檔案來開始。我們將增加一個:main 名稱空間,正如下面一段命令列所示。除了依賴於 Clojure 自身的語言庫,我們還新增了 Jepsen 庫和一個用於與 etcd 進行通訊的 Verschlimmbesserung 庫。

(defproject jepsen.etcdemo "0.1.0-SNAPSHOT"
  :description "A Jepsen test for etcd"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :main jepsen.etcdemo
  :dependencies [[org.clojure/clojure "1.10.0"]
                 [jepsen "0.2.1-SNAPSHOT"]
                 [verschlimmbesserung "0.1.3"]])

讓我們先嚐試用 lein run 來執行這個程式。

$ lein run
Exception in thread "main" java.lang.Exception: Cannot find anything to run for: jepsen.etcdemo, compiling:(/tmp/form-init6673004597601163646.clj:1:73)
...

執行完後看到這樣的資料結果並不意外,因為我們尚未寫任何實質性的程式碼讓程式去執行。在 jepsen.etcdemo 名稱空間下,我們需要一個 main 函式來接收命令列引數並執行測試。在 src/jepsen/etcdemo.clj 檔案中我們定義如下 main 函式:

(ns jepsen.etcdemo)

(defn -main
  "Handles command line arguments. Can either run a test, or a web server for
  browsing results."
  [& args]
  (prn "Hello, world!" args))

Clojure 預設接收跟在 lein run 指令後的所有引數作為-main 函式的呼叫引數。main 函式接收長度可變的引數(即 “&” 符號),引數列表叫做 args。在上述這段程式碼中,我們在 “Hello World” 之後列印引數列表。

$ lein run hi there
"Hello, world!" ("hi" "there")

Jepsen 囊括了一些用於處理引數、執行測試、錯誤處理和日誌記錄等功能的腳手架。現在不妨引入 jepsen.cli 名稱空間,簡稱為 cli,然後將我們的 main 函式轉為一個 Jepsen 測試執行器。

(ns jepsen.etcdemo
  (:require [jepsen.cli :as cli]
            [jepsen.tests :as tests]))


(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
         {:pure-generators true}
         opts))

(defn -main
  "Handles command line arguments. Can either run a test, or a web server for
  browsing results."
  [& args]
  (cli/run! (cli/single-test-cmd {:test-fn etcd-test})
            args))

cli/single-test-cmd 由 jepsen.cli 提供。它為測試解析命令列的引數並呼叫提供的:test-fn,然後應該返回一個包含 Jepsen 執行測試所需的所有資訊的鍵值對映表(map)。在上面這個樣例中,測試函式 etcd-test 從命令列中接受一些選項,然後用它們來填充一個什麼都不處理的空測試(即 noop-test)。

$ lein run
Usage: lein run -- COMMAND [OPTIONS ...]
Commands: test

如上述程式碼塊所示,沒有引數的話,cli/run! 會輸出一個基本的幫助資訊,提醒我們它接收一個命令作為其第一個引數。現在讓我們嘗試新增一下 test 命令吧!

$ lein run test
13:04:30.927 [main] INFO  jepsen.cli - Test options:
 {:concurrency 5,
 :test-count 1,
 :time-limit 60,
 :nodes ["n1" "n2" "n3" "n4" "n5"],
 :ssh
 {:username "root",
  :password "root",
  :strict-host-key-checking false,
  :private-key-path nil}}

INFO [2018-02-02 13:04:30,994] jepsen test runner - jepsen.core Running test:
 {:concurrency 5,
 :db
 #object[jepsen.db$reify__1259 0x6dcf7b6a "jepsen.db$reify__1259@6dcf7b6a"],
 :name "noop",
 :start-time
 #object[org.joda.time.DateTime 0x79d4ff58 "2018-02-02T13:04:30.000-06:00"],
 :net
 #object[jepsen.net$reify__3493 0xae3c140 "jepsen.net$reify__3493@ae3c140"],
 :client
 #object[jepsen.client$reify__3380 0x20027c44 "jepsen.client$reify__3380@20027c44"],
 :barrier
 #object[java.util.concurrent.CyclicBarrier 0x2bf3ec4 "java.util.concurrent.CyclicBarrier@2bf3ec4"],
 :ssh
 {:username "root",
  :password "root",
  :strict-host-key-checking false,
  :private-key-path nil},
 :checker
 #object[jepsen.checker$unbridled_optimism$reify__3146 0x1410d645 "jepsen.checker$unbridled_optimism$reify__3146@1410d645"],
 :nemesis
 #object[jepsen.nemesis$reify__3574 0x4e6cbdf1 "jepsen.nemesis$reify__3574@4e6cbdf1"],
 :active-histories #<Atom@210a26b: #{}>,
 :nodes ["n1" "n2" "n3" "n4" "n5"],
 :test-count 1,
 :generator
 #object[jepsen.generator$reify__1936 0x1aac0a47 "jepsen.generator$reify__1936@1aac0a47"],
 :os
 #object[jepsen.os$reify__1176 0x438aaa9f "jepsen.os$reify__1176@438aaa9f"],
 :time-limit 60,
 :model {}}

INFO [2018-02-02 13:04:35,389] jepsen nemesis - jepsen.core Starting nemesis
INFO [2018-02-02 13:04:35,389] jepsen worker 1 - jepsen.core Starting worker 1
INFO [2018-02-02 13:04:35,389] jepsen worker 2 - jepsen.core Starting worker 2
INFO [2018-02-02 13:04:35,389] jepsen worker 0 - jepsen.core Starting worker 0
INFO [2018-02-02 13:04:35,390] jepsen worker 3 - jepsen.core Starting worker 3
INFO [2018-02-02 13:04:35,390] jepsen worker 4 - jepsen.core Starting worker 4
INFO [2018-02-02 13:04:35,391] jepsen nemesis - jepsen.core Running nemesis
INFO [2018-02-02 13:04:35,391] jepsen worker 1 - jepsen.core Running worker 1
INFO [2018-02-02 13:04:35,391] jepsen worker 2 - jepsen.core Running worker 2
INFO [2018-02-02 13:04:35,391] jepsen worker 0 - jepsen.core Running worker 0
INFO [2018-02-02 13:04:35,391] jepsen worker 3 - jepsen.core Running worker 3
INFO [2018-02-02 13:04:35,391] jepsen worker 4 - jepsen.core Running worker 4
INFO [2018-02-02 13:04:35,391] jepsen nemesis - jepsen.core Stopping nemesis
INFO [2018-02-02 13:04:35,391] jepsen worker 1 - jepsen.core Stopping worker 1
INFO [2018-02-02 13:04:35,391] jepsen worker 2 - jepsen.core Stopping worker 2
INFO [2018-02-02 13:04:35,391] jepsen worker 0 - jepsen.core Stopping worker 0
INFO [2018-02-02 13:04:35,391] jepsen worker 3 - jepsen.core Stopping worker 3
INFO [2018-02-02 13:04:35,391] jepsen worker 4 - jepsen.core Stopping worker 4
INFO [2018-02-02 13:04:35,397] jepsen test runner - jepsen.core Run complete, writing
INFO [2018-02-02 13:04:35,434] jepsen test runner - jepsen.core Analyzing
INFO [2018-02-02 13:04:35,435] jepsen test runner - jepsen.core Analysis complete
INFO [2018-02-02 13:04:35,438] jepsen results - jepsen.store Wrote /home/aphyr/jepsen/jepsen.etcdemo/store/noop/20180202T130430.000-0600/results.edn
INFO [2018-02-02 13:04:35,440] main - jepsen.core {:valid? true}

Everything looks good! ヽ(‘ー`)ノ

如上面展示的程式碼塊所示,我們發現 Jepsen 啟動了一系列的 workers(類似於程序)。每一個 worker 負責執行針對資料庫的操作。此外,Jepsen 還啟動了一個 nemesis,用於製造故障。由於它們尚未被分配任何任務,所以它們馬上便關閉了。Jepsen 將這個簡易測試的結果輸出寫到了 store 目錄下,並列印出一個簡要分析。

noop-test 預設使用名為 n1、n2 ... n5 的節點。如果你的節點有不一樣的名稱,該測試會因無法連線這些節點而失敗。但是這並沒關係。你可以在命令列中來指定這些節點名稱:

$ lein run test --node foo.mycluster --node 1.2.3.4

亦或者透過傳入一個檔名來達到相同目的。檔案中要包含節點列表,且每行一個。如果你正在使用 AWS Marketplace 叢集,一個名為 nodes 的檔案已經生成於機器的 home 目錄下,隨時可用。

$ lein run test --nodes-file ~/nodes

如果你當前依然在不斷地遇到 SSH 錯誤,你應該檢查下你的 SSH 是否代理正在執行並且已經載入了所有節點的金鑰。ssh some-db-node 應該可以不用密碼就連線上資料庫。你也可以在命令列上重寫對應的使用者名稱、密碼和身份檔案。詳見 lein run test --help。

$ lein run test --help
#object[jepsen.cli$test_usage 0x7ddd84b5 jepsen.cli$test_usage@7ddd84b5]

  -h, --help                                                  Print out this message and exit
  -n, --node HOSTNAME             ["n1" "n2" "n3" "n4" "n5"]  Node(s) to run test on
      --nodes-file FILENAME                                   File containing node hostnames, one per line.
      --username USER             root                        Username for logins
      --password PASS             root                        Password for sudo access
      --strict-host-key-checking                              Whether to check host keys
      --ssh-private-key FILE                                  Path to an SSH identity file
      --concurrency NUMBER        1n                          How many workers should we run? Must be an integer, optionally followed by n (e.g. 3n) to multiply by the number of nodes.
      --test-count NUMBER         1                           How many times should we repeat a test?
      --time-limit SECONDS        60                          Excluding setup and teardown, how long should a test run for, in seconds?

在本指導教程中,我們將全程使用 lein run test ...來重新執行我們的 Jepsen 測試。每當我們執行一次測試,Jepsen 將在 store/下建立一個新目錄。你可以在 store/latest 中看到最新的一次執行結果。

$ ls store/latest/
history.txt  jepsen.log  results.edn  test.fressian

history.txt 展示了測試執行的操作。不過此處執行完的結果是空的,因為 noop 測試不會執行任何操作。jepsen.log 檔案擁有一份測試輸出到控制檯日誌的複製。results.edn 展示了對測試的簡要分析,也就是每次執行結束我們所看到的輸出結果。最後,test.fressian 擁有測試的原始資料,包括完整的機器可讀的歷史和分析,如果有需要可以對其進行事後分析。

Jepsen 還帶有內建的 Web 瀏覽器,用於瀏覽這些結果。 讓我們將其新增到我們的 main 函式中:

(defn -main
  "Handles command line arguments. Can either run a test, or a web server for
  browsing results."
  [& args]
  (cli/run! (merge (cli/single-test-cmd {:test-fn etcd-test})
                   (cli/serve-cmd))
            args))

上述程式碼之所以可以發揮作用,是因為 cli/run! 將命令名稱與命令處理器做了對映。我們將這些對映關係用 merge 來合併。

$ lein run serve
13:29:21.425 [main] INFO  jepsen.web - Web server running.
13:29:21.428 [main] INFO  jepsen.cli - Listening on http://0.0.0.0:8080/

我們可以在網路瀏覽器中開啟http://localhost:8080serve 命令帶有其自己的選項和幫助資訊:來探究我們的測試結果。當然,

$ lein run serve --help
Usage: lein run -- serve [OPTIONS ...]

  -h, --help                  Print out this message and exit
  -b, --host HOST    0.0.0.0  Hostname to bind to
  -p, --port NUMBER  8080     Port number to bind to

開啟一個新的終端視窗,並在其中一直執行 Web 伺服器。 那樣我們可以看到測試結果,而無需反覆啟動和關閉伺服器。

2.資料庫自動化

在單個 Jepsen 測試中,DB 封裝了用於設定和拆除我們所測試的資料庫、佇列或者其他分散式系統的程式碼。我們可以手動執行設定和拆除,但是讓 Jepsen 處理它可以讓我們在持續整合(CI)系統中執行測試、引數化資料庫配置和連續地從頭開始執行多個測試,等等。

在 src/jepsen/etcdemo.clj 中,我們需要使用 jepsen.db、jepsen.control、jepsen.control.util 和 jepsen.os.debian 名稱空間,每個名稱有別名作為簡稱。clojure.string 將幫助我們為 etcd 建立配置字串。我們還將從 clojure.tools.logging 中引入所有功能,為我們提供 log 功能,例如 info,warn 等。

(ns jepsen.etcdemo
  (:require [clojure.tools.logging :refer :all]
            [clojure.string :as str]
            [jepsen [cli :as cli]
                    [control :as c]
                    [db :as db]
                    [tests :as tests]]
            [jepsen.control.util :as cu]
            [jepsen.os.debian :as debian]))

然後,在給定特定版本字串的情況下,我們將編寫一個構造 Jepsen DB 的函式。

(defn db
  "Etcd DB for a particular version."
  [version]
  (reify db/DB
    (setup! [_ test node]
      (info node "installing etcd" version))

    (teardown! [_ test node]
      (info node "tearing down etcd"))))

如上程式碼塊所示,(defn db ...之後的字串是文件字串 ,記錄了函式的行為。 當獲得 version 時,db 函式使用 reify 構造一個滿足 Jepsen 的 DB 協議的新物件(來自 db 名稱空間)。該協議指定所有資料庫必須支援的兩個功能:(setup!db test node) 和 (teardown! db test node),分別代表設定和拆除資料這兩大功能。 我們提供存根(stub)實現在這裡,它僅僅是輸出一條參考訊息日誌。

現在,我們將透過新增:os 來擴充套件預設的 noop-test,以告訴 Jepsen 如何處理作業系統設定,以及一個:db,我們可以使用剛編寫的 db 函式來構造。我們將測試 etcd 版本 v3.1.5。

(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
         {:name "etcd"
          :os   debian/os
          :db   (db "v3.1.5")
          :pure-generators true}))

跟所有 Jepsen 測試一樣,noop-test 是一個有諸如:os, :name 和:db 等鍵的對映表。有關測試結構的概述詳見 jepsen.core,有關測試的完整定義詳見 jepsen.core/run。

當前 noop-test 只有這些鍵的一些存根實現。但是我們可以用 merge 來構建一份賦予這些鍵新值的 noop-test 對映表的複製。

如果執行此測試,我們將看到 Jepsen 使用我們的程式碼來設定 debian,假裝拆除並安裝 etcd,然後啟動其工作者。

$ lein run test
...
INFO [2017-03-30 10:08:30,852] jepsen node n2 - jepsen.os.debian :n2 setting up debian
INFO [2017-03-30 10:08:30,852] jepsen node n3 - jepsen.os.debian :n3 setting up debian
INFO [2017-03-30 10:08:30,852] jepsen node n4 - jepsen.os.debian :n4 setting up debian
INFO [2017-03-30 10:08:30,852] jepsen node n5 - jepsen.os.debian :n5 setting up debian
INFO [2017-03-30 10:08:30,852] jepsen node n1 - jepsen.os.debian :n1 setting up debian
INFO [2017-03-30 10:08:52,385] jepsen node n1 - jepsen.etcdemo :n1 tearing down etcd
INFO [2017-03-30 10:08:52,385] jepsen node n4 - jepsen.etcdemo :n4 tearing down etcd
INFO [2017-03-30 10:08:52,385] jepsen node n2 - jepsen.etcdemo :n2 tearing down etcd
INFO [2017-03-30 10:08:52,385] jepsen node n3 - jepsen.etcdemo :n3 tearing down etcd
INFO [2017-03-30 10:08:52,385] jepsen node n5 - jepsen.etcdemo :n5 tearing down etcd
INFO [2017-03-30 10:08:52,386] jepsen node n1 - jepsen.etcdemo :n1 installing etcd v3.1.5
INFO [2017-03-30 10:08:52,386] jepsen node n4 - jepsen.etcdemo :n4 installing etcd v3.1.5
INFO [2017-03-30 10:08:52,386] jepsen node n2 - jepsen.etcdemo :n2 installing etcd v3.1.5
INFO [2017-03-30 10:08:52,386] jepsen node n3 - jepsen.etcdemo :n3 installing etcd v3.1.5
INFO [2017-03-30 10:08:52,386] jepsen node n5 - jepsen.etcdemo :n5 installing etcd v3.1.5
...

看到了版本字串"v3.1.5"是怎麼從 etcd-test 傳遞到 db,最終被 reify 表示式獲取使用的嗎?這就是我們引數化 Jepsen 測試的方式,因此相同的程式碼可以測試多個版本或選項。另請注意物件 reify 返回的結果在其詞法範圍內關閉,記住 version 的值。

安裝資料庫

有了已經準備好的 DB 函式框架,就該實際安裝一些東西了。讓我們快速看一下 etcd 的安裝說明。 看來我們需要下載一個 tarball,將其解壓縮到目錄中,為 API 版本設定一個環境變數,然後使用它執行 etcd 二進位制檔案。

想要安裝這些包,必須先獲取 root 許可權。因此我們將使用 jepsen.control/su 來獲取 root 特權。請注意,su(及其伴隨的 sudo、cd 等等)確立的是動態而非詞法範圍,他們的範圍不僅作用於包起來部分的程式碼,還包括所有函式的呼叫棧。然後,我們將使用 jepsen.control.util/install-archive! 來下載 etcd 安裝檔案,並將其安裝到/opt/etcd 目錄下。

(def dir "/opt/etcd")

(defn db
  "Etcd DB for a particular version."
  [version]
  (reify db/DB
    (setup! [_ test node]
      (info node "installing etcd" version)
      (c/su
        (let [url (str "https://storage.googleapis.com/etcd/" version
                       "/etcd-" version "-linux-amd64.tar.gz")]
          (cu/install-archive! url dir))))

    (teardown! [_ test node]
      (info node "tearing down etcd"))))

在我們移除 etcd 目錄的時候,我們正在使用 jepsen.control/su 變成 root 使用者(透過 sudo)。jepsen.control 提供了全面的領域特定語言(DSL)在遠端節點上執行任意的 shell 命令。

現在,lein run test 將自動安裝 etcd。請注意,Jepsen 在所有節點上同時進行 “設定” 和 “拆卸”。這可能需要一些時間,因為每個節點都必須下載 tarball,但是在以後的執行中,Jepsen 將重新使用磁碟上快取的 tarball。

啟動資料庫

根據 etcd叢集化命令,我們需要生成一串形如"ETCD_INITIAL_CLUSTER="infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380"的字串。這樣我們的節點才知道哪些節點是叢集的一部分。讓我們寫幾個短函式來構造這些字串:

(defn node-url
  "An HTTP url for connecting to a node on a particular port."
  [node port]
  (str "http://" node ":" port))

(defn peer-url
  "The HTTP url for other peers to talk to a node."
  [node]
  (node-url node 2380))

(defn client-url
  "The HTTP url clients use to talk to a node."
  [node]
  (node-url node 2379))

(defn initial-cluster
  "Constructs an initial cluster string for a test, like
  \"foo=foo:2380,bar=bar:2380,...\""
  [test]
  (->> (:nodes test)
       (map (fn [node]
              (str node "=" (peer-url node))))
       (str/join ",")))

->>是 Clojure 的一個宏,將一個形式插入到下一個形式作為最後一個引數(因為作用類似於縫衣服時候的穿線,因此在英文中命名這個宏為 “threading”)。因此,(->> test :nodes) 就變成了 (:nodes test),而 (->> test :nodes (map-indexed (fn ...))) 就變成了 (map-indexed (fn ...) (:nodes test)),以此類推。普通的函式呼叫看起來就是 “由內而外”,但是->>這個宏讓我們 “按順序” 編寫一系列操作,類似於一個面嚮物件語言的 foo.bar().baz() 表示形式。

在函式 initial-cluster 中,我們從 test 對映表中獲取到了數個節點,並將每個節點透過 Clojure 內建的 map 對映為相應的字串:節點名稱、“=” 和節點的 peer 的 url。然後我們將這些字串用(英文)逗號合併起來。

準備好之後,我們會告訴資料庫怎麼以守護程序的方式啟動。我們可以使用初始化指令碼或者服務來啟動和關閉程式,不過既然我們正在使用的是一個單純的二進位制檔案,我們將使用 Debian 的 start-stop-daemon 命令在後臺執行 etcd。

我們還需要一些常量:etcd 二進位制檔名、日誌輸出的地方和儲存 pidfile 檔案的地方

(def dir     "/opt/etcd")
(def binary "etcd")
(def logfile (str dir "/etcd.log"))
(def pidfile (str dir "/etcd.pid"))

現在我們將使用 jepsen.control.util 內用於啟動和關閉守護程序的函式來啟動 etcd。根據documentation,我們將需要提供一個節點的名稱、用於監聽客戶端和 peer 節點的數個 URLs 和叢集初始狀態。

(setup! [_ test node]
  (info node "installing etcd" version)
  (c/su
    (let [url (str "https://storage.googleapis.com/etcd/" version
                   "/etcd-" version "-linux-amd64.tar.gz")]
      (cu/install-archive! url dir))

    (cu/start-daemon!
      {:logfile logfile
       :pidfile pidfile
       :chdir   dir}
      binary
      :--log-output                   :stderr
      :--name                         (name node)
      :--listen-peer-urls             (peer-url   node)
      :--listen-client-urls           (client-url node)
      :--advertise-client-urls        (client-url node)
      :--initial-cluster-state        :new
      :--initial-advertise-peer-urls  (peer-url node)
      :--initial-cluster              (initial-cluster test))

    (Thread/sleep 10000)))

我們將在啟動叢集之後呼叫 sleep 函式讓程式暫停一會兒,這樣叢集才能有機會完全啟動並執行初始的網路握手。

拆除

為了確保每次執行都是從零開始,即使先前的執行崩潰了,Jepsen 也會在測試開始之前進行 DB 拆除,然後再進行設定。然後在測試結束時將資料庫再次撕毀。要拆除,我們將使用 stop-daemon!,然後刪除 etcd 目錄,以便將來的執行不會意外地從當前執行中讀取資料

(teardown! [_ test node]
  (info node "tearing down etcd")
  (cu/stop-daemon! binary pidfile)
  (c/su (c/exec :rm :-rf dir)))))

我們使用 jepsen.control/exec 執行 shell 命令:rm -rf。Jepsen 會自動指定使用 exec,以便在 db/setup! 期間設定的 node 上執行一些操作,但是我們可以根據需要連線到任意節點。請注意,exec 可以混合使用字串、數字和關鍵字的任意組合,它將它們轉換為字串並執行適當的 shell 轉義。如果需要,可以將 jepsen.control/lit 用於未轉義的文字字串。:>和:>>是 Clojure 的關鍵字,被 exec 接收後可以執行 shell 的重定向。對於需要配置的資料庫,這是將配置檔案寫到磁碟的一個簡單方法。

現在讓我們試試看!

$ lein run test
NFO [2017-03-30 12:08:19,755] jepsen node n5 - jepsen.etcdemo :n5 installing etcd v3.1.5
INFO [2017-03-30 12:08:19,755] jepsen node n1 - jepsen.etcdemo :n1 installing etcd v3.1.5
INFO [2017-03-30 12:08:19,755] jepsen node n2 - jepsen.etcdemo :n2 installing etcd v3.1.5
INFO [2017-03-30 12:08:19,755] jepsen node n4 - jepsen.etcdemo :n4 installing etcd v3.1.5
INFO [2017-03-30 12:08:19,855] jepsen node n3 - jepsen.etcdemo :n3 installing etcd v3.1.5
INFO [2017-03-30 12:08:20,866] jepsen node n4 - jepsen.control.util starting etcd
INFO [2017-03-30 12:08:20,866] jepsen node n1 - jepsen.control.util starting etcd
INFO [2017-03-30 12:08:20,866] jepsen node n5 - jepsen.control.util starting etcd
INFO [2017-03-30 12:08:20,866] jepsen node n2 - jepsen.control.util starting etcd
INFO [2017-03-30 12:08:20,963] jepsen node n3 - jepsen.control.util starting etcd
...

上面的執行結果看起來很棒。我們可以透過在測試後檢查 etcd 目錄是否為空來確認 teardown 是否已完成工作。

$ ssh n1 ls /opt/etcd
ls: cannot access /opt/etcd: No such file or directory

日誌檔案

等等——如果我們在每次執行後刪除 etcd 的檔案,我們如何確定資料庫做了什麼?如果我們可以在清理之前下載資料庫日誌的副本,那就太好了。為此,我們將使用 db/LogFiles 協議,並返回要下載的日誌檔案路徑的列表。

(defn db
  "Etcd DB for a particular version."
  [version]
  (reify db/DB
    (setup! [_ test node]
      ...)

    (teardown! [_ test node]
      ...)

    db/LogFiles
    (log-files [_ test node]
      [logfile])))

現在,當我們執行測試時,我們將為每個節點找到一個日誌副本,儲存在本地目錄 store/latest//中。 如果我們在設定資料庫時遇到問題,我們可以檢查那些日誌以檢視出了什麼問題。

$ less store/latest/n1/etcd.log
...
2018-02-02 11:36:51.848330 I | raft: 5440ff22fe632778 became leader at term 2
2018-02-02 11:36:51.848360 I | raft: raft.node: 5440ff22fe632778 elected leader 5440ff22fe632778 at term 2
2018-02-02 11:36:51.860295 I | etcdserver: setting up the initial cluster version to 3.1
2018-02-02 11:36:51.864532 I | embed: ready to serve client requests
...

尋找 “選舉產生的領導者” 這一行,這表明我們的節點成功地形成了叢集。如果您的 etcd 節點彼此看不到,請確保使用正確的埠名,並在 node-url 中使用 http://而不是 https://,並且該節點可以互相 ping 通。

準備好了資料庫之後,可以進行編寫客戶端。

3.編寫一個客戶端

一個 Jepsen client 接收呼叫操作(英文術語 invocation operations),然後將其應用於要測試的系統,並返回相應的執行完成結果(這一階段稱為 completion operation)。 對於我們的 etcd 測試,我們可以將系統建模為單個暫存器:一個持有整數的特定鍵。針對該暫存器的操作可能是 read、write 和 compare-and-set,我們可以像這樣建模:

(defn r   [_ _] {:type :invoke, :f :read, :value nil})
(defn w   [_ _] {:type :invoke, :f :write, :value (rand-int 5)})
(defn cas [_ _] {:type :invoke, :f :cas, :value [(rand-int 5) (rand-int 5)]})

在上面這個程式碼塊中,是幾個構建 Jepsen 操作的函式。這是對資料庫可能進行的操作的一種抽象表示形式。:invoke 表示我們將進行嘗試操作 - 完成後,我們將使用一種型別,比如:ok 或:fail 來告訴我們發生了什麼。:f 告訴我們正在應用什麼函式到資料庫 - 例如,我們要對資料執行讀取或寫入操作。:f 可以是任何值-Jepsen 並不知道它們的含義。

函式呼叫通常都是透過入口引數和返回值來進行引數化。而 Jepsen 的操作是透過:value 來進行引數化。Jepsen 不會去檢查:value,因此:value 後可以跟任意指定的引數。我們使用函式 write 的:value 來指定寫入的值,用函式 read 的:value 來指定我們(最終)讀取的值。 當 read 被呼叫的時候,我們還不知道將讀到什麼值,因此我們將保持函式 read 的:value 為空。

這些函式能被 jepsen.generator 用於構建各種各樣的呼叫,分別用於讀取、寫入和 CAS。注意函式 read 的:value 是空的 - 由於無法預知到能讀取到什麼值,所以將其保留為空。直到客戶端讀到了一個特定的數值後,在 completion operation 階段,函式 read 的引數才會被填充。

連線到資料庫

現在我們需要拿到這些操作然後將其應用到 etcd。我們將會使用這個庫來與 etcd 進行通訊。我們將從引入Verschlimmbesserung開始,然後編寫一個 Jepsen Client 協議的空實現:

(ns jepsen.etcdemo
  (:require [clojure.tools.logging :refer :all]
            [clojure.string :as str]
            [verschlimmbesserung.core :as v]
            [jepsen [cli :as cli]
                    [client :as client]
                    [control :as c]
                    [db :as db]
                    [tests :as tests]]
            [jepsen.control.util :as cu]
            [jepsen.os.debian :as debian]))
...
(defrecord Client [conn]
  client/Client
  (open! [this test node]
    this)
​
  (setup! [this test])
​
  (invoke! [_ test op])
​
  (teardown! [this test])
​
  (close! [_ test]))

如上面程式碼塊所示,defrecord 定義了一種新的資料結構型別,稱之為 Client。每個 Client 都有一個叫做 conn 的欄位,用於保持到特定網路伺服器的連線。這些客戶端函式支援 Jepsen 的客戶端協議,就像對這個協議 “具象化”(英文術語 reify)了一樣,還提供客戶端功能的具體實現。

Jepsen 的客戶端有五部分的生命週期。我們先從種子客戶端 (client) 開始。當我們呼叫客戶端的 open! 的時候,我們得到跟一個特定節點繫結的客戶端的副本。setup! 函式測試所需要的所有資料結構 - 例如建立表格或者設定韌體。invoke! 將操作應用到系統然後返回相應的完成操作。teardown! 會清理 setup! 可能建立的任何表格。

close! 會斷開所有網路連線並完成客戶端的生命週期。

當需要將客戶端新增到測試中時,我們使用 (Client.) 來構建一個新的客戶端,並傳入 nil 作為 conn 的值。請記住,我們最初的種子客戶端沒有連線。Jepsen 後續會呼叫 open! 來獲取已連線的客戶端。

(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)}))

現在,讓我們來完成 open! 函式連線到 etcd 的功能。的這個教程告訴了我們建立客戶端所需要函式。這個函式使用 (connect url) 來建立一個 etcd 客戶端。其中 conn 裡面儲存的正是我們需要的客戶端。此處,我們設定 Verschlimmbesserung 呼叫的超時時間為 5 秒。

(defrecord Client [conn]
  client/Client
  (open! [this test node]
    (assoc this :conn (v/connect (client-url node)
                                 {:timeout 5000})))
​
  (setup! [this test])
​
  (invoke! [_ test op])
​
  (teardown! [this test])
​
  (close! [_ test]
    ; If our connection were stateful, we'd close it here. Verschlimmmbesserung
    ; doesn't actually hold connections, so there's nothing to close.
    ))
​
(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
         {:name "etcd"
          :os debian/os
          :db (db "v3.1.5")
          :client (Client. nil)}))

請記住,最初的客戶端並沒有任何連線。就像一個幹細胞一樣,它具備稱為活躍的客戶端的潛力,但是不會直接承擔任何工作。我們呼叫 (Client. nil) 來構建初始客戶端,其連線只有當 Jepsen 呼叫 open! 的時候才會被賦值。

客戶端讀操作

現在我們需要真正地開始用客戶端做點事情了。首先從 15 秒的讀操作開始,隨機地錯開大約一秒鐘。 我們將引入 jepsen.generator 來排程操作。

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

並編寫一個簡單的生成器:執行一系列的讀取操作,並將它們錯開一秒鐘左右,僅將這些操作提供給客戶端(而不是給 nemesis,它還有其他職責),然後在 15 秒後停止。

(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)
          :generator       (->> r
                                (gen/stagger 1)
                                (gen/nemesis nil)
                                (gen/time-limit 15))}))

上面這段程式碼執行後將丟擲一堆錯誤,因為我們尚未告訴客戶的如何去解讀這些到來的讀操作。

$ lein run test
...
WARN [2020-09-21 20:16:33,150] jepsen worker 0 - jepsen.generator.interpreter Process 0 crashed
clojure.lang.ExceptionInfo: throw+: {:type :jepsen.client/invalid-completion, :op {:type :invoke, :f :read, :value nil, :time 26387538, :process 0}, :op' nil, :problems ["should be a map" ":type should be :ok, :info, or :fail" ":process should be the same" ":f should be the same"]}

現在這個版本客戶端的 invoke! 函式,接收到呼叫操作,但是沒有進行任何相關處理,返回的是一個 nil 結果。Jepsen 透過這段日誌告訴我們,op 應該是一個對映表,尤指帶有相應的:type 欄位、:process 欄位和:f 欄位的對映表。簡而言之,我們必須構建一個完成操作來結束本次呼叫操作。如果操作成功,我們將使用型別:ok 來構建此完成操作;如果操作失敗,我們將使用型別:fail 來構建;或者如果不確定則使用:info 來構建。invoke 可以丟擲一個異常,會自動被轉為一個:info 完成操作。

現在我們從處理讀操作開始。我們將使用 v/get 來讀取一個鍵的值。我們可以挑選任意一個名字作為這個鍵的名稱,比如 “foo”。

(invoke! [this test op]
  (case (:f op)
    :read (assoc op :type :ok, :value (v/get conn "foo"))))

我們根據操作的:f 欄位來給 Jepsen 分派任務。當:f 是:read 的時候,我們呼叫 invoke 操作並返回其副本,帶有:type、:ok 和透過讀取暫存器 “foo” 得到的值。

$ lein run test
...
INFO [2017-03-30 15:28:17,423] jepsen worker 2 - jepsen.util 2  :invoke :read nil
INFO [2017-03-30 15:28:17,427] jepsen worker 2 - jepsen.util 2  :ok :read nil
INFO [2017-03-30 15:28:18,315] jepsen worker 0 - jepsen.util 0  :invoke :read nil
INFO [2017-03-30 15:28:18,320] jepsen worker 0 - jepsen.util 0  :ok :read nil
INFO [2017-03-30 15:28:18,437] jepsen worker 4 - jepsen.util 4  :invoke :read nil
INFO [2017-03-30 15:28:18,441] jepsen worker 4 - jepsen.util 4  :ok :read nil
這下好多了!由於“foo”這個鍵尚未被建立,因此讀到的值都是nil。為了更改這個值,我們將會新增一些寫操作到生成器上。
寫操作
我們將使用(gen/mix [r w]),來更改我們的生成器以將讀寫隨機組合。
(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)
          :generator       (->> (gen/mix [r w])
                                (gen/stagger 1)
                                (gen/nemesis nil)
                                (gen/time-limit 15))}))
為了處理這些寫操作,我們將使用v/reset!並返回帶有:type和:ok的操作。如果reset!失敗,那麼就會丟擲錯誤,而Jepsen的機制就是自動將錯誤轉為:info標註的崩潰。
    (invoke! [this test op]
               (case (:f op)
                 :read (assoc op :type :ok, :value (v/get conn "foo"))
                 :write (do (v/reset! conn "foo" (:value op))
                            (assoc op :type :ok))))
我們會透過觀察下面這個測試來確認寫操作成功了。

$ lein run test
INFO [2017-03-30 22:14:25,428] jepsen worker 4 - jepsen.util 4 :invoke :write 0
INFO [2017-03-30 22:14:25,439] jepsen worker 4 - jepsen.util 4 :ok :write 0
INFO [2017-03-30 22:14:25,628] jepsen worker 0 - jepsen.util 0 :invoke :read nil
INFO [2017-03-30 22:14:25,633] jepsen worker 0 - jepsen.util 0 :ok :read "0"

啊,看來我們這邊遇到了點小困難。etcd處理的是字串,不過我們喜歡與數字打交道。我們可以引入一個序列化庫(Jepsen就包含了一個簡單的序列化庫jepsen.codec),不過既然我們現在處理的只是整數和nil,我們可以擺脫序列化庫而直接使用Java的內建Long.parseLong(String str)方法。

(defn parse-long
"Parses a string to a Long. Passes through nil."
s)

...

(invoke! _ test op))
:write (do (v/reset! conn "foo" (:value op))
(assoc op :type :ok))))

注意只有當呼叫(when s ...)字串是邏輯true的時候(即字串非空),才會呼叫parseLong函式。如果when匹配不上,則返回nil,這樣我們就可以在無形之中忽略nil值。

$ lein run test
...
INFO [2017-03-30 22:26:45,322] jepsen worker 4 - jepsen.util 4 :invoke :write 1
INFO [2017-03-30 22:26:45,341] jepsen worker 4 - jepsen.util 4 :ok :write 1
INFO [2017-03-30 22:26:45,434] jepsen worker 2 - jepsen.util 2 :invoke :read nil
INFO [2017-03-30 22:26:45,439] jepsen worker 2 - jepsen.util 2 :ok :read 1

現在還剩一種操作沒去實現:比較並設定。
比較替換(CaS)
新增完CaS操作後,我們就結束本節關於客戶端內容的介紹。

(gen/mix [r w cas])

處理CaS會稍顯困難。Verschlimmbesserung提供了cas!函式,入參包括連線、鍵、鍵對映的舊值和鍵對映的新值。cas!只有當入參的舊值匹配該鍵對應的當前值的時候,才會將入參的鍵設定為入參的新值,然後返回一個詳細的對映表作為響應。如果CaS操作失敗,將返回false。這樣我們就可以將其用於決定CaS操作的:type欄位。

(invoke! _ test op))
:write (do (v/reset! conn "foo" (:value op))
(assoc op :type :ok))
:cas (let old new
:ok
:fail)))))

這邊的let繫結用於解構。它將操作的:value欄位的一對值[舊值 新值]分開到old和new上。由於除了false和nil之外所有值都是表示邏輯true,我們可以使用cas!呼叫的結果作為if語句中的條件斷言。

## Handling exceptions
如果你已經執行過幾次Jepsen了,你可能會看到以下內容:

$ lein run test
...
INFO [2017-03-30 22:38:51,892] jepsen worker 1 - jepsen.util 1 :invoke :cas [3 1]
WARN [2017-03-30 22:38:51,936] jepsen worker 1 - jepsen.core Process 1 indeterminate
clojure.lang.ExceptionInfo: throw+: {:errorCode 100, :message "Key not found", :cause "/foo", :index 11, :status 404}
at slingshot.support$stack_trace.invoke(support.clj:201) ~[na:na]
...

如果我們試圖對不存在的鍵進行CaS操作,Verschlimmbesserung會丟擲異常來告訴我們不能修改不存在的東西。這不會造成我們的測試結果返回誤報。Jepsen會將這種異常解讀為不確定的:info結果,並對這種結果的置若罔聞。然而,當看到這個異常時候,我們知道CaS的數值修改失敗了。所以我們可以將其轉為已知的錯誤。我們將引入slingshot異常處理庫來捕獲這個特別的錯誤碼。

(ns jepsen.etcdemo
(:require ...
[slingshot.slingshot :refer [try+]]))
引入之後,將我們的:cas 放進一個 try/catch 程式碼塊中。
(invoke! this test op))
:write (do (v/reset! conn "foo" (:value op))
(assoc op :type :ok))
:cas (try+
(let old new
:ok
:fail)))
(catch [:errorCode 100] ex
(assoc op :type :fail, :error :not-found)))))

[:errorCode 100]形式的程式碼告訴Slingshot去捕獲有這個特定的錯誤碼的異常,然後將其賦值給ex。我們已經新增了一個額外的:error欄位到我們的操作中。只要還考慮正確性,這件事情做不做都無所謂。但是在我們檢視日誌的時候,這能幫助我們理解當時到底發生了什麼。Jepsen將會把錯誤列印在日誌的行末。

$ lein run test
...
INFO [2017-03-30 23:00:50,978] jepsen worker 0 - jepsen.util 0 :invoke :cas [1 4]
INFO [2017-03-30 23:00:51,065] jepsen worker 0 - jepsen.util 0 :fail :cas [1 4] :not-found

這下看上去更加清楚了。通常,我們將從編寫最簡單的程式碼開始,然後允許Jepsen為我們處理異常。 一旦我們對測試出錯的可能的情況有大概瞭解,我們可以為那些錯誤處理程式和語義引入特殊的 失敗案例。
```...
INFO [2017-03-30 22:38:59,278] jepsen worker 1 - jepsen.util 11 :invoke :write  4
INFO [2017-03-30 22:38:59,286] jepsen worker 1 - jepsen.util 11 :ok :write  4
INFO [2017-03-30 22:38:59,289] jepsen worker 4 - jepsen.util 4  :invoke :cas  [2 2]
INFO [2017-03-30 22:38:59,294] jepsen worker 1 - jepsen.util 11 :invoke :read nil
INFO [2017-03-30 22:38:59,297] jepsen worker 1 - jepsen.util 11 :ok :read 4
INFO [2017-03-30 22:38:59,298] jepsen worker 4 - jepsen.util 4  :fail :cas  [2 2]
INFO [2017-03-30 22:38:59,818] jepsen worker 4 - jepsen.util 4  :invoke :write  1
INFO [2017-03-30 22:38:59,826] jepsen worker 4 - jepsen.util 4  :ok :write  1
INFO [2017-03-30 22:38:59,917] jepsen worker 1 - jepsen.util 11 :invoke :cas  [1 2]
INFO [2017-03-30 22:38:59,926] jepsen worker 1 - jepsen.util 11 :ok :cas  [1 2]

注意到某些 CaS 操作失敗,而其他成功了嗎?有些會失敗很正常,事實上,這正是我們想看到的。我們預計某些 CaS 操作會失敗,因為斷定的舊值與當前值不匹配,但有幾個(機率大概是 1/5,因為在任何時候,暫存器的值都只可能 5 個可能性)應該成功。另外,嘗試一些我們任務不可能成功的操作其實是值得的,因為如果它們真的成功,則表明存在一致性衝突。

有了可以執行操作的客戶端後,現在可以著手使用分析結果了。

4.檢查器

正確性校驗

透過生成器和客戶端執行一些操作,我們獲取到了用於分析正確性的歷史記錄。Jepsen 使用 model 代表系統的抽象行為,checker 來驗證歷史記錄是否符合該模型。我們需要 knossos.model 和 jepsen.checker:

(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]
                    [tests :as tests]]
            [jepsen.control.util :as cu]
            [jepsen.os.debian :as debian]
            [knossos.model :as model]
            [slingshot.slingshot :refer [try+]]
            [verschlimmbesserung.core :as v]))

還記得我們如何構建讀、寫和 cas 操作嗎?


(defn r   [_ _] {:type :invoke, :f :read, :value nil})
(defn w   [_ _] {:type :invoke, :f :write, :value (rand-int 5)})
(defn cas [_ _] {:type :invoke, :f :cas, :value [(rand-int 5) (rand-int 5)]})

Jepsen 並不知道:f :read 或:f :cas 的含義,就其而言,他們可以是任意值。然而,當它基於 (case (:f op) :read ...) 進行控制流轉時,我們的 client 知道如何解釋這些操作。現在,我們需要一個能夠理解這些相同操作的系統模型。Knossos 已經為我們定義好了模型資料型別,它接受一個模型或者操作作為輸入進行運算,並返回該操作產生的新模型。knossos.model 內部程式碼如下:


(definterface+ Model
  (step [model op]
        "The job of a model is to *validate* that a sequence of operations
        applied to it is consistent. Each invocation of (step model op)
        returns a new state of the model, or, if the operation was
        inconsistent with the model's state, returns a (knossos/inconsistent
        msg). (reduce step model history) then validates that a particular
        history is valid, and returns the final state of the model.
        Models should be a pure, deterministic function of their state and an
        operation's :f and :value."))

結果發現 Knossos 檢查器為鎖和暫存器等東西定義了一些常見的模型。下面的內容是一個 -- 正是我們需要建模的資料型別

(defrecord CASRegister [value]
  Model
  (step [r op]
    (condp = (:f op)
      :write (CASRegister. (:value op))
      :cas   (let [[cur new] (:value op)]
               (if (= cur value)
                 (CASRegister. new)
                 (inconsistent (str "can't CAS " value " from " cur
                                    " to " new))))
      :read  (if (or (nil? (:value op))
                     (= value (:value op)))
               r
               (inconsistent (str "can't read " (:value op)
                                  " from register " value))))))

只要 knossos 為我們正在檢測的元件提供了模型,我們就不需要在測試中寫 cas 暫存器。這只是為了你可以看到表面上一切順利,其實是依靠底層怎麼執行的。

此 defrecord 定義了一個名為 CASRegister 的新的資料型別,它擁有唯一不變的欄位,名為 value。它實現了我們之前討論的 Model 介面,它的 step 函式接收當前暫存器 r 和操作 op 作為引數。當我們需要寫入新值時,只需要簡單返回一個已經賦值的 CASRegister。為了對兩個值進行 cas,我們在操作中將當前值和新值分開,如果當前值和新值相匹配,則構建一個帶有新值的暫存器。如果它們不匹配,則返回帶有 inconsistent 的特定的模型型別,它表明上一操作不能應用於暫存器。讀操作也是類似,除了我們始終允許讀取到 nil 這一點。這允許我們有從未返回過的讀操作歷史。

為了分析歷史操作,我們需要為測試定義一個:checker,同時需要提供一個:model 來指明系統應該如何執行。

checker/linearizable 使用 Knossos 線性 checker 來驗證每一個操作是否自動處於呼叫和返回之間的位。線性 checker 需要一個模型並指明一個特定的演算法,然後在選項中將 map 傳遞給該演算法。

(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)
          :checker         (checker/linearizable
                             {:model     (model/cas-register)
                              :algorithm :linear})
          :generator       (->> (gen/mix [r w cas])
                                (gen/stagger 1)
                                (gen/nemesis nil)
                                (gen/time-limit 15))}))

執行測試,我們可以驗證 checker 的結果:

$ lein run test
...
INFO [2019-04-17 17:38:16,855] jepsen worker 0 - jepsen.util 0  :invoke :write  1
INFO [2019-04-17 17:38:16,861] jepsen worker 0 - jepsen.util 0  :ok :write  1
...
INFO [2019-04-18 03:53:32,714] jepsen test runner - jepsen.core {:valid? true,
 :configs
 ({:model #knossos.model.CASRegister{:value 3},
   :last-op
   {:process 1,
    :type :ok,
    :f :write,
    :value 3,
    :index 29,
    :time 14105346871},
   :pending []}),
 :analyzer :linear,
 :final-paths ()}
​
​
Everything looks good! ヽ(‘ー`)ノ

歷史記錄中最後的操作是 write 1,可以確信,checker 中的最終值也是 1,該歷史記錄是線性一致的。

多 checkers

checkers 能夠渲染多種型別的輸出 -- 包括資料結構、影像、或者視覺化互動動畫。例如:如果我們安裝了 gnuplot,Jepsen 可以幫我們生成吞吐量和延遲圖。讓我們使用 checker/compose 來進行線性分析並生成效能圖吧!


:checker (checker/compose
            {:perf   (checker/perf)
             :linear (checker/linearizable {:model     (model/cas-register)
                                            :algorithm :linear})})
$ lein run test
...
$ open store/latest/latency-raw.png

我們也可以生成歷史操作 HTML 視覺化介面。我們來新增 jepsen.checker.timeline 名稱空間吧!

(ns jepsen.etcdemo
  (:require ...
            [jepsen.checker.timeline :as timeline]
            ...))
給checker新增測試:
          :checker (checker/compose
                     {:perf   (checker/perf)
                      :linear (checker/linearizable
                                {:model     (model/cas-register)
                                 :algorithm :linear})
                      :timeline (timeline/html)})

現在我們可以繪製不同流程隨時間變化執行的操作圖,其中包括成功的、失敗的以及崩潰的操作等等。

$ lein run test
...
$ open store/latest/timeline.html

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."
:os debian/os
:db (db "v3.1.5">opts
: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確保所有的操作都是線性一致性",但是顯然事實並非如此,在隱藏著這麼一條不引人注意的註釋:

如果你想讓一次讀取是完全的線性一致,可以使用quorum=true。讀取和寫入的操作路徑會因而變得非常相似,並且具有相近的速度(譯者注:暗指速率變慢)。如果不確定是否需要此功能,請隨時向etcd開發者傳送電子郵件以獲取建議。

啊哈!所以我們需要使用quorum讀取,Verschlimmbesserung中有這樣的案例:

(invoke! this test op
parse-long)]
(assoc op :type :ok, :value value))
...

引入quorum讀取後測試透過。

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


恭喜!你已經成功寫完了第一個Jepsen測試,我在年提出了這個issue,並且聯絡了etcd開發團隊請他們介紹quorum讀機制。

# 6.完善測試
我們的測試確定了一個故障,但需要一些運氣和聰明的猜測才能發現它,現在是時候完善我們的測試了,使得它更快、更容易理解以及功能更加強大。

為了分析單個key的歷史記錄,Jepsen透過搜尋併發操作的每種排列,以查詢遵循cas暫存器操作規則的歷史記錄,這意味著在任何給定的時間點的併發運算元後,我們的搜尋是指數級的。

Jepsen執行時需要指定一個工作執行緒數,這通常情況下也限制併發操作的數量。但是,當操作崩潰(或者是返回一個:info的結果,再或者是丟擲一個異常),我們放棄該操作並且讓當前執行緒去做新的事情。這可能會出現如下情況:崩潰的程序操作仍然在執行,並且可能會在後面的時間裡被資料庫執行。這意味著對於後面整個歷史記錄的剩餘時間內,崩潰的操作跟其他操作是併發的。

崩潰的操作越多,歷史記錄結束時的併發操作就越多。併發數線性的增加伴隨著驗證時間的指數增加。我們的首要任務是減少崩潰的運算元量,下面我們將從讀取開始。

## 崩潰讀操作
當一個操作超時時,我們會得到類似下面的這樣一長串的堆疊資訊。

WARN [2018-02-02 16:14:37,588] jepsen worker 1 - jepsen.core Process 11 crashed
java.net.SocketTimeoutException: Read timed out
at java.net.SocketInputStream.socketRead0(Native Method) ~[na:1.8.0_40]
...

同時程序的操作轉成了一個:info的訊息,因為我們不能確定該操作是成功了還是失敗了。 但是,冪等操作,像讀操作,並不會改變系統的狀態。讀操作是否成功不影響,因為效果是相同的。因此我們可以安全的將崩潰的讀操作轉為讀操作失敗,並提升checker的效能。

(invoke! _ test op
parse-long)]
(assoc op :type :ok, :value value))
(catch java.net.SocketTimeoutException ex
(assoc op :type :fail, :error :timeout)))
:write (do (v/reset! conn "foo" (:value op))
(assoc op :type :ok))
:cas (try+
(let old new
:ok
:fail)))
(catch [:errorCode 100] ex
(assoc op :type :fail, :error :not-found)))))

更好的是,如果我們一旦能立即捕獲三個路徑中的網路超時異常,我們就可以避免所有的異常堆疊資訊出現在日誌中。我們也將處理key不存在錯誤(not-found errors),儘管它只出現在:cas操作中,處理該錯誤後,將能保持程式碼更加的清爽。

(invoke! _ test op
parse-long)]
(assoc op :type :ok, :value value))
:write (do (v/reset! conn "foo" (:value op))
(assoc op :type :ok))
:cas (let old new
:ok
:fail))))

(catch java.net.SocketTimeoutException e
(assoc op
:type (if (= :read (:f op)) :fail :info)
:error :timeout))

(catch [:errorCode 100] e
(assoc op :type :fail, :error :not-found))))

現在所有的操作,我們會得到很短的超時錯誤資訊,不僅僅讀操作。

INFO [2017-03-31 19:34:47,351] jepsen worker 4 - jepsen.util 4 :info :cas [4 4] :timeout

## 獨立的數個鍵
我們已經有了針對單個線性鍵的測試。但是,這些程序遲早將會crash,並且併發數將會上升,拖慢分析速度。我們需要一種方法來限制單個鍵的歷史操作記錄長度,同時又能執行足夠多的操作來觀察到併發錯誤。

由於獨立的鍵的線性操作是彼此線性獨立的,因此我們可以將對單個鍵的測試升級為對多個鍵的測試,jepsen.independent名稱空間提供這樣的支援。

(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]
[independent :as independent]
[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]))

我們已經有了一個對單個鍵生成操作的生成器,例如:{:type :invoke, :f :write, :value 3}。我們想升級這個操作為寫多個key。我們想操作value [key v]而不是:value v。

:generator (->> (independent/concurrent-generator
10
(range)
(fn k))
(gen/nemesis
(cycle [(gen/sleep 5)
{:type :info, :f :start}
(gen/sleep 5)
{:type :info, :f :stop}]))
(gen/time-limit (:time-limit opts)))}))

我們的read、write和cas操作的組合仍然不變,但是它被包裹在一個函式內,這個函式有一個引數k並且返回一個指定鍵的值生成器。我們使用concurrent-generator,使得每個鍵有10個執行緒,多個鍵來自無限的整數序列(range),同時這些鍵的生成器生成自(fn [k] ...)。 concurrent-generator改變了我們的values的結構,從v變成了[k v],因此我們需要更新我們的客戶端,以便知道如何讀寫不同的鍵。

(invoke! _ test op)

看看我們的硬編碼的鍵"foo"是如何消失的?現在每個鍵都被操作自身引數化了。注意我們修改數值的地方--例如:在:f :read中——我們必須構建一個指定independent/tuple的鍵值對。為元組使用特殊資料型別,才能允許jepsen.independent在後面將不同鍵的歷史記錄分隔開來。

最後,我們的檢查器以單個值的角度來進行驗證——但是我們可以把它轉變成一個可以合理處理好多個獨立值的檢查器,即依靠多個鍵來辨識這些獨立值。

:checker (checker/compose
{:perf (checker/perf)
:indep (independent/checker
(checker/compose
{:linear (checker/linearizable {:model (model/cas-register)
:algorithm :linear})
:timeline (timeline/html)}))})

寫一個檢查器,不費力地獲得一個由n個checker構成的家族,哈哈哈哈!

$ lein run test --time-limit 30
...
ERROR [2017-03-31 19:51:28,300] main - jepsen.cli Oh jeez, I'm sorry, Jepsen broke. Here's why:
java.util.concurrent.ExecutionException: java.lang.AssertionError: Assert failed: This jepsen.independent/concurrent-generator has 5 threads to work with, but can only use 0 of those threads to run 0 concurrent keys with 10 threads apiece. Consider raising or lowering the test's :concurrency to a multiple of 10.

阿哈,我們預設的併發是5個執行緒,但我們為了執行單個鍵,我們就要求了至少10個執行緒,執行10個鍵的話,需要100個執行緒。

$ lein run test --time-limit 30 --concurrency 100
...
142 :invoke :read [134 nil]
67 :invoke :read [133 nil]
66 :ok :read [133 1]
101 :ok :read [137 3]
181 :ok :write [135 3]
116 :ok :read [131 3]
111 :fail :cas [131 [0 0]]
151 :invoke :read [138 nil]
129 :ok :write [130 2]
159 :ok :read [138 1]
64 :ok :write [133 0]
69 :ok :cas [133 [0 0]]
109 :ok :cas [137 [4 3]]
89 :ok :read [135 1]
139 :ok :read [139 4]
19 :fail :cas [131 [2 1]]
124 :fail :cas [130 [4 4]]

看上述結果,在有限的時間視窗內我們可以執行更多的操作。這幫助我們能能快的發現bugs。
到目前為止,我們硬編碼的地方很多,下面在命令列中,我們將讓其中一些選項變得可配置。

# 7. 引數化配置
我們透過在讀操作的時候包含了一個quorum標示,讓我們上一個測試能夠透過。但是為了看到原始的髒讀bug,我們不得不再次編輯原始碼,設定標示為false。如果我們能從命令列調整該引數,那就太好了。Jepsen提供了一些預設的命令列選項jepsen.cli](https://github.com/jepsen-io/jepsen/blob/0.1.7/jepsen/src/jepsen/cli.clj#L52-L87),但是我們可以透過:opt-spec給cli/single-test-cmd新增我們自己的選項。

(def cli-opts
"Additional command line options."
[["-q" "--quorum" "Use quorum reads, instead of reading from any primary."]])

CLI選項是一個vector集合,給定一個簡短的名稱,一個全名,一個文件描述和一些決定著如何解析這些選項的選項,比如將這些選項解析為它們的預設值等等。這些資訊將傳遞給[tools.cli](https://github.com/clojure/tools.cli),一個標準的處理option的clojure庫。

現在,讓我們那個選項規範給傳遞CLI。

(defn -main
"Handles command line arguments. Can either run a test, or a web server for
browsing results."
& args
如果我們再次透過 lein run test -q ...執行我們的測試,我們將在我們的測試 map 中看到一個新的:quorum 選項。
10:02:42.532 [main] INFO jepsen.cli - Test options:
{:concurrency 10,
:test-count 1,
:time-limit 30,
:quorum true,
...

Jepsen解析我們的-q選項,發現該選項是我們提供的,並且新增:quorum true鍵值對到選項map中,該選項map會傳給etcd-test,etcd-test將會merge(合併)選項map到測試map中。Viola! 我們有了一個:quorum鍵在我們的測試中。

現在,讓我們使用quorum選項來控制是否客戶端觸發法定讀,在客戶端的invoke函式執行如下:

(case (:f op)
:read (let value (-> conn
(v/get k {:quorum? (:quorum test)})
parse-long)
)

讓我們嘗試攜帶-q和不攜帶 -q 引數執行lein run,然後看看能否再次觀察到髒讀bug。

$ lein run test -q ...
...

$ lein run test ...
...
clojure.lang.ExceptionInfo: throw+: {:errorCode 209, :message "Invalid field", :cause "invalid value for \"quorum\"", :index 0, :status 400}
...

哈。讓我們再次檢查在測試map中:quorum的值是什麼。每次jepsen開始執行時,它會被列印在日誌中:

2018-02-04 09:53:24,867{GMT} INFO [jepsen test runner] jepsen.core: Running test:
{:concurrency 10,
:db
#object[jepsen.etcdemo$db$reify_4946 0x15a8bbe5 "jepsen.etcdemo$db$reify4946@15a8bbe5"],
:name "etcd",
:start-time
#object[org.joda.time.DateTime 0x54a5799f "2018-02-04T09:53:24.000-06:00"],
:net
#object[jepsen.net$reify
3493 0x2a2b3aff "jepsen.net$reify3493@2a2b3aff"],
:client {:conn nil},
:barrier
#object[java.util.concurrent.CyclicBarrier 0x6987b74e "java.util.concurrent.CyclicBarrier@6987b74e"],
:ssh
{:username "root",
:password "root",
:strict-host-key-checking false,
:private-key-path nil},
:checker
#object[jepsen.checker$compose$reify
3220 0x71098fb3 "jepsen.checker$compose$reify3220@71098fb3"],
:nemesis
#object[jepsen.nemesis$partitioner$reify
3601 0x47c15468 "jepsen.nemesis$partitioner$reify3601@47c15468"],
:active-histories #,
:nodes ["n1" "n2" "n3" "n4" "n5"],
:test-count 1,
:generator
#object[jepsen.generator$time_limit$reify
1996 0x483fe83a "jepsen.generator$time_limit$reify1996@483fe83a"],
:os
#object[jepsen.os.debian$reify
2908 0x8aa1562 "jepsen.os.debian$reify_2908@8aa1562"],
:time-limit 30,
:model {:value nil}}

真奇怪,上面沒有列印出:quorum這個鍵,如果選項標誌出現在命令列中,則他們只會出現在選項map中;如果他們排除在命令列之外,則他們也排除在選項map外。當我們想要(:quorum test)時,test沒有:quorum選項,我們將會得到nil。

有一些簡單的方式來修復這個問題。在客戶端或者在 etcd-test中,透過使用(boolean (:quorum test)),我們可以強迫nil為false。或者我們可以強迫在該選項省略時,為該選項透過新增:default false指定一個預設值。我們將使用boolean在etcd-test。以防有人直接呼叫它,而不是透過CLI。

(defn etcd-test
"Given an options map from the command line runner (e.g. :nodes, :ssh,
:concurrency ...), constructs a test map. Special options:

:quorum Whether to use quorum reads"
opts
:quorum quorum

...

為了在兩個地方我們可以使用quorum的布林值,我們繫結quorum到一個變數上。我們新增它到測試的名稱上,這將會讓人很容易一看就知道哪個測試使用了quorum讀。我們也新增它到:quorum選項上。因為我們合併opts之前,我們的:quorum的布林版本將優先於opts中的變數。現在,不使用-q,我們的測試將會再次發現如下錯誤。
``
$ lein run test --time-limit 60 --concurrency 100 -q
...
Everything looks good! ヽ(‘ー`)ノ
​
$ lein run test --time-limit 60 --concurrency 100
...
Analysis invalid! (ノಥ益ಥ)ノ ┻━┻

可調整的複雜度

你也許已經注意到一些測試卡在痛苦緩慢的分析上,這依賴於你的計算機效能。很難預先控制這個測試複雜度,就像~n!,這兒的 n 表示併發數。幾個 crash 的程序會使得檢查的時間分佈在數秒和數天之間。

為了幫助解決這個問題,讓我們在我們的測試中新增一些調整選項,這些選項可以控制你在單個鍵上執行的運算元,以及生成操作的快慢。

在生成器中,讓我們將寫死的 1/10 秒的延遲變成一個引數,透過每秒的速率來給定。同時將每個鍵的生成器上硬編碼的 limit 也變成一個可配置的引數。

(defn etcd-test
  "Given an options map from the command line runner (e.g. :nodes, :ssh,
  :concurrency ...), constructs a test map."
  [opts]
  (let [quorum (boolean (:quorum opts))]
    (merge tests/noop-test
           opts
           {:pure-generators true
            :name            (str "etcd q=" quorum)
            :quorum          quorum
            :os              debian/os
            :db              (db "v3.1.5")
            :client          (Client. nil)
            :nemesis         (nemesis/partition-random-halves)
            :checker         (checker/compose
                               {:perf   (checker/perf)
                                :indep (independent/checker
                                         (checker/compose
                                           {:linear   (checker/linearizable
                                                        {:model (model/cas-register)
                                                         :algorithm :linear})
                                            :timeline (timeline/html)}))})
            :generator       (->> (independent/concurrent-generator
                                    10
                                    (range)
                                    (fn [k]
                                      (->> (gen/mix [r w cas])
                                           (gen/stagger (/ (:rate opts)))
                                           (gen/limit (:ops-per-key opts)))))
                                  (gen/nemesis
                                    (->> [(gen/sleep 5)
                                          {:type :info, :f :start}
                                          (gen/sleep 5)
                                          {:type :info, :f :stop}]
                                         cycle))
                                  (gen/time-limit (:time-limit opts)))})))

同時新增相應的命令列選項。

(def cli-opts
  "Additional command line options."
  [["-q" "--quorum" "Use quorum reads, instead of reading from any primary."]
   ["-r" "--rate HZ" "Approximate number of requests per second, per thread."
    :default  10
    :parse-fn read-string
    :validate [#(and (number? %) (pos? %)) "Must be a positive number"]]
   [nil "--ops-per-key NUM" "Maximum number of operations on any given key."
    :default  100
    :parse-fn parse-long
    :validate [pos? "Must be a positive integer."]]])

我們沒必要為每個選項引數都提供一個簡短的名稱:我們使用 nil 來表明--ops-per-key 沒有縮寫。每個標誌後面的大寫首字母 (例如:“HZ” & "NUM") 是你要傳遞的值的任意佔位符。他們將會作為使用文件的一部分被列印。我們為這兩選項都提供了:default,如果沒有透過命令列指定,它的預設值將會被使用。對 rates 而言,我們希望允許一個整數,浮點數和分數,因此,我們將使用 Clojure 內建的 read-string 函式來解析上述三類。然後我們將校驗它是一個正整數,以阻止人們傳遞字串,負數,0 等。

現在,如果我們想執行一個稍微不那麼激進的測試,我們可以執行如下命令。

$ lein run test --time-limit 10 --concurrency 10 --ops-per-key 10 -r 1
...
Everything looks good! ヽ(‘ー`)ノ

瀏覽每個鍵的歷史記錄,我們可以看到操作處理的很慢,同時每個鍵只有 10 個操作。這個測試更容易檢查。然而,它也不能發現 bug!這是 jepsen 中的固有的矛盾之處:我們必須積極地發現錯誤,但是驗證這些激進的歷史記錄更加困難——甚至是不可能的事情。

線性一致讀檢查是 NP 複雜度的問題;現在還沒有辦法能夠解決。我們會設計一些更有效的檢查器,但是最終,指數級的困難將使我們寸步難行。或許,我們可以驗證一個 weaker(稍弱) 的屬性,線性或者對數時間

8.新增一個 Set 測試

我們可以將 etcd 叢集建模為一組暫存器,每個暫存器用一個 key 來標識,並且該暫存器支援 read、write、cas 操作。但這不是我們可以建立在 etcd 之上的唯一可能的系統。例如,我們將其視為一組 key,並且忽略其 value。或者我們可以實現一個基於 etcd 叢集的佇列。理論上,我們可以對 etcd API 的每個部分進行建模,但是狀態空間將會很大,而且實現可能很耗時。典型地,我們將重點介紹 API 的重要部分或者常用部分。

但是什麼情況下一個測試有用呢?我們的線性一致性測試相當籠統,執行不同型別的隨機操作,並且決定這些操作的任何模式是否都是線性的。然而,這麼做代價也是非常昂貴的,如果我們能設計一個簡單驗證的測試,這就太好了,但它仍然能告訴我們一些有用的資訊

考慮一個支援 add 和 read 操作的集合。如果我們只讀,透過觀察空集合就能滿足我們的測試。如果我們只寫,每個測試將總會透過,因為給一個集合中新增元素總是合法的。很明顯,我們需要讀寫結合。此外,一個讀操作應該是最後發生的一個,因為最終讀操作之後的任何寫操作將不會影響測試輸出

我們應該新增什麼元素?如果我們總是新增相同的元素,該測試具有一定的分辨能力:如果每次新增都返回 ok,但是我們不去讀該元素,我們知道我們發現了一個 bug。然而,如果任何的新增有效,那麼最終的讀將會包含該元素,並且我們無法確定其他新增的元素是否有效。或許對元素去重是有用的,這樣每個新增操作對該讀操作產生一些獨立的影響。如果我們選擇有序的元素,我們可以粗略的瞭解損失是隨著時間平均分佈還是成塊出現,因此,我們也打算這樣做。

我們的操作將會類似於下面這樣

{:type :invoke, :f :add, :value 0}
{:type :invoke, :f :add, :value 1}
...
{:type :invoke, :f :read, :value #{0 1}}

如果每個新增都成功,我們將知道資料庫正確的執行了,並存在於最終讀的結果中。透過執行多次讀操作和追蹤哪些讀操作完成了或者哪些到目前為止正在進行中,我們能獲得更多的資訊。但現在,讓我們先簡單進行

A New Namespace

在 jepsen.etcdemo 中開始變的有些混亂了,因此我們要將這些內容分解為新測試的專用名稱空間中。我們將稱為 jepsen.etcdemo.set:

$ mkdir src/jepsen/etcdemo
$ vim src/jepsen/etcdemo/set.clj

我們將設計一個新的 client 和 generator,因此我們需要下面這些 jepsen 中的名稱空間。當然,我們將使用我們的 etcd client 庫,Verschlimmbesserung--我們將處理來自它的異常,因此也需要 Slingshot 庫

(ns jepsen.etcdemo.set
  (:require [jepsen
              [checker :as checker]
              [client :as client]
              [generator :as gen]]
            [slingshot.slingshot :refer [try+]]
            [verschlimmbesserung.core :as v]))

我們將需要一個能往集合中新增元素,並能讀取元素的一個 client--但我們必須選擇如何在資料庫中儲存上面的集合 set。一個選擇是使用獨立的 key,或者一個 key 池子。另一個選擇是使用單個 key,並且其 value 是一個序列化的資料型別,類似於 json 陣列或者 Clojure 的 set,我們將使用後者。

(defrecord SetClient [k conn]
  client/Client
    (open! [this test node]
        (assoc this :conn (v/connect (client-url node)

Oh。有一個問題。我們沒有 client-url 函式。我們可以從 jepsen.etcdemo 提取它,但我們後面想使用 jepsen.etcdemo 的 this 名稱空間,並且 Clojure 非常艱難的嘗試避免名稱空間中的迴圈依賴問題。我們建立一個新的稱為 jepsen.etcdemo.support 的名稱空間。像 jepsen.etcdemo.set 一樣,它也會有它自己的檔案。

$ vim src/jepsen/etcdemo/support.clj

讓我們將 url 建構函式從 jepsen.etcdemo 移動到 jepsen.etcdemo.support

(ns jepsen.etcdemo.support
  (:require [clojure.string :as str]))
​
(defn node-url
  "An HTTP url for connecting to a node on a particular port."
  [node port]
  (str "http://" node ":" port))
​
(defn peer-url
  "The HTTP url for other peers to talk to a node."
  [node]
  (node-url node 2380))
​
(defn client-url
  "The HTTP url clients use to talk to a node."
  [node]
  (node-url node 2379))
​
(defn initial-cluster
  "Constructs an initial cluster string for a test, like
  \"foo=foo:2380,bar=bar:2380,...\""
  [test]
  (->> (:nodes test)
       (map (fn [node]
              (str node "=" (peer-url node))))
       (str/join ",")))
現在我們在jepsen.etcdemo需要support名稱空間,並且替換,用新名稱呼叫這些函式:
(ns jepsen.etcdemo
  (:require [clojure.tools.logging :refer :all]
                      ...
            [jepsen.etcdemo.support :as s]
            ...))
​
...
​
(defn db
  "Etcd DB for a particular version."
  [version]
  (reify db/DB
    (setup! [_ test node]
      (info node "installing etcd" version)
      (c/su
        (let [url (str "https://storage.googleapis.com/etcd/" version
                       "/etcd-" version "-linux-amd64.tar.gz")]
          (cu/install-archive! url dir))
​
        (cu/start-daemon!
          {:logfile logfile
           :pidfile pidfile
           :chdir   dir}
          binary
          :--log-output                   :stderr
          :--name                         node
          :--listen-peer-urls             (s/peer-url   node)
          :--listen-client-urls           (s/client-url node)
          :--advertise-client-urls        (s/client-url node)
          :--initial-cluster-state        :new
          :--initial-advertise-peer-urls  (s/peer-url node)
          :--initial-cluster              (s/initial-cluster test))
​
        (Thread/sleep 5000)))
​
...
​
    (assoc this :conn (v/connect (s/client-url node)

處理完之後,回到 jepsen.etcdemo.set,這裡也需要我們的 support 名稱空間,並且在 client 中使用它

(defrecord SetClient [k conn]
  client/Client
  (open! [this test node]
    (assoc this :conn (v/connect (s/client-url node)
                                 {:timeout 5000})))

我們將使用 setup! 函式來初始化空 Clojure set:#{}中的單個 key 的 value。我們將再一次硬編碼,但在 SetClient 中有一個欄位的話,將會更加清晰一些。

(setup! [this test]
  (v/reset! conn k "#{}"))

我們的 invoke 函式看起來和之前的 client 中的實現有一些相似,我們將基於:f 來分發處理,並使用相似的錯誤處理器。

(invoke! [_ test op]
  (try+
    (case (:f op)
      :read (assoc op
                   :type :ok
                   :value (read-string
                            (v/get conn k {:quorum? (:quorum test)})))

怎麼樣往集合中新增一個元素呢?我們需要去讀取當前集合,新增新 value,如果它的值未變的話,然後寫入它。Verschlimmbesserung 有一個helper for thatswap! 函式,它可以轉換該 key 的值

  (invoke! [_ test op]
    (try+
      (case (:f op)
        :read (assoc op
                     :type :ok,
                     :value (read-string
                              (v/get conn k {:quorum? (:quorum test)})))
​
        :add (do (v/swap! conn k (fn [value]
                                   (-> value
                                       read-string
                                       (conj (:value op))
                                       pr-str)))
                 (assoc op :type :ok)))
​
      (catch java.net.SocketTimeoutException e
        (assoc op
               :type  (if (= :read (:f op)) :fail :info)
               :error :timeout))))

我們清除我們這兒的 key,但是處於該教程的目,我們將跳過這部分,當測試開始的時候,它將會刪除所有剩餘的資料。

  (teardown! [_ test])
​
  (close! [_ test]))

Good!現在我們需要用 generator 和 checker 來打包。我們會使用相同的名字、OS、DB、來自線性測試中的 nemesis,為了代替準備一個 full 的 test map,我們將稱它為"wordload",並且將其整合到後面的測試中。
新增一個元素到 set 中是一個通用的測試,jepsen 中內建了一個 checker/set.

(defn workload
  "A generator, client, and checker for a set test."
  [opts]
  {:client    (SetClient. "a-set" nil)
   :checker   (checker/set)
   :generator

對於 generator... hmm。我們知道它處理兩個部分:首先,我們將新增一組元素,並且在完成後,我們將執行單一次讀取。讓我們現在獨立的編寫這兩部分,並且考慮如何將它們結合。

我們如何獲得一組唯一的元素去新增呢?我們可以從頭編寫一個 generator,但是使用 Clojure 內建的序列庫來構建一個呼叫操作序列,每個數字一次,然後將其包裹在使用 gen/seq 生成額 generator 中,或許更容易一些,,就像我們為 nemesis 做的 starts,sleeps,stops 的無限迴圈那樣。

(defn workload
  "A generator, client, and checker for a set test."
  [opts]
  {:client (SetClient. "a-set" nil)
   :checker (checker/set)
   :generator (->> (range)
                   (map (fn [x] {:type :invoke, :f :add, :value x})))
   :final-generator (gen/once {:type :invoke, :f :read, :value nil})})

對於 final-generator,我們使用 gen/once 來發出一次讀,而不是無限次的讀取

Integrating the New Workload

現在,我們需要整合 workload 到主函式的 etcd-test 中,讓我們回到 jepsen.etcdemo,並且 require set 測試名稱空間。

(ns jepsen.etcdemo
  (:require [clojure.tools.logging :refer :all]
                        ...
            [jepsen.etcdemo [set :as set]
                            [support :as s]]

看 etcd-test,我們可以直接編輯它,但是最終我們將要回到我們的線性測試中,因此讓我們暫時保留所有內容,並新增一個新的 map,基於設定的 workload 覆蓋調 client,checker,generator

(defn etcd-test
  "Given an options map from the command line runner (e.g. :nodes, :ssh,
  :concurrency ...), constructs a test map. Special options:
​
      :quorum       Whether to use quorum reads
      :rate         Approximate number of requests per second, per thread
      :ops-per-key  Maximum number of operations allowed on any given key."
  [opts]
  (let [quorum    (boolean (:quorum opts))
        workload  (set/workload opts)]
    (merge tests/noop-test
           opts
           {:pure-generators true
            :name            (str "etcd q=" quorum)
            :quorum          quorum
            :os              debian/os
            :db              (db "v3.1.5")
            :client          (Client. nil)
            :nemesis         (nemesis/partition-random-halves)
            :checker         (checker/compose
                               {:perf   (checker/perf)
                                :indep (independent/checker
                                         (checker/compose
                                           {:linear   (checker/linearizable
                                                        {:model (model/cas-register)
                                                         :algorithm :linear})
                                            :timeline (timeline/html)}))})
            :generator       (->> (independent/concurrent-generator
                                    10
                                    (range)
                                    (fn [k]
                                      (->> (gen/mix [r w cas])
                                           (gen/stagger (/ (:rate opts)))
                                           (gen/limit (:ops-per-key opts)))))
                                  (gen/nemesis
                                    (->> [(gen/sleep 5)
                                          {:type :info, :f :start}
                                          (gen/sleep 5)
                                          {:type :info, :f :stop}]
                                         cycle))
                                  (gen/time-limit (:time-limit opts)))}
           {:client    (:client workload)
            :checker   (:checker workload)

多考慮一下 generator...我們知道它將處理兩個階段:新增和最終讀取。我們也知道我們想要讀取成功,這意味著我們想讓叢集正常並且恢復那一點,因此我們將在 add 階段執行普通的分割槽操作,然後停止分割槽,等待一會讓叢集恢復,最終執行我們的讀操作。gen/phases 幫助我們編寫這些型別的多階段 generators。

:generator (gen/phases
             (->> (:generator workload)
                  (gen/stagger (/ (:rate opts)))
                  (gen/nemesis
                    (cycle [(gen/sleep 5)
                            {:type :info, :f :start}
                            (gen/sleep 5)
                            {:type :info, :f :stop}]))
                  (gen/time-limit (:time-limit opts)))
             (gen/log "Healing cluster")
             (gen/nemesis (gen/once {:type :info, :f :stop}))
             (gen/log "Waiting for recovery")
             (gen/sleep 10)
             (gen/clients (:final-generator workload)))})))

讓我們試一下,看看會發生什麼?

$ lein run test --time-limit 10 --concurrency 10 -r 1/2
...
NFO [2018-02-04 22:13:53,085] jepsen worker 2 - jepsen.util 2    :invoke    :add    0
INFO [2018-02-04 22:13:53,116] jepsen worker 2 - jepsen.util 2    :ok    :add    0
INFO [2018-02-04 22:13:53,361] jepsen worker 2 - jepsen.util 2    :invoke    :add    1
INFO [2018-02-04 22:13:53,374] jepsen worker 2 - jepsen.util 2    :ok    :add    1
INFO [2018-02-04 22:13:53,377] jepsen worker 4 - jepsen.util 4    :invoke    :add    2
INFO [2018-02-04 22:13:53,396] jepsen worker 3 - jepsen.util 3    :invoke    :add    3
INFO [2018-02-04 22:13:53,396] jepsen worker 4 - jepsen.util 4    :ok    :add    2
INFO [2018-02-04 22:13:53,410] jepsen worker 3 - jepsen.util 3    :ok    :add    3
...
INFO [2018-02-04 22:14:06,934] jepsen nemesis - jepsen.generator Healing cluster
INFO [2018-02-04 22:14:06,936] jepsen nemesis - jepsen.util :nemesis    :info    :stop    nil
INFO [2018-02-04 22:14:07,142] jepsen nemesis - jepsen.util :nemesis    :info    :stop    :network-healed
INFO [2018-02-04 22:14:07,143] jepsen nemesis - jepsen.generator Waiting for recovery
...
INFO [2018-02-04 22:14:17,146] jepsen worker 4 - jepsen.util 4    :invoke    :read    nil
INFO [2018-02-04 22:14:17,153] jepsen worker 4 - jepsen.util 4    :ok    :read    #{0 7 20 27 1 24 55 39 46 4 54 15 48 50 21 31 32 40 33 13 22 36 41 43 29 44 6 28 51 25 34 17 3 12 2 23 47 35 19 11 9 5 14 45 53 26 16 38 30 10 18 52 42 37 8 49}
...
INFO [2018-02-04 22:14:29,553] main - jepsen.core {:valid? true,
 :lost "#{}",
 :recovered "#{}",
 :ok "#{0..55}",
 :recovered-frac 0,
 :unexpected-frac 0,
 :unexpected "#{}",
 :lost-frac 0,
 :ok-frac 1}
​
​
Everything looks good! ヽ(‘ー`)ノ

看上面的 55 個新增操作,所有的新增都在最終讀取中儲存完整,如果有任何資料丟了,他們將會顯示在:lost 集合中
讓我們將線性的暫存器重寫為 workload,因此它將與設定測試相同。

(defn register-workload
  "Tests linearizable reads, writes, and compare-and-set operations on
  independent keys."
  [opts]
  {:client    (Client. nil)
   :checker   (independent/checker
                (checker/compose
                  {:linear   (checker/linearizable {:model     (model/cas-register)
                                                    :algorithm :linear})
                   :timeline (timeline/html)}))

我們忘記效能展示圖了。這些圖對於每次測試似乎是有用的,因此我們將其排除在 workload 外,對於這個特殊的 workload,我們需要線性一致性和 HTML 時序圖的獨立 checker。下一節,我們需要併發的 generator

:generator (independent/concurrent-generator
             10
             (range)
             (fn [k]
               (->> (gen/mix [r w cas])
                    (gen/limit (:ops-per-key opts)))))})

這個 generator 比之前的更簡單!nemesis、rate limiting 和 time limits 透過 etcd-test 來應用,因此我們可以將它們排除在 workload 之外。我們這兒也不需要 :final-generator,因此我們保留一個空白--"nil",這個 generator 意味著啥也不做。

在 workload 之間切換,讓我們起一個簡短的名字

(def workloads
  "A map of workload names to functions that construct workloads, given opts."
  {"set"      set/workload
   "register" register-workload})

現在,讓我們避免在 etcd-test 中指定 register,純粹的讓 workdload 來處理。我們將採用字串 workload 選型,讓它去檢視適當的 workload 函式,然後使用 opts 呼叫來簡歷適當的 workload。我們也更新我們的測試名稱,以包含 workload 名稱。

(defn etcd-test
  "Given an options map from the command line runner (e.g. :nodes, :ssh,
  :concurrency ...), constructs a test map. Special options:
​
      :quorum       Whether to use quorum reads
      :rate         Approximate number of requests per second, per thread
      :ops-per-key  Maximum number of operations allowed on any given key
      :workload     Type of workload."
  [opts]
  (let [quorum    (boolean (:quorum opts))
        workload  ((get workloads (:workload opts)) opts)]
    (merge tests/noop-test
           opts
           {:pure-generators true
            :name       (str "etcd q=" quorum " "
                             (name (:workload opts)))
            :quorum     quorum
            :os         debian/os
            :db         (db "v3.1.5")
            :nemesis    (nemesis/partition-random-halves)
            :client     (:client workload)
            :checker    (checker/compose
                          {:perf     (checker/perf)
                           :workload (:checker workload)})
   ...

現在,讓我們給 CLI 傳遞 workload 選項

(def cli-opts
  "Additional command line options."
  [["-w" "--workload NAME" "What workload should we run?"
    :missing  (str "--workload " (cli/one-of workloads))
    :validate [workloads (cli/one-of workloads)]]
   ...

我們用:missing 使 tools.cli 持續提供一些 value,cli/one-of 是一個縮寫,它用來確保在 map 中該值是一個有效的 key;它給我們一些有用的錯誤資訊。現在如果我們不帶 workload 來執行測試,它將告訴我們需要選擇一個有效的 workload。

$ lein run test --time-limit 10 --concurrency 10 -r 1/2
--workload Must be one of register, set

並且我們只需要按一下開關,就可以執行任一 workload

$ lein run test --time-limit 10 --concurrency 10 -r 1/2 -w set
...
$ lein run test --time-limit 10 --concurrency 10 -r 1/2 -w register
...

就我們這堂課而言,你可以試想下,將 register 測試移動到它自己的名稱空間中,並將 set 測試拆分使用獨立鍵,謝謝閱讀!

https://jaydenwen123.gitbook.io/zh_jepsen_doc/

相關文章