第四章 分布式协调服务:ZooKeeper核心概念与协议

聊到分布式系统,有个东西绕不开——ZooKeeper。我最早接触它是在2015年,当时做一个电商的秒杀系统,服务节点一多,配置同步、选主、锁这些事全乱套了。后来用了ZK,才算是把这些问题理顺了。

说白了,ZooKeeper就是给分布式系统当「管家」的。它不存业务数据,专门管那些协调信息。你想想看,几十上百台机器要协同工作,总得有个地方让大家能统一看法吧?ZK就是干这个的。

4.1 ZNode:ZK的数据模型

ZK的数据结构,其实就是一个树形命名空间。每个节点叫ZNode。嗯,这里要注意,ZNode跟文件系统的目录很像,但又有本质区别。

核心要点:ZNode可以存数据(通常1MB以内),也可以有子节点。每个ZNode还有一个stat状态信息,记录了版本号、时间戳这些元数据。

ZNode分四种类型,我列个表方便你对照:

类型 特点 典型场景
持久节点 创建后一直存在,除非手动删除 配置信息、服务注册
临时节点 客户端会话断开后自动删除 服务心跳检测、选主
持久顺序节点 持久 + 自动编号(如/lock-0000000001) 分布式队列、公平锁
临时顺序节点 临时 + 自动编号 分布式锁(最常用)

我在项目中遇到过一个问题:有人把ZK当数据库用,往ZNode里塞了几百MB的数据。结果整个集群性能直线下降。记住,ZK不是存数据的,是存协调信息的。超过1MB的数据,你就该考虑用别的存储了。

4.2 Watcher:ZK的事件通知机制

Watcher是ZK最巧妙的设计之一。它让客户端不用轮询,而是「订阅」某个ZNode的变化。一旦数据变了,ZK会主动通知你。

为什么会这样设计?你想想看,如果每个客户端都每隔几秒去查一下配置有没有变,那ZK的负载得有多大?Watcher机制说白了就是「反向推送」——你注册好监听,有变化我告诉你。

个人经验:Watcher是一次性的。触发一次后,如果你还想继续监听,得重新注册。我刚开始做的时候踩过这个坑——注册了Watcher,第一次变更收到了通知,第二次就没反应了。排查了半天才发现是Watcher被消费掉了。

Watcher的触发条件包括:

  • 节点数据变化(setData)
  • 子节点变化(create、delete)
  • 节点删除
  • 会话过期

避坑指南:我曾经在一个高并发场景下,给一个频繁变化的节点注册了Watcher。结果每次变化都触发通知,客户端处理不过来,反而拖垮了业务。后来我加了个「合并通知」的逻辑——短时间内多次变化只处理最后一次。这个思路你可以参考。

4.3 Paxos与ZAB协议

聊ZK的底层协议,得先说说Paxos。Paxos是分布式一致性协议的「祖师爷」,但说实话,它太难懂了。我当年啃了三四遍论文,才勉强搞明白。

ZK没有直接用Paxos,而是实现了ZAB(ZooKeeper Atomic Broadcast)协议。ZAB跟Paxos的核心思想一样,但更聚焦于ZK的实际场景——主备同步和崩溃恢复。

ZAB协议的核心流程:

  1. 选举阶段:集群启动或Leader挂了,所有节点投票选出一个Leader
  2. 同步阶段:Leader把最新的数据同步给所有Follower
  3. 广播阶段:所有写请求都由Leader处理,通过两阶段提交广播给Follower
  4. 恢复阶段:Leader挂了,重新选举,保证数据不丢

我记得有一次线上ZK集群的Leader节点突然宕机,整个集群花了大概3秒完成重新选举。这3秒里所有写请求都失败了。嗯,这就是ZK的「不可用窗口」。所以如果你的业务对写操作的可用性要求极高,得考虑这个因素。

ZAB vs Paxos:ZAB简化了Paxos的很多细节,比如它要求消息必须按顺序投递,而Paxos允许乱序。ZAB还引入了「epoch」机制来标识Leader的任期,这跟Raft有点像。说白了,ZAB是Paxos在ZK这个特定场景下的「工程优化版」。

4.4 典型应用:配置管理

配置管理是ZK最基础的应用。我习惯的做法是这样的:

  • 把配置信息写到ZK的一个持久节点上,比如/config/app1/db_url
  • 所有服务启动时,先从这个节点读取配置
  • 同时注册一个Watcher,监听这个节点的变化
  • 配置变更时,ZK通知所有服务,服务重新加载配置

这样做的好处是:不用重启服务就能改配置。你想想看,线上几百台机器,如果每次改个数据库连接池大小都要重启,那运维得疯掉。

// 伪代码示例:配置监听
public class ConfigWatcher {
    public void init() {
        // 读取配置
        byte[] data = zk.getData("/config/app1/db_url", 
            new Watcher() {
                public void process(WatchedEvent event) {
                    // 配置变了,重新加载
                    reloadConfig();
                    // 重新注册Watcher(重要!)
                    zk.getData("/config/app1/db_url", this, null);
                }
            }, null);
        applyConfig(data);
    }
}

个人建议:配置变更不要太频繁。我曾经见过一个团队,每秒钟改一次配置,结果Watcher通知风暴把ZK集群打崩了。配置变更最好有个「冷却期」,比如5秒内多次变更只生效最后一次。

4.5 典型应用:分布式锁

分布式锁是ZK的另一个经典应用。实现思路其实不复杂:

  1. 所有竞争锁的客户端,在同一个锁路径下创建临时顺序节点
  2. 每个客户端检查自己创建的节点是不是序号最小的
  3. 如果是,就获得锁;如果不是,就监听前一个节点的删除事件
  4. 前一个节点被删除(锁释放),当前客户端收到通知,再次检查

这种实现方式叫「排他锁」或「写锁」。它有几个好处:

  • 不会出现「惊群效应」——每个客户端只监听前一个节点,不是监听所有节点
  • 客户端挂了,临时节点自动删除,锁自动释放——不会死锁
  • 顺序节点保证了公平性——先来的先拿到锁

避坑指南:我曾经遇到过一个线上问题——某个业务线程获取锁后,处理时间超过了ZK的会话超时时间。结果ZK认为客户端挂了,自动删除了临时节点,锁被释放了。但业务线程还在继续执行!这就导致了「锁失效」的问题。

解决方案:要么延长会话超时时间,要么在业务代码里加一个「续期」机制——定期检查会话是否还活着。

还有一种叫「读写锁」的实现:读锁可以共享,写锁必须互斥。实现思路是:

  • 读锁:检查比自己序号小的节点中,有没有写锁节点。没有就获得读锁
  • 写锁:检查比自己序号小的节点中,有没有任何锁节点。没有就获得写锁

说白了,读写锁就是「读读不互斥,读写互斥,写写互斥」。这个在缓存一致性场景里特别有用。

4.6 小结

ZK的核心就三样东西:ZNode存数据、Watcher做通知、ZAB保一致。配置管理和分布式锁是最常见的两个应用场景。

最后说一句:ZK不是万能的。如果你的集群规模特别大(几百上千台),或者写操作特别频繁,ZK可能会成为瓶颈。这时候你可以考虑etcd或者Consul。但中小规模系统,ZK依然是首选。

下一章我们聊聊分布式存储,那又是另一个有意思的话题了。