READ_COMMITTED隔离级别下的@Transactional能否可靠防止领奖场景的竞争条件?
READ_COMMITTED隔离级别下的@Transactional能否可靠防止领奖场景的竞争条件?
这个问题其实挺典型的,很多人刚接触事务的时候都会误以为@Transactional加默认的READ_COMMITTED隔离级别就能搞定这类并发领奖的竞争,我来给你掰扯清楚:
首先直接给结论:仅靠@Transactional + READ_COMMITTED,没办法100%可靠防止你说的这种竞争条件,大概率会出现两个玩家都拿到奖金的情况。
为什么会这样?
先回到你的代码逻辑:事务里先查询ticket的winner是否为null,确认后再设置赢家、给玩家加钱,最后保存。当两个请求同时进来时,READ_COMMITTED的隔离级别在这里帮不上忙——因为READ_COMMITTED只是保证事务不会读到其他事务未提交的修改(解决脏读),但它不会阻止两个事务同时读到同一个旧状态:
- 事务A和事务B几乎同时启动,都去查询目标ticket的
winner字段 - 这时候两个事务都还没提交任何修改,所以它们读到的
winner都是null - 接着两个事务都执行了设置
winner、给玩家加钱的操作 - 最后两个事务都正常提交,结果就是两个玩家都拿到了100奖金,完全不符合预期
那@Transactional在这里的作用是什么?它确实能保证事务的原子性——比如如果设置赢家后加钱失败,整个事务的修改都会回滚,但它本身不会自动处理并发下的竞争锁问题,这是很多人容易混淆的点:事务的原子性和并发竞争是两个不同的问题。
那该怎么解决这个竞争条件?
你需要给ticket的操作加上锁机制,常见的有两种方案:
- 悲观锁:在查询ticket的时候就给它加排他锁,比如用
SELECT ... FOR UPDATE语句。这样第一个事务查询到ticket后,会把这条记录锁住,其他事务必须等第一个事务提交或回滚后才能查询到最新状态,自然就不会同时读到winner=null的情况。在Spring Data JPA里,你可以自定义带FOR UPDATE的查询方法,或者用支持锁的查询方法。 - 乐观锁:给ticket实体加一个
version版本号字段,每次更新的时候带上当前版本号,更新语句会变成UPDATE ticket SET winner = ?, version = version + 1 WHERE id = ? AND version = ?。当两个事务同时更新时,只有一个会成功(影响行数为1),另一个会发现影响行数为0,这时候你可以抛出异常或者提示用户“奖品已被领取”。
再回到你的疑问
你觉得READ_COMMITTED应该能保护你,其实是混淆了隔离级别和锁的作用:隔离级别主要解决的是不同事务之间的可见性问题,而并发写竞争需要的是锁来保证同一时间只有一个事务能修改目标资源。@Transactional只是帮你管理事务的生命周期,它本身不会自动加锁来防止竞争。
内容来源于stack exchange
相关产品推荐
相关产品推荐

