在一个 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 只锁住目标方法本身,导致事务提交发生在锁释放之后,给并发操作留下了时间窗口。
简单来说,锁释放了,但事务还没提交,数据就裸奔了。