积分并发扣款竞态 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 而不是 02.3 根本原因
CreditService 的 hold、consume、refund 三个方法在事务内读取 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 修复操作
- 释放所有 held 残留: 将所有 held > 0 的 credit 行重置为 0
- 补充缺失的 consume 流水: 为多条 succeeded 但缺少 consume 的 generation 补写流水记录,并更新对应 credit 行的 used/remaining
- 一致性验证: 确认
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 建议后续补充
- 定期一致性巡检: 部署定时任务,执行上述一致性检查 SQL,发现异常数据后自动告警
- 监控告警: consume/refund 出现 shortfall 时不仅打日志还应发即时告警
- 压测验证: 模拟同一用户并发 10+ 生成任务,验证积分扣款正确性
- 审计对账: 定期比对
sum(credit.used)与sum(credit_transaction.amount WHERE type='consume')是否一致
七、经验教训
- 计费系统必须用数据库原子操作,不能在应用层"读-算-写"——这是经典的 Lost Update 问题
- 两阶段扣款(hold → consume)的并发场景比一阶段更复杂,hold 的分配和 consume 的扣减必须都是并发安全的
- 测试阶段就应该做并发测试,单线程测试无法暴露竞态问题
- 数据一致性检查应该作为常规巡检,不能只在出问题后才人工排查