里程碑③:拔掉一个节点
3 节点集群持续读写,当场 kill 掉一个节点:写入不中断、已有数据全能读、节点回归后自动补齐。用你熟悉的运维手法验收自己的系统。
学完这节你能做到
- 编排三节点故障演练脚本并全部通过
- 观测故障切换期间的延迟毛刺并解释来源
- 写出运维手册第一版:部署、扩容、换盘
这次演练,桌子两边都是你
你做过很多次故障演练:拔盘、断网、kill 进程,然后盯着别人写的系统看它的表现。 这次不一样 —— 被演练的系统是你写的,验收标准也是你定的。里程碑③的目标一句话: 3 节点 forge 集群,持续读写中 kill 掉任意一个节点,客户端无感知;节点回来,数据自动补齐。
先盘点手里的零件:forge-store(单机引擎)、forge-proto(协议)、forge-node
(守护进程:RPC + gossip + Raft)、forge-cluster(成员、slot 表、EC 编解码)、
forge-bench(L2 的压测,直方图这次接着用)、forge-cli(运维入口)。
本课把它们接成完整的写路径:客户端从任一节点拿 slot 表(带 epoch),
本地 EC 编码出 6 片,按表直连并行落盘,收齐 6 个确认返回 —— 元数据走 Raft,
数据一个字节都不碰 leader。
3 节点跑 4+2:先把这笔账摊开
一个绕不开的矛盾:4+2 需要 6 个独立故障域,我们只有 3 个节点。硬凑会怎样? 每节点放 2 片,单节点故障同时丢 2 片 —— 刚好贴着容错上限,再坏一块盘就是数据丢失。
forge 的取舍是把故障域从「节点」降级到「盘」:每个节点挂 2 块盘(实验环境用 2 个数据目录代替),集群共 6 个盘级故障域,放置约束改成「6 片落 6 个不同的盘, 且每节点至多 2 片」。这笔账要算得诚实:
| 方案 | 容量开销 | 容忍节点故障 | 容忍盘故障 | 与生产代码路径的差异 |
|---|---|---|---|---|
| 2+1(每节点 1 片) | 1.5 倍 | 1 个 | 1 块 | 条带宽度不同,聚合与重建逻辑退化 |
| 4+2(每节点 2 片) | 1.5 倍 | 1 个 | 任意 2 块 | 无,与 6 节点生产形态同一套代码 |
两个方案容量开销相同、都只容忍 1 个节点故障,4+2 多出「任意 2 块盘」的容错, 且代码路径和真正的生产形态(6 节点以上、故障域回升为节点)完全一致 —— 所以选 4+2。 但必须白纸黑字写进运维手册:3 节点形态下,同时挂 2 个节点等于数据丢失, 这是实验拓扑的天花板,不是 4+2 的天花板。生产部署 4+2 的下限是 6 个节点, 建议 7 个以上(留一个热备故障域,重建有处可去)。
3 节点、每节点 2 盘、按盘做故障域的 4+2 集群,下列哪种故障会造成数据丢失?
演练准备:拓扑、部署与基线负载
三台虚机(或本机三个进程、三组端口),每台两个数据目录。部署长这样:
# 每个节点(以 n1 为例;n2、n3 改 ID 和地址)
forge-node --id n1 \
--listen 10.0.0.1:7301 --raft-listen 10.0.0.1:7401 \
--data /data/n1a,/data/n1b
# 任一节点上初始化集群并拉入成员
forge-cli cluster init --node n1=10.0.0.1
forge-cli cluster add-node n2=10.0.0.2
forge-cli cluster add-node n3=10.0.0.3
forge-cli cluster status # 预期:leader 已选出,epoch 稳定,256 个 slot 分配完毕
基线负载是所有验收项的观测底座 —— 没有持续负载,「无感知」三个字无从谈起:
# forge-bench 的集群模式:1 写 4 读混合,1MiB 对象,每次操作记录时间戳、延迟、结果
forge-bench cluster --peers 10.0.0.1,10.0.0.2,10.0.0.3 \
--mix put=1,get=4 --object-size 1MiB \
--duration 900s --log /tmp/ops.csv
先空跑 2 分钟记下基线:预期 put P99 在 10ms 上下(6 片并行落盘,受最慢分片拖尾), get P99 在 5ms 上下。你的绝对数字会不同,先有基线再演练,这是你的老规矩。
验收项 1:kill 数据节点,客户端无感知
# 1. 确认 n2 不是 Raft leader(leader 演练是验收项 2,变量要一次只动一个)
forge-cli cluster status | grep leader
# 2. 负载运行中,直接 kill -9
ssh 10.0.0.2 'pkill -9 forge-node'
# 3. 观察 3 分钟,然后分析 ops.csv
判定标准,每条都要在 ops.csv 里找到证据:
- get 错误数为 0。条带缺 2 片(n2 的两块盘),客户端凑齐剩下 4 片 RS 解码 —— 降级读。P99 允许从 5ms 抬到 15ms 左右:多了一次重试发现、加解码计算
- put 错误数为 0,允许一个 3 秒以内的延迟尖峰。尖峰来源:故障检测(phi 判 SUSPECT 到 Raft 提交成员变更约 2~3 秒)期间,发往 n2 的分片在吃超时。检测完成后进入降级写: 6 片落到剩余 4 块盘上(必有两块盘各接 2 片,暂时违反故障域约束),条带标记 degraded, 等 n2 回归后再摆正 —— 写可用性优先,容错暂降,这个取舍要出现在你的运维手册里
forge-cli cluster status显示 n2 为 DEAD,epoch 增加(Raft 记录了这次事件)
验收项 2:kill Raft leader,写恢复小于 2 秒
# 1. 找到当前 leader(假设是 n1)
forge-cli raft status
# 2. kill -9,掐表
ssh 10.0.0.1 'pkill -9 forge-node'
# 3. 从 ops.csv 计算 put 操作最大时间戳间隙
awk -F, '$2=="put" && $4=="ok"' /tmp/ops.csv | sort -t, -k1n \
| awk -F, 'NR>1 && $1-prev>gap {gap=$1-prev} {prev=$1} END {print gap, "ms"}'
判定:put 成功记录的最大间隙小于 2000ms。拆开算这笔账:选举超时 150300ms
随机触发,一轮投票一个 RTT,新 leader 上任后客户端一次重试退避(最多几百毫秒)——
正常应落在 0.51 秒。超过 2 秒说明有问题:常见是选举超时配得过大,
或客户端退避上限太高把恢复时间吃掉了。
还有一个必须解释的现象:get 几乎看不到毛刺。想想为什么 —— 读路径拿着缓存的 slot 表直连数据节点,根本不经过 leader;掉的又是 leader 而非数据节点,数据面毫发无损。 这就是元数据面与数据面分离的价值在监控曲线上的直接投影,截图留好,这是你的论据。
验收项 3:节点回归,自动重建校验
# 1. 重启 n2(数据还在,只是落后);换盘场景则先清空一个数据目录再启动
ssh 10.0.0.2 'forge-node --id n2 --listen ... --data /data/n2a,/data/n2b &'
# 2. 观察回归与重建
forge-cli node ls # n2:DEAD -> ALIVE,incarnation 加一
forge-cli repair status # 待补齐分片数,应持续下降到 0
# 3. 重建完成后全量校验
forge-bench verify --peers ... --all # 逐对象读回,校验 crc32c 与长度
判定:repair 待办归零后,verify 零错误;degraded 条带(验收项 1 里降级写产生的) 全部重新按故障域摆正。背后的重建循环是声明式的,你可以按 K8s controller 理解: 期望状态(slot 表说分片该在哪)减去实际状态(scrub 扫出来分片实际在哪)等于任务队列, 每补一片先 RS 重建、再验 crc、落盘、最后更新条带元数据。注意 forge 现在的重建是 不限速的全速冲 —— 3 节点数据量小无所谓,规模一大就是你熟悉的重建风暴,限速留给 L4。
这个脚本以后每次改动集群代码都要跑 —— 它就是 L3 的回归测试。review 时重点核对 断言的方向:AI 很容易把「get 错误数为 0」写成「get 成功数大于 0」, 后者在 99% 请求失败时也能通过。另外确认 leader 解析真的每次现查, 演练顺序打乱后脚本仍然正确。
为 forge 写验收演练脚本 scripts/drill.sh,自动化里程碑③的三个验收项: 前置:读取环境变量 FORGE_PEERS(三个 host:port,逗号分隔)与 FORGE_SSH_USER;脚本任何断言失败立即退出并打印失败项。 阶段 0 基线:启动 forge-bench cluster(mix put=1,get=4,1MiB,后台运行,日志 /tmp/ops.csv),空跑 120s,记录 put/get 的 P99 作基线; 阶段 1 数据节点:选一个非 leader 节点(通过 forge-cli raft status 解析,不许写死),ssh 过去 pkill -9 forge-node,观察 180s。断言:窗口内 get 错误数为 0;put 错误数为 0;put 最大时间戳间隙小于 3000ms; 阶段 2 leader:解析出当前 leader,pkill -9,观察 60s。断言:put 成功记录最大间隙小于 2000ms;get P99 相比基线抬升不超过 50%; 阶段 3 回归:重启阶段 1 杀掉的节点,轮询 forge-cli repair status 直到待补齐为 0(超时 600s 判失败),然后跑 forge-bench verify --all,断言零错误; 收尾:输出一页汇总:每阶段耗时、关键指标对比基线、通过与否。 用 bash 写,set -euo pipefail,解析 ops.csv 用 awk。写完后逐段解释脚本里每个断言对应正文哪个验收标准。
复盘:你以前运维的系统在这些场景下表现如何
演练全绿之后,做一件只有你这种背景的人能做好的事:横向对照。
| 本次演练现象 | Ceph 里的对应物 | forge 还缺什么 |
|---|---|---|
| 降级读,P99 抬 3 倍 | PG degraded,EC 池降级读 | 读时优先选本地分片、并行取 5 片留冗余 |
| 故障检测 2~3 秒 | mon 判 osd down(默认要 20 秒以上) | 我们更激进,但误判代价还没被大集群检验 |
| leader 切换 1 秒内恢复写 | mon 选举期间 osdmap 冻结 | 多 Raft 组(元数据本身分片,L4 的方向) |
| 回归后全速重建 | backfill/recovery,有限速参数 | 限速、优先级、业务流量隔离(L4) |
再回答三个复盘问题,写进项目的 docs/(这也是 objectives 里运维手册的雏形):
当年你运维的系统在这三个场景里哪个表现更好、为什么;forge 的 2~3 秒故障检测
在 500 节点集群上敢不敢用;如果验收项 1 里检测期间的 put 尖峰不可接受,
你有哪些手段把它压下去(提示:副本式写日志先返回、后台转 EC —— Weka 的答案)。
让 AI 写手册的隐藏价值在最后一条:它为了描述行为会去读代码, 读出来的「实际行为」和你脑中的「设计意图」对不上的地方,十有八九是 bug。 review 时把第 4 节已知限制逐条和正文的容错账核对 —— 手册里的容错承诺 写大一个字,都是未来事故报告里的一行。
基于 forge 仓库当前代码,起草 docs/ops-manual.md 运维手册第一版,只写已实现的行为,不许描绘愿景: 1. 部署:3 节点拓扑、每节点参数含义、初始化命令序列、健康检查清单; 2. 扩容:add-node 之后发生什么(slot 迁移由哪条 Raft 命令驱动、搬迁期间读写走哪),以及当前实现的限制; 3. 换盘:单盘故障的现象、更换步骤、重建观测命令、预计时长的估算方法(按分片数与重建读放大 4 倍推算); 4. 已知限制(诚实清单):3 节点形态同时挂 2 节点即数据丢失;降级写期间容错下降;重建不限速;故障检测参数未经大规模检验; 5. 故障速查表:症状、可能原因、诊断命令三列,至少覆盖本课三个验收项的场景。 写完后列出你在写手册过程中发现的代码与文档不一致之处(如果有),这些就是 bug 线索。
小结
- 3 节点跑 4+2 靠故障域降级到盘:容任意 2 盘、仅容 1 节点,和 2+1 容量相同但代码路径与生产一致 —— 取舍写进手册
- 验收项 1:kill 数据节点,get 零错误(降级读),put 一个 3 秒内尖峰后进入降级写
- 验收项 2:kill leader,put 最大间隙小于 2 秒;get 无感 —— 数据面不经过 leader 的直接证据
- 验收项 3:回归后声明式重建(期望减实际等于任务),补齐后全量 crc 校验归零
- 复盘的价值是横向对照:forge 比 Ceph 检测快但未经规模检验,重建还没有限速 —— 债都记在 L4 的账上
- 里程碑③达成:forge 是一个真正的分布式存储系统了。L4 把它长成文件系统