導讀:etcd 做爲 K8s 集羣中的存儲組件,讀寫性能方面會受到不少壓力,而 etcd 3.4 中的新特性將有效緩解壓力,本文將從 etcd 數據讀寫機制的發展歷史着手,深刻解讀 etcd 3.4 新特性。
背景node
etcd 是 Kubernetes 集羣中存儲元數據,保證分佈式一致性的組件,它的性能每每影響着整個集羣的響應時間。而在 K8s 的使用中,咱們發現除了平常的讀寫壓力外,還存在某些特殊的場景會對 etcd 形成巨大的壓力,好比 K8s 下 apiserver 組件重啓或是其餘組件繞過 apiserver cache 直接查詢 etcd 最新數據的狀況時,etcd 會收到大量的 expensive read(後文會介紹該概念)請求,這對 etcd 讀寫會形成巨大的壓力。更爲嚴重的是,若是客戶端中存在失敗重試邏輯或客戶端數目較多,會產生大量這樣的請求,嚴重狀況可能形成 etcd crash。算法
etcd 3.4 中增長了一個名爲「Fully Concurrent Read」的特性,較大程度上解決了上述的問題。在這篇文章中咱們將重點解讀它。本篇文章首先回顧 etcd 數據讀寫機制發展的歷史,以後剖析爲什麼這個特性能大幅提高 expensive read 場景下 etcd 的讀寫性能,最後經過真實實驗驗證該特性的效果。api
etcd 讀寫發展歷史緩存
etcd v3.0 及以前早期版本網絡
etcd 利用 Raft 算法實現了數據強一致性,它保證了讀操做的線性一致性。在 raft 算法中,寫操做成功僅僅覺得着寫操做被 commit 到日誌上,並不能確保當前全局的狀態機已經 apply 了該寫日誌。而狀態機 apply 日誌的過程相對於 commit 操做是異步的,所以在 commit 後當即讀取狀態機可能會讀到過時數據。併發
爲了保證線性一致性讀,早期的 etcd(etcd v3.0 )對全部的讀寫請求都會走一遍 Raft 協議來知足強一致性。然而一般在現實使用中,讀請求佔了 etcd 全部請求中的絕大部分,若是每次讀請求都要走一遍 raft 協議落盤,etcd 性能將很是差。mvc
etcd v3.1app
所以在 etcd v3.1 版本中優化了讀請求(PR#6275),使用的方法知足一個簡單的策略:每次讀操做時記錄此時集羣的 commit index,當狀態機的 apply index 大於或者等於 commit index 時便可返回數據。因爲此時狀態機已經把讀請求所要讀的 commit index 對應的日誌進行了 apply 操做,符合線性一致讀的要求,即可返回此時讀到的結果。異步
根據 Raft 論文 6.4 章的內容,etcd 經過 ReadIndex 優化讀取的操做核心爲如下兩個指導原則:分佈式
讓 Leader 處理 ReadIndex 請求,Leader 獲取的 commit index 即爲狀態機的 read index,follower 收到 ReadIndex 請求時須要將請求 forward 給 Leader;
保證 Leader 仍然是目前的 Leader,防止由於網絡分區緣由,Leader 已經再也不是當前的 Leader,須要 Leader 廣播向 quorum 進行確認。
ReadIndex 同時也容許了集羣的每一個 member 響應讀請求。當 member 利用 ReadIndex 方法確保了當前所讀的 key 的操做日誌已經被 apply 後,即可返回客戶端讀取的值。對 etcd ReadIndex 的實現,目前已有相對較多的文章介紹,本文再也不贅述。
etcd v3.2
即使 etcd v3.1 中經過 ReadIndex 方法優化了讀請求的響應時間,容許每一個 member 響應讀請求,但當咱們把視角繼續下移到底層 k/v 存儲 boltdb 層,每一個獨立的 member 在獲取 ReadIndex 後的讀取任然存在性能問題。
v3.1 中利用 batch 來提升寫事務的吞吐量,全部的寫請求會按固定週期 commit 到 boltDB。當上層向底層 boltdb 層發起讀寫事務時,都會申請一個事務鎖(如如下代碼片斷),該事務鎖的粒度較粗,全部的讀寫都將受限。對於較小的讀事務,該鎖僅僅下降了事務的吞吐量,而對於相對較大的讀事務(後文會有詳細解釋),則可能阻塞讀、寫,甚至 member 心跳都有可能出現超時。
// release-3.2: mvcc/kvstore.go
func (s *store) TxnBegin() int64 {
... s.tx = s.b.BatchTx() // boltDB 事務鎖,全部的讀寫事務都須要申請該鎖 s.tx.Lock() ...
}
針對以上提到的性能瓶頸,_etcd v3.2_ 版本中對 boltdb 層讀寫進行優化,包含如下兩個核心點:
實現「N reads 或 1 write」的並行,將上文提到的粗粒度鎖細化成一個讀寫鎖,全部讀請求間相互並行;
利用 buffer 來提升了吞吐量。3.2 中對 readTx,batchTx 分別增長了一個 buffer,全部讀事務優先從 buffer 進行讀取,未命中再經過事務訪問 boltDB。一樣,寫事務在寫 boltDB 的同時,也會向 batchTx 的 buffer 寫入數據,而 batch commit 結束時,batchTx 的 buffer 會 writeBack 回 readTx 的 buffer 防止髒讀。
// release-3.3: mvcc/kvstore_txn.go
func (s *store) Read() TxnRead {
tx := s.b.ReadTx() // 獲取讀事務的 RLock 後進行讀操做 tx.RLock()
}
// release-3.3: mvcc/backend/batch_tx.go
func (t *batchTxBuffered) commit(stop bool) {
// 獲取讀事務的 Lock 以確保 commit 以前全部的讀事務都已經被關閉 t.backend.readTx.Lock() t.unsafeCommit(stop) t.backend.readTx.Unlock()
}
徹底併發讀
etcd v3.2 的讀寫優化解決了大部分讀寫場景的性能瓶頸,但咱們再從客戶端的角度出發,回到文章開頭咱們提到的這種場景,仍然有致使 etcd 讀寫性能降低的危險。
這裏咱們先引入一個 expensive read 的概念,在 etcd 中,全部客戶端的讀請求最後都是轉化爲 range 的請求向 KV 層進行查詢,咱們以一次 range 請求的 key 數量以及 value size 來衡量一次 read 請求的壓力大小。綜合而言,當 range 請求的 key 數量越多,平均每一個 key 對應的 value size 越大,則該 range 請求對 DB 層的壓力就越大。而實際劃分 expensive read 和 cheap read 邊界視 etcd 集羣硬件能力而定。
從客戶端角度,在大型集羣中的 apiserver 進行一次 pod、node、pvc 等 resource 的全量查詢,能夠視爲一次 expensive read。簡要分析下爲什麼 expensive read 會對 boltDB 帶來壓力。上文提到,爲了防止髒讀,須要保證每次 commit 時沒有讀事務進行,所以寫事務每次 commit 以前,須要將當前全部讀事務進行回滾,因此 commit interval 時間點上須要申請 readTx.lock ,會將該鎖從 RLock() 升級成 Lock() ,該讀寫鎖的升級會可能致使全部讀操做的阻塞。
以下圖(如下圖中,藍色條爲讀事務,綠色條爲寫事務,紅色條爲事務因鎖問題阻塞),t1 時間點會觸發 commit,然而有事務未結束,T5 commit 事務因申請鎖被阻塞到 t2 時間點才進行。理想狀態下大量的寫事務會在一個 batch 中結束,這樣每次 commit 的寫事務僅僅阻塞少部分的讀事務(如圖中僅僅阻塞了 T6 這個事務)。
然而此時若是 etcd 中有很是大的讀請求,那麼該讀寫鎖的升級將被頻繁阻塞。以下圖,T3 是一個很是長的讀事務,跨過了多個 commit batch。每一個 commit batch 結束時間點照常觸發了 commit 的寫事務,然而因爲讀寫鎖沒法升級,寫事務 T4 被推遲,一樣 t2 commit 點的寫事務 T7 由於申請不到寫鎖同樣也被推遲。
此外,在寫事務的 commit 進行了以後,須要將寫緩存裏的 bucket 信息寫入到讀緩存中,此時一樣須要升級 readTx.lock 到 Lock() 。而上層調用 backend.Read() 獲取 readTx 時,須要確保這些 bucket 緩存已經成功寫過來了,須要申請讀鎖 readTx.RLock() ,而若是這期間存在寫事務,該鎖則沒法獲得,這些讀事務都沒法開始。如上的情形下,在第三個 batch(t2-t3)中其餘讀事務由於得不到讀鎖都沒法進行了。
總結而言,因 expensive read 形成讀寫鎖頻繁升級,致使寫事務的 commit 不斷被後移(一般咱們將這種問題叫作 head-of-line blocking),從而致使 etcd 讀寫性能雪崩。
etcd v3.4 中,增長了一個 「Fully Concurrent Read」 的 feature,核心指導思想是以下兩點:
將上述讀寫鎖去除(事實上是對該鎖再次進行細化),使得全部讀和寫操做再也不因該鎖而頻繁阻塞;
每一個 batch interval 再也不 reset 讀事務 readTxn ,而是建立一個新的 concurrentReadTxn 實例去服務新的讀請求,而原來的 readTxn 在全部事務結束後會被關閉。每一個 concurrentReadTxn 實例擁有一片本身的 buffer 緩存。
除了以上兩點變更外,fully concurrent read 在建立新的 ConcurrentReadTx 實例時須要從 ReadTx copy 對應的 buffer map,會存在必定的額外開銷,社區也在考慮將這個 copy buffer 的操做 lazy 化,在每一個寫事務結束後或者每一個 batch interval 結束點進行。然而在咱們的實驗中發現,該 copy 帶來的影響並不大。改動的核心代碼如如下片斷所示:
// release-3.4: mvcc/backend/read_tx.go
type concurrentReadTx struct {
// 每一個 concurrentReadTx 實例保留一份 buffer,在建立時從 readTx 的 buffer 中得到一份 copy buf txReadBuffer ...
}
// release-3.4: mvcc/backend/backend.go
func (b *backend) ConcurrentReadTx() ReadTx {
// 因爲須要從 readTx 拷貝 buffer,建立 concurrentReadTx 時須要對常駐的 readTx 上讀鎖。 b.readTx.RLock() defer b.readTx.RUnlock() ...
}
// release-3.4: mvcc/backend/read_tx.go
// concurrentReadTx 的 RLock 中不作任何操做,再也不阻塞讀事務
func (rt *concurrentReadTx) RLock() {}
// release-3.4: mvcc/kvstore_tx.go
func (s *store) Read() TxnRead {
// 調用 Read 接口時,返回 concurrentReadTx 而不是 readTx tx := s.b.ConcurrentReadTx() // concurrentReadTx 的 RLock 中不作任何操做 tx.RLock()
}
咱們再回到上文提到的存在 expensive read 的場景。在 fully concurrent read 的改動以後,讀寫場景以下圖所示。
首先在 mvcc 建立 backend 時會建立一個常駐的 readTx 實例,和以後的寫事務 batchTx 存在鎖衝突的也僅僅只有這一個實例。以後的全部讀請求(例如 T1,T2,T3 等),會建立一個新的 concurrentReadTx 實例進行服務,同時須要從 readTx 拷貝 buffer;當出現 expensive read 事務 T3 時,T4 再也不被阻塞並正常執行。同時 T5 須要等待 T4 commit 完成後, readTx 的 buffer 被更新後,再進行 buffer 拷貝,所以阻塞一小段時間。而 t二、t3 commit 時間點的寫事務 T七、T8 也由於沒有被阻塞而順利進行。
在 fully concurrent read 的讀寫模式下, concurrentReadTx 僅在建立時可能存在阻塞(由於依賴從 readTx 進行 buffer 拷貝的操做),一旦建立後則再也不有阻塞的狀況,所以整個流程中讀寫吞吐量有較大的提高。
讀寫性能驗證明驗
針對 etcd v3.4 fully concurrent read 的新 feature,咱們在集羣中進行了實驗對比增長該 feature 先後讀寫性能的變化。爲了排除網絡因素干擾,咱們作了單節點 etcd 的測試,可是已經足以從結果上看出該 feature 的優點。如下是驗證明驗的設置:
讀寫設置
模擬集羣已有存儲量,預先寫入 100k KVs,每一個 KV 由一個128B key和 一個 1~32KB 隨機的 values 組成(平均 16KB)
expensive read:每次 range 20k keys,每秒 1 併發。
cheap read:每次 range 10 keys,每秒 100 併發。
write:每次 put 1 key,每秒 20 併發。
對照組
普通讀寫場景:cheap read + write;
模擬存在較重的讀事務的場景:cheap read + expensive read + write。
對比版本:
etcd - ali2019rc2 未加入該優化
etcd - ali2019rc3 加入該優化
防止偶然性:每組 test case 跑 5 次,取 99 分位(p99)的響應時間的平均值做爲該組 test case 的結果。
實驗結果以下表所示。對於普通讀寫場景,3.4 中的讀寫性能和 3.3 近似;對於存在較重的讀事務的場景,3.4 中的 fully concurrent read feature 必定程度下降了 expensive read 的響應時間。而在該場景下的 cheap read 和 write,rc2 中因讀寫鎖致使讀寫速度很是緩慢,而 rc3 中實現的徹底並行使得讀寫響應時間減小到約爲原來的 1/7。
| etcd
version | cheap
read + write | | expensive
read + cheap read + writep99 read (ms)p99 write (ms)read (ms)p99 cheap read (ms)p99 write (ms)3.314.115.13.4 (with FCR)16.114.2
其餘場景下,如在 Kuberentes 5000節點性能測試,也代表在大規模讀壓力下,P99 寫的延時下降 97.4%。
總結
etcd fully concurrent read 的新 feature 優化 expensive 下降了近 85% 的寫響應延遲以及近 80% 的讀響應延遲,同時提升了 etcd 的讀寫吞吐量,解決了在讀大壓力場景下致使的 etcd 性能驟降的問題。調研和實驗的過程當中感謝宇慕的指導,目前咱們已經緊跟社區應用了該新能力,通過長時間測試表現穩定。將來咱們也會不斷優化 etcd 的性能和穩定性,並將優化以及最佳實踐經驗反饋回社區。
本文做者:陳潔(墨封) 阿里雲開發工程師
原文連接:https://yq.aliyun.com/article...本文爲雲棲社區原創內容,未經容許不得轉載。