Jespen的基础知识(纯干货)

本教程将指引你从零开始编写一个 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 #,
 :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 测试拆分使用独立键,谢谢阅读!

你可能感兴趣的:(算法)