1. 程式人生 > >Etcd和ZooKeeper,究竟誰在watch的功能表現更好?

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

子節點 EDA 通知 code ole 基本功 沒有 解決 api文檔

技術分享圖片

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

本文主要關註這兩者在watch上的功能差異。ZooKeeper和Etcd都可以對某個key進行watch,並在當這個key發生改變(比如有更新值,或刪除key的操作發生)時觸發。

ZooKeeper的watch

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

但是光看文檔不足以對watch功能有一個具體的感受。所以接下來就讓我們安裝並運行一個ZooKeeper服務端,實際體驗一下。

ZooKeeper下載安裝和啟動

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

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

其conf目錄中是配置文件,我們需要將zoo_sample.cfg復制為zoo.cfg
然後執行bin目錄下的zkServer.sh啟動ZooKeeper服務:

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

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

用Go語言寫的ZooKeeper的watch示例

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

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

watch children

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

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上。有興趣的小夥伴可以嘗試一下。

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