十万个why:方法明明加了 synchronized,为什么套上 Spring 事务之后又线程不安全了?
大家好,我是小富。
《十万个why》系列持续更新中
在一个 Service 方法上加了 synchronized,又加了 @Transactional,认为既有锁又有事务,并发安全妥妥的,只要压测一跑,数据还是乱了。
明明加了锁,为什么还不安全?
先看一个典型的翻车场景
@Service
public class AccountService {
@Transactional
public synchronized void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountMapper.selectById(fromId);
Account to = accountMapper.selectById(toId);
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(from);
accountMapper.updateById(to);
}
}
两个人同时给同一个账户转账,按理说 synchronized 保证了同一时刻只有一个线程能进入这个方法,数据应该是对的。
但实际跑起来,为什么余额对不上?
根因:锁的范围比事务小
Spring 的 @Transactional 是通过 AOP 动态代理 实现的。当你调用这个方法时,实际的执行流程是这样的:
外部调用 → 代理对象 → 开启事务 → 调用目标方法(synchronized 在这里) → 提交事务
用伪代码还原一下代理的逻辑:
// Spring 生成的代理类大致逻辑
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1. 开启事务(获取数据库连接,设置 autocommit = false)
TransactionStatus status = transactionManager.getTransaction(definition);
try {
// 2. 调用目标对象的真实方法(synchronized 在这一步生效)
target.transfer(fromId, toId, amount);
// 3. 提交事务(这一步在 synchronized 锁释放之后!)
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
注意第 2 步和第 3 步的时序:
synchronized锁住的是target.transfer()这个方法方法执行完毕后,锁就释放了
但事务的
commit是在锁释放之后才执行的
这就产生了一个致命的时间窗口:

线程 A 释放锁后、事务提交前,线程 B 拿到了锁并开始执行。此时线程 A 的更新还没有 commit,MySQL 默认的 REPEATABLE READ 隔离级别下,线程 B 读到的仍然是旧值 1000(而不是 900)。
然后线程 B 也扣了 100,更新为 900。线程 A 的事务提交了(余额 900),线程 B 的事务也提交了(余额也是 900)。两个人各扣了 100,但余额只少了 100。丢失更新发生了。
问题就出在释放锁和事务提交之间的空隙。
怎么解决?
把 synchronized 加在事务外面
手动控制事务,让锁的范围包住整个事务:
@Service
public class AccountService {
@Autowired
private TransactionTemplate transactionTemplate;
public synchronized void transfer(Long fromId, Long toId, BigDecimal amount) {
transactionTemplate.execute(status -> {
Account from = accountMapper.selectById(fromId);
Account to = accountMapper.selectById(toId);
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(from);
accountMapper.updateById(to);
return null;
});
}
// 注意:这里没有 @Transactional,事务由 TransactionTemplate 管理
// synchronized 锁住了整个方法,包括事务的开启和提交
}
这样 synchronized 的范围覆盖了事务的开启、执行和提交。线程 B 必须等线程 A 的事务提交完毕并释放锁之后才能进入。
用数据库层面的锁
既然问题出在 JVM 层面的锁管不到数据库事务,那就直接用数据库的锁:
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 规避死锁:严格按照 ID 大小顺序加锁
Long firstId = fromId < toId ? fromId : toId;
Long secondId = fromId < toId ? toId : fromId;
// 先锁 ID 小的,再锁 ID 大的
Account first = accountMapper.selectByIdForUpdate(firstId);
Account second = accountMapper.selectByIdForUpdate(secondId);
// 锁好之后再分配回 from 和 to 账户进行业务操作
Account from = fromId.equals(first.getId()) ? first : second;
Account to = toId.equals(first.getId()) ? first : second;
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(from);
accountMapper.updateById(to);
}
SELECT ... FOR UPDATE 会在数据库层面加排他锁,事务提交前其他事务无法修改这些行。这样即使不加 synchronized,也能保证并发安全。
而且这种方式在分布式环境(多实例部署)下也有效。synchronized 只在单 JVM 内有效,多个实例部署时根本锁不住。
不过用 FOR UPDATE 有个经典的隐患:如果 A 给 B 转账(先锁 A 再锁 B),同时 B 给 A 转账(先锁 B 再锁 A),高并发下一旦相互等待就直接发生死锁了。所以上面代码里做了一个小优化。
不管谁转给谁,在加锁前先排个序,严格按照先锁小 ID,再锁大 ID的顺序去拿锁,只要加锁顺序保持一致,就能彻底规避死锁。
乐观锁
适合冲突不频繁的场景:
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountMapper.selectById(fromId);
// UPDATE account SET balance = ?, version = version + 1
// WHERE id = ? AND version = ?
int rows = accountMapper.updateWithVersion(from.getId(),
from.getBalance().subtract(amount),
from.getVersion());
if (rows == 0) {
throw new OptimisticLockException("并发冲突,请重试");
}
// ... 同样处理 to 账户
}
总结
这个问题本质上是 Spring AOP 代理和 synchronized 锁的范围不匹配导致的。
@Transactional通过代理在方法执行前后织入事务逻辑,而 synchronized 只锁住目标方法本身,导致事务提交发生在锁释放之后,给并发操作留下了时间窗口。
简单来说,锁释放了,但事务还没提交,数据就裸奔了。
