一次 RedissonLock 与 Reactor 线程模型兼容问题的线上排查与解决
一、问题起因:锁明明加了,却没锁住
某天凌晨的日志监控发现,提现接口出现了同一用户多次并发提现的情况,余额出现异常。
我们当时的代码逻辑非常标准:
boolean locked = simpleRedisLock.tryLock("IAccountTransactionService.withdraw." + memberId, "1", 3000, 30000);
if (locked) {
return iAccountTransactionService.withdraw(withdrawRequest);
} else {
throw new ServiceException("系统繁忙,请稍后再试");
}
tryLock() 使用 Redis 的 SETNX 实现,按理说同一用户(同一 memberId)不可能并发进入。
然而日志却显示:
[reactor-http-nio-3] 调用钱包转账接口,金额: 1.00
[reactor-http-nio-5] 调用钱包转账接口,金额: 1.00
[reactor-http-nio-9] 调用钱包转账接口,金额: 1.00
短短几十毫秒内,三个不同的 Reactor 线程同时执行提现逻辑,三笔转账同时发生。
换句话说,Redis 分布式锁根本没有生效。
二、初步怀疑:RedissonLock 与 Reactor 不兼容
在项目中我们原本用过 Redisson 的 RLock,后改为自研轻量锁。
但当时就发现:在 Reactor (WebFlux) 框架中,
RedissonLock 的行为异常 —— 有时解锁时报错,有时锁直接失效。
怀疑点集中在:Reactor 的线程模型与 RedissonLock 的可重入机制冲突。
三、深入分析:锁的原理与 Reactor 的线程机制
1. Redisson 可重入锁的实现
Redisson 的 RLock 底层依赖 Redis hash 存储锁信息:
key: lock:user:123
hash: { threadId : reentrancyCount }
当线程加锁时,会把当前的 Thread.currentThread().getId() 写入 Redis;
同一线程再次加锁不会冲突(可重入)。
2. Reactor 的线程模型
Reactor 基于 事件循环 (EventLoop),线程可复用:
- 每个请求不是独立线程,而是在 Netty I/O 线程中被异步调度;
- 一个请求可能在不同的线程间切换(尤其是使用
publishOn/subscribeOn时); ThreadLocal在线程切换后失效。
结果:
Redisson 认为 “A 请求的线程 ID 变了”,
从 Redis 看,这个锁已经不是同一线程持有。✅ 于是锁失效、❌ 解锁报错、⚠️ 甚至多个线程并发执行。
四、真实复现:日志印证 Reactor 多线程并发
日志里能清楚看到同一个用户请求被多个 EventLoop 并发处理:
[reactor-http-nio-3] WalletClient 调用钱包转账接口...
[reactor-http-nio-5] WalletClient 调用钱包转账接口...
[reactor-http-nio-9] WalletClient 调用钱包转账接口...
锁逻辑在 Reactor 的异步流中执行,而不是在独立线程内。
最终导致三次 SETNX 同时触发,都返回 true —— 于是三个请求同时进入业务逻辑。
五、进一步分析:我们的 SimpleRedisLock 也踩了坑
为了规避 Redisson 的线程 ID 问题,我们自己封装了一个轻量 Redis 锁:
setIfAbsent(key, "1", expireSeconds);
但这同样存在两个问题:
-
value 固定为 “1”
所有线程都写入相同的锁值;
后来的请求可以直接unlock(key, "1")删除别人加的锁;
相当于“解锁互相覆盖”。 -
Reactor 是并发的
tryLock()是阻塞实现(Thread.sleep 重试),
Reactor 的 EventLoop 不会等待,而是多个线程同时执行tryLock()。
于是 Redis 瞬时收到了多个SETNX,全部成功。
最终表现为:
同一 memberId 的三个提现请求同时通过了分布式锁校验。
六、排查结论
| 问题 | 根本原因 |
|---|---|
| RedissonLock 在 Reactor 下锁失效 | Reactor 线程切换导致 ThreadId 变化 |
| 自研 SimpleRedisLock 失效 | 锁 value 固定、并发时互相覆盖 |
tryLock() 在 Reactor 执行无效 |
Reactor 的事件循环并发执行,不会等待阻塞锁 |
| @Transactional 事务未生效 | Reactor 跨线程执行导致 ThreadLocal 丢失事务上下文 |
七、最终方案:重写分布式锁(Reactor 友好)
我们最终实现了一个 非阻塞、不可重入、唯一 value 的分布式锁,
基于 SET NX EX + Lua 脚本,示意逻辑如下:
// 加锁:SET key value NX EX expire
boolean success = redis.setIfAbsent(key, requestId, expireSeconds);
// 解锁(Lua):
if (redis.get(key) == requestId) then
redis.del(key)
end
- 每次锁的 value = UUID,保证唯一;
- 解锁前校验 value 一致性;
- 支持等待时间(自旋重试),防止 Redis 抖动;
- 非可重入,线程安全。
并且:
- 在 WebFlux 环境中,逻辑运行在
Schedulers.boundedElastic()(独立阻塞线程池); - 确保锁竞争在同一线程上下文内。
八、其他可选方案(但架构改动较大)
| 方案 | 思路 | 优缺点 |
|---|---|---|
| RedissonReactiveClient | 使用官方 Reactive API (Mono / Flux) 实现非阻塞分布式锁 |
✅ 原生兼容 Reactor;❌ 需要全链路改造为响应式 |
| TransactionalOperator (R2DBC) | 使用响应式事务管理(绑定 Reactor Context) | ✅ 真正非阻塞事务;❌ 必须使用 R2DBC 数据库驱动 |
| 单线程 executor per key | 按用户 key 建立单线程队列串行化任务 | ✅ 无锁、高性能;❌ 需重构任务调度体系 |
| 本地锁 + 分布式锁双层保护 | 先 ConcurrentHashMap 本地锁,再 Redis 分布式锁 |
✅ 抢锁压力更小;❌ 仍然需 Reactor 线程隔离 |
九、最终选择与结果
我们最终采用了:
- ✅ 自研 SimpleRedisLock(唯一 value + Lua 解锁);
- ✅ 在 Reactor 中用
Mono.fromCallable(...).subscribeOn(boundedElastic())包裹业务逻辑; - ✅ 事务控制与锁逻辑保持在同一线程;
- ✅ 对现有接口无侵入改造。
上线后再次压测,同一用户并发提现时:
只有一个请求成功进入,其他线程全部等待或直接提示“系统繁忙”。
锁机制彻底生效,问题解决。
🔚 十、总结与经验教训
| 教训 | 说明 |
|---|---|
| 1. Reactor 与传统线程语义不同 | EventLoop 模式会打破基于 ThreadLocal 的事务与锁逻辑 |
| 2. RedissonLock 依赖 ThreadId,可重入但不跨线程 | 在 WebFlux / 协程中易失效 |
| 3. 分布式锁必须保证唯一 value | 否则解锁时会互相覆盖 |
| 4. @Transactional 在 Reactor 中不自动生效 | 跨线程会导致事务上下文丢失 |
| 5. 同步阻塞逻辑必须放入 boundedElastic | 避免卡死 Reactor 主线程 |
一句话总结:
⚙️ RedissonLock 在 Reactor 世界中失效的根因,是线程模型的割裂。
🧠 要么使用响应式锁 (Reactive Redis),要么重写分布式锁逻辑,保持线程与上下文一致。
✍️ 后记
这次问题虽然看似只是“锁没锁住”,但背后是 Reactor 非阻塞架构与 Spring 传统事务/锁机制之间的根本冲突。
它提醒我们:
当项目从阻塞 MVC 迁移到 WebFlux 时,不仅仅是接口改为 Mono/Flux,
还要重新审视 所有基于线程上下文的机制 ——
包括锁、事务、ThreadLocal、MDC、用户上下文、日志链路追踪等等。
只有彻底理解 Reactor 的执行模型,
才能写出既高性能又正确的分布式应用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)