竞态条件
用确定性复现找并修并发 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')防未来编辑破不变量。