故障模式运行手册:快照与确定性
最可怕的故障不会让任何东西崩溃。集群继续运行,仪表盘依旧一片绿色,而各节点却在悄无声息地对”现实”产生了分歧。本页就是针对这类 bug 的运行手册——快照损坏、快照不一致,以及那些悄然毒害你状态机的确定性违规。
两种痛苦:吵闹的与静默的
Section titled “两种痛苦:吵闹的与静默的”故障可以清晰地分为两类。吵闹型故障损害可用性——节点启动失败、恢复被延迟,你能看到出了问题。静默型故障更糟:集群看起来很健康,而节点却在分叉。下文中静默型故障被标记为 Critical,因为等你注意到时,分叉很可能已经固化进了你手上的每一份快照里。
快照是节点无需回放整个日志即可重建状态的手段。当快照出问题时,你要么失去可用性,要么——更糟糕的是——继续在损坏的状态上运行。
| 情形 | 数据丢失风险 | 可用性影响 | 恢复复杂度 | 补救措施 |
|---|---|---|---|---|
| 快照损坏 | 可能 | 恢复延迟 | 中等——回退到较旧的快照 | 使用 ClusterTool 检查 recording-log;将损坏快照置为失效;节点回退到较旧快照 + 更长的日志回放 |
| 快照不一致(onTakeSnapshot/loadSnapshot 中的应用 bug) | 静默分叉 | 无(看似健康) | Critical——难以察觉 | 添加快照校验和;定期跨节点比对状态;修复序列化 bug;重新生成干净快照 |
| 在状态不一致期间生成的快照 | 静默分叉 | 无(看似健康) | Critical——修复应用逻辑 | 确保 onTakeSnapshot 中的状态捕获是原子的;修复应用逻辑;将坏快照置为失效;重新生成快照 |
| 快照缺失且日志被截断 | 数据缺口 | 恢复延迟 | 高——节点无法重建 | 从外部备份或其他节点恢复快照;若都不可用,则从健康对等节点重建该节点 |
| 快照版本不匹配(滚动升级) | 可能 | 节点启动失败 | 中等——迁移策略 | 实现快照版本管理与迁移逻辑;升级前先生成快照;确保向后兼容 |
静默分叉问题
Section titled “静默分叉问题”这里最危险的故障是那些被标记为”看似健康”的——集群持续运行,但各节点的状态已不相同。它只会在以下情况显现:
- 发生故障切换,新的 leader 拥有不同的状态。
- 客户端从不同节点获得不同的结果(如果允许从 follower 读取)。
- 一次快照比对揭示出不匹配。
请注意这些触发条件。分叉在隐形状态下持续存在,直到一次故障切换或一次 follower 读取把它拖到明面上来——而故障切换恰恰是你最承受不起意外的时刻。这种延迟正是整个问题的核心:bug 上线后,干净地运行数周,然后在一次事故中引爆。
这是所有类别中最隐蔽的一种——一切看似健康,节点却在静默分叉。集群化状态机必须在每个节点上、对相同的输入产生逐位相同的输出。一旦你的服务逻辑依赖了某个在节点间存在差异的东西,快照就会分叉,你便又回到了静默损坏的处境。
| 情形 | 数据丢失风险 | 可用性影响 | 恢复复杂度 | 补救措施 |
|---|---|---|---|---|
| 非确定性应用逻辑(时间、随机数、HashMap 等) | 静默分叉 | 无(看似健康) | Critical——难以察觉 | 审计代码:使用 cluster.time()、带种子的 Random、有序集合(TreeMap/LinkedHashMap);增加跨节点状态哈希比对 |
| 跨 JVM/CPU 的浮点非确定性 | 静默分叉 | 无(看似健康) | Critical | 用 StrictMath 替换 Math;使用定点数(long 表示的”分”)而非 double;统一各节点的 JVM 版本 |
| 服务逻辑中的外部副作用(HTTP、DB、文件 I/O) | 静默分叉 | 无(看似健康) | Critical——重新设计服务 | 从 onSessionMessage 中移除所有外部调用;通过 ingress 消息把外部数据推入集群;副作用仅在 leader egress 上发生 |
常见的确定性陷阱
Section titled “常见的确定性陷阱”// ❌ WRONG — non-deterministiclong now = System.currentTimeMillis(); // Different on each nodedouble result = Math.sin(x); // May differ across JVMs/CPUsMap<String, Order> orders = new HashMap<>(); // Iteration order undefinedUUID id = UUID.randomUUID(); // Different on each nodehttpClient.get("https://api.price.com"); // External call in service
// ✅ CORRECT — deterministiclong now = cluster.time(); // Cluster-provided timedouble result = StrictMath.sin(x); // Guaranteed identicalMap<String, Order> orders = new TreeMap<>(); // Deterministic iteration// Use cluster-provided sequence numbers instead of UUID// Push external data into cluster via ingress, not pull from service经验法则:时间、随机性、迭代顺序、数学运算以及外部世界,在服务逻辑内部一律禁止。这些东西一个都不要主动去拉取;让集群把它们交到你手上。
检测与恢复操作指南
Section titled “检测与恢复操作指南”你不能指望靠偶然撞见来发现分叉。要把检测机制内建进去,因为这种故障模式本身就是静默。
- 加载时校验。 给每份快照追加校验和/哈希,并在
loadSnapshot中验证它。一次失败的校验,能把静默损坏变成吵闹的、可恢复的故障。 - 跨节点比对。 定期对每个节点的内存状态做哈希并相互比对。在几小时内捕获分叉,而不是等到故障切换时。
- 用 ClusterTool 检查。 对于快照损坏,使用 ClusterTool 检查 recording-log,将损坏的快照置为失效,让节点回退到较旧的快照外加更长的日志回放。
- 从健康对等节点重建。 当快照缺失且日志被截断时,从外部备份或其他节点恢复;若两者都不可用,则从健康对等节点重建该节点。
- 升级前先做版本管理。 在滚动升级前生成快照,实现快照版本管理与迁移逻辑,并确保向后兼容,这样节点就绝不会因版本不匹配而启动失败。
这些故障如何影响你的指标
Section titled “这些故障如何影响你的指标”这些 bug 不会表现为延迟回退——这正是它们危险之处。相反,它们会在另外两个地方让你付出代价。
- p99 / 恢复时间——快照损坏会迫使系统回退到较旧的快照外加更长的日志回放。当节点重启时,正是这段回放拉长了你的恢复窗口,尽管稳态下的 p50/p99 直到事故发生前一刻看起来都完美无缺。
- 正确性,而非延迟——确定性违规和静默的快照不一致,在集群运行期间根本不会触及 p50、p99 或吞吐量。它们是纯粹的正确性故障。代价会在故障切换时以错误状态浮出水面的形式降临——而这正是发现它的最糟糕时刻。
一句话总结:吵闹型快照故障消耗的是你的恢复时间;静默型故障消耗的是你的信任。现在就把校验和与跨节点哈希比对建起来,让静默故障变成你真正能修复的吵闹故障。