第四章 分布式协调服务: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协议的核心流程:
- 选举阶段:集群启动或Leader挂了,所有节点投票选出一个Leader
- 同步阶段:Leader把最新的数据同步给所有Follower
- 广播阶段:所有写请求都由Leader处理,通过两阶段提交广播给Follower
- 恢复阶段: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的另一个经典应用。实现思路其实不复杂:
- 所有竞争锁的客户端,在同一个锁路径下创建临时顺序节点
- 每个客户端检查自己创建的节点是不是序号最小的
- 如果是,就获得锁;如果不是,就监听前一个节点的删除事件
- 前一个节点被删除(锁释放),当前客户端收到通知,再次检查
这种实现方式叫「排他锁」或「写锁」。它有几个好处:
- 不会出现「惊群效应」——每个客户端只监听前一个节点,不是监听所有节点
- 客户端挂了,临时节点自动删除,锁自动释放——不会死锁
- 顺序节点保证了公平性——先来的先拿到锁
避坑指南:我曾经遇到过一个线上问题——某个业务线程获取锁后,处理时间超过了ZK的会话超时时间。结果ZK认为客户端挂了,自动删除了临时节点,锁被释放了。但业务线程还在继续执行!这就导致了「锁失效」的问题。
解决方案:要么延长会话超时时间,要么在业务代码里加一个「续期」机制——定期检查会话是否还活着。
还有一种叫「读写锁」的实现:读锁可以共享,写锁必须互斥。实现思路是:
- 读锁:检查比自己序号小的节点中,有没有写锁节点。没有就获得读锁
- 写锁:检查比自己序号小的节点中,有没有任何锁节点。没有就获得写锁
说白了,读写锁就是「读读不互斥,读写互斥,写写互斥」。这个在缓存一致性场景里特别有用。
4.6 小结
ZK的核心就三样东西:ZNode存数据、Watcher做通知、ZAB保一致。配置管理和分布式锁是最常见的两个应用场景。
最后说一句:ZK不是万能的。如果你的集群规模特别大(几百上千台),或者写操作特别频繁,ZK可能会成为瓶颈。这时候你可以考虑etcd或者Consul。但中小规模系统,ZK依然是首选。
下一章我们聊聊分布式存储,那又是另一个有意思的话题了。