Skip to content
返回构建教程

竞态条件

用确定性复现找并修并发 bug,非靠运气。

为什么

竞态条件是最难 bug 因非确定:测试过 99 次第 100 次败,不能复现。两异步操作访问共享状态且顺序相关时发生——'检查后行动'(读余额后写余额减额)是经典。无方法开发者加 sleep'让它稳定'发布——prod 负载下 bug 回。修总是其一:操作原子化(DB 事务、互斥)、检查行动合一(compare-and-swap)、或串行化操作(队列)。方法:确定性复现(注入延迟、用确定性调度器)、找交错、用对修。发 prod 的并发 bug 致数据损坏远贵于你花一周找它。

何时使用

bug 间歇(同输入有时过有时败)时。约 2-4 小时。共享状态代码永不跳过。

如何做

1. 确定性复现——强你怀疑的交错
   - 边界注入人工延迟:检查行动间 await sleep(100)
   - 用确定性异步调度器(sinon fake timers、@effect/Trie)控序

2. 识别共享状态和两竞争操作
   - "余额被 withdraw() 和 deposit() 读;读后写非原子"
   - 纸上画交错:T1 读 100、T2 读 100、T1 写 50、T2 写 200(应 150)

3. 选匹配问题的修模式
   - 原子操作:DB 事务(BEGIN; ... COMMIT)包检查+行动
   - compare-and-swap:UPDATE accounts SET balance = balance - 50 WHERE id = 1 AND balance >= 50
   - 串行化:操作推队列,每账户一次一个处理

4. 加压测并发跑操作 1000 次
   - 有修,不变量(余额和 = 常数)每次持
   - 无修,测试约 30% 败——那是证明

5. 审兄弟代码同模式——竞态爱伴
   - 共享状态每"检查后行动"是嫌疑;此 PR 全修

示例

// BUG:余额经典检查后行动竞态
async function withdraw(accountId, amount) {
  const balance = await db.getBalance(accountId);   // T1: 读 100
  if (balance < amount) throw new Error("余额不足");
  await db.setBalance(accountId, balance - amount); // T2: 写 50,但 T2 也读 100!
}
// 余额 100 并发 100 次取 10 → 余额可负

// 修 1:原子 compare-and-swap(最佳——一查询无事务)
await db.query(
  "UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1",
  [amount, accountId],
);
// 0 行影响 → 余额不足(他人赢竞态)

// 修 2:行锁事务(需读后写更多逻辑时)
await db.transaction(async (tx) => {
  const balance = await tx.query("SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", [accountId]);
  if (balance < amount) throw new Error("余额不足");
  await tx.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, accountId]);
});

// 压测——证修
test("100 并发取款永不透支", async () => {
  await db.setBalance(1, 1000);
  const results = await Promise.allSettled(Array.from({ length: 100 }, () => withdraw(1, 10)));
  const finalBalance = await db.getBalance(1);
  expect(finalBalance).toBeGreaterThanOrEqual(0);  // 永不负
  expect(finalBalance + results.filter(r => r.status === "fulfilled").length * 10).toBe(1000);
});

常见问题

  • ⚠加 sleep()'修'竞态——低载藏 bug 高载回。
  • ⚠DB 竞态用 JS 互斥——互斥每进程;两服务器实例仍 DB 竞态。
  • ⚠全函数包事务但忘 FOR UPDATE,读仍未锁。
  • ⚠修一竞态不审兄弟——同检查后行动模式在另 5 处。

最佳实践

  • ✓优先 compare-and-swap(UPDATE ... WHERE 条件)非读后写——DB 级原子无需事务。
  • ✓每共享状态操作加并发压测;CI 跑 1000 次让竞态败构建非 prod。
  • ✓事务内用 SELECT ... FOR UPDATE 锁将更新行——无它读非锁且竞态。
  • ✓共享状态模块顶记并发模型(如'所有变更经单 actor')防未来编辑破不变量。