Appearance
Spring 事务隔离级别和丢失更新问题
先说结论
- Spring 事务隔离级别本质上是在使用数据库隔离能力,真正生效的核心仍然是底层数据库
- 丢失更新不是只靠把隔离级别调高就一定能解决,很多时候还要配合乐观锁、悲观锁或条件更新
- 排查这类问题时,先分清是“并发读写冲突”还是“业务流程覆盖写”
一、先搞清什么是丢失更新
最常见的场景是:
- 事务 A 读出一条记录,值是
10 - 事务 B 也读出同一条记录,值也是
10 - A 更新成
11 - B 也更新成
11
最后结果看起来像只执行了一次更新,这就是典型的丢失更新。
它和脏读、不可重复读、幻读不完全是一回事,重点是:
- 两次更新互相覆盖了
- 业务以为自己改成功了
- 实际有一次更新被“吃掉了”
二、Spring 事务隔离级别到底管到哪
Spring 里的隔离级别配置,本质上只是把要求传给数据库。
也就是说:
READ_COMMITTEDREPEATABLE_READSERIALIZABLE
这些真正的行为差异,最终还是由 MySQL、PostgreSQL 这类数据库去决定。
所以要特别注意两件事:
- 不同数据库默认隔离级别不一样。
- Spring 配了隔离级别,不代表你的业务写法就天然不会丢失更新。
三、为什么很多系统还是会遇到丢失更新
因为实际业务里,更常见的是下面这几种写法:
1. 先查再改
先查库存、余额、状态,再把计算后的结果回写。
这类写法在并发下最容易互相覆盖。
2. 跨层逻辑过长
查出来的数据经过很多业务判断、远程调用、参数拼装后才回写,窗口越长,冲突概率越高。
3. 没有版本号或条件约束
更新语句只是:
sql
update account set balance = ? where id = ?这种写法本身不具备并发保护能力。
四、解决思路一般分三类
1. 乐观锁
最常见的做法是加 version 字段:
sql
update account
set balance = ?, version = version + 1
where id = ? and version = ?这样更新失败时,应用就知道这次写入已经过期了。
2. 悲观锁
例如 select ... for update。
适合强一致、冲突概率高、临界资源很明确的场景。
但代价是:
- 等锁时间更长
- 吞吐下降
- 死锁风险上升
3. 原子条件更新
有些库存、计数类场景,其实不用先查再改,可以直接写成:
sql
update stock
set count = count - 1
where id = ? and count > 0这种方式往往比在 Java 里先读后算更稳。
五、最容易踩的坑
1. 以为 @Transactional 就自动防并发覆盖
事务能保证一段逻辑在一个事务里提交或回滚,但不等于自动解决并发写冲突。
2. 一上来就调成串行化
SERIALIZABLE 能更强,但对吞吐和锁竞争影响很大,不适合当默认止血手段。
3. 只盯数据库,不看业务更新方式
很多丢失更新问题,根因其实是业务先查再改的写法,而不是数据库隔离级别太低。
总结
Spring 事务隔离级别解决的是事务读写可见性问题,丢失更新更多是并发写控制问题。真正稳的做法通常不是单纯调高隔离级别,而是结合乐观锁、悲观锁或原子条件更新,把“并发覆盖写”这件事直接收住。