数据库基础体系 · 第 139/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据库故障演练:延迟、断网、磁盘满、主库切换和恢复验证

数据库故障演练不是“把数据库弄坏,再看服务是否还能访问”。一次合格的演练需要回答五个问题:

  1. 故障发生在哪一层,影响了哪些数据流?
  2. 数据库和业务分别进入了什么状态?
  3. 监控是否能区分正常变慢、连接失败、复制中断和数据不可用?
  4. 故障消除后,系统是否自动恢复,还是留下了复制、连接池或数据一致性问题?
  5. 恢复结果是否满足既定的 RPO、RTO 和数据正确性要求?

本文以 PostgreSQL 和 MySQL 8.4 的公开稳定语义为边界。命令会明确引擎、事务和部署假设。自动故障转移、半同步复制、备份软件、云厂商托管服务和代理层的具体行为并不由数据库 SQL 标准统一规定,必须结合实际部署验证。


一、先定义演练对象和成功条件

1. 故障演练中的几个基本概念

故障注入是有意改变系统条件,例如增加网络延迟、丢弃数据包、阻断主库连接或填满测试磁盘。

故障表现是组件对故障的可见反应,例如:

  • SQL 请求延迟升高;
  • 新连接超时;
  • 事务回滚;
  • 复制延迟增加;
  • 备用库进入无法追赶的状态;
  • 主库无法写入;
  • 自动切换失败;
  • 恢复后出现缺少事务或重复执行。

恢复至少有三种不同含义:

  1. 故障条件消失:例如删除 tc netem 规则或恢复网络路由;
  2. 组件恢复服务:数据库进程能够接受连接;
  3. 数据恢复正确:数据、复制位点、约束、业务不变量都符合预期。

第三种才是恢复验证的最终目标。进程能启动,不等于数据库已经恢复。

2. 示例部署边界

后文使用两类示例:

  • PostgreSQL:一个主库和一个物理流复制备用库,客户端通过应用或连接池访问;
  • MySQL 8.4:一个 source 和一个 replica,使用二进制日志复制。MySQL 8.4 文档仍使用 SOURCEREPLICA 术语;较旧版本常见的 MASTERSLAVE 命令属于旧命名。

除非特别说明:

  • 演练在隔离环境进行;
  • 业务使用显式事务;
  • 连接池、负载均衡器和故障转移控制器都属于演练范围;
  • 数据库节点之间的网络故障按双向故障考虑;
  • 不把“客户端重试成功”直接当成“原事务成功”。

3. 需要记录的基线

演练开始前,先记录正常状态,而不是故障后才寻找比较对象。

至少记录:

  • 数据库版本、操作系统和部署拓扑;
  • 主库、备用库、代理、应用实例的地址;
  • 当前主库身份;
  • 连接数、活跃事务、锁等待、提交延迟;
  • 复制状态和复制延迟;
  • 磁盘容量、inode、WAL 或 binlog 占用;
  • 最近一次备份的时间、类型和校验结果;
  • 当前业务写入速率;
  • 当前 SLO、RPO、RTO。

对于 PostgreSQL,可查看基础状态:

SELECT
    version(),
    current_database(),
    pg_is_in_recovery(),
    now();

主库上的 pg_is_in_recovery() 应返回 false,备用库上应返回 true

查看 PostgreSQL 主库复制状态:

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication;

这里要区分:

  • sent_lsn:主库已经发送到备用库的 WAL 位置;
  • write_lsn:备用库已经写入接收端的 WAL 位置;
  • flush_lsn:备用库已经持久化的 WAL 位置;
  • replay_lsn:备用库已经重放并对查询可见的 WAL 位置。

发送、持久化和可见不是同一个时间点。

MySQL 8.4 的复制状态可以在 replica 上查看:

SHOW REPLICA STATUS\G

重点关注:

  • Replica_IO_Running:复制 I/O 线程是否运行;
  • Replica_SQL_Running:复制 SQL 线程是否运行;
  • Seconds_Behind_Source:一个近似延迟指标,不是严格的提交时间差;
  • Relay_Source_Log_FileExec_Source_Log_Pos:中继日志接收与执行位置;
  • GTID 相关字段:如果部署启用了 GTID,应同时记录已接收和已执行的 GTID 集合。

Seconds_Behind_Source = 0 不能证明复制无延迟。例如复制线程可能刚好追上,也可能源库没有新的事务;网络、磁盘和提交确认延迟还需要单独观测。


二、用 RPO、RTO 和数据不变量定义“成功”

1. RPO 和 RTO

**RPO(Recovery Point Objective,恢复点目标)**表示故障后最多允许丢失多长时间范围内的数据。

如果某次恢复得到的数据状态对应于时刻 trt_r,故障发生时刻为 tft_f,则实际数据丢失窗口可近似写为:

D=tftrD = t_f - t_r

若要求:

DRPOD \leq RPO

则恢复点满足 RPO。

**RTO(Recovery Time Objective,恢复时间目标)**表示从故障被确认到服务恢复到约定可用状态的最长时间。实际恢复时间通常应拆成:

Trecover=Tdetect+Tdecide+Tpromote+Troute+TvalidateT_{\text{recover}} = T_{\text{detect}} + T_{\text{decide}} + T_{\text{promote}} + T_{\text{route}} + T_{\text{validate}}

其中:

  • TdetectT_{\text{detect}}:发现故障;
  • TdecideT_{\text{decide}}:确认故障并决定切换或恢复;
  • TpromoteT_{\text{promote}}:提升备用库或启动恢复;
  • TrouteT_{\text{route}}:让客户端访问新主库;
  • TvalidateT_{\text{validate}}:完成最低限度的数据和服务验证。

如果只测“数据库进程启动耗时”,而不测 DNS、连接池、应用重试和数据验证,就没有测到完整 RTO。

2. 事务边界比 SQL 行数更重要

一次业务操作可能包含多个 SQL:

BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

故障发生在两个 UPDATE 之间时:

  • 数据库事务应整体回滚;
  • 客户端可能只收到连接断开;
  • 客户端无法仅凭“请求失败”判断数据库是否已经提交;
  • 盲目重试可能造成重复扣款或重复创建订单。

因此,演练必须给每个测试事务设置唯一业务标识,例如 order_idrequest_id,并在恢复后核对:

  • 事务是否提交;
  • 是否重复提交;
  • 是否部分提交;
  • 是否违反余额、库存或状态机约束。

数据库连接断开不是事务语义的替代品。客户端得到异常时,提交结果可能是“未提交”,也可能是“数据库已经提交但响应在网络中丢失”。


三、延迟故障:慢不等于挂,复制确认会改变故障形态

1. 延迟故障影响哪些路径

网络延迟可以发生在不同链路:

  1. 客户端到数据库;
  2. 应用到连接池;
  3. 主库到备用库;
  4. 数据库到存储系统;
  5. 数据库到备份仓库。

这些延迟的结果不同。

客户端到数据库增加 200 ms,主要表现为请求耗时上升;主库到备用库增加 200 ms,则可能导致复制落后、同步提交变慢或主备切换后丢失更多最近事务。

如果事务包含 nn 次必须等待响应的网络往返,每次往返延迟增加 Δ\Delta,则仅网络部分的额外耗时近似为:

ΔTn×Δ\Delta T \approx n \times \Delta

这是近似关系,不包含服务器排队、锁等待、批量发送和并行执行。

2. 使用 Linux tc netem 注入延迟

以下命令在 Linux 测试节点的 eth0 上增加 200 ms 延迟和 20 ms 抖动:

sudo tc qdisc add dev eth0 root netem delay 200ms 20ms

查看规则:

tc qdisc show dev eth0

删除规则:

sudo tc qdisc del dev eth0 root

命令的语义是:

  • delay 200ms:为匹配到的数据包增加约 200 ms 延迟;
  • 20ms:增加随机抖动;
  • 未指定过滤器时,可能影响该网卡上的所有流量,包括 SSH、监控、复制和业务流量。

因此,生产演练不能直接对承载管理流量的物理网卡执行这条命令。更安全的做法是在隔离的网络命名空间、测试容器、专用网卡或明确的端口过滤器中注入。tc 的实际作用点是操作系统网络栈,不是数据库内部;它无法模拟数据库进程停止、存储延迟或 SQL 锁等待。

3. PostgreSQL 同步复制下的延迟

PostgreSQL 流复制可以是异步或同步。同步复制的关键语义是:提交是否需要等待某个同步备用库达到规定的确认状态,取决于 synchronous_commit 和同步备用库配置。

因此,增加主库到同步备用库的网络延迟,可能直接增加提交延迟:

SHOW synchronous_commit;
SHOW synchronous_standby_names;

如果 synchronous_commiton,提交通常要等待规定级别的远端确认;如果为 remote_apply,要求更强,远端不仅要持久化,还要完成重放。具体确认点由配置和版本语义决定,不能简单等同于“所有查询都已经可见”。

测试事务:

CREATE TABLE IF NOT EXISTS chaos_probe (
    id          bigint PRIMARY KEY,
    created_at  timestamptz NOT NULL DEFAULT clock_timestamp(),
    payload     text NOT NULL
);

BEGIN;
INSERT INTO chaos_probe(id, payload)
VALUES (1001, 'latency-test');
COMMIT;

演练时同时观察:

SELECT
    pid,
    wait_event_type,
    wait_event,
    state,
    query
FROM pg_stat_activity
WHERE state <> 'idle';

可能看到的结果包括:

  • 客户端提交耗时上升,但事务最终成功;
  • 连接池活跃连接数增加;
  • 应用超时,数据库中事务随后成功提交;
  • 备用库 replay_lag 增大;
  • 同步复制场景下,提交等待远端确认;
  • 异步复制场景下,主库提交较快,但切换时可能缺少尚未到达备用库的事务。

这说明“业务超时”不一定表示“事务未提交”。

4. MySQL 复制和半同步的边界

MySQL 异步复制中,source 提交成功与 replica 已经接收、持久化、执行之间没有同步提交保证。增加复制链路延迟通常会使 replica 的接收或执行落后。

如果启用了半同步复制,提交确认还受半同步插件、超时和降级配置影响。半同步不是“绝不丢数据”的保证:

  • 达到配置的确认条件后,source 才向客户端确认;
  • 超时或插件状态变化时,系统可能退回异步行为;
  • 确认“收到”与“已经执行并对外可读”仍可能不同。

MySQL 8.4 replica 上可执行:

SHOW REPLICA STATUS\G

主库上可结合:

SHOW BINARY LOG STATUS\G

查看当前二进制日志位置。具体字段和复制过滤配置必须结合实际拓扑判断,不能只看一个延迟字段。

5. 延迟演练的验证顺序

应使用一个带唯一标识的事务,并记录四个时间:

  1. 客户端发起时间;
  2. 数据库收到或开始执行的时间;
  3. 客户端收到响应的时间;
  4. 备用库能够读到该标识的时间。

例如在 PostgreSQL 中:

SELECT id, created_at, payload
FROM chaos_probe
WHERE id = 1001;

如果主库已提交但备用库查询不到,不能立即判定数据丢失,可能只是备用库尚未重放。应继续记录 WAL 位点和重放时间,区分:

  • 延迟;
  • 复制中断;
  • 事务回滚;
  • 已提交但查询路由到了旧备用库。

四、断网故障:必须区分客户端断网、复制断网和脑裂风险

1. 断网的三种不同实验

“断网”不是单一故障。

客户端到主库断网

数据库可能仍在正常提交事务,只是客户端收不到响应。此时最危险的是客户端重试。

主库到备用库断网

主库可能继续写入,备用库停止接收 WAL 或 binlog。异步复制下,主库通常仍可用;同步复制下,主库提交可能阻塞或超时,取决于同步配置。

主库与故障转移控制面断网

数据库节点可能都认为对方不可达。若没有仲裁、租约或 fencing(隔离旧主),直接提升备用库会产生双主写入,也就是脑裂。

2. PostgreSQL 复制断网后的状态

备用库通过流复制接收 WAL。网络中断后,主库上的复制连接可能消失,备用库停止接收新 WAL。主库可查看:

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind
FROM pg_stat_replication;

pg_wal_lsn_diff 返回两个 WAL 位点之间的字节差,是积压量的近似表示,不是时间延迟。

如果主库继续产生 WAL,必须关注 WAL 保留能力。备用库断开时间过长时:

  • 保留的 WAL 可能足够,备用库重新连接后可以继续追赶;
  • 如果所需 WAL 已被回收,备用库可能无法直接追赶,通常需要重新建立基线;
  • wal_keep_size、复制槽和归档配置会影响结果;
  • 复制槽可以防止仍需要的 WAL 被过早回收,但也可能使主库磁盘持续增长。

查看复制槽:

SELECT
    slot_name,
    slot_type,
    active,
    restart_lsn,
    confirmed_flush_lsn,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    ) AS retained_wal
FROM pg_replication_slots;

复制槽不是无限存储。网络故障与磁盘满可能形成连锁故障:备用库断开导致 WAL 保留,WAL 保留又填满主库磁盘,最终主库无法写入。

3. MySQL 复制断网后的状态

MySQL replica 断网后,I/O 线程可能无法从 source 获取新的 binlog;SQL 线程可能继续执行已经写入 relay log 的内容。

因此,I/O 线程停止不一定意味着 SQL 线程停止。要分别查看:

SHOW REPLICA STATUS\G

典型状态组合包括:

  • Replica_IO_Running: NoReplica_SQL_Running: Yes:暂时无法拉取,但已有中继日志仍在执行;
  • 两者都是 No:复制完全停止或发生错误;
  • I/O 恢复后,SQL 线程仍需一段时间追赶积压。

如果使用 GTID,应记录 source 和 replica 的 GTID 集合,恢复后验证 replica 是否已经执行了故障期间应保留的事务。只使用文件名和位置时,切换和重新指向 source 更容易因位置判断错误而产生遗漏或重复。

4. 安全的断网验证顺序

断网实验应按以下逻辑执行:

  1. 先确认当前唯一写主库;
  2. 记录主库身份、复制位点和测试事务标识;
  3. 只阻断一条明确链路;
  4. 观察数据库、复制、应用和告警;
  5. 恢复网络;
  6. 确认复制重新建立;
  7. 确认备用库追平;
  8. 再进行主库切换。

如果计划在断网期间提升备用库,必须先确保旧主库已被隔离。仅仅“旧主库目前连不上”不等于它已经停止写入。旧主库可能继续接受来自其他客户端的写请求,恢复网络后就可能出现双向写入。


五、磁盘满:容量、inode、WAL、临时空间不是同一个问题

1. “磁盘满”的四种含义

文件系统块耗尽

df -h

显示可用空间为 0,普通数据文件、日志或 WAL 无法继续扩展。

inode 耗尽

df -i

即使还有容量,只要 inode 用完,也无法创建新文件。大量小文件、临时文件或日志切片可能导致这种情况。

某个挂载点耗尽

数据库数据目录、WAL 目录、binlog 目录、临时目录和备份目录可能位于不同文件系统。根分区有空间,不代表数据库所在挂载点有空间。

配额或容器限制耗尽

容器、云盘、项目配额可能在操作系统看似还有空间时拒绝写入。数据库只能看到写入失败。

2. PostgreSQL 磁盘满的故障路径

PostgreSQL 写入通常需要:

  1. 生成 WAL;
  2. 将数据页和相关元数据写入;
  3. 在提交时满足相应持久化语义;
  4. 临时排序、哈希或创建索引时可能使用临时文件。

磁盘不足可能出现在:

  • pg_wal 所在文件系统;
  • 数据目录;
  • 表空间;
  • temp_tablespaces 指定的临时空间;
  • 日志目录。

先查看文件系统:

df -hT
df -i
du -xhd1 /var/lib/postgresql

du 只能帮助定位已存在文件,不能完整解释已删除但仍被进程打开的文件。此时需要结合:

sudo lsof +L1

不要直接删除 PostgreSQL 数据目录中的文件,也不要手工删除 WAL 文件。WAL 的保留和回收由数据库、归档、复制槽等机制共同决定。

查看复制槽保留量:

SELECT
    slot_name,
    active,
    restart_lsn,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    ) AS retained_wal
FROM pg_replication_slots;

常见错误表现包括:

  • could not extend file
  • No space left on device
  • 事务写入失败;
  • 建索引或排序时失败;
  • 归档失败导致 WAL 持续积压;
  • 备用库因无法写入而停止重放。

3. MySQL 磁盘满的故障路径

MySQL 可能需要写入:

  • InnoDB 数据文件;
  • redo log;
  • undo 空间;
  • binary log;
  • relay log;
  • tmpdir 临时文件;
  • 错误日志、慢查询日志;
  • 表空间文件。

可执行:

df -hT
df -i
du -xhd1 /var/lib/mysql

MySQL 侧查看日志和临时目录等配置:

SHOW VARIABLES WHERE Variable_name IN
(
  'datadir',
  'log_bin',
  'log_bin_basename',
  'relay_log',
  'tmpdir',
  'innodb_data_file_path'
);

磁盘满时,可能出现:

  • InnoDB 事务提交失败;
  • 复制线程因无法写 relay log 或数据页而停止;
  • binlog 无法追加;
  • 临时查询失败;
  • 数据库可以连接但不能执行写入;
  • 连接建立成功,实际 SQL 失败。

“能登录”只能说明部分控制路径还可用,不能说明业务可写。

4. 安全的磁盘满演练

不要在数据库数据目录中创建填充文件。应在独立的测试挂载点进行,例如:

mkdir -p /srv/chaos-fill
fallocate -l 8G /srv/chaos-fill/fill.bin

演练前必须知道该文件系统的剩余空间,并设置自动清理和硬性停止条件。恢复:

rm -f /srv/chaos-fill/fill.bin
sync
df -hT /srv/chaos-fill

如果要测试数据库所在文件系统,至少应预留一个不被填充的紧急空间,并提前确认:

  • 谁有权限删除测试文件;
  • 如何保留 SSH、监控和数据库日志通道;
  • 复制槽或 binlog 保留不会无限增长;
  • 备份进程不会因空间不足留下大量临时文件。

5. 空间恢复后的验证

删除文件后,不代表数据库立即恢复:

  1. 文件系统需要重新显示可用空间;
  2. 数据库可能仍有失败事务;
  3. 复制线程可能已经停止,需要人工恢复;
  4. 归档或备份任务可能进入失败状态;
  5. 连接池可能保留已失效连接;
  6. 之前超时的业务请求可能正在被重试。

PostgreSQL 可重新检查:

SELECT
    application_name,
    state,
    sync_state,
    replay_lag
FROM pg_stat_replication;

MySQL 重新执行:

SHOW REPLICA STATUS\G

并确认 I/O、SQL 线程、错误字段和延迟都恢复,而不是只看到磁盘空间增加。


六、主库切换:提升、路由、隔离和数据损失是四件事

1. 主库切换的两个方向

**计划内切换(switchover)**是在旧主库仍然可控时进行:

  1. 停止或冻结业务写入;
  2. 等待备用库追平;
  3. 确认旧主库不再接受写入;
  4. 提升备用库;
  5. 更新路由;
  6. 验证新主库;
  7. 将旧主库重建为新的备用库。

**故障切换(failover)**是在旧主库不可用或无法确认状态时进行。此时不一定有机会等复制追平,可能发生 RPO 范围内的数据丢失。更重要的是,必须防止旧主库恢复后继续写入。

2. PostgreSQL 手工提升示例

在确认备用库已经停止接收主库流复制,并且旧主库已被隔离后,可以在 PostgreSQL 备用库执行:

SELECT pg_is_in_recovery();
SELECT pg_promote();

先确认第一次查询返回 truepg_promote() 请求备用库退出恢复并成为可写主库;它不是完整的故障转移编排器,不负责:

  • 关闭旧主库;
  • 修改 DNS 或负载均衡;
  • 更新应用连接池;
  • 确认客户端不再访问旧主库;
  • 自动重建旧主库为备用库。

提升后验证:

SELECT
    pg_is_in_recovery(),
    current_wal_lsn();

应返回 false,且能够执行受控写入:

CREATE TABLE IF NOT EXISTS failover_probe (
    probe_id    bigint PRIMARY KEY,
    promoted_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

INSERT INTO failover_probe(probe_id)
VALUES (2001);

如果旧主库没有被隔离,不能因为备用库已经提升就认为切换成功。切换成功的必要条件是:新主库是唯一写入口。

3. MySQL 8.4 手工提升示例

在 MySQL replica 上,先停止复制:

STOP REPLICA;
SHOW REPLICA STATUS\G

确认复制线程已停止,并记录最后执行位置或 GTID 状态。随后根据部署的只读策略解除只读。例如:

SET GLOBAL super_read_only = OFF;
SET GLOBAL read_only = OFF;

这些设置需要足够权限,并且可能被配置管理系统重新覆盖。执行前应确认该实例确实已被选为新主库,且旧 source 已隔离。

验证:

SELECT @@read_only, @@super_read_only;

然后执行唯一标识写入:

CREATE TABLE IF NOT EXISTS failover_probe (
    probe_id    BIGINT PRIMARY KEY,
    promoted_at TIMESTAMP(6) NOT NULL
);

INSERT INTO failover_probe(probe_id, promoted_at)
VALUES (2001, CURRENT_TIMESTAMP(6));

MySQL 本身不会因为执行 STOP REPLICA 就自动完成完整的拓扑切换。路由更新、旧主隔离、GTID 关系、复制用户、过滤规则和新 replica 重建都属于运维编排。

如果重新配置旧 source 作为 replica,不能凭经验直接执行 RESET REPLICA ALL。该操作会清理复制连接配置和 relay log 信息,使用前必须已经保存所需的 GTID 或位点,并确认不会破坏恢复依据。

4. 为什么“备用库可写”不是切换成功

至少还要验证以下路径:

  • 旧主库无法接受业务写入;
  • 读写路由指向新主库;
  • DNS、VIP、代理和连接池已经刷新;
  • 新连接和已有连接的行为符合预期;
  • 应用不会把只读查询继续发到旧主库;
  • 复制监控已经切换监控对象;
  • 业务事务在新主库提交;
  • 新主库的备份、日志和复制任务已重新建立。

还要测试连接池生命周期。许多连接池不会因为 DNS 变化立即断开已有连接,导致部分请求继续访问旧主库。切换验证应包含“已有连接”和“新建连接”两类客户端。


七、恢复验证:恢复的是数据库状态,不只是数据库进程

1. 物理恢复、逻辑恢复和 PITR

物理备份保存数据库的数据文件或物理页,恢复时通常需要匹配的数据库版本、架构和备份工具语义。PostgreSQL 的物理基础备份结合 WAL 归档可以支持时间点恢复;MySQL 的物理备份能力取决于具体工具和发行版。

逻辑备份保存表、对象和数据的逻辑表示,例如:

pg_dump -Fc -d appdb -f appdb.dump
createdb appdb_restore
pg_restore -d appdb_restore appdb.dump

这是 PostgreSQL 的逻辑导出和恢复示例。它可以验证对象和数据是否能被逻辑恢复,但不能证明物理备份、WAL 归档或完整 PITR 链路可用。

MySQL 的逻辑示例:

mysqldump \
  --single-transaction \
  --routines \
  --events \
  --triggers \
  appdb > appdb.sql

mysql appdb_restore < appdb.sql

--single-transaction 对事务型表可以在一致性读语义下减少锁表影响,但不等价于对所有存储引擎、非事务表和所有对象都提供同样的一致性保证。生产数据还应考虑字符集、用户权限、触发器、事件、生成列和外部对象。

**PITR(Point-in-Time Recovery,时间点恢复)**的目标不是恢复到“最近一个备份”,而是:

恢复状态=基础备份+从备份结束后到目标时间的连续日志\text{恢复状态} = \text{基础备份} + \text{从备份结束后到目标时间的连续日志}

PostgreSQL 中,这通常意味着基础备份加连续 WAL 归档。MySQL 中,通常意味着全量或物理备份加连续 binlog;具体恢复步骤依赖使用的备份工具和备份格式。

2. PostgreSQL 备份完整性验证

如果使用 PostgreSQL 的物理基础备份,pg_verifybackup 可以验证备份目录的校验和和备份清单:

pg_verifybackup /backup/base_20250301

它能发现备份文件缺失、校验不匹配等问题,但不能证明:

  • WAL 归档链完整;
  • 备份能在目标主机启动;
  • 应用连接配置正确;
  • 恢复后的业务数据满足不变量。

所以仍必须执行一次隔离恢复:

  1. 创建干净的恢复目录;
  2. 恢复基础备份;
  3. 提供所需 WAL;
  4. 指定恢复目标时间;
  5. 启动实例;
  6. 确认恢复完成;
  7. 执行数据和业务验证。

恢复目标应明确到时间点或事务边界。若目标时间落在事务中间,数据库恢复到的是符合数据库恢复语义的某个一致状态,不是把一个事务拆成半提交状态。

3. 恢复后的三层验证

第一层:服务验证

SELECT 1;
SELECT version();

确认:

  • 进程运行;
  • 端口可连接;
  • 用户认证正常;
  • 目标数据库可打开;
  • 应用所需扩展、字符集和参数存在。

第二层:数据库一致性验证

检查主键、外键、唯一约束和关键表行数。不要只依赖行数,因为删除一行再插入一行可能保持行数不变。

例如:

SELECT COUNT(*) FROM orders;
SELECT COUNT(*) FROM order_items;

SELECT order_id
FROM order_items
GROUP BY order_id
HAVING COUNT(*) = 0;

更有价值的是使用业务不变量:

SELECT COUNT(*)
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
WHERE i.order_id IS NULL
  AND o.status IN ('PAID', 'SHIPPED');

该查询只是示例,真实不变量必须来自业务模型。

第三层:业务一致性验证

设计一组恢复前写入的探针数据:

演练批次:20250301-01
订单:100001
支付流水:pay-100001
库存扣减:sku-A, -1

恢复后验证:

  • 订单是否存在且状态正确;
  • 支付流水是否最多一次;
  • 库存扣减是否与订单状态匹配;
  • 关联表是否完整;
  • 唯一键和幂等键是否仍然有效;
  • 恢复点之后不应出现的数据是否确实不存在。

“数据库能启动”和“关键订单状态正确”是两个不同的成功条件。


八、复制延迟和切换时的数据损失如何计算

1. 以时间计算 RPO

假设:

  • 主库最后确认提交的测试事务时间为 10:00:00.800
  • 备用库最后已经持久化或执行到的事务时间为 09:59:58.300
  • 故障发生在 10:00:01.000

若切换到备用库后只能确认到 09:59:58.300,则可观测数据缺口约为:

10:00:01.00009:59:58.300=2.700 秒10{:}00{:}01.000 - 09{:}59{:}58.300 = 2.700\text{ 秒}

这只是时间近似。严格 RPO 应根据事务提交顺序、WAL/binlog 位点和恢复目标计算,而不是仅看 Seconds_Behind_Source 或某个监控采样值。

2. 以日志位点计算积压

PostgreSQL 可以用:

SELECT pg_wal_lsn_diff(
    pg_current_wal_lsn(),
    replay_lsn
)
FROM pg_stat_replication;

结果表示尚未被备用库重放的 WAL 字节数。它不能直接转换成秒数,除非结合一段时间内的 WAL 生成速率:

TapproxBbehindRwalT_{\text{approx}} \approx \frac{B_{\text{behind}}}{R_{\text{wal}}}

其中:

  • BbehindB_{\text{behind}}:积压 WAL 字节数;
  • RwalR_{\text{wal}}:单位时间 WAL 生成字节数。

如果写入速率剧烈波动,这个估算会失真。

MySQL 使用 GTID 时,优先比较已执行事务集合;仅比较日志文件和位置时,必须确认日志没有轮转、过滤规则一致,并且位置语义对应同一 source。

3. 一个常见反例:备用库“延迟为零”仍可能丢数据

假设:

  1. 备用库在 10:00:00 追平;
  2. 10:00:0110:00:05 没有复制流量;
  3. 监控在 10:00:05 看到延迟为零;
  4. 主库在 10:00:06 接收一个事务;
  5. 10:00:06.2 主库断电;
  6. 备用库尚未收到该事务。

监控中的“零延迟”只说明采样时没有可见积压,不能保证下一瞬间提交的事务已经复制。切换前需要冻结写入或使用明确的同步确认,而不是把历史监控值当作切换时刻的保证。


九、把故障注入、观测和恢复串成一个可重复流程

1. 演练前置检查

演练开始前:

  • 确认数据已隔离或允许损失;
  • 确认备份和恢复链路有最近成功记录;
  • 确认有数据库超级用户或等价运维权限;
  • 确认 SSH、带外管理和监控不依赖被注入的链路;
  • 确认故障注入命令有明确的撤销命令;
  • 确认停止条件,例如错误率、磁盘剩余空间和复制积压阈值;
  • 确认谁有权中止演练;
  • 记录所有时间点和命令输出。

2. 故障中的观测维度

数据库可观测性不能只看 CPU 和连接数,应同时观察:

连接

  • 新连接成功率;
  • 连接建立耗时;
  • 连接池等待时间;
  • 连接数是否达到上限;
  • 失效连接是否被清理。

事务

  • 提交、回滚和超时数量;
  • 长事务;
  • 活跃事务年龄;
  • 客户端超时后数据库是否仍有提交;
  • 重试是否带来重复业务操作。

锁和执行

  • wait_event 或锁等待;
  • 慢查询;
  • 执行计划是否因资源不足改变;
  • 临时文件和排序是否放大磁盘压力。

复制

  • 连接状态;
  • 接收、持久化、重放位点;
  • 延迟和积压;
  • WAL、binlog、relay log 增长;
  • 复制错误和重连次数。

SLO

  • 成功率;
  • p95、p99 延迟;
  • 错误类型;
  • 数据库不可写持续时间;
  • 切换后恢复到正常流量的时间。

3. 恢复后的检查顺序

推荐顺序如下:

  1. 撤销故障注入;
  2. 确认操作系统资源恢复;
  3. 确认数据库进程和监听恢复;
  4. 确认复制线程或流复制恢复;
  5. 确认复制积压下降并最终追平;
  6. 确认路由只指向唯一主库;
  7. 清理或重建失效连接池连接;
  8. 执行探针事务;
  9. 执行数据库一致性检查;
  10. 验证备份、归档和监控重新正常;
  11. 持续观察一段时间,再宣布演练结束。

如果只恢复网络、不检查复制状态,可能留下“业务已恢复、备用库已永久落后”的隐性故障。


十、常见误解和失败表现

误解一:请求超时就代表事务失败

错误表现:

  • 应用超时;
  • 操作人员重试;
  • 恢复后发现同一订单有两条支付或两次扣库存。

正确做法是使用幂等键查询数据库最终状态,并在事务设计中明确客户端无法确认提交结果时的处理方式。

误解二:主备切换只需要执行一个 promote 命令

提升数据库只是改变数据库角色的一步。没有旧主隔离、路由更新和连接池处理,仍可能出现:

  • 双主;
  • 部分请求访问旧主;
  • 只读节点被误当成新主;
  • 监控继续监控旧主;
  • 新主没有备份和复制。

误解三:删除大文件就完成磁盘恢复

如果空间被已删除但仍打开的文件占用,删除目录项后空间不会立即归还;如果真正原因是复制槽、归档失败或 relay log,删除日志还可能破坏恢复链。应先定位增长源,再用数据库和运维工具按其生命周期清理。

误解四:Seconds_Behind_Source = 0 就没有复制风险

该值受 source 时间、复制线程状态和采样时机影响,不能独立证明数据已持久化、已执行或切换时不会丢失。

误解五:备份文件存在就代表可恢复

备份可能存在以下问题:

  • 文件损坏;
  • 缺少连续 WAL 或 binlog;
  • 密钥不可用;
  • 权限和版本不匹配;
  • 恢复后缺少扩展、事件或权限;
  • 数据能恢复但业务不变量已破坏。

恢复演练必须真正启动隔离实例,并执行数据和业务验证。

误解六:网络故障一定比进程崩溃安全

网络故障更容易产生“双方都活着但互相看不见”的状态。若故障转移系统没有仲裁和 fencing,网络分区比单节点宕机更容易导致脑裂。


十一、一次完整演练的最小算例

下面给出一个不绑定具体厂商编排器的流程。设定:

  • PostgreSQL 一主一备;
  • 业务表中使用 request_id 唯一约束;
  • 目标 RPO 不超过 5 秒;
  • 目标 RTO 不超过 60 秒;
  • 演练只针对测试租户。

测试事务:

CREATE TABLE IF NOT EXISTS chaos_business_probe (
    request_id  text PRIMARY KEY,
    tenant_id   bigint NOT NULL,
    amount      numeric(12,2) NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT clock_timestamp()
);

BEGIN;

INSERT INTO chaos_business_probe(request_id, tenant_id, amount)
VALUES ('chaos-20250301-0001', 99, 10.00);

COMMIT;

执行步骤:

  1. 记录主库 pg_is_in_recovery()、复制状态、磁盘状态和当前时间;
  2. 在客户端到数据库链路增加延迟;
  3. 提交上述事务,记录客户端耗时;
  4. 在主库到备用库链路制造短暂断网;
  5. 确认备用库停止接收或重放,并记录积压;
  6. 恢复网络;
  7. 等待备用库追平;
  8. 在旧主库仍可控时执行计划内切换,或先隔离旧主再执行故障切换;
  9. 通过新路由查询 request_id = 'chaos-20250301-0001'
  10. 在新主库写入另一个唯一探针;
  11. 确认旧主库不能看到或接受新的业务写入;
  12. 检查复制、备份、日志和应用错误率;
  13. 恢复旧主库为备用库,而不是直接把它重新当主库接入。

成功条件必须同时满足:

  • 事务结果符合预期;
  • 切换期间没有双主写入;
  • 新主库对外可写;
  • 旧主库对外不可写;
  • 复制链路重新建立;
  • 恢复时间不超过 60 秒;
  • 若发生数据缺口,缺口不超过 5 秒;
  • 应用重试没有制造重复业务结果;
  • 恢复后的备份链路仍然可用。

十二、如何判断演练结果真实可信

演练结果应分为三类,而不是简单写“成功”或“失败”。

1. 数据库层成功,业务层失败

例如:

  • PostgreSQL 成功提升;
  • SQL 查询正常;
  • 但应用连接池仍访问旧主;
  • 订单服务持续报连接错误。

这说明数据库操作成功,但切换编排或路由失败。

2. 服务层成功,数据层失败

例如:

  • 恢复实例成功启动;
  • 表可以查询;
  • 但恢复点缺少最后 30 秒订单;
  • 或订单存在而支付流水缺失。

这说明 RTO 可能满足,但 RPO 或业务一致性不满足。

3. 故障已消除,系统仍未恢复

例如:

  • 磁盘空间已释放;
  • 但 PostgreSQL 复制槽导致 WAL 继续保留;
  • MySQL replica 的 SQL 线程仍停止;
  • 监控恢复为绿色,但备用库实际上没有追平。

这说明恢复验证不完整,故障后的状态机没有回到健康状态。


数据库故障演练的核心不是制造更大的破坏,而是验证系统能否在明确的事务边界、复制语义、资源约束和路由规则下,完成“发现故障—隔离风险—切换或恢复—验证数据—重建冗余”的闭环。延迟揭示等待和超时问题,断网揭示分区和脑裂问题,磁盘满揭示资源耗尽与日志保留问题,主库切换揭示角色和路由问题,而恢复验证最终回答的是:系统恢复后,数据是否仍然可信。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。