注册中心zookeeper重启恢复后,在线的微服务全部下线。发生了什么?!近日,由于一次运维操作失误,导致在线注册中心zk重启。但是zk重启后,发现所有在线的微服务开始不断下线,导致P0故障,持续30分钟。整体排查过程深入研究了zookeeper的session机制,以及RPC框架在这种异常情况下如何处理。好吧,让我们一起回顾一下这个在线失败。最佳实践总结在最后,不要错过。一、现象描述一天晚上19点43分左右,在线的zk集群被误下线(停止)。一共7个节点,其中6个节点离线,导致zk停止工作。发现节点宕机后,19:51左右重启(started)所有zk节点。期间业务正常运行,未收到批量业务调用的报错和客户投诉。直到19点56分,我们才开始接到大面积呼叫失败的告警和客户投诉。我们尝试依赖自研RPC框架与zk重连后的“自动恢复”机制,希望能在短时间内批量恢复。但遗憾的是,将近8分钟后,并没有任何大规模复苏的迹象。结合zkznode节点数量增长非常缓慢的情况,我们采取了紧急措施,原地重启所有微服务pod。重启后效果显着,短时间内大面积服务逐渐恢复。2.初步分析我们自研的RPC框架采用典型的注册中心+提供者+消费者模型,通过zk临时节点来注册和发现服务,如下图所示。结合故障过程中出现的现象,我们进行初步分析:第一阶段:zk集群停止期间,业务可以正常调用。原因是消费者无法访问zk,暂时失去了服务发现能力。所以这期间只要不重启服务,本地服务发现提供者缓存列表provider-list就不会被刷新,调用是正常的。Phase2:zk集群启动后,服务之间立即出现调用问题。原因是consumer连接到zk后,会立即进行服务发现操作。但是此时provider服务还没有重新注册到zk,读取的是一个空的地址列表,导致了一批业务错误。Stage3:zk恢复一段时间后,provider服务仍然没有“自动重连”到zk,导致consumer继续报错。所有服务完全重启后,provider服务重新注册成功,consumer恢复。这里有个问题:为什么providerclient的“自动重连”注册中心机制在zk集群恢复后没有生效?结果消费者被空地址列表推送后,并没有收到提供者的重注册节点信息。3、深入排查(1问题复现)根据大量测试,我们找到了稳定重现该问题的方法:zksession过期包括“服务器过期”和“客户端过期”,恢复情况为“clientexpiration”zk集群会导致“临时节点”丢失,无法自动恢复。(2原因分析1)集群重启恢复后,RPC框架客户端立即与zk集群重连,存储在本地内存中为以后注册的提供者节点+要订阅的消费者节点重新构建。2)但是此时zk集群根据快照恢复的“临时节点”(包括提供者和消费者)仍然存在,所以重建操作返回NodeExist异常,重建失败。(问题一:为什么没有重试?)3)集群重启恢复40秒后,所有与过期Session相关的临时节点被移除。(问题2:为什么要移除?)4)消费者监听节点移除的空列表,清空本地提供者列表。发生故障。基于此分析,我们需要围绕两个问题进一步定位源码:问题1:zk集群恢复后,为什么RPC框架的客户端在前40s创建临时节点失败后没有重试?问题2:zk集群恢复后,为什么zk会在40秒后删除之前恢复的所有临时节点?(3)问题一:为什么没有重试就创建临时节点失败了?通过源码分析可以看出,RPC框架客户端和服务端重新连接后,会重新创建内存中的旧临时节点。这个逻辑似乎没有问题。只有doRegister成功后节点才会从失败列表中移除,否则会每隔一定时间继续重试创建。继续往下,重点来了:这里我们可以看到,在创建临时节点的时候,吞掉了服务端返回的NodeExistsException,这样整个外层的doRegister和doSubscribe(订阅)方法在这种情况下都被认为是被拒绝了重新创建成功,所以只创建一次。上面分析过,其实一般情况下,这里不处理NodeExistsException是没有问题的,即节点已经存在,不需要添加,也不需要重试,但是旧的sessionId会在服务器踢出旧的sessionId的同时删除相关的临时节点导致故障。(4)问题2:为什么zk会删除恢复的临时节点?1)从zk的session机制来看,众所周知,zk的session管理是在客户端和服务端都实现的,两者通过心跳进行交互。发送心跳包时,客户端会携带自己的sessionId,服务端收到请求,检查sessionId确认存活,然后将结果回传给客户端。如果client发送了一个server不知道的sessionId,server会生成一个新的sessionId下发给client,client收到后会在本地刷新sessionid。2)zk客户端(curator)session过期机制当client(curator)本地sessionTimeout超时时,会重建(reset)本地zk对象。我们从源码中可以看出,本地sessionId默认重置为0。zkserver随后收到这个“0”的sessionId,认为需要创建一个未知的session,然后为client创建了一个新的sessionId。3)服务端(zookeeper)session过期处理机制服务端(zookeeper)sessionTimeout的管理是在zk会话管理器中看到一个线程任务,不断判断管理的session是否超时(获取下一个过期时间点nextExpirationTime超时会话),并清理会话。我们继续往下走,重点来了。在清理session的过程中,除了从本地的expiryMap中清除sessionId外,还清理了临时节点:原来zkserver端使用sessionId和它创建的临时节点做了Binding。随着服务器sessionId过期,所有绑定的临时节点也将被删除。因此,在zk集群恢复40s后,zkserversession超时,删除所有过期session的相关临时节点。4.故障根因总结1)第一次恢复zk集群,读取zk快照文件并初始化zk数据,找回旧session,执行createsession操作,完成一次继续旧会话大约(重置40秒)。集群恢复的关键入口点-reloadsnapshot:执行session恢复(创建)操作,默认session超时时间为40s:2)此时clientsession已经过期,以空sessionid0x0重新连接,获取新的会话ID。但此时RPC框架吞噬了临时节点注册失败后服务端返回的NodeExistsException,算是重新创建成功,所以只创建了一次。3)zk集群恢复40s后,由于serversession过期,过期的sessionId和绑定的临时节点被清除。4)消费者监听节点移除的空列表,清空本地提供者列表。发生故障。5、解决方案经过上面的源码分析,有两种解决方案:解决方案一:客户端(curator)设置session过期时间长一些或者不过期,那么client会在集群后的前40s携带原来的sessionidrecovery向服务器发起请求后,合约会自动续约,不再过期。方案二:clientsession过期后,空sessionid0x0重连时,处理NodeExsitException,执行delete-re-add操作,确保重连成功。于是我们调研了业界使用zk的开源微服务框架是否支持自愈,以及如何实现:dubbo采用了方案二。评论也很明确:“ZNode路径已经存在,我们只是尝试重新创建session过期时的节点,所以这个重复可能是zk服务器的删除延迟造成的,也就是说旧的过期session可能还保存着这个ZNode,只是服务器来不及删除而已。在这个情况下,我们可以尝试将其删除并重新创建。”看来以后dubbo确实会考虑这种边界场景,防止踩坑。所以最终我们的解决方案是使用dubbofix替换节点的逻辑:先deletePath,再createPath。之所以会这样就是把zkserver内存维护的过期的sessionId替换成新的sessionId,避免后续zk清理旧的sessionId,所有绑定的节点都被删除6.最佳实践回顾整个失败,其实我们忽略了一个最佳实践很少,RPC框架除了优化异常的捕获和处理外,还应该对注册中心的空地址推送进行特殊判断,用行业专业术语来说就是“空推送防护”。所谓“推空保护”,就是在服务发现监听器获取到空节点列表时,维护本地服务发现列表缓存,而不是清除它。这完全避免了这样的问题。
