事务隔离级别选对了,为什么还是会出现丢失更新?聊聊 5 个并发误区

很多团队遇到并发写入问题时,会先把数据库事务隔离级别调高:

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
  • 事务中是否避免等待外部服务
  • 跨服务操作是否有幂等键和补偿
  • 是否监控锁等待、冲突率和重试次数
  • 是否有真实并发测试覆盖交错执行

事务隔离级别很重要,但它不是并发正确性的全部答案。

业务代码仍然需要明确:

这次修改基于哪个状态、谁来保证状态没有被别人改过、冲突后应该怎么恢复。

你们的库存或余额更新是行锁、乐观锁,还是原子条件更新?有没有遇到过接口返回成功但实际没有写入的情况?欢迎在评论区聊聊。

如果你也在排查并发写入、事务隔离或数据一致性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。

相关推荐
一条泥憨鱼2 小时前
【Redis | 黑马点评】达人探店功能源码解读
数据库·redis·缓存
赵渝强老师2 小时前
【赵渝强老师】崖山数据库的命令行客户端工具
数据库·国产数据库·yashandb·崖山数据库
忡黑梨2 小时前
PostgreSQL 小版本升级实战:14.23 升级到 14.24(源码编译方式)
数据库·postgresql
LMT流氓兔2 小时前
第五单元——第11课:自动测试“拒绝后绝不能写入”
数据库·人工智能·python·langchain
小朱爱编程1233 小时前
AI点了两次发布,我多了两篇事故
数据库·人工智能·大模型·软件工程
时间售卖铺3 小时前
MySQL DML 与 DQL 语句详解
运维·数据库·mysql·云原生
codeejun3 小时前
每日一Go·MySQL-9、索引存储结构|页分裂、页合并、索引高度与磁盘
数据库·mysql
Pniubi3 小时前
事务(Spring&MySQL)
数据库·sql·spring
DBA_G4 小时前
GBase 8a MPP集群个人电脑安装环境浅析
数据库