目录

积分并发扣款竞态 Bug 复盘:从 Lost Update 到三重防护

前言

最近在我们的 AI 生成平台上遇到了一个 P0 级别的并发 Bug——用户同时发起多个生成任务时,积分扣款出现了竞态条件,导致积分被永久锁住。从发现到修复经历了两轮迭代(紧急修复 → 优化重构),本文完整记录这次复盘过程。


一、问题发现

用户在后台看到某个视频生成模型同时生成了两条任务(两个不同的 taskId),均成功返回了视频。但检查数据库发现:

  • generation 表有两条 succeeded 记录,各消耗 205 积分
  • credit_transaction 表只有一条 consume 流水(-205)
  • 另一条 generation 只有 hold 流水,没有 consume 也没有 refund
  • credit 表的 held 字段有残留值无法释放

症状: 用户被扣了两次积分的 hold,但只落账了一次 consume,导致积分被永久锁住。


二、根因分析

2.1 数据现场

=== generation ===
记录A  succeeded  cost=205  created=05:10:22
记录B  succeeded  cost=205  created=05:10:57

=== credit_transaction ===
流水1  hold     amount=0    gen=记录A  created=05:10:21  ← 只有 hold
流水2  consume  amount=-205 gen=记录B  created=05:13:08  ← 有 consume
(记录A 没有 consume 流水!)

2.2 竞态时序还原

两条 generation 几乎同时完成(都在 05:13 左右),webhook/轮询回调同时触发 CreditService.consume

时刻 T1: consume(gen_A, 205) 开始
         → 读取 credit 行: held=410 (A的205 + B的205)
         
时刻 T2: consume(gen_B, 205) 开始(并发)
         → 读取 credit 行: held=410 (同一个旧值!)

时刻 T3: consume(gen_A) 写入: held = 410 - 205 = 205, used += 205
时刻 T4: consume(gen_B) 写入: held = 410 - 205 = 205, used += 205
         ↑ 覆盖了 T3 的结果!最终 held=205 而不是 0

2.3 根本原因

CreditServiceholdconsumerefund 三个方法在事务内读取 credit 行时没有加行级锁,存在经典的 Lost Update 问题。

// 旧代码 — 无锁读取,先 SELECT 再 UPDATE
const credits = await this._getAvailableCreditsInTx(tx, userId, featureId);
// ↑ 普通 SELECT,两个并发事务读到相同的旧值

for (const c of credits) {
  await tx.update(credit).set({ held: c.held + holdAmount })  // ← 基于旧值计算
    .where(eq(credit.id, c.id));
}

PostgreSQL 默认的 Read Committed 隔离级别下,两个事务可以同时读到相同的行数据,各自基于旧值计算新值后写入,后写的会覆盖先写的。


三、影响范围

3.1 直接影响

  • 并发生成任务时,积分可能只落账一次但实际消耗了多次
  • held 字段残留,用户可用余额被永久减少
  • credit_transaction 流水不完整,审计数据不准确

3.2 触发条件

  • 同一用户在短时间内(< 5 秒)发起多个生成任务
  • 多个任务几乎同时完成,webhook/轮询并发触发 consume

3.3 影响的操作

操作 风险 说明
hold 并发 hold 可能 double-hold 同一笔积分 两个 hold 读到相同的 remaining-held 可用额度
consume 并发 consume 可能覆盖彼此的 held 扣减 已确认发生
refund 并发 refund 可能覆盖彼此的 held 释放 理论风险

四、修复方案

4.1 第一轮:紧急修复(SELECT … FOR UPDATE 行级锁)

紧急修复采用 SELECT ... FOR UPDATE 行级排他锁,把同一用户的积分操作串行化:

// 第一轮修复 — FOR UPDATE 锁住行
const credits = await tx.execute(
  sql`SELECT * FROM credit WHERE user_id = ${userId} ... FOR UPDATE`
);
// 后续 UPDATE 使用 JS 计算的新值
await tx.update(credit).set({ held: c.held + holdAmount }).where(eq(credit.id, c.id));

优点: 快速解决问题,改动小

缺点:

  • 同一用户的所有积分操作被串行化,高并发时后面的请求要排队等锁
  • UPDATE 仍然使用 JS 端计算的值(c.held + holdAmount),虽然 FOR UPDATE 保证了读到最新值,但不够优雅
  • 锁粒度粗(锁住用户所有 active 的 credit 行)

4.2 第二轮:优化重构(FOR UPDATE + 原子 UPDATE + WHERE 二次校验)

优化后采用 FOR UPDATE 锁行 + 原子 UPDATE 语句 + WHERE 条件二次校验的三重防护:

// hold — FOR UPDATE 锁行 + 原子 UPDATE + WHERE 二次校验
const candidates = await tx.execute(
  sql`SELECT id, remaining - held as available
      FROM credit
      WHERE user_id = ${userId} AND remaining > held AND status = 'active' ...
      ORDER BY priority ASC, end_at ASC NULLS LAST
      FOR UPDATE`
);

for (const row of rows) {
  const holdAmount = Math.min(row.available, remaining);
  // 原子 UPDATE:WHERE 条件保证不超扣
  await tx.execute(
    sql`UPDATE credit
        SET held = held + ${holdAmount}
        WHERE id = ${row.id}
          AND remaining - held >= ${holdAmount}`  // ← 二次校验
  );
}
// consume — FOR UPDATE + 原子减
const candidates = await tx.execute(
  sql`SELECT id, held FROM credit
      WHERE user_id = ${userId} AND held > 0 AND status = 'active'
      ORDER BY priority ASC, end_at ASC NULLS LAST
      FOR UPDATE`
);

for (const row of rows) {
  const consumeAmount = Math.min(row.held, remaining);
  await tx.execute(
    sql`UPDATE credit
        SET held = held - ${consumeAmount},
            used = used + ${consumeAmount},
            remaining = remaining - ${consumeAmount}
        WHERE id = ${row.id}
          AND held >= ${consumeAmount}`  // ← 二次校验
  );
}
// refund — 同样的模式
await tx.execute(
  sql`UPDATE credit
      SET held = held - ${refundAmount}
      WHERE id = ${row.id}
        AND held >= ${refundAmount}`  // ← 二次校验
);

4.3 三重防护机制

防护层 机制 作用
第一层 FOR UPDATE 锁住候选行,确保同一用户串行执行
第二层 原子 UPDATE SET held = held + N 用数据库当前值计算,不依赖 JS 端旧值
第三层 WHERE remaining - held >= N 即使前两层失效,WHERE 条件也能防止超扣

4.4 优化后的并发行为

时刻 T1: hold(user_A, 205) 开始事务
         → SELECT ... FOR UPDATE: 锁住 credit 行
         → UPDATE SET held = held + 205 WHERE remaining - held >= 205
         → COMMIT, 释放锁

时刻 T2: hold(user_A, 205) 等待锁...拿到锁
         → 读取最新值(T1 已提交的)
         → UPDATE SET held = held + 205 WHERE remaining - held >= 205
         → COMMIT

两次 hold 各自正确增加 205,互不干扰 ✅
时刻 T1: consume(gen_A, 205) 开始事务
         → SELECT ... FOR UPDATE: 锁住 credit 行, held=410
         → UPDATE SET held = held - 205 WHERE held >= 205
         → COMMIT

时刻 T2: consume(gen_B, 205) 等待锁...拿到锁
         → 读取最新值: held=205
         → UPDATE SET held = held - 205 WHERE held >= 205
         → COMMIT
         
最终: held=0, used 正确增加了 410 ✅

4.5 与纯 CAS(无锁)方案的对比

曾考虑完全去掉 FOR UPDATE,只用原子 UPDATE + WHERE 二次校验:

-- 纯 CAS:不加锁,靠 WHERE 条件保证
UPDATE credit SET held = held + $amount
WHERE id = $id AND remaining - held >= $amount;
-- 检查 affected rows,如果为 0 说明被并发抢了,换下一行

最终没有采用纯 CAS 的原因:

  • 积分分配需要"逐行分配"(一笔 hold 可能跨多个 credit 行),纯 CAS 需要 retry 循环,逻辑复杂
  • FOR UPDATE 的性能开销在我们的场景下可以接受(同一用户并发生成不会超过个位数)
  • FOR UPDATE + 原子 UPDATE + WHERE 三重防护已经足够安全

五、数据修复

5.1 修复操作

  1. 释放所有 held 残留: 将所有 held > 0 的 credit 行重置为 0
  2. 补充缺失的 consume 流水: 为多条 succeeded 但缺少 consume 的 generation 补写流水记录,并更新对应 credit 行的 used/remaining
  3. 一致性验证: 确认 used + remaining = total 全部通过

5.2 一致性检查 SQL(可复用)

-- 1. held 卡住(不应有)
SELECT * FROM credit WHERE held > 0;

-- 2. 卡住的 generation(不应有)
SELECT * FROM generation WHERE status IN ('queued', 'processing', 'completing');

-- 3. succeeded 但缺 consume(不应有)
SELECT g.id, g.model_id, g.credits_cost
FROM generation g
WHERE g.status = 'succeeded'
  AND NOT EXISTS (
    SELECT 1 FROM credit_transaction ct
    WHERE ct.generation_id = g.id AND ct.type = 'consume'
  );

-- 4. failed/timeout 但缺 refund(不应有)
SELECT g.id, g.model_id, g.status
FROM generation g
WHERE g.status IN ('failed', 'timeout')
  AND NOT EXISTS (
    SELECT 1 FROM credit_transaction ct
    WHERE ct.generation_id = g.id AND ct.type IN ('refund', 'consume')
  );

-- 5. 数值一致性
SELECT * FROM credit WHERE used + remaining != total;

5.3 修复后验证结果

1. held 卡住: ✅ 无
2. 卡住的 generation: ✅ 无
3. 缺 consume: ✅ 无
4. 缺 refund: ✅ 无
5. 数值不一致 (used + remaining ≠ total): ✅ 无

六、预防措施

6.1 已实施

  • hold/consume/refund 三重防护:FOR UPDATE + 原子 UPDATE + WHERE 二次校验
  • 每条 UPDATE 的 WHERE 条件保证即使读到旧值也不会超扣
  • 废弃了旧的无锁查询调用路径

6.2 建议后续补充

  1. 定期一致性巡检: 部署定时任务,执行上述一致性检查 SQL,发现异常数据后自动告警
  2. 监控告警: consume/refund 出现 shortfall 时不仅打日志还应发即时告警
  3. 压测验证: 模拟同一用户并发 10+ 生成任务,验证积分扣款正确性
  4. 审计对账: 定期比对 sum(credit.used)sum(credit_transaction.amount WHERE type='consume') 是否一致

七、经验教训

  1. 计费系统必须用数据库原子操作,不能在应用层"读-算-写"——这是经典的 Lost Update 问题
  2. 两阶段扣款(hold → consume)的并发场景比一阶段更复杂,hold 的分配和 consume 的扣减必须都是并发安全的
  3. 测试阶段就应该做并发测试,单线程测试无法暴露竞态问题
  4. 数据一致性检查应该作为常规巡检,不能只在出问题后才人工排查