跳至主要內容

十万个why:方法明明加了 synchronized,为什么套上 Spring 事务之后又线程不安全了?

程序员小富大约 4 分钟

大家好,我是小富。

《十万个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 只锁住目标方法本身,导致事务提交发生在锁释放之后,给并发操作留下了时间窗口。

简单来说,锁释放了,但事务还没提交,数据就裸奔了。

上次编辑于: