备用节点只有在提升路径可用、旧写者不能破坏新历史时才有价值。故障切换既是决策,也是流程与演练。副本看起来健康,不代表客户端能连上它,也不代表所有已确认写入都已经复制过去。先确定恢复时间目标与可接受的数据损失,再决定何时切换,而不是等报警响起才现场讨论。
提前设计好决定
确定谁有权批准提升、什么证据足以说明故障,以及由自动化还是操作员执行。心跳丢失可能是网络分区,而不是主库已经停止。故障检测本身不能授权两个独立写者。系统需要真正执行所有权边界的机制,例如经过确认的节点隔离,或存储端校验的 fencing,并使它与复制协议的保证相匹配。
单写者服务的安全目标是「最多一个被接受的写者」,并非每一瞬间都恰好有一个可用写者。隔离旧节点后、提升新节点前,暂时没有写者可能正是正确的停机选择。操作手册应写明候选节点、复制位置、可接受的丢失范围、客户端如何重连,以及前提不成立时由谁叫停。自动切换能缩短决策时间,却不能凭空补回尚未复制的数据。
演练真实经过的路径
根据风险决定演练频率,并在相关变更后重测。演练只能证明实际覆盖到的路径:绕过 DNS 的数据库提升不能验证地址缓存;测试客户端使用另一套身份,也不能证明生产权限正确。在安全前提下,加入真实的重连流程和已确认写入核对。记录范围、耗时、复制延迟、拒绝条件及恢复证据,不要把最近一次成功的日期当成完整的就绪评分。
开始前先安排安全停止点和恢复步骤。新主库一旦接受写入,切回旧节点往往就不再是撤销一项配置那么简单。旧主库可能需要对账或重建为副本,不能因为进程重新启动就恢复写权限。备份提供另一条恢复路径,但「有备份」本身也不等于已经验证能恢复。
坏掉的样子
旧主库在提升之后仍可写,就会产生脑裂。控制器里记录一句「已隔离」不够,存储或隔离机制必须真正阻止旧写入。落后的候选节点也可能在切换成功后丢失已确认写入。延迟阈值必须有明确单位,并与数据损失目标相连;允许非零延迟,不能同时宣称零数据丢失。
健康检查反复抖动会触发来回切换,应设置明确的故障判定和恢复条件,不要在重新同步之前自动切回。DNS 修改只影响后续查询:已有连接可能持续使用旧地址,按旧 TTL 缓存的回答也不会立刻消失。事故发生时调低 TTL,不能追溯缩短已经缓存的寿命。除了权威 DNS 的响应,还要验证真实客户端的重试、缓存和重连行为,否则控制台显示成功,用户路径仍可能失败。
快速验证
将下面内容保存为 failover_drill.py,在临时目录运行 python3 failover_drill.py。它只修改内存对象。健康状态和复制延迟是模拟值,fence() 只是更新集合,并不执行真实隔离。模型保留一个被指定的主节点,但隔离期间允许没有可写节点。候选节点通过检查后会被明确解除隔离,因此切回旧节点时不会出现「已经成为主库,却还留在隔离集合中」的自相矛盾状态。
"""Failover drill: single-primary switch with health and lag guards (stdlib only)."""
from dataclasses import dataclass
MAX_LAG = 128 # Synthetic units, not seconds or a zero-data-loss guarantee.
class FailoverRefused(Exception):
pass
@dataclass
class Node:
name: str
healthy: bool = True
lag: int = 0
is_primary: bool = False
class Cluster:
def __init__(self):
self.nodes = {"a": Node("a", is_primary=True), "b": Node("b")}
self.fenced: set[str] = set()
@property
def primary(self) -> str:
primaries = [n for n in self.nodes.values() if n.is_primary]
assert len(primaries) == 1, f"must keep exactly one primary, saw {[p.name for p in primaries]}"
return primaries[0].name
def can_write(self, name: str) -> bool:
node = self.nodes[name]
return node.is_primary and node.healthy and name not in self.fenced
def health_check(self, name: str) -> bool:
node = self.nodes[name]
return node.healthy and node.lag <= MAX_LAG
def fence(self, name: str) -> None:
self.fenced.add(name)
assert not self.can_write(name)
def failover(self, target: str, *, fence_old: bool) -> str:
old = self.primary
if target == old:
raise FailoverRefused("target is already primary")
if target not in self.nodes:
raise FailoverRefused(f"unknown target {target}")
candidate = self.nodes[target]
if not fence_old or old not in self.fenced:
raise FailoverRefused(f"refuse: old primary {old} is not fenced; fence first to avoid split brain")
if not candidate.healthy:
raise FailoverRefused(f"refuse: standby {target} is unhealthy")
if candidate.lag > MAX_LAG:
raise FailoverRefused(f"refuse: standby {target} lags by {candidate.lag} (> {MAX_LAG})")
self.nodes[old].is_primary = False
# Model explicit reactivation after the old writer is fenced.
self.fenced.discard(target)
candidate.is_primary = True
assert self.primary == target and self.can_write(target)
assert sum(self.can_write(name) for name in self.nodes) <= 1
return target
def expect_refused(fn, label: str) -> None:
try:
fn()
except FailoverRefused as exc:
print(f"refused as expected ({label}): {exc}")
return
raise AssertionError(f"should have refused: {label}")
def main() -> None:
# 1. Clean drill: healthy standby, caught-up replica, old primary fenced.
cluster = Cluster()
assert cluster.primary == "a"
assert cluster.health_check("b")
cluster.fence("a")
assert cluster.failover("b", fence_old=True) == "b"
print("clean switch: a -> b")
# 2. Refuse lag beyond this policy; even accepted positive lag may lose writes.
cluster = Cluster()
cluster.nodes["b"].lag = 900
assert not cluster.health_check("b")
cluster.fence("a")
expect_refused(lambda: cluster.failover("b", fence_old=True), "stale replica")
assert cluster.primary == "a"
# 3. Unhealthy standby must not promote.
cluster.nodes["b"].lag = 0
cluster.nodes["b"].healthy = False
expect_refused(lambda: cluster.failover("b", fence_old=True), "unhealthy standby")
assert cluster.primary == "a"
# 4. Unfenced old primary must block promotion (split-brain guard).
cluster.nodes["b"].healthy = True
unfenced = Cluster() # nobody fenced yet
expect_refused(lambda: unfenced.failover("b", fence_old=False), "split brain")
expect_refused(lambda: unfenced.failover("b", fence_old=True), "split brain without fence record")
assert unfenced.primary == "a"
# 5. Failback: resynchronize the candidate, then fence the CURRENT writer.
cluster = Cluster()
cluster.fence("a")
cluster.failover("b", fence_old=True)
cluster.nodes["a"].healthy = True
cluster.nodes["a"].lag = 0
cluster.fence("b")
assert cluster.failover("a", fence_old=True) == "a"
print("failback: b -> a")
print("PASS: at most one writable node; 4 refusals + clean switch + failback passed")
if __name__ == "__main__":
main()
输出包括一次正常切换、分属三类的四次拒绝,以及一次切回,最后为 PASS: at most one writable node; 4 refusals + clean switch + failback passed。拒绝提升会保留原有的主节点标记,但已经被隔离的旧主库仍不可写,并不代表服务自动恢复可用。正常切换与切回场景的延迟都是零;策略另外允许最多 128 个模拟单位的正延迟,这包含可能丢失数据的取舍,不应误读为真实数据库的推荐阈值。
旧版练习记录的环境是 macOS、Python 3.14.4;修订版在 Linux、Python 3.12.14 下通过断言。当前测试验证顺序执行的决策逻辑,也检查曾被隔离的目标节点是否真正恢复为可写。它不验证并发提升、真实复制、权限、DNS 传播、存储隔离或生产恢复。那些结论需要另外获准的预发布演练,使用实际机制和具有代表性的客户端;这个小模型不能替代它们。
延伸阅读
来源
- PostgreSQL 18:故障切换,旧主库隔离、重建冗余与书面切换流程,已核验 2026-10-15。
- AWS:配置 DNS 故障切换,健康检查及路由配置,已核验 2026-10-15。
- Google SRE:可靠性测试,生产测试范围、自动化防护与灾难恢复测试,已核验 2026-10-15。
- AWS:记录 TTL,解析器缓存以及切换前的 TTL 调整,已核验 2026-10-15。