找回密码
立即注册
搜索
发回帖 发新帖

6253

积分

0

好友

776

主题
发表于 3 小时前 | 查看: 1| 回复: 0

在一个 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 是在锁释放之后才执行的

这就产生了一个致命的时间窗口:

数据库事务中锁释放与提交的时序图:线程B在事务提交前读到旧数据

线程 A 释放锁后、事务提交前,线程 B 拿到了锁并开始执行。此时线程 A 的更新还没有 commit,在 MySQL 默认的 REPEATABLE READ 隔离级别下,线程 B 读到的仍然是旧值 1000(而不是 900)。

然后线程 B 也扣了 100,更新为 900。线程 A 的事务提交了(余额 900),线程 B 的事务也提交了(余额也是 900)。两个人各扣了 100,但余额只少了 100,丢失更新发生了。

MySQL REPEATABLE READ 隔离级别下丢失更新问题时序图

问题就出在 释放锁 和 事务提交 之间的空隙。

怎么解决?

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

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




上一篇:DeepSeek Harness 初体验:源码部署、API Key 配置与插件定制
下一篇:延迟双删真能保证 Redis 与 MySQL 缓存一致性吗?高并发下并不靠谱
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-8 03:12 , Processed in 0.073794 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表