备用节点只有在提升路径可用、旧写者不能破坏新历史时才有价值。故障切换既是决策,也是流程与演练。副本看起来健康,不代表客户端能连上它,也不代表所有已确认写入都已经复制过去。先确定恢复时间目标与可接受的数据损失,再决定何时切换,而不是等报警响起才现场讨论。

提前设计好决定

确定谁有权批准提升、什么证据足以说明故障,以及由自动化还是操作员执行。心跳丢失可能是网络分区,而不是主库已经停止。故障检测本身不能授权两个独立写者。系统需要真正执行所有权边界的机制,例如经过确认的节点隔离,或存储端校验的 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 传播、存储隔离或生产恢复。那些结论需要另外获准的预发布演练,使用实际机制和具有代表性的客户端;这个小模型不能替代它们。

延伸阅读

来源