Hotdry.
systems

利用 PostgreSQL 同步屏障实现可控的确定性并发测试

本文介绍如何利用同步屏障(Synchronization Barriers)技术,在 PostgreSQL 数据库测试中精确制造和验证多线程环境下的竞态条件,摆脱对随机时序的依赖,实现确定性的并发安全验证。

在并发系统的开发中,竞态条件(Race Condition)如同幽灵,在单线程测试中隐匿无踪,却在生产环境的高并发压力下骤然现身,导致数据不一致、资金损失等严重后果。传统并发测试依赖随机时序(如插入 sleep)或概率性重复运行,不仅低效、缓慢,更无法提供确定性的验证。本文将深入探讨一种工程化解决方案:利用同步屏障(Synchronization Barriers),在 PostgreSQL 数据库测试中精确、可重复地制造并发交错,实现对竞态条件的确定性捕捉与防护验证。

传统并发测试的困境

考虑一个经典的账户充值场景:两个并发请求同时为同一账户增加 50 元余额。初始余额为 100 元,理想最终余额应为 200 元。一个未受保护的实现可能如下:

const credit = async (accountId, amount) => {
  const [row] = await db.execute(
    sql`SELECT balance FROM accounts WHERE id = ${accountId}`
  );
  const newBalance = row.balance + amount;
  await db.execute(
    sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
  );
};

在并发执行时,可能产生如下交错序列:

  1. 请求 A 读取余额 → 100
  2. 请求 B 读取余额 → 100
  3. 请求 A 计算并写入新余额 → 150
  4. 请求 B 计算并写入新余额 → 150

最终余额错误地变为 150 元,一笔充值 “消失” 了。数据库忠实地执行了每个请求的指令,没有抛出任何错误。

在单线程的测试套件中,这种交错永远不会发生。开发者可能会尝试在 SELECTUPDATE 之间插入 sleep 来模拟并发,但这只会得到一个缓慢、不稳定的测试,能否捕捉到缺陷全凭运气。正如 Mikael Lirbank 在其文章中所指出的:“没有竞态条件测试,系统中每一个潜在的竞态条件都只差一次代码重构就能溜进生产环境。”

同步屏障:制造确定性的并发交错

同步屏障是一种并发控制原语,其核心思想是:让 N 个并发任务在代码的特定点(屏障)处暂停,直到所有 N 个任务都到达此点,然后同时释放它们继续执行。这使我们能够精确控制并发操作的交错顺序。

一个简单的屏障实现如下(TypeScript 示例):

function createBarrier(count: number) {
  let arrived = 0;
  let release: () => void;
  const barrier = new Promise<void>((resolve) => {
    release = resolve;
  });

  return async () => {
    arrived++;
    if (arrived === count) {
      release();
    }
    await barrier;
  };
}

当我们将这个屏障放置在数据库操作的 “读” 与 “写” 之间时,就能强制制造出前述的危险交错:所有并发任务都完成数据读取后,才允许任何一个任务进行写入。这正是我们想要测试的竞态条件场景,并且每次测试都能 100% 重现。

在 PostgreSQL 测试中应用屏障:从裸查询到行级锁

阶段一:裸查询(无事务,无锁)

将屏障直接插入 SELECTUPDATE 之间,运行两个并发充值任务。测试确定性地失败,余额为 150 元,成功复现了竞态条件。

阶段二:添加事务(READ COMMITTED)

将操作包裹在事务中。在 PostgreSQL 默认的 READ COMMITTED 隔离级别下,事务并不能阻止这种 “读 - 改 - 写” 竞态。屏障测试依然失败,证明单纯的事务不足以提供保护。

阶段三:添加行级锁(SELECT … FOR UPDATE)

使用 SELECT … FOR UPDATE 在读取时获取行级排他锁。此时,如果屏障仍放在 SELECT 之后,会立即导致死锁:第一个事务持有锁并等待在屏障处;第二个事务在尝试获取锁时被阻塞,永远无法到达屏障。这个死锁本身是一个强烈的信号,证明了锁正在起作用。

阶段四:调整屏障位置,验证锁的有效性

死锁提示我们需要调整屏障的位置。将屏障移动到事务开始之后、SELECT 查询之前。这样,两个事务同时开始,屏障释放后它们再竞争锁。测试结果:

  • FOR UPDATE:测试通过。锁保证了序列化执行,最终余额为 200 元。
  • FOR UPDATE:测试失败。竞态条件再次出现,余额为 150 元。

这是屏障测试的核心验证逻辑:一个有效的测试必须在 “有防护措施时通过,无防护措施时失败”。如果两个状态都通过,则这是一个无用的 “虚荣测试”。

工程化实现:钩子注入与无侵入生产代码

屏障是测试基础设施,不应污染生产代码。实现这一点的优雅模式是钩子(Hooks)注入

async function credit(
  accountId: number,
  amount: number,
  hooks?: { onTxBegin?: () => Promise<void> | void }
) {
  await db.transaction(async (tx) => {
    // 注入点:事务开始后,任何查询前
    if (hooks?.onTxBegin) {
      await hooks.onTxBegin();
    }
    const [row] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
    );
    const newBalance = row.balance + amount;
    await tx.execute(
      sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
    );
  });
}

生产环境调用 credit(1, 50) 时不传递任何钩子,零开销。测试环境则注入屏障函数:

const barrier = createBarrier(2);
await Promise.all([
  credit(1, 50, { onTxBegin: barrier }),
  credit(1, 50, { onTxBegin: barrier }),
]);

可落地参数清单与监控要点

成功实施 PostgreSQL 同步屏障测试,需要关注以下可操作的工程参数与检查点:

1. 屏障关键参数

  • 参与者计数 (count):必须与并发任务数严格一致。通常为 2,用于测试最常见的竞态;复杂场景可扩展。
  • 放置位置
    • 验证锁有效性时:置于事务开始后、获取锁的查询之前(如 onTxBegin)。
    • 制造纯读写竞态时:置于读取之后、写入之前。
    • 黄金法则:放置后,移除防护(如锁),测试必须失败。

2. 测试环境配置

  • 真实数据库:必须使用真实的 PostgreSQL 实例(或高度兼容的服务如 Neon),Mock 或内存数据库无法模拟锁与事务隔离。
  • 独立数据库:每个测试用例应使用独立的数据库或 schema,确保状态隔离,避免测试间污染。
  • 连接池管理:确保测试使用独立的数据库连接,模拟真实的多客户端场景。

3. 死锁处理与超时

  • 设置测试超时:屏障测试必须设置合理的超时(如 5-10 秒),以防止死锁导致 CI 挂起。
  • 死锁即证据:测试中出现死锁通常意味着防护机制(如锁)已生效,但屏障位置需要调整。这是一个需要分析的信号,而非单纯的失败。

4. 回归测试策略

  • 防护措施变更时:每当修改用于防护竞态的技术方案(如将 FOR UPDATE 改为乐观锁版本检查),必须用屏障测试重新验证其有效性。
  • 数据访问层重构时:任何 ORM 查询重构、函数重组后,对应的屏障测试应作为回归测试的一部分运行,确保并发安全未被意外破坏。
  • 监控点:在 CI 流水线中,屏障测试的通过率应是关键质量门禁指标。任何失败必须阻止部署。

5. 与高级隔离级别的协同

  • SERIALIZABLE 隔离级别:虽然 SERIALIZABLE 能消除一大类序列化异常,但其引入的序列化失败需要应用层重试逻辑。屏障测试同样可用于验证重试逻辑的正确性。
  • 混合策略:在实际系统中,可根据负载和业务关键性混合使用隔离级别。屏障测试可以帮助验证 “关键路径用 SERIALIZABLE,非关键路径用 READ COMMITTED + 显式锁” 的混合策略是否在边界处依然安全。

总结

PostgreSQL 同步屏障测试将并发安全验证从 “概率性艺术” 转变为 “确定性工程”。它通过精确控制并发任务的执行交错,使原本难以捉摸的竞态条件变得可观测、可重复、可验证。其核心价值不仅在于发现缺陷,更在于为防护措施(如行级锁、乐观锁、约束)提供了一张可靠的 “安全证明”。

实施此技术的关键在于:理解屏障与锁的交互,掌握钩子注入的无侵入模式,并遵循 “有防护则过,无防护则败” 的验证原则,将其固化为持续集成流水线中不可或缺的质量关卡。当代码库因重构而演变时,这些确定的屏障测试将成为守护并发安全的忠实哨兵。

资料来源

  1. Mikael Lirbank. Harnessing Postgres race conditions. (2026-02-13). 本文核心概念与代码示例的主要参考。
  2. Hacker News 讨论 Testing Postgres race conditions with synchronization barriers (id=47039834). 其中作者关于屏障放置与死锁关系的实践见解提供了重要补充。
查看归档