SuperAPI AI 工具集

staff-engineering-skills-race-conditions

编程与开发

查看项目

更新于 10-16 04:24

技能介绍

用途

该技能用于检测与预防并发代码中的竞态条件(Race Condition)。当代码需要「先读取共享状态、再据此写入」时,若两个副本同时运行,逐行看似正确的代码可能损坏数据。文档建议在编写任何此类代码前先自问:如果另一个进程在我的读与写之间改变了状态,会发生什么?

三种典型模式

  1. Check-Then-Act(TOCTOU):读取状态、做出判断、再执行操作,另一进程在检查与操作之间改变状态。示例:两个请求都发现不存在该用户,于是都创建了一个。

  2. Read-Modify-Write:读取值、计算新值、写回,另一进程读取到同样的旧值。示例:两个请求都读到库存=1,都写回库存=0,造成超卖 1 件。

  3. Check-Act-With-Side-Effects:读取状态、执行不可逆操作、更新状态,另一进程在更新前读到同样的状态。示例:两个进程都看到 status="paid",都触发发货。

检测信号

当出现以下情况时应停下并修复:

  • findUnique/findFirst 之后接 create:典型 check-then-act,两个进程都通过检查后才创建。应使用唯一约束配合 upsert 或 create-catch-conflict。
  • 读取 X、计算、再写回 X(X = X + 1):应使用原子操作({ increment: 1 }、UPDATE ... SET x = x + 1)。
  • 状态读取与更新分散在独立操作中(if (status === "A") update("B")):应使用条件式 WHERE ... SET status='B' WHERE status='A' 并检查受影响行数。
  • 对同一行任何无事务或原子约束的读后写:两者之间的间隙就是竞态窗口。
  • 对非幂等操作进行重试:首次尝试可能已成功但响应丢失,重试会与已完成的首次尝试产生竞态。
  • if (!fs.existsSync(path)) fs.writeFileSync(path, data):文件系统 TOCTOU;应使用 O_CREAT | O_EXCL。

修复:让数据库充当仲裁者

数据库是事实来源与并发执行者。应把竞态防护下沉到数据库层,而非应用代码。文档给出以下方案及示例代码:

  • 唯一约束(用于 check-then-act):在 email 上建唯一索引,只有一个 create 成功,失败方回退到 find;或使用 ORM 的原子 upsert。
  • 带条件 WHERE 的原子更新(用于 read-modify-write):在单条原子语句中完成递减并检查(UPDATE ... SET inv = inv - 1 WHERE id = ? AND inv > 0)。
  • 条件式状态转换(用于状态机):在任何副作用之前就以原子方式认领转换(UPDATE ... SET status='shipping' WHERE id=? AND status='paid')。
  • 带版本列的乐观锁:通过版本不匹配检测并发修改。
  • 悲观锁:当必须在读取状态后执行外部调用(API)再写入时,使用 SELECT FOR UPDATE 锁定行。代价是同一行的操作会串行化,降低吞吐。

Redis 原子操作

Redis 命令是单线程且原子的,适用于对共享计数器、标志或集合做快速原子操作(无需数据库持久性的场景):

  • 原子计数器:使用 DECR,若变为负数则 INCR 回滚并抛出缺货错误。
  • Lua 脚本执行多步原子操作:整个脚本在 Redis 中原子执行,例如领取优惠券。
  • SET NX 实现简单锁:原子 set-if-not-exists 并带自动过期以防死锁;释放锁时用 Lua 确保只释放自己的锁。

分布式锁与 Redlock

当需要跨进程或跨服务器互斥、而 SELECT FOR UPDATE 不适用时(例如临界区涉及非数据库资源),可能需要分布式锁:

  • 单实例 Redis(通常足够):SET NX PX 对大多数应用已够用。失败模式明确:若 Redis 宕机则锁丢失,两个进程可能进入临界区。当锁只是尽力而为的防护、而非安全关键的互斥量时,这是可接受的。
  • Redlock(单实例不够时):使用 N 个独立 Redis 实例(通常 5 个),客户端在时限内获得多数(N/2 + 1)才算持有锁。文档给出 redlock 的示例代码与使用方式。

Redlock 注意事项:依赖同步时钟,时钟偏移可能让两个进程都认为持有锁;Kleppmann 的《How to do distributed locking》认为它不适用于正确性关键场景,建议加入 fencing token(下游服务校验的单调递增值)作为安全层;若受保护操作有副作用(API 调用、扣款),仅有锁不够,需配合幂等键。仅在有明确理由说明单实例 Redis 不够时才使用 Redlock,否则数据库约束或单 Redis 锁更简单。

优先级顺序

修复竞态时建议按此顺序选择方案,序号越高代表代码、复杂度和失败模式越多:

  1. 唯一约束——数据库拒绝重复,零应用代码。
  2. 原子更新——SET x = x + 1 WHERE condition,天然原子。
  3. Upsert——INSERT ... ON CONFLICT UPDATE,原子创建或更新。
  4. Redis 原子操作——DECR、SETNX、Lua,用于快速内存协调。
  5. 乐观锁——WHERE 中的版本列,冲突时重试。
  6. 悲观锁——SELECT FOR UPDATE,阻塞并发访问。
  7. 分布式锁(Redis/Redlock)——跨进程互斥,最后手段。

反模式

  • Check-then-act(TOCTOU):findUnique 后 create,或「检查不存在再插入」。两个请求都通过检查并都写入。用唯一约束修复。
  • 应用代码中的 read-modify-write:读取计数器或余额、计算新值、写回。并发写入者会互相覆盖增量。用原子 UPDATE ... SET x = x + n 或版本列修复。
  • 基于过期状态行动:读到 status === "pending" 后扣款/发货/发送。重试会读到相同状态并二次执行。用条件式转换(UPDATE ... WHERE status = 'pending')并检查受影响行数来把副作用拦在门外。
  • 跨外部调用持有分布式锁且无 fence:锁可能在操作中途过期,而第二个 worker 已获得锁。应携带 fencing token,使过期的持有者被拒绝。

相关陷阱

  • 幂等性:许多竞态表现为重复操作。如果每个操作都幂等,竞态就不会造成危害。幂等键是防御 webhook 与队列重试竞态的主要手段。
  • 一致性模型:副本延迟会在单进程代码中制造竞态——写入主库、从副本读取、数据尚未到达。读己之写必须打到主库。
  • 把对象存储当数据库:不带 If-Match 的 S3 GET-modify-PUT 就是 read-modify-write 竞态。应使用条件写实现对象存储上的乐观并发。

限制

该资料为技能说明文档,主要给出概念、模式与代码示例,不包含收费、价格或平台支持等商业信息。文档未描述具体产品的安装方式或运行环境要求,相关资料暂未提供。