聊聊Redisson最常见的8个坑

前言

不知道你有没有遇到过这种场景:项目里明明用了 Redisson,代码里也老老实实写了 lock 和 unlock,结果偶尔还是会出现重复扣库存、同一笔订单被处理两次,或者两个线程同时修改同一份数据。

有些小伙伴第一反应是:Redisson 是不是有 bug?

我一般情况下不会先怀疑 Redisson。咱们得先看锁到底加在了什么地方、什么时候释放、保护的是不是同一个资源,以及锁失效以后业务能不能自己兜底。

Redisson 帮我们封装了可重入、自动续期和持有者校验,这些都是好东西。但它管不了咱们的业务边界,也不知道锁里面的那段代码执行完以后,事务有没有提交,更不会替业务保证幂等。

所以锁能加成功,不等于业务就一定互斥。

今天这篇文章就专门跟大家一起聊聊 Redisson 里最常见的 8 个坑,希望对你会有所帮助。

先看一张整体图,这 8 个坑分别会出现在加锁、执行业务和释放锁的哪个阶段:

图 1:Redisson 加锁之后,8 个问题分别落在资源与加锁、执行、释放与续期、组合与故障四个部分。

从图里也能看出来,大部分问题都不是"锁命令写错了",而是锁和业务之间的边界没有设计好。

下面咱们一个一个聊。

更多项目实战在Java突击队网:susan.net.cn/project

1 传了 leaseTime,看门狗其实已经下班了

先问小伙伴们一个问题:看到下面这段代码,你觉得它的锁会一直自动续期吗?

bash 复制代码
RLock lock = redissonClient.getLock("order:lock:" + orderNo);
boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS);

有不少人会说会,因为用的是 Redisson。

但这里传了 30 秒的 leaseTime。锁到期就是到期,Redisson 不会再让看门狗给它续命。

我对着 Redisson 3.35 的源码看了一下,RedissonLock 只有在 leaseTime <= 0 时才会启动续期。像 tryLock(waitTime, leaseTime, unit) 这种传了正数租期的重载,走的是固定过期时间。

如果业务执行超过 30 秒,锁就会先失效。后面那个线程根本不用等你 unlock,它已经可以拿到同一把锁了。

整个过程大概是这样:

图 2:固定 leaseTime 和看门狗的路径不同,不要把两者混在一起。

常见重载的语义可以先记成下面这张表:

写法 最长等待时间 锁的初始租期 看门狗
lock() 一直等待 默认 30 秒 开启
lock(30, SECONDS) 一直等待 30 秒 关闭
tryLock(2, SECONDS) 2 秒 默认 30 秒 开启
tryLock(2, 30, SECONDS) 2 秒 30 秒 关闭

Redisson 3.35 里,tryLock(waitTime, unit) 实际上会转成 tryLock(waitTime, -1, unit),所以它不传固定租期,仍然可以续期。

那咱们应该怎么选?

如果你的临界区耗时不可预测,例如需要等待一次比较慢的数据库操作,通常用不带 leaseTime 的写法更合适,再配合业务超时和监控,防止死循环一直续期。

如果临界区有明确的上限,例如一段固定大小的数据同步,你可以传一个明显大于最长执行时间的租期。关键是这个上限得有依据,不能拍脑袋写个 30 秒。

最怕的是代码里随手传了 leaseTime,心里却默认"Redisson 会帮我续期"。这种错觉很危险。

2 事务提交前就把锁放了

这个问题在 Spring 项目里特别常见,我一般会先盯着 @Transactional 的方法看一遍。

假设咱们有一个支付完成的方法:

bash 复制代码
@Service
public class PaymentService {
    private final RLock lock;
    private final PaymentRepository repository;

    @Transactional
    public void complete(String orderNo) {
        boolean locked = false;
        try {
            locked = lock.tryLock(200, TimeUnit.MILLISECONDS);
            if (!locked) {
                throw new IllegalStateException("订单正在处理");
            }
            repository.markPaid(orderNo);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("等待订单锁被中断", exception);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

有些小伙伴看到 try/finally 就觉得稳了:异常也会释放锁,能有什么问题?

问题不在 finally,而在 Spring 事务的提交时机。

@Transactional 的提交发生在方法返回之后。finally 却在方法返回之前执行,于是实际顺序变成了:

bash 复制代码
加锁 -> 更新数据库 -> 解锁 -> 事务提交

解锁和提交之间出现了一个很短的窗口。另一个线程如果在这个窗口拿到锁,就可能读到数据库的旧状态,接着再改一次。

这个时候你去看代码,锁确实加了,finally 也确实执行了,但临界区已经被切开了。

图 3:锁应该包住事务提交,不能只包住事务方法里面的一段代码。

看到这里,有些小伙伴会问:那我把事务方法单独抽出去,不就行了吗?

思路是对的,但还要注意 Spring AOP 的代理边界。同类内部方法直接调用,事务注解可能不会生效。

更稳妥的做法是让锁控制事务外层,用 TransactionTemplate 明确事务从哪里开始、在哪里提交:

下面为了聚焦锁和事务的顺序,示例省略了仓储实现、异常映射和事务配置。

bash 复制代码
@Service
public class PaymentService {
    private final RedissonClient redissonClient;
    private final TransactionTemplate transactionTemplate;

    public PaymentService(
            RedissonClient redissonClient,
            TransactionTemplate transactionTemplate
    ) {
        this.redissonClient = redissonClient;
        this.transactionTemplate = transactionTemplate;
    }

    public void complete(String tenantId, String orderNo) {
        RLock lock = redissonClient.getLock(
                "order:lock:" + tenantId + ":" + orderNo
        );
        boolean locked = false;
        try {
            locked = lock.tryLock(200, TimeUnit.MILLISECONDS);
            if (!locked) {
                throw new IllegalStateException("订单正在处理,请稍后重试");
            }

            // transactionTemplate 返回时,事务已经提交。
            transactionTemplate.executeWithoutResult(status ->
                    markPaid(orderNo)
            );
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("等待订单锁被中断", exception);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

这样事务提交完,才轮到 finally 释放锁。

代价是锁的持有时间会变长。所以锁里面只放必须串行化的数据库操作,不要在锁里发短信、调远程接口或者做大批量导出。

3 一把大锁,把整个系统串成了单线程

有些小伙伴为了保险,喜欢直接来一把大锁:

bash 复制代码
RLock lock = redissonClient.getLock("stock:lock");

所有 SKU、所有仓库、所有租户都抢同一把锁。

从正确性看,超卖确实不容易发生了。但从性能看,系统也被咱们自己串成了单线程。

请求 A 只想扣 SKU-1,请求 B 只想扣 SKU-2,两个商品八竿子打不着,却要排在同一队列里。

锁的粒度至少得和真正需要互斥的资源对齐:

bash 复制代码
RLock lock = redissonClient.getLock(
        "stock:lock:" + tenantId + ":" + warehouseId + ":" + skuId
);

这样同一个仓库里的同一个 SKU 会互斥,不同 SKU 之间还能并行处理。

图 4:锁 key 要围绕资源设计,不能围绕一个模糊的"大业务"设计。

不过锁也不是越细越好。

如果每次请求都生成一个随机 key,那就等于没加锁。key 里如果放了请求 ID,也很容易制造出一堆只用一次、谁也拦不住的锁。

我判断 key 是否合理,通常就看一句话:两个请求到底因为什么资源发生冲突?

同一个租户、同一个仓库、同一个 SKU,这是明确的资源。它们对应的请求才应该互相等待。

4 在 A 线程加锁,让 B 线程去解锁

有些小伙伴喜欢在请求线程里加锁,然后把释放动作丢给线程池:

bash 复制代码
RLock lock = redissonClient.getLock(lockKey);
lock.lock();

executor.submit(() -> {
    lock.unlock();
});

这段代码看着像是在"异步优化",实际上是不行的。

Redisson 的 RLock 不是一把普通的跨线程信号量。它的持有者标识里包含客户端实例和线程信息,同一个线程重复获取同一把锁,才会被当作重入。

换一个线程过来,它就不是原来的持有者了。

图 5:锁的所有权和幂等是两个问题,跨线程传递 RLock 对象不代表传递了锁的所有权。

还有一种更常见的错误,是 finally 里面无条件解锁:

bash 复制代码
RLock lock = redissonClient.getLock(lockKey);

try {
    lock.tryLock(200, TimeUnit.MILLISECONDS);
    doBusiness();
} finally {
    lock.unlock();
}

这里连 tryLock 的返回值都没判断。万一没拿到锁,finally 还是去解锁,轻则抛 IllegalMonitorStateException,重则把真正的业务异常盖掉。

老老实实把状态记下来:

bash 复制代码
boolean locked = false;
try {
    locked = lock.tryLock(200, TimeUnit.MILLISECONDS);
    if (!locked) {
        return;
    }
    doBusiness();
} catch (InterruptedException exception) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("等待锁被中断", exception);
} finally {
    if (locked && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

如果项目里用的是异步线程、响应式编程或者自定义线程池,锁的生命周期尽量限制在同一个同步调用栈里。

千万不要把"把对象传给另一个线程"理解成"把锁也交给另一个线程了"。

5 以为加了锁,就不会重复处理

这个问题也经常有小伙伴问:"我明明加了分布式锁,为什么支付回调还是处理了两次?"

因为锁解决的是同时执行,幂等解决的是重复执行。

这两件事差得很远。

假设支付回调第一次已经更新成功,但在返回结果给第三方时网络超时了。对方没有收到成功响应,于是过一会儿又发了一次。

这个时候,第一次请求早就释放了锁,第二次请求当然能重新拿到锁。

锁不会记得上一次已经处理过,也不应该替业务记这个事。

订单支付这种场景,不能只写一句"先加锁,再更新"。

bash 复制代码
UPDATE orders
SET status = 'PAID',
    paid_at = CURRENT_TIMESTAMP
WHERE order_no = ?
  AND status = 'PENDING';

只有影响行数为 1,才继续后面的发货、通知等操作。如果影响行数为 0,就按订单当前状态返回,别再扣一次钱。

更完整的重复请求处理,可以对照高并发下如何保证接口幂等性这篇文章。

有些小伙伴可能会说:数据库加锁不就行了?

数据库锁当然也能解决一部分问题,但它的粒度和吞吐量得结合业务看。对于支付、余额、库存这种不能错的场景,数据库唯一约束、状态 CAS、版本号和审计补偿,通常比"相信 Redis 锁永远有效"更可靠。

锁是并发控制工具,不是数据正确性的最后一道防线。

这句话咱们得多念两遍。

6 把看门狗当成了永不过期的保险

看门狗很好用,但它有一个前提:客户端还活着,续期任务还能正常执行,Redis 也还能收到命令。

有些小伙伴看到自动续期,就放心地把长任务塞进锁里。短时间看没事,遇到故障时就容易出问题。

图 6:看门狗只能处理"客户端正常但业务还没跑完"的续期,不能保证 JVM、网络和 Redis 永远正常。

如果进程长时间 Full GC,续期线程可能来不及执行。

如果客户端和 Redis 之间的网络断了,续期命令发不过去。

如果 Redis 主从切换,异步复制还没把锁数据同步过去,新的主节点上就可能看不到这把锁。

还有一种情况,业务代码进入了死循环。看门狗一直续期,其他请求就一直排队。这虽然不是锁丢失,但同样能把系统拖垮。

所以长任务最好拆成状态阶段,不要指望一把锁从头抱到尾:

bash 复制代码
检查状态 -> 写入处理中 -> 获取短锁 -> 提交事务 -> 释放锁 -> 异步继续处理

异步处理阶段再通过任务表、消息队列或者状态机推进。进程被杀以后,业务还能根据"处理中"状态继续恢复。

看门狗能解决续期,解决不了业务状态应该怎么设计。

7 拿着读锁,还想原地升级成写锁

读写锁很适合读多写少的场景。读读可以共享,读写互斥,写写也互斥。

但有些小伙伴会写出这样的代码:

bash 复制代码
RReadWriteLock rwLock = redissonClient.getReadWriteLock("config:lock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();

readLock.lock();
try {
    Config config = loadConfig();
    if (needRefresh(config)) {
        writeLock.lock(); // 危险:当前线程还持有读锁
    }
} finally {
    readLock.unlock();
}

这段代码的风险在于,当前线程还持有读锁,却又要申请写锁。

Redisson 的读锁和写锁共用同一个读写锁名称,但它们的获取条件不一样。读锁没有释放时,写锁会在 Redis 里看到当前还有读模式,然后继续等待。

申请写锁的线程,恰好就是持有读锁的线程。如果它一直等下去,读锁又永远没有机会释放。

图 7:需要修改数据时,一开始就走写锁路径,不要在读锁里面临时升级。

正确的姿势是把读和写的入口拆开。

需要刷新配置时,直接申请写锁,在写锁里面重新读取并修改:

bash 复制代码
public void refreshConfig() {
    RLock writeLock = rwLock.writeLock();
    writeLock.lock();
    try {
        Config current = loadConfig();
        if (needRefresh(current)) {
            updateConfig(current);
        }
    } finally {
        writeLock.unlock();
    }
}

这样逻辑会直白很多,也不会出现"自己等自己释放锁"的尴尬。

另外,读锁不等于免费。拿着读锁做一次慢远程调用,所有写线程照样会被挡住。锁里面只放必须保护的共享状态,别把整个业务流程都塞进去。

8 把 RedLock 和 MultiLock 当成一个东西

有些小伙伴看到 Redisson 提供了 RedissonMultiLock 和 RedissonRedLock,会觉得后者是前者的升级版,节点越多越安全。

这里得掰开说。

RedissonMultiLock 是把多个独立 RLock 当成一把组合锁来操作。Redisson 3.35 里,它的 failedLocksLimit() 默认返回 0,也就是所有子锁都成功,这次组合加锁才算成功。

RedissonRedLock 继承自 RedissonMultiLock,但它改了失败容忍数,采用的是多数派判断。比如 5 个节点里成功 3 个,就可以认为加锁成功。

不过 RedissonRedLock 在 Redisson 3.35 里已经标记为 @Deprecated。这说明这个方案存在适用边界和复杂度问题,不建议咱们看到旧博客就直接搬到生产环境。

图 8:RedissonMultiLock 默认要求全部成功,RedissonRedLock 才是多数派判断,不能混为一谈。

有些小伙伴紧接着会问:那用了 RedLock,是不是就能保证跨集群强一致了?

也不能这么理解。

多个 Redis 节点可以降低单节点故障带来的影响,但网络分区、客户端停顿和锁过期以后继续执行的问题,仍然存在。

对于商品库存、支付、余额这种强一致场景,我更建议把数据库约束、状态 CAS、版本号或者 fencing token 放在资源侧。哪怕锁曾经成功过,也要让真正写数据的地方拒绝过期持有者。

Redis 锁更适合任务防重、短时间互斥、缓存刷新这类可以容忍少量重复的场景。

选什么锁,得先问业务能不能接受一次锁失效。

把一次请求从头走到尾

聊完 8 个坑,咱们再用一条完整的请求链路把它们串起来。

上线之前,我一般会拿一笔真实请求,顺着时间线走几遍。先看正常路径,再看异常路径,最后看 Redis 和网络出故障时业务还能不能自保。

图 9:一次 Redisson 加锁请求的正常路径、失败路径和超时恢复路径。

第一遍看正常流程。

请求进来以后,锁 key 是不是落到了真正冲突的资源上?等待时间有没有上限?拿到锁以后,数据库更新和事务提交是不是都在锁里面?提交完成以后,是不是由同一个线程检查持有者再释放?

这一遍看的是代码顺序。

第二遍看失败流程。

tryLock 返回 false 的时候,业务是快速失败、有限重试,还是返回缓存里的旧值?有没有可能没拿到锁,却继续执行后面的更新?线程被中断时,中断标记有没有恢复?

这一遍看的是失败分支有没有写完整。

第三遍看故障流程。

如果进程被杀、Full GC、Redis 主从切换,锁丢了以后业务能不能靠唯一键、状态机、任务表或者补偿任务继续保证正确?等待时间、持锁时间、续期失败和 tryLock 失败次数,有没有可观测的数据?

这一遍看的是锁失效以后,系统还能不能自己站起来。

把这三遍走完,很多看似偶发的问题,其实在上线前就能暴露出来。

更多项目实战在Java突击队网:susan.net.cn/project

总结

Redisson 是个好工具,可重入、自动续期和持有者校验,确实帮咱们省了不少事。

但它解决的是"怎么安全地操作一把 Redis 锁",解决不了"业务应该在哪加锁、锁什么时候能释放、锁失效以后怎么办"。

我的建议很朴素:

用不用 leaseTime,先看业务最长执行时间和能否预测;事务必须在锁内提交,不能让 finally 跑在提交前面;锁 key 要围绕租户、业务类型和资源 ID 设计,但不要细到一个请求一把锁;跨线程不要传递锁的所有权;锁外面还要有数据库唯一约束、状态 CAS 和幂等机制兜底。

看门狗、读写锁、MultiLock 和 RedLock 也都有自己的边界。别把它们当成"更高级所以更安全"的开关。

把锁当成并发控制,把数据库状态当成事实来源,把幂等和监控当成收尾。这三样一起用,Redisson 才算真正落到业务里。

希望这篇文章能让你少踩一点坑,也欢迎小伙伴们在评论区聊聊:你们项目里的 Redisson 锁,最长会在锁里面停留多久?

相关阅读

相关推荐
学长毕设3 小时前
基于SpringBoot的野生动物公益保护平台设计与实现-附源码
java·spring boot·后端
梦里寻码3 小时前
我用 NestJS + Vue3 写了一套完整的研发管理系统(58 表 / 229 API / 130 项断言,全开源
前端·后端·算法
鶴哥只手遮天3 小时前
《Redis 8.10.3 深度剖析》系列 ·第 10 章 Hash 一:`listpack`、`hashtable` 与 `LISTPACK_EX`
redis·后端·开源
johnjincaaaa4 小时前
WebAssembly 逆向实战:从 0 搞懂猿人学第 15 题
后端
Gopher_HBo4 小时前
Go CPU 剖析:四角色白话机制 + 实测实验
后端
猿人谷4 小时前
Surrogate Model:让进化算法学会“预测”,为什么它正在成为智能优化的重要技术?
后端·算法·架构
the局外人4 小时前
LangChain ToolRuntime 入门:context、state 与 store 的实际用法
后端·langchain·agent
回家路上绕了弯5 小时前
当 AI 越来越能干,我们如何理解自己的价值?
后端
陈随易5 小时前
Node.js 2026上半年都有哪些性能提升?
前端·后端·程序员