Etcd和ZooKeeper,究竟誰在watch的功能表現更好?

ZooKeeper和Etcd的主要異同能夠參考這篇文章,此外,Etcd的官網上也有對比表格(https://coreos.com/etcd/docs/latest/learning/why.html),本文不加贅述。html

本文主要關注這二者在watch上的功能差別。ZooKeeper和Etcd均可以對某個key進行watch,並在當這個key發生改變(好比有更新值,或刪除key的操做發生)時觸發。node

ZooKeeper的watch

ZooKeeper的watch功能可參考其官網文檔git

可是光看文檔不足以對watch功能有一個具體的感覺。因此接下來就讓咱們安裝並運行一個ZooKeeper服務端,實際體驗一下。程序員

ZooKeeper下載安裝和啓動

首先,要使用ZooKeeper,咱們能夠去其官網的Release頁面下載最新的ZooKeeper。
下載下來是一個tar包,解壓並進入zookeeper目錄:github

tar zxvf zookeeper-3.4.14.tar.gz
cd zookeeper-3.4.14

其conf目錄中是配置文件,咱們須要將zoo_sample.cfg複製爲zoo.cfg
而後執行bin目錄下的zkServer.sh啓動ZooKeeper服務:docker

cp conf/zoo_sample.cfg conf/zoo.cfg
bin/zkServer.sh start

ZooKeeper服務啓動後會在本地默認的2181端口開始監聽。apache

用Go語言寫的ZooKeeper的watch示例

首先,咱們須要下載這樣一個第三方的go包用來訪問ZooKeeper服務:api

go get github.com/samuel/go-zookeeper/zk

watch children

go-zookeeper源碼的example目錄中提供了一個basic.go,這個程序能夠watch根目錄"/"下的子節點的建立和刪除事件:app

package main

import (
        "fmt"
        "time"

        "github.com/samuel/go-zookeeper/zk"
)

func main() {
        c, _, err := zk.Connect([]string{"127.0.0.1"}, time.Second) //*10)
        if err != nil {
                panic(err)
        }
        children, stat, ch, err := c.ChildrenW("/")
        if err != nil {
                panic(err)
        }
        fmt.Printf("%+v %+v\n", children, stat)
        e := <-ch
        fmt.Printf("%+v\n", e)
}

這個示例代碼調用ChildrenW方法watch根目錄"/"下的children節點。
用go run運行這段代碼:測試

$ go run basic.go
2019/04/16 16:11:33 Connected to 127.0.0.1:2181
2019/04/16 16:11:33 Authenticated: id=72753663009685508, timeout=4000
2019/04/16 16:11:33 Re-submitting `0` credentials after reconnect
[1 zookeeper] &{Czxid:0 Mzxid:0 Ctime:0 Mtime:0 Version:0 Cversion:2 Aversion:0 EphemeralOwner:0 DataLength:0 NumChildren:2 Pzxid:32}

咱們能夠看到客戶端已經鏈接上並打印出了根目錄"/"的children和stat,目前根目錄"/"下的children共有兩個,分別是"1"和"zookeeper"。
程序如今阻塞在ChildrenW建立的channel ch上,等待事件發生。
接下來,讓咱們另開一個console運行ZooKeeper自帶的客戶端zkCli.sh並用create命令建立一個子節點"/2":

$ bin/zkCli.sh
[zk: localhost:2181(CONNECTED) 2] create /2 value
Created /2

此時,因爲根目錄下新增了一個子節點,以前的basic.go程序打印出watch事件並退出:

{Type:EventNodeChildrenChanged State:Unknown Path:/ Err:<nil> Server:}

須要注意的是,這個watch操做觸發一次後channel就會關閉。因此試圖用range ch的方式循環watch不可行,客戶端代碼必須再次調用ChildrenW才能watch下一個事件。
通過屢次相似測試後,咱們能夠發現,ChildrenW僅能watch子節點 child的建立和刪除等操做,對某個child的值進行更新操做是沒法被watch捕捉的,並且也沒法捕捉孫節點的建立刪除操做。

watch node

若是須要捕捉某個節點的值的更新操做,咱們須要用GetW方法來進行watch,見下列示例watch.go:

package main

import (
        "fmt"
        "os"
        "time"

        "github.com/samuel/go-zookeeper/zk"
)

func main() {
        c, _, err := zk.Connect([]string{"127.0.0.1"}, time.Second) //*10)
        if err != nil {
                panic(err)
        }
        b, stat, ch, err := c.GetW(os.Args[1])
        if err != nil {
                panic(err)
        }
        fmt.Printf("%+v %+v\n", string(b), stat)
        e := <-ch
        fmt.Printf("%+v\n", e)
}

運行watch.go監視/1節點的內容變動:

$ go run watch.go /1
2019/04/16 16:56:16 Connected to 127.0.0.1:2181
2019/04/16 16:56:16 Authenticated: id=72753663009685517, timeout=4000
2019/04/16 16:56:16 Re-submitting `0` credentials after reconnect
value &{Czxid:2 Mzxid:60 Ctime:1555314817581 Mtime:1555404853396 Version:11 Cversion:4 Aversion:0 EphemeralOwner:0 DataLength:5 NumChildren:2 Pzxid:28}

在zkCli中用set命令設置/1的值

[zk: localhost:2181(CONNECTED) 12] set /1 value

watch.go打印出事件:

{Type:EventNodeDataChanged State:Unknown Path:/1 Err:<nil> Server:}

注意這裏的事件Type是EventNodeDataChanged,且"/1"節點必須一開始存在,若是"/1"節點不存在,試圖對"/1"進行GetW就會報錯。

watch existence

若是咱們但願watch某個節點的存在性發生的變化,咱們須要用ExistsW,見示例exist.go

package main

import (
        "fmt"
        "os"
        "time"

        "github.com/samuel/go-zookeeper/zk"
)

func main() {
        c, _, err := zk.Connect([]string{"127.0.0.1"}, time.Second) //*10)
        if err != nil {
                panic(err)
        }
        b, stat, ch, err := c.ExistsW(os.Args[1])
        if err != nil {
                panic(err)
        }
        fmt.Printf("%+v %+v\n", b, stat)
        e := <-ch
        fmt.Printf("%+v\n", e)
}

運行exist.go監視"/2"的存在性

$ go run exist.go /2
2019/04/16 17:12:33 Connected to 127.0.0.1:2181
2019/04/16 17:12:33 Authenticated: id=72753663009685521, timeout=4000
2019/04/16 17:12:33 Re-submitting `0` credentials after reconnect
false &{Czxid:0 Mzxid:0 Ctime:0 Mtime:0 Version:0 Cversion:0 Aversion:0 EphemeralOwner:0 DataLength:0 NumChildren:0 Pzxid:0}

用zkCli建立/2

[zk: localhost:2181(CONNECTED) 14] create /2 2
Created /2

exist.go打印事件

{Type:EventNodeCreated State:Unknown Path:/2 Err:<nil> Server:}

注意這裏create事件的Type是EventNodeCreated。一樣,若是發生delete事件,那麼Type將是EventNodeDeleted

ZooKeeper總結

  1. watch children只能watch子節點,不能遞歸watch孫節點
  2. watch children只能watch子節點的建立和刪除,不能watch子節點值的變化
  3. watch node只能對已經存在的node進行watch,對不存在的node須要watch existence
    除了上述的這些不足之外,在其官網文檔中本身也提到,在watch被觸發和從新設置之間發生的事件將被丟棄,沒法被捕捉。
    接下來讓咱們看看Etcd的watch。

Etcd的watch

Etcd的watch功能見其API文檔:https://coreos.com/etcd/docs/latest/learning/api.html#watch-api

Etcd支持Docker鏡像啓動而無需安裝,只要咱們預先安裝了Docker,那麼只需執行一條簡單的命令就能夠直接在本機啓動Etcd服務。

用Docker啓動Etcd

Etcd在其github的Release頁面:https://github.com/etcd-io/etcd/releases上提供了Docker啓動命令,讓咱們能夠免去繁瑣的下載安裝步驟,只需執行下列代碼,就能夠將這個docker鏡像下載到本地運行

rm -rf /tmp/etcd-data.tmp && mkdir -p /tmp/etcd-data.tmp && \
  docker rmi gcr.io/etcd-development/etcd:v3.3.12 || true && \
  docker run \
  -p 2379:2379 \
  -p 2380:2380 \
  --mount type=bind,source=/tmp/etcd-data.tmp,destination=/etcd-data \
  --name etcd-gcr-v3.3.12 \
  gcr.io/etcd-development/etcd:v3.3.12 \
  /usr/local/bin/etcd \
  --name s1 \
  --data-dir /etcd-data \
  --listen-client-urls http://0.0.0.0:2379 \
  --advertise-client-urls http://0.0.0.0:2379 \
  --listen-peer-urls http://0.0.0.0:2380 \
  --initial-advertise-peer-urls http://0.0.0.0:2380 \
  --initial-cluster s1=http://0.0.0.0:2380 \
  --initial-cluster-token tkn \
  --initial-cluster-state new

用Go語言寫Etcd的watch

Etcd自己就是用Go寫的,且官方提供了Go的SDK,當前最新的版本是v3,咱們能夠直接用go get獲取:

go get go.etcd.io/etcd/clientv3

prefix watch

Etcd支持單點watch,prefix watch以及ranged watch。
和ZooKeeper不一樣,Etcd不會根據事件的不一樣而要求調用不一樣的watch API,三類watch的區別僅在於對key的處理不一樣:
單點watch僅對傳入的單個key進行watch;
ranged watch能夠對傳入的key的範圍進行watch,範圍內的key的事件都會被捕捉;
而prefix則能夠對全部具備給定prefix的key進行watch。
做爲示例,本文僅給出prefix watch的代碼prefix.go以下:

package main

import (
        "context"
        "fmt"
        "log"
        "time"

        "go.etcd.io/etcd/clientv3"
)

func main() {
        cli, err := clientv3.New(clientv3.Config{
                Endpoints:   []string{"127.0.0.1:2379"},
                DialTimeout: 5 * time.Second,
        })
        if err != nil {
                log.Fatal(err)
        }
        defer cli.Close()

        rch := cli.Watch(context.Background(), "foo", clientv3.WithPrefix())
        for wresp := range rch {
                for _, ev := range wresp.Events {
                        fmt.Printf("%s %q : %q\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
                }
        }
}

能夠看到,Etcd的watch channel是能夠重複利用的,客戶端能夠不停地從channel中接收到來自服務端的事件通知。
運行prefix.go,客戶端就會一直阻塞在channel上等待事件通知:

$ go run prefix.go

在另外一個console下面,咱們能夠用docker鏡像中提供的Etcd的客戶端etcdctl來進行一些PUT和DELETE操做

$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl put foo 1"
OK
$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl put foo2 2"
OK
$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl put foo/1 a"
OK
$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl put foo/2 b"
OK
$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl del foo"
1
$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl del foo/1"
1
$ docker exec etcd-gcr-v3.3.12 /bin/sh -c "ETCDCTL_API=3 /usr/local/bin/etcdctl del foo/2"
1

與之對應的prefix.go輸出是:

$ go run prefix.go
PUT "foo" : "1"
PUT "foo2" : "2"
PUT "foo/1" : "a"
PUT "foo/2" : "b"
DELETE "foo" : ""
DELETE "foo/1" : ""
DELETE "foo/2" : ""

能夠看到,Etcd的PUT語義覆蓋了ZooKeeper的create語義和set語義。同時,prefix watch不只能夠watch節點自身的PUT和DELETE,也能夠watch其全部的子孫節點的PUT和DELETE。

ZooKeeper和Etcd的watch基本功能就介紹到這裏,接下來,咱們要談談watch機制一個相當重要的問題:

事件發生的太快來不及watch怎麼辦

一般咱們使用watch功能是爲了讓程序阻塞等待某些事件的發生並進行相應的處理,然而現實世界中處理的速度有可能跟不上事件發生的速度。
好比ZooKeeper的watch在捕捉到一個事件後channel就會關閉,須要咱們再次去發送watch請求。在此期間發生的事件將丟失,下文引用自ZooKeeper官網文檔原文:

Because watches are one time triggers and there is latency between getting the event and sending a new request to get a watch you cannot reliably see every change that happens to a node in ZooKeeper. Be prepared to handle the case where the znode changes multiple times between getting the event and setting the watch again. (You may not care, but at least realize it may happen.)

Etcd解決這個問題的方法是在API的請求和響應中添加了一個版本號,客戶端能夠在watch請求中指定版本號來獲取自該版本號以來發生的全部變化,見prefix_with_rev.go的示例:

package main

import (
        "context"
        "fmt"
        "log"
        "os"
        "strconv"
        "time"

        "go.etcd.io/etcd/clientv3"
)

func main() {
        cli, err := clientv3.New(clientv3.Config{
                Endpoints:   []string{"127.0.0.1:2379"},
                DialTimeout: 5 * time.Second,
        })
        if err != nil {
                log.Fatal(err)
        }
        defer cli.Close()

        rev := 0
        if len(os.Args) > 1 {
                rev, err = strconv.Atoi(os.Args[1])
                if err != nil {
                        log.Fatal(err)
                }
        }
        rch := cli.Watch(context.Background(), "foo", clientv3.WithPrefix(), clientv3.WithRev(int64(rev)))
        for wresp := range rch {
                for _, ev := range wresp.Events {
                        fmt.Printf("%s %q : %q, %d\n", ev.Type, ev.Kv.Key, ev.Kv.Value, ev.Kv.ModRevision)
                }
        }
}

注意和prefix.go相比,這裏在調用Watch方法時額外提供了一個clientv3.WithRev(int64(rev))的參數用來指定版本號,rev=0意味着不指定。同時,咱們還會打印出捕捉到的事件中發生的改變的版本號ev.Kv.ModRevision。

如今咱們指定版本號1運行prefix_with_rev.go,程序當即打印出ModRevision大於等於1的全部變化,並繼續阻塞等待新的事件:

$ go run prefix_with_rev.go 1
PUT "foo" : "bar", 2
PUT "foo" : "1", 3
PUT "foo/1" : "1", 4
PUT "foo/1" : "1", 5
PUT "foo" : "1", 6
PUT "foo" : "2", 7
PUT "foo/2" : "2", 8
DELETE "foo/2" : "", 9
PUT "foo" : "1", 10
PUT "foo2" : "2", 11
PUT "foo/1" : "a", 12
PUT "foo/2" : "b", 13
DELETE "foo" : "", 14
DELETE "foo/1" : "", 15
DELETE "foo/2" : "", 16
PUT "foo" : "a", 17
PUT "foo" : "a", 18
PUT "foo" : "a", 19
PUT "foo" : "a", 20
PUT "foo" : "a", 21
PUT "foo" : "a", 22
PUT "foo" : "a", 23

注意ModRevision等於1的事件並無出如今結果中,這是由於該事件的Key不知足prefix=foo條件。

總結

不得不認可,做爲後起之秀,Etcd在watch方面完勝ZooKeeper。

從功能的角度來看,Etcd只須要調用一次watch操做就能夠捕捉全部的事件,相比ZooKeeper大大簡化了客戶端開發者的工做量。
ZooKeeper的watch得到的channel只能使用一次,而Etcd的watch得到的channel能夠被複用,新的事件通知會被不斷推送進來,而無需客戶端重複進行watch,這種行爲也更符合咱們對go channel的預期。

ZooKeeper對事件丟失的問題沒有解決辦法。Etcd則提供了版本號幫助客戶端儘可能捕捉每一次變化。要注意的是每一次變化都會產生一個新的版本號,而這些版本不會被永久保留。Etcd會根據其版本留存策略定時將超出閾值的舊版本從版本歷史中清除。

從開發者的角度來看,ZooKeeper是用Java寫的,且使用了本身的TCP協議。對於程序員來講不太友好,若是離開了ZooKeeper提供的SDK本身寫客戶端會有必定的技術壁壘,而ZooKeeper官方只提供了Java和C語言的SDK,其它語言的開發者就只能去尋求第三方庫的幫助,好比github.com/samuel/go-zookeeper/zk。

另外一方面,Etcd是用Go寫的,使用了Google的gRPC協議,官方除了提供Go語言的SDK以外,也提供了Java的SDK:https://github.com/etcd-io/jetcd
另外Etcd官方還維護了一個zetcd項目:https://github.com/etcd-io/zetcd,它在Etcd外面套了一個ZooKeeper的殼。讓那些ZooKeeper的客戶端能夠無縫移植到Etcd上。有興趣的小夥伴能夠嘗試一下

相關文章
相關標籤/搜索