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

数据库测试体系:事务测试、迁移测试、Testcontainers 和故障注入

数据库测试不是“执行几条 SQL,再检查返回值”。一个完整的数据库测试体系至少要回答四类问题:

  1. 事务是否满足预期的隔离和原子性?
  2. 数据库迁移是否能在真实引擎、真实版本和真实数据规模下安全执行?
  3. 测试环境是否足够接近生产,而不是被 SQLite 或 Mock 掩盖问题?
  4. 锁等待、死锁、连接中断、进程崩溃和磁盘空间不足等故障发生时,应用和数据库能否正确恢复?

这些问题彼此关联。事务语义决定并发测试如何写;迁移过程本身是事务和锁的组合;Testcontainers 提供接近真实数据库的临时环境;故障注入则验证系统从正常状态进入异常状态后,是否能回到可接受状态。


一、先定义测试对象:数据库、应用和发布过程

数据库测试经常混淆三个对象。

1. 数据库内核语义

这是 PostgreSQL、MySQL/InnoDB 等引擎保证的内容,例如:

  • 提交前的数据是否对其他事务可见;
  • 一个事务中的多个语句看到的是同一个快照,还是不同快照;
  • 唯一约束冲突如何表现;
  • 死锁如何被检测;
  • DDL 是否能回滚;
  • 连接断开后未提交事务如何处理。

这些行为不能用 Mock 替代。

2. 数据访问层语义

这是驱动、连接池和 ORM 的行为,例如:

  • 连接池是否把上一个事务的隔离级别带给下一个请求;
  • 异常发生后是否自动回滚;
  • 是否重试了整个事务,而不是只重试最后一条 SQL;
  • 多线程或协程是否错误共享同一个连接。

数据库本身正确,并不代表数据访问层正确。

3. 发布过程语义

这是迁移工具、应用版本和部署顺序的组合,例如:

  • 旧应用能否访问扩展后的表结构;
  • 新应用是否能在旧迁移未完成时启动;
  • 回滚应用版本时,数据库结构是否仍兼容;
  • 长事务是否阻塞了迁移;
  • 迁移失败后是否能继续执行,还是留下半完成状态。

因此,“迁移脚本执行成功”只证明了很小的一部分。


二、事务测试的基础:从状态转移开始

2.1 事务的形式化模型

设数据库状态为 SS,事务操作序列为:

T=(o1,o2,,on)T = (o_1, o_2, \ldots, o_n)

执行事务后得到状态 SS'。事务的原子性要求:

S={FT(S),事务提交S,事务回滚S' = \begin{cases} F_T(S), & \text{事务提交}\\ S, & \text{事务回滚} \end{cases}

也就是说,外部观察者不能看到“只完成了一半”的提交结果。

例如转账:

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

正确的事务不变量是:

balance1+balance2=Cbalance_1 + balance_2 = C

其中 CC 是转账前两账户余额总和。测试不能只断言账户 1 减少了 100,还必须断言:

  • 两条更新都成功时,总额不变;
  • 第二条更新失败时,第一条更新也不可见;
  • 事务提交前,其他连接看不到中间状态;
  • 事务回滚后,两个账户都恢复原状。

2.2 一个可执行的 PostgreSQL 事务测试

下面示例使用 PostgreSQL。先创建表:

CREATE TABLE accounts (
    id      bigint PRIMARY KEY,
    balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
);

INSERT INTO accounts(id, balance)
VALUES (1, 100.00), (2, 50.00);

应用代码可以写成:

def transfer(conn, source_id, target_id, amount):
    with conn.transaction():
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE accounts
                   SET balance = balance - %s
                 WHERE id = %s
                   AND balance >= %s
                """,
                (amount, source_id, amount),
            )

            if cur.rowcount != 1:
                raise ValueError("insufficient balance or source account missing")

            cur.execute(
                """
                UPDATE accounts
                   SET balance = balance + %s
                 WHERE id = %s
                """,
                (amount, target_id),
            )

            if cur.rowcount != 1:
                raise ValueError("target account missing")

这里的关键不是 with 语法,而是错误处理边界:

  1. 第一条 UPDATE 成功;
  2. 第二条 UPDATE 失败;
  3. 异常离开事务上下文;
  4. 整个事务回滚,而不是只回滚第二条语句。

测试应当验证最终状态:

def test_transfer_rolls_back_when_target_missing(conn):
    with pytest.raises(ValueError):
        transfer(conn, 1, 999, 10)

    with conn.cursor() as cur:
        cur.execute("SELECT balance FROM accounts WHERE id = 1")
        assert cur.fetchone()[0] == 100

如果测试只检查异常,却不检查余额,就可能掩盖“异常返回了,但第一条更新已经提交”的严重问题。

2.3 可见性测试必须使用两个连接

同一个连接无法可靠地验证事务对其他事务的可见性。至少需要连接 A 和连接 B。

PostgreSQL 默认的 READ COMMITTED 隔离级别下,每条语句通常获取自己的快照。测试过程如下:

步骤 连接 A 连接 B
1 开启事务,更新余额但不提交
2 查询余额
3 提交 A
4 再次查询余额

连接 B 在第 2 步不应看到 A 未提交的数据;第 4 步的新查询通常能看到已提交数据。

伪代码:

conn_a.execute("BEGIN")
conn_a.execute(
    "UPDATE accounts SET balance = balance - 10 WHERE id = 1"
)

before_commit = conn_b.execute(
    "SELECT balance FROM accounts WHERE id = 1"
).fetchone()[0]

conn_a.execute("COMMIT")

after_commit = conn_b.execute(
    "SELECT balance FROM accounts WHERE id = 1"
).fetchone()[0]

在 PostgreSQL READ COMMITTED 下,预期是:

before_commit == 100
after_commit  == 90

但不能把这个结论无条件推广到所有数据库和所有隔离级别。必须在测试中显式设置并记录:

  • 数据库引擎和版本;
  • 隔离级别;
  • 是否自动提交;
  • 是否使用普通读还是锁定读;
  • 连接是否复用。

三、隔离级别测试:验证现象,而不是只验证配置字符串

隔离级别描述并发事务之间允许观察到哪些现象。测试的目标不是“连接参数等于 SERIALIZABLE”,而是验证不变量在并发下是否仍成立。

3.1 丢失更新

两个事务都读取余额 100,分别加 10 和加 20:

T1: 读取 100
T2: 读取 100
T1: 写入 110
T2: 写入 120

最终结果是 120,T1 的更新丢失。

错误代码通常是:

balance = select_balance(conn, account_id)
new_balance = balance + amount
update_balance(conn, account_id, new_balance)

更安全的写法是让数据库执行原子更新:

UPDATE accounts
SET balance = balance + :amount
WHERE id = :id;

如果业务需要先判断余额,应使用条件更新:

UPDATE accounts
SET balance = balance - :amount
WHERE id = :id
  AND balance >= :amount;

然后检查受影响行数。这种写法把“读取、判断、写入”的关键条件放进一个数据库操作,避免应用层读改写窗口。

3.2 写偏差

写偏差比丢失更新更容易被忽略。假设业务规则是:

至少有一名值班医生。

表中有医生 A、B,两人都值班。两个事务分别执行:

SELECT count(*)
FROM doctors
WHERE on_call = true;

两者都看到数量为 2,于是各自把自己设为不值班:

UPDATE doctors
SET on_call = false
WHERE id = :my_id;

最终数量为 0,两个事务都没有修改同一行,因此简单的行锁可能无法阻止这个问题。

可以采用:

  • SERIALIZABLE,让数据库检测冲突并中止其中一个事务;
  • 锁定代表整个约束范围的行;
  • 把业务状态设计成数据库可直接约束的结构;
  • 使用单独的互斥资源行,例如锁定一个 duty_roster 记录。

PostgreSQL 中,串行化事务可能抛出序列化失败;MySQL/InnoDB 也可能因为冲突或锁等待产生需要重试的错误。应用必须把这类错误视为整个事务失败,而不是只重试最后一条 SQL。

3.3 串行化重试必须重试整个事务

错误做法:

try:
    cur.execute("UPDATE ...")
except SerializationFailure:
    cur.execute("UPDATE ...")  # 只重试这一条,事务上下文可能已经失效

正确的结构是:

for attempt in range(max_attempts):
    try:
        with conn.transaction():
            result = business_transaction(conn)
        return result
    except (SerializationFailure, DeadlockDetected):
        if attempt + 1 == max_attempts:
            raise
        sleep(backoff(attempt))

原因是序列化失败或死锁回滚的是当前事务,而不是某一条 SQL。重试必须重新读取数据、重新计算业务决策并重新提交。

重试还要考虑:

  • 事务是否包含外部副作用,例如发消息、扣款接口调用;
  • 是否使用幂等键;
  • 退避时间是否有上限;
  • 数据库错误是否确实属于可重试类别;
  • 重试次数耗尽后是否报警。

绝不能把所有数据库异常都重试。唯一键冲突、CHECK 约束失败、SQL 语法错误通常不是瞬时故障。


四、事务测试的完整覆盖范围

4.1 原子性测试

至少测试以下路径:

  1. 所有语句成功,事务提交;
  2. 第一条语句失败;
  3. 中间语句失败;
  4. 提交前连接断开;
  5. 业务代码主动抛出异常;
  6. 事务嵌套或保存点行为。

保存点不是独立事务。以 PostgreSQL 为例:

BEGIN;

INSERT INTO audit_log(event) VALUES ('start');

SAVEPOINT before_optional_step;

INSERT INTO optional_table(id) VALUES (1);

ROLLBACK TO SAVEPOINT before_optional_step;

INSERT INTO audit_log(event) VALUES ('finish');

COMMIT;

最终 audit_log 中有 startfinish,但 optional_table 的插入被撤销。测试应区分:

  • 回滚到保存点;
  • 回滚整个事务;
  • 驱动是否正确释放或重置保存点状态。

4.2 约束测试

不要只在应用层验证约束。数据库约束是并发下最后一道防线,应直接测试:

  • 主键重复;
  • 唯一键重复;
  • 外键引用不存在;
  • CHECK 失败;
  • NOT NULL 失败;
  • 删除父记录时的外键行为;
  • ON CONFLICT 或 MySQL ON DUPLICATE KEY UPDATE 的实际结果。

异常测试还要验证事务状态。某些驱动在一条 SQL 失败后,会把当前事务标记为 failed transaction;如果不回滚,后续 SQL 会继续失败。测试应明确调用回滚,而不是假设连接自动恢复。

4.3 连接池污染测试

连接池复用的是连接状态,不只是 TCP 通道。测试一个请求设置了:

  • 隔离级别;
  • SET 会话变量;
  • 时区;
  • 当前 schema;
  • 临时表;
  • 未提交事务;

请求结束后,下一次借用同一个连接必须得到预期状态。

一个常见故障是:

请求 A:
BEGIN
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
执行 SQL
异常退出,没有 ROLLBACK

请求 B:
从连接池拿到同一连接
执行普通查询

请求 B 可能遇到“当前事务已失败”或继承不应继承的会话状态。连接池释放连接时应执行回滚和必要的状态重置;测试要刻意在异常路径释放连接。


五、为什么事务测试不能依赖 SQLite 或 Mock

SQLite 的 SQL 子集和并发模型与 PostgreSQL、MySQL/InnoDB 并不相同。Mock 更无法模拟:

  • 行锁和间隙锁;
  • MVCC 快照;
  • 死锁检测;
  • DDL 锁;
  • 连接断开后的事务清理;
  • 版本相关的执行计划;
  • 真实约束和索引行为。

SQLite 可以用于纯业务逻辑的快速测试,但不能替代目标数据库的集成测试。只要测试涉及事务隔离、迁移、索引、锁、方言或错误码,就应使用目标引擎。


六、Testcontainers:把真实数据库放进测试生命周期

6.1 Testcontainers 解决什么问题

Testcontainers 是通过容器临时启动依赖服务的测试工具。数据库测试中,它通常负责:

  1. 拉取指定数据库镜像;
  2. 启动容器;
  3. 等待端口和数据库协议真正就绪;
  4. 提供动态连接信息;
  5. 执行迁移和测试;
  6. 测试结束后删除容器和临时数据。

它解决的是“测试数据库来源和生命周期”问题,不会自动解决:

  • 测试数据隔离;
  • 迁移回滚;
  • 并发编排;
  • 故障注入;
  • 生产数据规模模拟;
  • 备份恢复验证。

6.2 Python 端到端示例

以下示例使用:

  • PostgreSQL 16 容器;
  • Python;
  • pytest
  • testcontainers
  • psycopg
  • SQL 文件或 Python SQL 初始化。

安装依赖:

pip install pytest testcontainers[postgresql] psycopg[binary]

宿主机需要 Docker Engine 或兼容的 Docker API,并且当前用户有启动容器的权限。

测试代码:

import psycopg
import pytest
from testcontainers.postgres import PostgresContainer


@pytest.fixture(scope="session")
def postgres_url():
    with PostgresContainer("postgres:16") as postgres:
        yield postgres.get_connection_url()


@pytest.fixture
def conn(postgres_url):
    with psycopg.connect(postgres_url) as connection:
        with connection.cursor() as cur:
            cur.execute("""
                CREATE TABLE IF NOT EXISTS accounts (
                    id bigint PRIMARY KEY,
                    balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
                )
            """)
            cur.execute("TRUNCATE accounts")
            cur.execute("""
                INSERT INTO accounts(id, balance)
                VALUES (1, 100.00), (2, 50.00)
            """)
        connection.commit()

        yield connection

这里有几个重要的生命周期点:

  • scope="session" 让整个测试会话复用一个容器;
  • 每个测试仍然清理自己的表数据;
  • with PostgresContainer(...) 退出时负责停止容器;
  • with psycopg.connect(...) 负责关闭连接;
  • 初始化 SQL 使用 commit(),否则连接关闭时可能回滚初始化数据。

测试:

def test_transfer(conn):
    with conn.transaction():
        with conn.cursor() as cur:
            cur.execute("""
                UPDATE accounts
                   SET balance = balance - 10
                 WHERE id = 1
                   AND balance >= 10
            """)
            assert cur.rowcount == 1

            cur.execute("""
                UPDATE accounts
                   SET balance = balance + 10
                 WHERE id = 2
            """)
            assert cur.rowcount == 1

    with conn.cursor() as cur:
        cur.execute("""
            SELECT id, balance
            FROM accounts
            ORDER BY id
        """)
        assert cur.fetchall() == [(1, 90), (2, 60)]

运行:

pytest -q

预期看到测试通过。第一次运行可能需要拉取镜像,耗时会比后续运行更长。

6.3 不要用固定宿主机端口

容器内 PostgreSQL 监听 5432,不意味着宿主机必须绑定 5432。测试框架应读取动态映射后的端口和连接地址。固定端口会造成:

  • 并行测试互相冲突;
  • 本机已有数据库占用端口;
  • CI 多任务无法同时运行;
  • 测试连接到了错误的数据库。

6.4 容器复用与隔离

Testcontainers 的容器复用可以减少启动时间,但会增加状态泄漏风险。复用时必须明确:

  • 每个测试是否使用独立 schema;
  • 是否清理表、序列、临时对象;
  • 迁移版本是否重置;
  • 并行任务是否使用不同数据库名;
  • 测试失败后容器是否保留用于诊断。

对于迁移测试,通常更适合每个测试类或每组迁移使用全新数据库,而不是复用已经被部分迁移过的实例。


七、迁移测试:测试的是状态转换和兼容窗口

设数据库 schema 版本为 ViV_i,迁移脚本为 MiM_i,则基本条件是:

Mi(Vi)=Vi+1M_i(V_i) = V_{i+1}

但生产发布还存在应用版本。设应用版本为 AiA_i,真正需要验证的是:

Compatible(Ai,Vi),Compatible(Ai,Vi+1)Compatible(A_i, V_i), \quad Compatible(A_i, V_{i+1})

在滚动发布期间,旧应用和新应用可能同时运行,因此通常还要满足:

Compatible(Aold,Vnew)Compatible(A_{\text{old}}, V_{\text{new}})

这就是“迁移成功”与“可安全发布”的区别。

7.1 四类迁移测试

从空数据库执行全部迁移

验证:

  • 迁移顺序;
  • 初始 schema;
  • 脚本是否依赖本地残留对象;
  • 全新部署能否启动。

从每个历史版本升级

对每个 ViV_i 执行:

创建 V_i 数据库
加载代表性数据
执行 M_i
验证 V_{i+1}

这能发现“只在全新数据库可执行,但无法从真实旧版本升级”的问题。

重复执行或恢复执行

迁移工具通常记录版本,但数据库迁移脚本本身也应考虑:

  • 中途进程退出;
  • DDL 已完成但版本记录未写入;
  • 数据回填执行了一部分;
  • 重试时对象已经存在。

不能盲目给所有 SQL 加 IF NOT EXISTS。这可能掩盖列类型错误、索引定义错误或已存在但不兼容的对象。迁移系统需要明确哪些操作可幂等,哪些失败后必须人工处理。

失败后的恢复

人为让迁移在中间步骤失败,检查:

  • 事务性 DDL 是否整体回滚;
  • 非事务性 DDL 是否留下部分结果;
  • 重新执行是否安全;
  • 是否需要补偿迁移;
  • 应用是否仍能启动或必须停止发布。

八、PostgreSQL 与 MySQL 的 DDL 事务边界不同

8.1 PostgreSQL

PostgreSQL 的许多 DDL 可以放在事务中:

BEGIN;

ALTER TABLE accounts ADD COLUMN currency text;

-- 如果后续失败,前面的 ALTER TABLE 通常可随事务回滚
SELECT 1 / 0;

COMMIT;

上例中的除零错误会导致事务回滚,新增列不会提交。

但这不表示所有 PostgreSQL DDL 都可放进普通事务。例如:

CREATE INDEX CONCURRENTLY idx_accounts_currency
ON accounts(currency);

CREATE INDEX CONCURRENTLY 不能在事务块中执行。迁移工具必须允许“非事务迁移”,而测试必须分别验证:

  • 事务性 DDL 失败后的回滚;
  • 非事务性 DDL 中途失败后的残留状态;
  • 并发建索引期间应用读写是否继续;
  • 索引失败后是否留下需要清理的无效对象。

8.2 MySQL 8.4 与 InnoDB

MySQL 的 DDL 不能简单按 PostgreSQL 的事务性 DDL 理解。许多 DDL 会隐式提交事务,典型风险是:

START TRANSACTION;

UPDATE accounts SET balance = balance - 10 WHERE id = 1;

ALTER TABLE accounts ADD COLUMN note varchar(100);

ROLLBACK;

不能据此假设前面的 UPDATE 一定会随 ROLLBACK 撤销。MySQL 的 DDL、隐式提交和具体算法必须按 MySQL 文档及目标版本验证。

此外,MySQL 的在线 DDL 选项并不意味着“完全无锁”。ALGORITHM=INSTANTINPLACECOPY 以及 LOCK=NONE 的可用性和实际行为取决于表结构、操作类型、存储引擎和版本。迁移测试应记录:

  • 实际采用了哪种算法;
  • 是否等待元数据锁;
  • 是否阻塞读写;
  • 执行失败时表结构和数据处于什么状态。

因此,不能把同一份迁移脚本仅通过方言替换就认为同时适用于 PostgreSQL 和 MySQL。


九、Expand-Contract:在线变更的可测试状态机

Expand-Contract 是一种把不可兼容变更拆成多个兼容阶段的方法。

以把 users.name 拆成 first_namelast_name 为例。

阶段 1:Expand

新增可为空列,不删除旧列:

ALTER TABLE users
ADD COLUMN first_name text,
ADD COLUMN last_name text;

此时旧应用仍只读写 name,新应用可以识别新列,但不能假设新列已经有值。

测试条件:

旧应用 + 新 schema       可用
新应用只读旧数据          可用
新旧应用并存              可用

阶段 2:双写

应用写入时同时维护旧列和新列:

UPDATE users
SET name = :name,
    first_name = :first_name,
    last_name = :last_name
WHERE id = :id;

如果旧列和新列的语义不完全等价,不能简单复制字符串。应明确规范化规则,并测试:

  • Unicode;
  • 空字符串与 NULL;
  • 超长输入;
  • 旧数据无法解析;
  • 双写部分失败;
  • 重试是否幂等。

阶段 3:回填

回填不能默认一次性执行:

UPDATE users
SET first_name = split_part(name, ' ', 1),
    last_name  = split_part(name, ' ', 2)
WHERE first_name IS NULL;

大表上一次性更新可能产生长事务、膨胀、锁竞争和复制延迟。更安全的测试应模拟批量回填:

WITH batch AS (
    SELECT id
    FROM users
    WHERE first_name IS NULL
    ORDER BY id
    LIMIT 1000
)
UPDATE users u
SET first_name = split_part(u.name, ' ', 1),
    last_name  = split_part(u.name, ' ', 2)
FROM batch
WHERE u.id = batch.id;

每批提交,并验证:

count(first_name IS NULL)0count(first\_name\ IS\ NULL) \rightarrow 0

但“变成 0”还不够,还要验证转换结果与业务规则一致。

阶段 4:切换读路径

新应用优先读取新列,必要时在新列为空时回退旧列:

SELECT COALESCE(first_name || ' ' || last_name, name)
FROM users
WHERE id = :id;

测试旧应用和新应用并行运行,尤其要测试:

  • 新应用写入后旧应用是否还能读取;
  • 旧应用写入后新应用是否能看到一致结果;
  • 双写失败时是否出现新旧列不一致;
  • 消息消费者、报表任务和后台脚本是否仍使用旧列。

阶段 5:Contract

确认所有读写方都已切换后,才删除旧列。删除是破坏性操作,通常不应与应用切换放在同一个发布步骤:

ALTER TABLE users
DROP COLUMN name;

如果发现新应用有问题,回滚应用版本之前必须保证旧应用仍能访问数据库。因此,Contract 的时机必须晚于应用回滚窗口。


十、锁风险测试:迁移成功不等于迁移安全

迁移在空表上只用几毫秒,不代表生产安全。锁风险取决于:

  • 表大小;
  • 活跃事务;
  • DDL 所需锁模式;
  • 读写频率;
  • 事务持续时间;
  • 是否存在长查询;
  • 副本和变更捕获系统的处理能力。

10.1 PostgreSQL 锁等待示例

连接 A 持有未提交写锁:

BEGIN;

UPDATE accounts
SET balance = balance + 1
WHERE id = 1;

连接 B 执行需要更强表锁的 DDL:

ALTER TABLE accounts ADD COLUMN note text;

B 可能等待 A 结束。测试时应设置超时,避免测试永久挂起:

SET lock_timeout = '2s';
SET statement_timeout = '5s';

ALTER TABLE accounts ADD COLUMN note text;

预期是等待超过 lock_timeout 后失败。测试应验证:

  1. DDL 返回锁超时错误;
  2. 迁移工具识别为可诊断的失败;
  3. 连接事务状态被回滚;
  4. 应用读写仍然可用;
  5. 释放 A 的事务后,重试迁移能够成功。

10.2 诊断等待者和持有者

PostgreSQL 可以检查活动会话和等待状态:

SELECT pid,
       usename,
       state,
       wait_event_type,
       wait_event,
       query
FROM pg_stat_activity
WHERE datname = current_database();

锁关系可通过 pg_lockspg_stat_activity 关联分析。测试环境应在失败时保留这些信息,否则只看到“迁移超时”,无法判断是锁、网络还是数据库负载。

MySQL 8.4 则可结合:

SHOW PROCESSLIST;

以及 Performance Schema 中的锁等待信息诊断。不要把 PostgreSQL 的系统视图名称直接套到 MySQL。


十一、故障注入:让系统在可控条件下失败

故障注入是主动制造故障,并验证系统的状态转移、错误分类和恢复结果。

故障应至少明确四个属性:

  1. 注入点:客户端、连接、数据库进程、磁盘、网络;
  2. 发生时机:事务前、事务中、提交前、提交后;
  3. 预期结果:回滚、重试、报警、人工恢复;
  4. 验证方式:数据不变量、日志、指标、数据库状态。

11.1 事务中断连接

事务过程:

BEGIN
写入第一行
客户端连接断开

数据库通常会在检测到会话断开后回滚未提交事务,但应用不能只凭“通常”推断结果。测试应:

  1. 连接 A 开启事务;
  2. 写入一部分数据;
  3. 强制关闭连接或停止客户端;
  4. 连接 B 查询结果;
  5. 验证未提交数据不可见;
  6. 验证连接池不会把失效连接重新交给其他请求。

若使用 Testcontainers,可以停止整个数据库容器:

container.stop()

但这模拟的是数据库实例不可用,不等价于单个客户端连接断开。两者必须分开测试。

11.2 数据库进程崩溃和重启

容器故障测试可以模拟:

docker kill <container-id>
docker start <container-id>

或者在测试代码中调用容器的 stop/start 操作。应验证:

  • 已提交事务的数据是否仍存在;
  • 未提交事务的数据是否不存在;
  • 应用连接池是否丢弃旧连接;
  • 重启后是否能重新建立连接;
  • 迁移记录是否与实际 schema 一致;
  • 业务请求是否出现重复执行。

“已提交数据存在”依赖数据库正确完成持久化和恢复,但测试不能仅看容器重新启动成功,还应执行校验查询和业务不变量检查。

11.3 死锁注入

死锁需要两个事务以相反顺序锁定资源。

连接 A:

BEGIN;
UPDATE accounts SET balance = balance + 1 WHERE id = 1;
-- 暂停
UPDATE accounts SET balance = balance + 1 WHERE id = 2;

连接 B:

BEGIN;
UPDATE accounts SET balance = balance + 1 WHERE id = 2;
-- 暂停
UPDATE accounts SET balance = balance + 1 WHERE id = 1;

当 A 等待 B 持有的行锁、B 等待 A 持有的行锁时,形成环:

AB,BAA \rightarrow B,\quad B \rightarrow A

数据库应检测并中止其中一个事务。测试重点是:

  • 只有一个事务失败;
  • 失败事务的全部修改回滚;
  • 成功事务可以提交;
  • 应用是否按错误类型重试;
  • 重试是否遵守统一的锁顺序。

一个常见修复是按主键排序后锁定:

SELECT id
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

如果所有事务都按同一顺序获取锁,形成环的条件会减少。但这不是对所有业务流程的自动保证,仍需测试。

11.4 语句超时与锁超时

statement_timeoutlock_timeout 不是同一个概念:

  • lock_timeout:等待锁的时间超过上限;
  • statement_timeout:整个语句执行时间超过上限。

测试要分别注入:

SET lock_timeout = '1s';
SET statement_timeout = '3s';

锁超时更适合验证迁移不会无限等待;语句超时更适合验证慢查询、回填或复杂更新的失败路径。

超时发生后,当前事务可能已经处于失败状态。应用必须回滚或丢弃连接,再进入重试或错误响应路径。

11.5 网络故障与提交不确定性

最危险的故障之一发生在提交请求已经发送,但客户端没有收到响应:

客户端发送 COMMIT
数据库可能已提交
网络中断
客户端收到连接错误

此时客户端不能简单认为“提交失败”,因为真实状态可能是已提交。这叫提交结果不确定性。

正确的测试不能只验证客户端收到异常,而要验证业务设计:

  • 是否有幂等业务键;
  • 是否能通过请求 ID 查询结果;
  • 是否会重复扣款或重复创建订单;
  • 重试前是否先查询业务状态;
  • 数据库唯一约束是否能阻止重复写入。

例如订单创建可设置业务唯一键:

CREATE TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    request_id text NOT NULL UNIQUE,
    amount numeric(12, 2) NOT NULL
);

客户端重试时再次提交相同 request_id,应返回已有订单或明确的幂等结果,而不是创建第二笔订单。


十二、备份恢复与迁移测试的关系

迁移的“回滚”经常被误解为执行反向 DDL。对于删除列、数据转换和大规模回填,反向迁移可能无法恢复原始信息。

因此需要区分:

  • 应用回滚:部署旧版本应用;
  • schema 回滚:执行反向迁移;
  • 数据恢复:从备份或时间点恢复;
  • 补偿操作:修正已经提交的业务数据。

12.1 RPO 和 RTO

恢复点目标 RPO 表示最多能接受丢失多少时间的数据;恢复时间目标 RTO 表示服务恢复所需的最长时间。

一次恢复演练至少应记录:

备份开始时间
备份完成时间
恢复开始时间
数据库可连接时间
应用可读时间
应用可写时间
数据校验完成时间

验证不能只看“数据库启动了”,还要检查:

  • 关键表行数或校验和;
  • 最近一笔已知业务;
  • 序列值和自增值;
  • 迁移版本表;
  • 外键和唯一约束;
  • 应用是否可以正常读写。

PITR 场景尤其要测试恢复到指定时间点时,是否恢复到了迁移前、迁移中或迁移后的状态。迁移记录、业务事件和备份时间线必须能够相互解释。


十三、迁移与故障注入的组合测试

单独测试迁移成功和单独测试数据库崩溃仍不够。真实风险常出现在两者交叉处。

可以构造以下场景:

场景一:事务性迁移中途失败

开始迁移
完成 DDL A
执行 DDL B 时故障
恢复数据库
重新执行迁移

预期:

  • PostgreSQL 中,若 A、B 在同一个可回滚事务中,恢复后不应看到半完成状态;
  • 对不能事务化的操作,必须识别残留对象并设计恢复步骤;
  • MySQL 中不能假设整个迁移原子回滚,应按实际 DDL 逐步验证。

场景二:回填期间应用重启

应用正在双写
回填执行若干批
应用重启
继续回填

预期:

  • 回填条件能识别已完成和未完成数据;
  • 重复执行不会破坏已正确数据;
  • 双写与回填不会互相覆盖错误值;
  • 失败批次可以安全重试。

场景三:迁移等待锁时发布取消

长事务持有锁
迁移启动并等待
超过 lock_timeout
发布系统取消迁移

验证:

  • 迁移进程退出;
  • 数据库连接回滚;
  • 迁移版本记录没有错误地标记为成功;
  • 后续发布可以在释放长事务后重试。

十四、测试分层:速度、真实性和诊断信息的取舍

一个实用的分层方式如下。

单元测试

测试:

  • SQL 参数生成;
  • 事务函数的异常路径;
  • 重试分类;
  • 迁移版本选择;
  • 数据转换函数。

优点是快,缺点是不能证明数据库行为。

数据库集成测试

使用 Testcontainers 或专用测试实例,测试:

  • 真实 SQL;
  • 真实约束;
  • 真实事务;
  • 隔离级别;
  • 锁和死锁;
  • 迁移;
  • 连接池。

这是事务和迁移测试的核心层。

并发测试

使用多个真实连接和屏障同步,而不是随机 sleep。例如:

T1 到达屏障
T2 到达屏障
同时放行
分别执行锁定或更新
收集提交、回滚、超时结果

sleep 只能增加碰撞概率,不能证明并发顺序。测试需要记录每个事务的:

  • 开始时间;
  • SQL;
  • 等待时间;
  • 提交或回滚结果;
  • 数据库错误码。

发布验收测试

在类似生产的 schema、索引和数据分布上验证:

  • 迁移耗时;
  • 锁等待;
  • 复制延迟;
  • 应用兼容性;
  • 回滚路径;
  • 监控和告警。

容器适合验证语义和流程,但不自动等价于生产性能。小容器、少量数据和单节点网络无法证明大表在线变更一定安全。


十五、常见错误及其失败表现

错误一:测试通过 ROLLBACK 清理,但代码使用了多个连接

测试只回滚主连接,后台任务、异步消费者或触发器使用其他连接写入的数据不会被回滚。最终测试之间互相污染。

诊断方法:

  • 为每个测试使用独立 schema 或数据库;
  • 检查所有连接来源;
  • 测试结束后统计残留行;
  • 不要把跨连接副作用伪装成单连接事务。

错误二:把数据库异常转换成普通业务错误

死锁、序列化失败、连接断开和唯一键冲突的恢复策略不同。如果统一返回“操作失败”,应用既无法正确重试,也无法正确报警。

应保留:

  • SQLSTATE 或 MySQL 错误码;
  • 原始异常链;
  • 事务是否已经回滚;
  • 请求 ID;
  • 数据库实例和迁移版本。

错误三:认为“加了锁”就解决了并发问题

锁的对象、锁的顺序和锁的持续时间都很重要。锁错行、锁得太晚、锁住后又调用外部服务,仍然可能出现写偏差、死锁或吞吐下降。

错误四:只测试向前迁移,不测试旧应用兼容性

扩展列、索引或表名后,新应用可能正常,但旧应用在滚动发布期间启动失败。迁移测试必须把应用版本作为状态机的一部分。

错误五:把 IF NOT EXISTS 当成通用幂等方案

对象存在不代表定义正确。一个名称相同但类型、列顺序、约束或索引条件不同的对象,可能让迁移“成功跳过”,却留下错误 schema。

错误六:用杀容器模拟所有故障

杀死容器模拟的是数据库实例故障,不能代替:

  • 单连接断开;
  • 代理连接重置;
  • DNS 失败;
  • 提交响应丢失;
  • 锁超时;
  • 磁盘空间耗尽。

故障注入必须与故障点对应,否则测试结论会被过度外推。


十六、交付前的判定标准

一个事务和迁移测试体系至少应能回答以下具体问题:

  • 未提交数据是否对其他连接不可见?
  • 中间语句失败时,前面写入是否回滚?
  • 死锁或序列化失败是否重试整个事务?
  • 连接池是否清理了失败事务和会话状态?
  • 从空库和每个历史版本执行迁移是否都成立?
  • PostgreSQL 和 MySQL 的 DDL 事务边界是否按各自语义验证?
  • Expand-Contract 的每个兼容窗口是否有旧应用、新应用和混合部署测试?
  • 迁移等待锁时是否有超时、诊断和恢复路径?
  • 数据库崩溃后,已提交和未提交数据是否符合预期?
  • 提交响应丢失后,业务重试是否幂等?
  • 备份恢复后,schema 版本、业务数据和应用读写是否一致?
  • 测试失败时,是否保留足够的 SQL、错误码、锁信息和容器日志?

数据库测试的核心不是增加更多 SQL,而是把数据库视为一个具有状态、并发、锁、版本和故障恢复行为的系统。事务测试验证状态是否按原子规则转移;迁移测试验证 schema 和应用能否跨越兼容窗口;Testcontainers 提供真实引擎和可重复生命周期;故障注入则验证系统在异常状态下是否仍能恢复到明确、可验证的结果。


系列导航与关联阅读

官方资料

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