一、问题起因:锁明明加了,却没锁住

某天凌晨的日志监控发现,提现接口出现了同一用户多次并发提现的情况,余额出现异常。
我们当时的代码逻辑非常标准:

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);

但这同样存在两个问题:

  1. value 固定为 “1”
    所有线程都写入相同的锁值;
    后来的请求可以直接 unlock(key, "1") 删除别人加的锁;
    相当于“解锁互相覆盖”。

  2. 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 的执行模型,
才能写出既高性能又正确的分布式应用。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐