Spring Boot同一实体更新的竞态问题及优化方案问询
首先直接给结论:是的,Line B-3会覆盖Line A-2的修改,这是典型并发场景下的「丢失更新」问题,我来一步步拆解原因和可行的解决方案:
为什么会出现覆盖?
你给出的两个方法都没有添加@Transactional,这意味着每个数据库操作(findById和save)都是独立的事务:
- 请求A执行Line A-1,从数据库读取到旧的User对象(比如年龄20,名字张三)
- 请求B执行Line B-1,同样读取到这个旧的User对象
- 请求A修改年龄为25(Line A-2,此时只是内存中的对象变化)
- 请求B修改名字为李四(Line B-2,同样是内存中的对象变化)
- 请求A执行Line A-3,把修改后的User(年龄25,名字张三)写入数据库
- 请求B执行Line B-3,把自己内存中的User(年龄20,名字李四)写入数据库——这一步就会覆盖掉请求A对年龄的修改,最终数据库里的用户年龄又变回20了
是不是所有方法都要加@Transactional?
答案是不需要,@Transactional主要用于保证多个数据库操作的原子性(要么全成功要么全回滚),或者利用事务内的实体缓存特性。但仅仅给这两个方法加上@Transactional,并不能解决你遇到的并发覆盖问题——因为两个请求是独立的事务,默认的事务隔离级别(比如MySQL的REPEATABLE READ)下,每个事务还是会读取到自己的快照,最终提交时依然可能覆盖对方的修改。
更优的解决方案
针对这种并发修改场景,推荐以下几种方案,按适用优先级排序:
1. 直接执行数据库更新语句(最推荐)
不要先查询实体再修改保存,而是直接编写更新语句,让数据库原子性地完成修改。比如在Repository中定义方法:
@Modifying @Query("update User u set u.age = :newAge where u.id = :userId") void updateAgeById(@Param("userId") long userId, @Param("newAge") int newAge); @Modifying @Query("update User u set u.name = :newName where u.id = :userId") void updateNameById(@Param("userId") long userId, @Param("newName") String newName);
这样每个更新都是数据库层面的原子操作,完全避免了内存中对象和数据库不一致的问题,性能也最高。
2. 使用乐观锁(高并发场景首选)
在User实体中添加版本号字段,利用JPA的乐观锁机制:
@Entity public class User { // 其他字段... @Version private Integer version; // 版本号,每次更新会自动递增 }
当两个并发请求尝试修改同一个用户时,第二个请求执行save时会发现版本号不匹配(因为第一个请求已经更新了版本号),此时会抛出OptimisticLockingFailureException。你可以捕获这个异常,然后重试操作或者提示用户“数据已更新,请刷新后重试”。这种方式不会阻塞请求,适合高并发场景。
3. 使用悲观锁(低并发场景可选)
在查询用户时直接加写锁,确保同一时间只有一个事务能修改这条记录:
public void updateAge(long userId, int newAge) { User theUser = userRepository.findById(userId, LockModeType.PESSIMISTIC_WRITE).orElse(null); theUser.setAge(newAge); userRepository.save(theUser); }
这种方式会锁住数据库中的这条记录,其他请求必须等待当前事务提交才能读取和修改,避免了并发覆盖,但会降低并发性能,适合并发量不高的场景。
内容的提问来源于stack exchange,提问作者Chor Wai Chun

