记一次线上翻车:加了Redisson分布式锁,数据还是被并发打穿了

记一次线上翻车:加了Redisson分布式锁,数据还是被并发打穿了

前阵子接了个需求,场景很简单:用户并发操作某个资源(比如领取限量优惠券,或者高频修改某个配置)。这种防并发超卖的场景,老 Java 开发闭着眼睛都能想到一套组合拳:Spring Boot + @Transactional + Redisson 分布式锁

当时我咔咔一顿敲,代码写得极其丝滑,本地单线程测了没毛病,直接提测上线。结果上线第一天,运营跑过来说:"后台数据不对啊,怎么同一个资源被重复扣了两次?"

我当时心里一句卧槽,赶紧去看日志。不看不知道,一看麻了:虽然加了分布式锁,但在极高并发下,锁竟然"失效"了!

今天给大家复盘一下这个差点让我背绩效 C 的坑。

还原当时的"作死"代码

为了直观,我把当时的业务代码简化一下。大体逻辑是这样的:查询数据库中的剩余量 -> 判断是否足够 -> 扣减 -> 保存。

Java

java 复制代码
@Service
public class ResourceServiceImpl implements ResourceService {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ResourceMapper resourceMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void consumeResource(String resourceId) {
        String lockKey = "lock:resource:" + resourceId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 尝试获取锁,最多等3秒
            if (lock.tryLock(3, TimeUnit.SECONDS)) {
                // 1. 查数据库当前库存
                Resource res = resourceMapper.selectById(resourceId);
                if (res.getStock() > 0) {
                    // 2. 扣减
                    res.setStock(res.getStock() - 1);
                    resourceMapper.updateById(res);
                } else {
                    throw new RuntimeException("库存不足");
                }
            } else {
                throw new RuntimeException("系统繁忙,请重试");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 3. 释放锁
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

各位大佬看看,这代码是不是很眼熟?@Transactional 保证事务,try...finally 保证锁必定释放。这代码不管怎么 Review,逻辑上都没毛病啊!

但是,并发一上来,数据库的库存还是出现了负数。

揪出真凶:锁释放与事务提交的时差

排查了大半天,抓了 MySQL 的 binlog 和 Redis 的执行日志对比,终于发现了问题所在:Spring AOP 的锅。

大家回忆一下 @Transactional 的底层原理。Spring 是通过 AOP 动态代理来实现事务的,相当于在你的业务方法外层包了一层:

Java

typescript 复制代码
// Spring 代理类的伪代码
public void proxyConsumeResource(String resourceId) {
    // 1. 开启数据库事务
    connection.setAutoCommit(false); 
    try {
        // 2. 执行你的真实业务逻辑(包含加锁、改数据、释放锁)
        target.consumeResource(resourceId); 
        // 3. 提交事务
        connection.commit(); 
    } catch (Exception e) {
        connection.rollback();
    }
}

看出致命问题了吗?!

在我的业务代码里,finally 块执行了 lock.unlock(),此时分布式锁已经被释放了 。但是!此时 target.consumeResource() 方法才刚刚执行完,Spring 代理类的 connection.commit() 还没执行!

也就是说,锁已经没了,但数据还没落盘。

这时候,如果有另一个线程(线程B)并发打进来:

  1. 线程B看到 Redis 里没有锁,顺利拿到锁。
  2. 线程B去查数据库。因为线程A的事务还没 commit,线程B查到的还是老数据
  3. 线程B拿着老数据做扣减。
  4. 线程A提交事务,线程B紧接着也提交事务。

完美,脏写产生了,数据被彻底打穿。

填坑方案:让锁多飞一会儿

找到原因后,解决起来就非常简单了。核心思想只有一个:必须保证事务提交之后,再释放锁。

方案一:粗暴拆分(推荐)

直接把加锁的逻辑往上提,放到 controller 层,或者再包一层 service。确保锁的范围大于事务的范围。

Java

typescript 复制代码
@Service
public class ResourceLockService {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ResourceServiceImpl resourceService; // 注入原来的事务Service

    public void safeConsume(String resourceId) {
        String lockKey = "lock:resource:" + resourceId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (lock.tryLock(3, TimeUnit.SECONDS)) {
                // 调用事务方法,由于事务方法是一个独立的 proxy,
                // 执行完毕返回到这里时,事务已经 commit 啦!
                resourceService.consumeResource(resourceId); 
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

注:注意不要在同一个类里写这两个方法直接 this 调用,会导致 AOP 失效,老生常谈了。

方案二:编程式事务

如果你不想多写一层类,可以使用 TransactionTemplate 手动控制事务的边界。

Java

kotlin 复制代码
// 在获取锁之后执行:
transactionTemplate.execute(status -> {
    // 查库、扣减、更新
    return null;
});
// 事务提交完毕,再进入 finally 释放锁

顺便提一嘴的坑:Redisson 看门狗失效

借着这个机会,再分享一个很多人用 Redisson 容易踩的坑。有些哥们喜欢在加锁的时候传 leaseTime(锁过期时间):

Java

csharp 复制代码
// 试图加锁,等待3秒,锁定10秒后自动释放
lock.tryLock(3, 10, TimeUnit.SECONDS); 

一旦你显式传入了 leaseTimeRedisson 的 WatchDog(看门狗)机制就会失效! 如果你的业务逻辑执行时间超过了 10 秒(比如发生了 Full GC 或者调了很慢的第三方接口),锁会自动释放,其他线程就会趁虚而入。

正确做法是,如果不知道业务具体执行多久,千万别传 leaseTime

Java

csharp 复制代码
// 只传等待时间,不传 leaseTime,看门狗机制生效,会自动帮你续期
lock.tryLock(3, TimeUnit.SECONDS); 

总结

平时的业务开发中,@Transactional 和各种锁(包括本地锁 synchronized 和分布式锁)一起用的时候,一定要多留个心眼,画一画它们的作用域边界。

有时候真不是底层组件不行,纯粹是我们没把 Spring AOP 的执行顺序盘明白。希望我这次的血泪教训,能帮大家少掉几根头发。大家在线上还踩过什么离谱的并发坑?欢迎评论区交流一波~

相关推荐
无籽西瓜a11 小时前
Plan-and-Execute 里的 DAG 是怎么工作的
java·后端·ai·agent·dag
我登哥MVP11 小时前
SpringCloud 核心组件解析:服务网关
java·spring boot·后端·spring·spring cloud·java-ee·maven
北城以北888812 小时前
RocketMQ简介
java·spring boot·后端·rocketmq
GoGeekBaird19 小时前
从 Prompt Engineering 到 Loop Engineering,我觉得 AI 开发这事儿终于开始变味了
后端·github
一条泥憨鱼19 小时前
【Redis】数据类型和常用命令
java·数据库·redis·后端·缓存
Oneslide20 小时前
初始化微信小程序
后端
hboot21 小时前
AI工程师第一课 - Python
前端·后端·python
阿正的梦工坊21 小时前
【Rust】12-借用检查器与非词法生命周期
开发语言·后端·rust
飞天狗1111 天前
零基础JavaWeb入门——第2课:让网页“活”起来 —— JSP是什么?
java·开发语言·前端·后端·web
梦@_@境1 天前
面向 Spring Boot 的可观测业务流程编排引擎
java·spring boot·后端