很多团队遇到并发写入问题时,会先把数据库事务隔离级别调高:
rust
READ COMMITTED -> REPEATABLE READ -> SERIALIZABLE
看起来已经足够严格,但线上依然可能出现:
- 两个请求同时扣款,最后只扣了一次;
- 库存先查后改,明明判断了余量还是超卖;
- 乐观锁版本号更新了,业务结果却被覆盖;
- 重试逻辑存在,但重试拿的还是旧数据;
- 服务 A 更新成功,服务 B 更新失败,最终状态不一致。
这些问题不一定是隔离级别选错了,而是代码把"读取、判断、写入"拆成了三个没有形成原子边界的步骤。
隔离级别只能约束数据库内部事务的可见性和冲突行为,无法替业务代码自动表达"这次修改必须基于哪个版本"。
下面聊聊并发写入里最常见的 5 个误区。
误区一:以为调高隔离级别就能解决丢失更新
丢失更新的典型时序是:
ini
事务 A:读取 balance = 100
事务 B:读取 balance = 100
事务 A:写入 balance = 110
事务 B:写入 balance = 120
如果两个事务都基于最初的 100 做计算,最终可能是 120,A 增加的 10 就"丢了"。
不同数据库、不同隔离级别对这种情况的处理并不完全相同:
- 有的实现通过锁阻塞写入;
- 有的实现使用多版本快照,写入时检测冲突;
- 有的实现要求业务显式加锁或使用版本号;
- 相同的隔离级别名称,在不同数据库上的并发语义也可能有差异。
所以,不能只根据"我选了 REPEATABLE READ"就推断业务不会丢失更新。
真正的原则是:
涉及读后写的关键状态,必须有明确的并发控制策略,并验证数据库实际行为。
不要只测试单请求顺序执行,要在并发场景下测试两个事务交错执行。
误区二:先 SELECT,再 UPDATE,中间没有任何锁
下面的代码在单线程下完全正确:
ini
const account = await repo.findById(accountId);
if (account.balance < amount) {
throw new Error("insufficient balance");
}
account.balance -= amount;
await repo.save(account);
但两个请求可能同时读到相同的余额,然后各自扣减,最后一次写入覆盖前一次结果。
一种方式是在同一个事务里使用行锁:
ini
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT;
这里的关键不是 BEGIN 本身,而是:
- SELECT 和 UPDATE 在同一个事务中;
- 对同一行使用锁;
- 业务判断和更新基于锁住后的最新状态。
但这种写法会阻塞同一行的其他写操作,需要评估锁等待、超时和热点行问题。
不要在一个长事务里锁住行,再去调用远程服务或等待用户输入。
误区三:有乐观锁,却没有正确处理失败重试
乐观锁的常见实现是版本号:
ini
UPDATE accounts
SET balance = balance - 100,
version = version + 1
WHERE id = 1
AND version = 17
AND balance >= 100;
如果影响行数是 0,说明:
- 版本号已经被其他事务改过;
- 余额已经不足;
- 记录不存在;
- 其他业务条件不满足。
很多代码只检查"有没有抛异常",却没有检查影响行数:
csharp
await db.query(sql, params);
return { success: true };
结果数据库没更新,接口却返回成功。
更合理的处理是:
csharp
const result = await db.query(sql, params);
if (result.affectedRows === 0) {
const latest = await repo.findById(id);
if (!latest) throw new NotFoundError();
if (latest.balance < amount) throw new ConflictError();
throw new RetryableConflictError();
}
乐观锁重试还需要注意:
- 必须重新读取最新版本;
- 不能用旧的版本号反复重试;
- 重试要有次数、退避和总时长上限;
- 业务请求必须幂等;
- 重试次数过多时,应该返回冲突,而不是无限自旋。
乐观锁不是"加一个 version 字段"就完成了,它还需要一整套冲突处理流程。
误区四:把业务条件放在应用层,没有放进 UPDATE
库存扣减最容易出现这种问题:
javascript
const stock = await getStock(sku);
if (stock.quantity <= 0) {
throw new Error("out of stock");
}
await updateStock(sku, stock.quantity - 1);
查询和修改之间的时间窗口,足以让其他请求把库存扣完。
更安全的方式是把关键条件写进原子 UPDATE:
ini
UPDATE inventory
SET stock = stock - 1
WHERE sku = 'sku-1'
AND stock > 0;
然后检查影响行数:
ini
const result = await db.query(sql, [sku]);
if (result.affectedRows === 0) {
throw new ConflictError("stock unavailable");
}
类似地,余额扣减应该写成:
ini
UPDATE accounts
SET balance = balance - $1
WHERE id = $2
AND balance >= $1;
数据库在这个 UPDATE 中同时完成条件判断和写入,不再依赖客户端先读后判断。
这并不能解决所有一致性问题,但它能消除"读到旧值后覆盖"的常见窗口。
误区五:跨服务写入没有幂等和补偿
单库事务能保证同一数据库内的原子性,但跨服务操作无法靠一个本地事务覆盖。
例如:
订单服务:创建订单
库存服务:扣减库存
积分服务:增加积分
消息服务:发送事件
如果订单服务成功后,库存服务超时,但库存实际已经扣减,重试可能再次扣减。
这类问题需要:
- 每个写操作有稳定业务键和幂等键;
- 服务端根据业务键做唯一约束;
- 超时后先查询状态,不盲目重试;
- 使用 Saga 或状态机记录步骤;
- 使用 Outbox 保证本地事务和事件投递一致;
- 有对账和补偿任务。
"最终一致"不是一句口号,而是每个失败分支都能被观察到、被重试或被补偿。
隔离级别应该如何选择
可以从业务风险出发:
普通读取、非关键展示:READ COMMITTED
同一事务内需要稳定快照:REPEATABLE READ
强冲突、低并发关键操作:SERIALIZABLE 或显式锁
高并发计数、库存、余额:优先原子更新、版本号或专用一致性方案
但最终选择要结合数据库实现和实际负载。
不要只根据理论定义做决定,要确认:
- 当前数据库版本如何处理冲突;
- 事务隔离和锁是否与连接池兼容;
- 长事务是否会导致锁等待;
- 热点行的并发上限;
- 冲突后业务是否能安全重试;
- 监控能否看到锁等待和失败率。
并发写入的测试方式
单线程单元测试无法覆盖这些问题。
至少需要:
- 两个事务交错执行;
- 同一版本号并发更新;
- 库存为 1 时多个请求同时扣减;
- 乐观锁失败后重试;
- 跨服务超时后的重复请求;
- 消息重复投递;
- 热点行持续写入。
测试不仅要断言最终数据,还要断言:
- 是否只有一个请求成功;
- 失败请求是否返回明确冲突;
- 重试是否会重复产生副作用;
- 事务是否释放连接和锁;
- 数据是否能通过对账恢复。
最后检查这 8 项
- 关键读后写是否有原子边界
- 是否检查 UPDATE 的影响行数
- 乐观锁是否使用最新版本并限制重试
- 库存、余额等条件是否写进原子 UPDATE
- 事务中是否避免等待外部服务
- 跨服务操作是否有幂等键和补偿
- 是否监控锁等待、冲突率和重试次数
- 是否有真实并发测试覆盖交错执行
事务隔离级别很重要,但它不是并发正确性的全部答案。
业务代码仍然需要明确:
这次修改基于哪个状态、谁来保证状态没有被别人改过、冲突后应该怎么恢复。
你们的库存或余额更新是行锁、乐观锁,还是原子条件更新?有没有遇到过接口返回成功但实际没有写入的情况?欢迎在评论区聊聊。
如果你也在排查并发写入、事务隔离或数据一致性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。