clickhouse集群zookeeper平滑搬迁实践
更多精彩内容,请关注微信公众号:后端技术小屋
〇、背景
注:为简化表述,本文中将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集群从香港到新加坡的平滑迁移。