图 1:本文封面,投票的在台上,旁听的在观众席。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:Zookeeper 集群下各 Server 的工作状态与角色是什么?答案的骨架是三种角色加一条状态机,但真正的考点在 Observer 的设计动机——为什么扩读不能靠加 Follower。
三种角色的分工。
Leader 唯一,是写路径的总闸:所有写请求最终都汇集到它(Follower 收到写请求会转发给它),由它把写转成提案广播出去,等过半投票成员确认后广播提交。Follower 是投票成员:参与提案的过半确认、参与 Leader 选举投票,同时可以独立应答读请求。Observer 是旁听生:像 Follower 一样同步日志、应答读请求、把写请求转给 Leader,但完全不参与投票与过半计数——官方文档的表述是,Observer 只听到投票的结果,不听产生结果的协商过程。
状态机把角色与时机连起来:Server 在 LOOKING(寻找 Leader,选举期)、FOLLOWING、LEADING、OBSERVING 四个状态间迁移;稳态下每台非 Leader 机器处于 FOLLOWING 或 OBSERVING,Leader 宕机则集体回到 LOOKING 重新选举。
Observer 为什么存在:写慢与读多的矛盾。
Zookeeper 的写要快,取决于过半确认有多快——投票成员越多,要等齐的确认越多,写延迟越高。官方 Observer 文档把这个问题说得很直白:增加投票成员会让写性能下降,因为写操作需要集群中至少一半节点达成一致,投票者越多,一次投票的代价越大。而读请求任何节点都能本地应答。于是矛盾清晰了:读多写少的协调服务想扩读,加 Follower 会同时拖慢写;Observer 就是为此造的——加多少都不进投票计数,写路径纹丝不动,读能力随便扩。附带的可用性红利:Observer 不参与投票,它宕机或与集群断连不损害服务可用性,因此可以跨机房部署在不那么可靠的链路上。
用模型把扩容决策跑一遍。
下面这份模拟对比「加 Follower」与「加 Observer」对写延迟与读容量的影响,用 Node.js 22.15.0 直接运行(node zkroles.js):
'use strict';
// Leader 收写请求、发提案、等过半确认后提交;Follower 参与投票;Observer 只同步不投票。
// 写延迟 ≈ 等第 quorum 快的 Follower 确认(RTT 带长尾),读请求任何节点本地应答。
let seed = 7; // 固定种子,输出可复现
const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
function writeLatencyMs(followerRtts) {
const quorum = Math.floor((followerRtts.length + 1) / 2); // 含 Leader 的过半,减 Leader 自身
const sorted = [...followerRtts].sort((a, b) => a - b);
return sorted[quorum - 1]; // 等第 quorum 快的确认
}
function cluster(followers, observers) {
return {
writeMs() {
const rtts = Array.from({ length: followers }, () => 4 + rand() ** 2 * 30); // 各 Follower RTT
return writeLatencyMs(rtts);
},
readCapacity: followers + 1 + observers, // 每台都能本地答读,含 Observer
};
}
const avg = (fn, times = 500) => { let s = 0; for (let i = 0; i < times; i++) s += fn(); return s / times; };
console.log('配置\t写延迟均值ms\t读服务能力');
for (const [f, o, label] of [
[2, 0, '3 投票成员(1L+2F)'],
[4, 0, '5 投票成员(1L+4F)'],
[6, 0, '7 投票成员(1L+6F)'],
[2, 10, '3 投票成员 + 10 Observer'],
[2, 100, '3 投票成员 + 100 Observer'],
]) {
const c = cluster(f, o);
console.log(`${label}\t${avg(() => c.writeMs()).toFixed(1)}\t\t${c.readCapacity} 台本地应答`);
}
// Observer 宕机演示:投票成员不变,写路径延迟分布不变
const c = cluster(2, 10);
seed = 7; const before = avg(() => c.writeMs());
seed = 7; const after = avg(() => cluster(2, 0).writeMs()); // 同种子对比
console.log(`10 个 Observer 全部宕机:写延迟 ${before.toFixed(1)}ms -> ${after.toFixed(1)}ms(写路径不变),读容量 13 台回落到 3 台`);
实测输出:
配置 写延迟均值ms 读服务能力
3 投票成员(1L+2F) 9.0 3 台本地应答
5 投票成员(1L+4F) 10.1 5 台本地应答
7 投票成员(1L+6F) 10.4 7 台本地应答
3 投票成员 + 10 Observer 8.9 13 台本地应答
3 投票成员 + 100 Observer 8.8 103 台本地应答
10 个 Observer 全部宕机:写延迟 9.0ms -> 9.0ms(写路径不变),读容量 13 台回落到 3 台
两行结论一目了然:投票成员从 3 加到 7,写延迟均值从 9.0 涨到 10.4——要多等齐更多确认;而加 10 个甚至 100 个 Observer,写延迟纹丝不动,读容量却从 3 台涨到 103 台。最后一行是可用性红利:Observer 全挂,写路径数字一模一样,只是读容量回落。
图 2:读写路径分开扩,不参与共识的副本去扛读。
可能的追问。
「扩容决策一句话?」读多写少加 Observer;写变多或需要投票冗余(容忍更多机器宕机)才加 Follower,且投票成员通常保持奇数台——4 台与 3 台一样只能容忍 1 台宕机,却多等一份确认,不划算。「Observer 的读会不会读到旧值?」会,Observer 与 Follower 一样是异步同步日志,读到的是已提交但可能不是最新的值;要读到最新写,客户端得用 sync 或直连 Leader 的语义——这是最终一致窗口的代价。「跨机房怎么用?」官方文档给的例子就是把 Observer 放在另一个数据中心:分区不影响主机房投票集群,远端客户端读本地 Observer 拿低延迟。
协调服务读多写少时,「用 Observer 扩读还是加 Follower」是容量规划的真实决策。你的集群里 Observer 用上了吗?还是一直在用 Follower 硬扛读流量?欢迎聊聊。