在分布式系统里使用分布式锁来保证 Schedule Job 的线程安全是很常见的问题,为了保证同一时刻有且只有一个服务在运行该 Job。
我有一个 通过 spring Schedule 来调度的 Job,执行频率是 1 小时一次,使用 zookeeper 做的分布式锁,服务部署了三台,但今天遇到了一个诡异的问题,这个 Job 从昨天晚上 9点 到今天上午11点 一次都没有执行。看日志发现两台服务因为没有拿到锁而跳过,而拿到锁的机器一直处于假死状态。
01 技术背景
有三台 SpringMvc 的web服务,机器分别是 meta01、meta02、meta03,借助 apache curator 实现的 zookeeper 分布式锁,zookeeper 是三节点集群也部署在meta01、meta02、meta03。
服务启动时通过 afterPropertiesSet 向 zookeeper 注册临时顺序节点,头节点则拿到锁。服务 stop 时通过 destroy 放弃锁并停止与 zookeeper 的心跳和监听。
即:在服务启动时就决定了谁持有这把 zookeeper 锁,并一直持有,除非断开心跳。
02. 问题表象
一小时执行一次的 Job 从昨天晚上 9点 到今天上午11点 一次都没有执行。
从日志上看 meta01、meta02 当天正常去调度但不是 zookeeper 头节点,meta03 调度日志停留在昨天,然后这台机器上的服务进程确实还在,查到这里我的内心不由得很兴奋,因为前段时间出现的妖怪又出现了。
到此得出第一步结论:leader 节点假死,但是在 zookeeper 上的临时顺序节点并没有删除而造成锁未释放。
为了快速解决问题,我手动把 meta03 的锁节点删除了。
03. 问题排查
节点假死的原因是什么,是不是因为 FGC?我立刻查看了 heap 占用和 GC 情况,但发现这些都很正常。并且内存、CPU使用都是比较低的。
日志没有异常、内存、CPU负载低,GC正常,基本可以断定问题出在外部。
于是找到平台的人一起来排查,果然在监控平台上看到 meta03 这台机器这台是异常的。可以看到服务指标出现了断层,这台机器上的所有服务都死了。
查看各组件日志,发现了异常日志,显示系统时间被修改了,这时我才意识到 QA 为了测试是将系统时间改到前一天,当把系统时间改正确之后系统恢复正常。
但是为什么修改了系统时间会造成所有服务假死,zookeeper 的心跳不在了为什么节点没有删除?这是 zookeeper 或者 apache curator 的 bug 吗?一台服务不可用,其他服务拿不到锁就执行不了任务,这是我们需要探究的问题。
读时钟失败,并且 session timeout
此时 meta03 还是 leader
改回系统时间之后恢复正常,有了最新日志
得出第二步结论:假死原因是修改系统时间,造成集群时间不同步。
此时作出进一步猜想:按 zk 心跳机制,meta03 无法向 server 集群发出心跳,此时该 zk 锁的临时顺序节点应该被删除。但不巧的是,假死的 meta03 也是集群的 leader,写操作又由 leader 负责,所以节点无法删除。此时 meta01、meta02 也无法跟leader 通信,应该发起了选主流程,但是都是选自己,所以一直没有产生新的 leader,就这样一直尴尬下去,一直假死下去,等待 meta03 恢复正常。
04. 对猜想的分析
▍探究 zookeeper 心跳机制
前段时间看过 zookeeper 的源码,不得不说东西太多了,只看了一点皮毛。在前面的文章《 给 gRPC 写服务发现》介绍过 zookeeper的基本原理,感兴趣的可以看一下,这次重点看了心跳机制。
谈心跳机制之前先介绍一下 zookeeper 的启动流程。在其源码里有 mainClasses 这么一个文件,里面写着
也就是说 ZooKeeperMain 是其 Client 的入口,QuorumPeerMain 是 server 的启动类。ps 可以看到进程
Client 的功能包括向 server 发心跳、创建节点等,Server 端比如数据存储、服务端数据同步、选主。zoo.cfg 是服务启动配置项
在 ZooKeeperMain 类的 connectToZK 方法创建 ,ZooKeeperAdmin 类实例,在其构造方法里创建里 ClientCnxn 的实例,并调用 clientCnxn.start() 方法。
ClientCnxn 类创建了两个线程,这两个线程就是负责从 Client 向 Server 发送心跳包的。
其中,SendThread 负责将ZooKeeper的信息封装成一个Packet,发送给 Server ,并维持同 Server 的心跳,EventThread负责解析通过 SendThread 得到的Response,之后发送给Watcher::processEvent进行详细的事件处理。
1. SendThread
SendThread 是心跳线程,run 方法核心逻辑如下
- 建立和 Server 之间的 socket 链接
- 判断链接是否超时
- 定时发送心跳任务
- 将ZooKeeper指令发送给Server
▍心跳频率
代码注释翻译:1000(1秒)是为了防止竞争条件丢失而发送第二个ping,也请确保在readTimeout很小时不要发送太多ping。
以上面的 zoo.cfg 为例,sessionTimeout = 4000,readTimeout = 2666。getIdleSend() 是距离上次心跳发送的时间(now - lastSend),可以理解为心跳间隔毫秒数,得出频率 大约每 1333 毫秒一次。
▍心跳逻辑 sendPing
往 outgoingQueue 放入心跳包 Packet
▍Client 与 Server 长连接
clientCnxnSocket 是 Client 和 Server 建立的 Socket 长连接,而这个实例是上面的 getClientCnxnSocket() 创建的。源码如下,默认选择 NIO 方式(ClientCnxnSocketNIO 类)建立 Socket 连接。
在 ClientCnxnSocketNIO connect 方法中,Client 与 Server 建立类 Socket 连接。
▍session 超时
在SendThread::run中,可以看到针对链接是否建立分别有readTimeout和connetTimeout 两种超时时间,一旦发现链接超时,则抛出异常,终止 SendThread。上面提到来 readTimeout = 2666。
在没有超时的情况下,如果判断距离上次心跳时间超过了1/2个超时时间,会再次发送心跳数据,避免访问超时。
▍doTransport
通过 ClientCnxnSocketNIO 向 Server 发送指令
sendThread.primeConnection() 核心逻辑如下,可以看到在这里并没有真正向 Server 发送,而是 先放入 Queue 异步发送的。
SendThread run 方法消费 outgoinQueue 发送的心跳
2. EventThread
EventThread 线程逻辑就简单的多,就是处理 finishPacket 放到 waitingEvents 的事件。
在EventThread中通过processEvent对队列中的事件进行消费,并分发给不同的Watcher。
EventThread 线程并非本次问题的关键点,这里不再详细分析介绍。
结论:zookeeper 的心跳机制是从 ClientCnxn 的 SendThread 线程发出去的,既然系统假死,心跳肯定是没有了。而 meta03 的 EventThread 也因假死处理不了任何事件,所以就删除不了节点。
3. 细数 zookeeper 分布式锁缺陷:
- 加锁性能低
- 锁释放惊群效应
- 多主或无主。leader 出现 FGC 或者其他假死情况时,心跳暂停触发选主,若选举出新leader,但老leader依然认为自己是leader就出现多主(脑裂)。无主即为上文的为空。
以上问题基本都是因为其严格的 CP 设计,任何一种分布式锁都有优缺点,当我们选择一款产品的时候要明确的知道,并接受它的缺点,为它的缺点负责。
公众号:看起来很美(kanqilaihenmei_)