更多精彩內容,請關注微信公眾號:后端技術小屋
〇、背景
注:為簡化表述,本文中將clickhouse簡稱為ck, 將zookeeper簡稱為zk。
我司從去年年底開始啟動從香港到新加坡機房的遷移。目前Clickhouse集群所有實例都已經搬遷從香港搬遷到了新加坡機房,還剩下其依賴的Zookeeper集群在香港機房,因此我們近期准備將Zookeeper集群平滑搬遷到香港機房。
0.1 目標與挑戰
0.1.1 zk跨洲搬遷需對用戶基本無感知
ck集群發展到現在已經承載了整個公司的實時數據分析需求,還支持了許多在線服務。這要求ck集群不能夠停機,在任何時候都是可用的。ck集群每時每刻都在執行數據插入和查詢、表變更,而ck在架構設計上重度依賴zk做元數據存儲、副本同步和表變更,zk一旦不可用,ck的讀寫都會受到影響。zk集群的遷移中間必然引起leader的切換,如何在zk集群搬遷的過程中保證讀寫,這是一個不小的挑戰。
0.1.2 熱升級+動態配置更新
為了實現上面的目標,我們在遷移的過程中,一方面要從寫入層做好重試,避免zk切主過程中的失敗。同時,也要盡可能的縮短zk不可用的時間。對zk的操作都要采用熱升級的方式,滾動操作,同時因為zk的集群ip都換了,必然要更改很多配置,所有的配置也盡量采用reload的方式,而不是重啟服務。
一、整體方案
1.1 第一步:zk從靜態配置版本升級到動態配置版本
zk 3.5.0之后支持動態配置特性。利用動態配置特性可方便進行擴容和縮容操作,而不需要對整個zk集群中的所有實例進行滾動重啟。但是不巧的是,ck用的zk集群還沒有使用動態配置,因此zk集群搬遷的第一步就是將zk集群從靜態配置版本平滑升級到動態配置版本,簡化后續的擴容和縮容操作。zk動態配置版本詳情可參考:https://backendhouse.github.io/post/zookeeper_dynamic_config/
1.2 第二步:zk擴縮容實現搬遷
第二步,在ck集群升級到動態配置版本之后,通過擴容和縮容操作實現zk集群從香港老機器到新加坡新機器的平滑搬遷:
- 擴容:將新加坡機房的新機器一台一台加入到zk集群中
- ck實例修改zk配置:將zk配置全部從老機器換成新機器
- 縮容:將香港機房的老機器一台一台從zk集群中摘除。
二、遇到的問題和解決方案
為了保證線上zk搬遷過程中不出問題,我們事前進行充分的影響面預估和線下演練,在這個過程中發現了以下問題:
2.1 zk靜態配置版本與動態配置版本不兼容
在1.1中,首先將zk中的Follower實例從靜態配置升級到動態配置版本時,發現升級中的Follower實例報錯:
Follower報錯日志如下:
2021-02-25 11:07:03,081 [myid:5] - WARN [QuorumPeer[myid=5](plain=/0:0:0:0:0:0:0:0:2185)(secure=disabled):Follower@96] - Exception when following the leader
java.io.EOFException
at java.io.DataInputStream.readInt(DataInputStream.java:392)
at org.apache.jute.BinaryInputArchive.readInt(BinaryInputArchive.java:63)
at org.apache.zookeeper.server.quorum.QuorumPacket.deserialize(QuorumPacket.java:85)
at org.apache.jute.BinaryInputArchive.readRecord(BinaryInputArchive.java:99)
at org.apache.zookeeper.server.quorum.Learner.readPacket(Learner.java:158)
at org.apache.zookeeper.server.quorum.Learner.registerWithLeader(Learner.java:336)
at org.apache.zookeeper.server.quorum.Follower.followLeader(Follower.java:78)
at org.apache.zookeeper.server.quorum.QuorumPeer.run(QuorumPeer.java:1271)
2021-02-25 11:07:03,081 [myid:5] - INFO [QuorumPeer[myid=5](plain=/0:0:0:0:0:0:0:0:2185)(secure=disabled):Follower@201] - shutdown called
java.lang.Exception: shutdown Follower
at org.apache.zookeeper.server.quorum.Follower.shutdown(Follower.java:201)
at org.apache.zookeeper.server.quorum.QuorumPeer.run(QuorumPeer.java:1275)
同時,尚未升級的Leader實例報錯如下:
2021-02-26 19:35:08,065 [myid:6] - WARN [LearnerHandler-/xx.xx.xx.xx:52906:LearnerHandler@644] - ******* GOODBYE /xx.xx.xx.xx:52906 ********
2021-02-26 19:35:08,066 [myid:6] - INFO [WorkerReceiver[myid=6]:FastLeaderElection$Messenger$WorkerReceiver@285] - 6 Received version: b00000000 my version: 0
2021-02-26 19:35:08,066 [myid:6] - INFO [WorkerReceiver[myid=6]:FastLeaderElection@679] - Notification: 2 (message format version), 5 (n.leader), 0xb0000000c (n.zxid), 0x2 (n.round), LOOKING (n.state), 5 (n.sid), 0xb (n.peerEPoch), LEADING (my state)b00000000 (n.config version)
2021-02-26 19:35:08,067 [myid:6] - ERROR [LearnerHandler-/xx.xx.xx.xx:52908:LearnerHandler@629] - Unexpected exception causing shutdown while sock still open
java.io.IOException: Follower is ahead of the leader (has a later activated configuration)
at org.apache.zookeeper.server.quorum.LearnerHandler.run(LearnerHandler.java:398)
經過定位,發現問題出在靜態版本的Leader實例與動態版本的Follower實例不可共存。在串行升級zk的過程中,為了盡量減少zk集群不可用時間,我們先升級完所有Follower, 最后再升級Leader。 當一個Follower實例從靜態配置版本升級到動態配置版本之后,此時Leader還處於靜態配置版本,其config version是0; 而Follower此時處於動態版本,config version大於0。
Follower啟動之后會請求Leader,請求數據中帶上config version. Leader收到請求之后會對Follower的config version做校驗,如果發現對方config version大於自己,便拋異常(Follower is ahead of the leader)並主動關閉連接。
if (learnerInfoData.length >= 20) {
long configVersion = bbsid.getLong();
if (configVersion > leader.self.getQuorumVerifier().getVersion()) {
throw new IOException("Follower is ahead of the leader (has a later activated configuration)");
}
}
而Follower讀到EOF之后也會拋異常(EOFException),並不斷重試。
擺在眼前的解決方案有兩種:
- 方案一:串行升級改成並行升級,避免靜態版本與動態版本的實例同時存在。
- 方案二:由於靜態版本和動態版本同時存在的時間很短,zk增加一個臨時版本,該版本中去掉Leader對Follower的config version檢查,繞過上述問題
考慮到zk集群中數據較多,zk實例啟動時間較長,並行升級會導致zk集群在2-4min內不可用。一旦出現問題發生回滾,zk集群不可用時間還會翻倍,風險較大。於是我們摒棄方案一,選擇方案二:先將靜態版本串行升級到去掉config version檢查的靜態版本,再升級到動態版本。
2.2 ck無法動態加載zk配置
在線下演練過程中發現,在1.2中,當修改clickhouse配置文件中的zk server列表后,配置變更並不會被ck動態加載。而clickhouse上的應用已經如此之多,其中不乏一些2B業務或對實時性要求非常強的業務,通過重啟ck集群去加載新的zk server列表顯然不可接受。
最終的解決方案是增加clickhouse對zk配置動態加載的支持,從而避免重啟ck集群影響用戶。目前這一優化已經合並到社區,PR: https://github.com/ClickHouse/ClickHouse/pull/14678
2.3 zk實例重啟導致少量ck查詢失敗
ck查詢一般不涉及zk交互,因此zk搬遷大部分情況下不影響ck查詢。但是在線下演練過程中發現,當clickhouse開啟了開關optimize_trivial_count_query
之后, 對應PR: https://github.com/ClickHouse/ClickHouse/pull/7510,執行一些簡單的select count
查詢會被zk搬遷所影響。
追查代碼發現,optimize_trivial_count_query
開啟后,對於簡單的select count
查詢,ck會跳過常規的查詢過程,轉而從metadata中獲取總行數(在此過程中會訪問zk),以此來提高select count
的查詢速度。因此,在zk集群搬遷之前,我們將clickhouse集群中的開關optimize_trivial_count_query
設置為0,待zk搬遷完成之后再將其開啟。
2.4 zk實例重啟導致寫入ck失敗
在向ck寫入數據時,ck依賴zk集群分配blockid,並將數據從當前副本同步到配偶副本。因此zk搬遷過程中必定會影響ck寫入。而我們要做的便是將影響寫入的時間段盡量縮短;同時一旦發現寫入失敗針對同一個分片下的副本不斷重試,保證zk集群恢復時,ck寫入也能自動恢復。
目前有Flink、Spark和clickhouse_sinker三個入口往ck寫數據,在zk搬遷之前我們需要提前確保它們寫ck的過程已經有了重試機制。
2.5 zk實例重啟導致ck表變更失敗
表變更操作包括創建/刪除表、新增/刪除/修改字段。ck集群在執行表變更時必然會訪問ck集群,因此zk搬遷過程中會影響ck集群中的表變更。因為ck表變更相對於ck寫入與查詢來說頻率較低。因此在zk搬遷過程中,我們對ck平台進行功能降級,即此時不支持ck表變更操作,以此避免zk實例重啟導致ck表變更失敗。
三、最終的搬遷方案
3.0 初始狀態
假設香港機器:A1, A2, A3, A4, A5,新加坡機器:B1, B2, B3, B4, B5
初始狀態下,zk集群部署於香港機房,初始版本為靜態配置版本。
我們的目標是將zk集群搬遷到新機房,最終版本為動態配置版本。
3.1 升級到動態配置版本
版本升級路線:靜態版本 -> (不帶config version檢查的)靜態版本 -> 動態版本
3.1.1 靜態版本 -> 不帶(config version檢查的)靜態版本
串行升級,先升級Follower, 最后升級Leader。每升級完一台機器,檢查集群中Leader/Follower狀態,檢查ck查詢和寫入是否有異常。等待zk實例完成啟動后,接着再升級下一個zk實例。
預期影響:
- 升級Follower實例時,ck到該zk實例上的連接被斷開,部分ck寫入可能會報
zookeeper session expired
錯誤,ck重連其他zk實例后恢復正常,恢復時間不超過40s。 - 升級Leader實例時,zk會進入選舉周期並會產生新的Leader, ck寫入會報
table in readonly mode
錯誤,新Leader產生之后ck寫入恢復正常,恢復時間不超過3min.
回滾方案:直接並行回滾到初始的靜態版本
3.1.2 不帶(config version檢查的)靜態版本 -> 動態版本
升級步驟、預期影響面、回滾方案同3.1.1
3.2 動態擴縮容
3.2.1 擴容:將新加坡新機器加入到zk集群中
串行擴容步驟:
- 在新機器B1上部署zk實例, 其配置中包含A1-A5和B1。通過
reconfig -add 6=B1:2888:3888;2181
將B1加入到集群中。接着檢查當前所有zk實例的本地配置是否已更新,檢查Leader/Follower狀態,檢查ck讀寫是否有異常,確認無問題后擴容下一台機器 - 在新機器B2上部署zk實例, 其配置中包含A1-A5和B1-B2。通過
reconfig -add 7=B2:2888:3888;2181
將B2加入到集群中。檢查步驟同上 - ...
- 重復執行以上步驟,直到所有新機器都已經加入到zk集群中。
預期影響:無
回滾步驟:在串行擴容過程中,如果有任何一步出現異常,則將新實例通過reconfig -remove <id>
命令從集群中摘掉。
3.2.2 修改ck配置:將zk配置改成新加坡新機器
修改ck配置,將zookeeper-servers
從舊機器A1-A5改成新機器B1-B5,並下發到所有ck實例。netstat
命令檢查ck是否與新zk實例建立連接。
<zookeeper-servers>
<node index="0">
<host>A1</host>
<port>2181</port>
</node>
<node index="1">
<host>A2</host>
<port>2181</port>
</node>
<node index="2">
<host>A3</host>
<port>2181</port>
</node>
<node index="3">
<host>A4</host>
<port>2181</port>
</node>
<node index="4">
<host>A5</host>
<port>2181</port>
</node>
</zookeeper-servers>
預期影響:無
回滾:一旦檢查過程中發現異常,將ck配置回滾並重新下發。
3.2.3 縮容:將香港老機器從zk集群中摘掉
串行縮容過程中,應當遵循先縮容Follower實例,最后縮容Leader實例的順序,具體步驟如下:
- 縮容A1: 通過
reconfig -remove 1=A1:2888:3888;2181
命令,將老機器A1從集群中摘除。接着檢查當前所有zk實例的本地配置是否自動更新,檢查Leader/Follower狀態,檢查ck讀寫是否出現異常。確認無問題后,將老機器A1上的zk實例下線。 - 縮容A2, 操作同上
- ...
- 重復執行以上操作,直到所有香港老機器都已經從zk集群中摘除。
預期影響:同3.1.1
回滾:在串行縮容過程中,如果有任何一步出現異常,通過reconfig -add <id>=<ip>:2888:3888;2181
命令將待下線實例重新加入zk集群中。
四、總結
通過線下環境中充分的zk搬遷演練,我們得以及時發現zk搬遷中出現的各種問題,並一一加以解決。最終在ck用戶基本無感知的情況下,完成了zk集群從香港到新加坡的平滑遷移。