基于 Redisson 解决分布式微服务多节点抢占 ThreadLocal 内存泄漏与锁竞争闭环
基于 Redisson 解决分布式微服务多节点抢占 ThreadLocal 内存泄漏与锁竞争闭环
前言
兄弟们,说实话,搞技术这条路真是各种坑。咱们做开发的,说白了就是要不断踩坑、不断成长,这才是技术人的常态。
昨晚凌晨三点,电话把我从床上拽了起来。
生产环境报警,某个核心微服务的 JVM 内存直接爆表,Full GC 疯狂触发,系统响应慢得像蜗牛。
我连滚带爬地登录服务器,一看堆栈信息,心里咯噔一下。
又是 ThreadLocal 惹的祸。
咱们做微服务的,谁还没用过 ThreadLocal 存用户信息、TraceID?
觉得方便啊,线程内共享,不用到处传参数。
但在分布式环境下,尤其是用了线程池异步处理的时候,这玩意儿就是个定时炸弹。
线程复用,ThreadLocal 里的数据没清理,越积越多,最后直接把内存撑爆。
更惨的是,多个节点同时抢资源,本地锁不管用,分布式锁又没写好,直接死锁。
今天咱们就扒开这个烂摊子,看看怎么把 ThreadLocal 的内存泄漏堵上,再用 Redisson 把锁竞争理顺。
一、 底层原理
1.1 核心机制
你得先搞懂 ThreadLocal 到底存哪儿了。
它不是全局变量,它是存在当前线程里的。
每个线程都有一个ThreadLocalMap,这个 Map 的 Key 是 ThreadLocal 对象本身,Value 才是你存的数据。
重点来了,Key 是弱引用,Value 是强引用。
如果 ThreadLocal 对象没被引用了,GC 会回收 Key,变成 null。
但 Value 还在 Map 里挂着,只要线程不死,这个 Value 就永远拿不走。
在 Tomcat 这种容器里,线程是复用的。
请求来了,线程干活,存个 ThreadLocal。
请求完了,线程没销毁,回收到线程池。
下次请求再来,这个线程带着上次的脏数据继续跑。
日积月累,内存自然爆掉。
再看分布式锁。
本地synchronized或ReentrantLock只管得住自己 JVM 内的线程。
微服务有十几个实例,大家同时改数据库里的同一行数据,本地锁就是废纸。
Redisson 是基于 Redis 实现的分布式锁。
它利用 Redis 的setnx命令,保证同一时间只有一个节点能拿到锁。
它还带了个“看门狗”机制,防止业务逻辑没跑完,锁就过期了。
下面这张图,把 ThreadLocal 泄漏和 Redisson 锁的关系画清楚了。
sequenceDiagram participant User as 用户请求 participant Thread as 线程池线程 participant TL as ThreadLocalMap participant Redis as Redisson/Redis participant DB as 数据库 User->>Thread: 发起请求 Thread->>TL: 存入用户上下文 (未 remove) Thread->>Redis: 尝试获取分布式锁 Redis-->>Thread: 锁获取成功 Thread->>DB: 执行业务逻辑 DB-->>Thread: 操作完成 Thread->>Redis: 释放锁 Note over Thread: ⚠️ 关键:此处必须 remove ThreadLocal Thread->>TL: 清理上下文 (否则泄漏) Thread->>User: 返回响应设计优势很明显。
ThreadLocal 解决了单线程内的上下文传递,不用参数透传。
Redisson 解决了多节点间的资源互斥,保证数据一致性。
两者结合,前提是必须把 ThreadLocal 的生命周期管好。
1.2 与同类方案的对比
光说不练假把式,咱们对比一下几种常见的上下文传递方案。
| 方案 | 内存安全性 | 分布式支持 | 性能开销 | 适用场景 |
|---|---|---|---|---|
| ThreadLocal | 低 (易泄漏) | 不支持 | 极低 | 单 JVM 内线程隔离 |
| InheritableThreadLocal | 中 (子线程继承) | 不支持 | 低 | 主线程创建子线程场景 |
| TransmittableThreadLocal | 中 (需封装线程池) | 不支持 | 中 | 阿里开源,适配线程池 |
| Redisson + Redis | 高 (外部存储) | 支持 | 高 (网络 IO) | 跨节点共享状态 |
| Merged 方案 | 高 (推荐) | 支持 | 中 | 微服务上下文 + 分布式锁 |
看清楚了,ThreadLocal 本身不支持分布式。
要想在微服务里稳,必须配合外部存储或严格的清理机制。
二、 快速上手
别整那些复杂的,先来个最小可运行示例。
咱们模拟一个场景:记录当前操作用户,并用 Redisson 锁住一个计数器。
引入依赖,Maven 里加上redisson-spring-boot-starter。
配置好 Redis 连接,剩下的交给代码。
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.util.concurrent.TimeUnit; public class QuickStartDemo { // 模拟 ThreadLocal,存用户信息 private static final ThreadLocal<String> userContext = new ThreadLocal<>(); private final RedissonClient redissonClient; public QuickStartDemo(RedissonClient redissonClient) { this.redissonClient = redissonClient; } public void handleRequest(String userId) { // 1. 设置上下文 userContext.set(userId); System.out.println("当前操作人:" + userContext.get()); try { // 2. 获取分布式锁 RLock lock = redissonClient.getLock("resource_lock"); // 尝试加锁,等待 10 秒,锁自动过期 30 秒 boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS); if (isLocked) { try { // 3. 执行业务 System.out.println("锁获取成功,正在处理业务..."); Thread.sleep(1000); } finally { // 4. 必须释放锁 lock.unlock(); System.out.println("锁已释放"); } } else { System.out.println("获取锁失败,其他节点正在处理"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("线程被打断,退出处理"); } finally { // 5. 【关键】清理 ThreadLocal,防止内存泄漏 userContext.remove(); System.out.println("ThreadLocal 已清理"); } } }三分钟见效。
你看finally块里,锁释放和remove清理必须都在里面。
这是铁律,漏一步就是生产事故。
三、 核心 API / 深水区
3.1 核心方法速查
Redisson 的 API 设计得很人性化,不像原生 Jedis 那么底层。
咱们常用的也就这几个,记下来够用。
| 方法 | 说明 | 生产建议 |
|---|---|---|
getLock(String key) | 获取互斥锁 | 最常用,保证独占 |
tryLock(wait, lease, unit) | 尝试加锁 | 必须设超时,防止死锁 |
unlock() | 释放锁 | 必须在 finally 中调用 |
getReadWriteLock() | 读写锁 | 读多写少场景,提升并发 |
getAtomicLong(name) | 分布式原子长整型 | 替代本地 AtomicLong |
3.2 生产级配置
别直接用默认配置,那是给 Demo 用的。
生产环境得调参数。
比如锁的自动续期时间,默认是 30 秒。
如果你的业务逻辑可能跑 1 分钟,锁就会提前释放,导致并发安全问题。
Redisson 的看门狗(WatchDog)会自动续期,但前提是锁没手动设置过期时间。
如果你手动设了leaseTime,看门狗就失效了。
这点千万注意。
还有重试机制。
网络抖动时,tryLock失败不代表业务失败,可以配置重试策略。
// 生产级锁配置示例 RLock lock = redissonClient.getLock("order_lock"); // 等待 5 秒,锁持有时间 60 秒,自动续期 boolean res = lock.tryLock(5, 60, TimeUnit.SECONDS);3.3 高级定制
有时候 ThreadLocal 不够用,得跨线程传递。
比如主线程设置了用户信息,异步子线程也要用。
这时候得用TransmittableThreadLocal(TTL)。
它是阿里开源的,专门解决线程池复用导致的上下文丢失。
配合 Redisson,就是“本地上下文 + 分布式锁”的终极形态。
四、 实战演练
来个真实的场景。
电商系统的库存扣减。
多个微服务实例同时收到下单请求,都要改同一个商品的库存。
如果用本地锁,A 实例扣了,B 实例不知道,库存变负数。
如果用 ThreadLocal 存订单 ID,不清理,内存泄漏。
咱们写个完整的扣减逻辑。
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.util.concurrent.TimeUnit; public class InventoryService { // 用 ThreadLocal 存当前请求的订单号,方便日志追踪 private static final ThreadLocal<String> orderTraceId = new ThreadLocal<>(); private final RedissonClient redissonClient; public InventoryService(RedissonClient redissonClient) { this.redissonClient = redissonClient; } public boolean deductStock(String productId, int quantity, String orderId) { // 1. 绑定上下文 orderTraceId.set(orderId); System.out.println("订单 " + orderTraceId.get() + " 开始扣减库存"); // 2. 构造分布式锁的 Key,按商品 ID 锁,避免全局锁 String lockKey = "stock_lock:" + productId; RLock lock = redissonClient.getLock(lockKey); try { // 3. 尝试加锁,最多等 3 秒,锁自动过期 10 秒 if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { try { // 4. 模拟数据库查询库存 int currentStock = getStockFromDb(productId); if (currentStock >= quantity) { // 5. 扣减库存 updateStockInDb(productId, currentStock - quantity); System.out.println("订单 " + orderTraceId.get() + " 扣减成功"); return true; } else { System.out.println("订单 " + orderTraceId.get() + " 库存不足"); return false; } } finally { // 6. 释放锁,防止死锁 lock.unlock(); } } else { System.out.println("订单 " + orderTraceId.get() + " 获取锁超时,拒绝服务"); return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("订单 " + orderTraceId.get() + " 处理被打断"); return false; } finally { // 7. 【核心避坑】务必清理 ThreadLocal orderTraceId.remove(); } } // 模拟数据库操作 private int getStockFromDb(String pid) { return 100; } private void updateStockInDb(String pid, int num) { /* 执行 SQL */ } }结果分析很清晰。
锁按商品粒度拆分,不同商品互不影响,并发度高。
ThreadLocal 只存 TraceID,用完即焚,内存安全。
五、 避坑指南与最佳实践
这几年踩过的坑,都给你总结在这儿了。
💡技巧 1:AOP 自动清理
别在每个方法里写finally { remove() },太累还容易漏。
用 Spring AOP 切面,拦截所有 Controller 方法,结束后自动清理 ThreadLocal。
@AfterFinally public void clearContext() { userContext.remove(); orderTraceId.remove(); }⚠️警告 2:异步线程池陷阱
如果用了@Async或自定义线程池,父线程的 ThreadLocal 子线程拿不到。
必须用TransmittableThreadLocal包装,并配置线程池装饰器。
✅推荐 3:锁粒度控制
别锁整个类,别锁全局变量。
锁具体的资源 ID,比如lock:order:1001。
粒度越细,系统吞吐量越高。
⚠️警告 4:Redis 宕机怎么办
Redisson 依赖 Redis,Redis 挂了锁就失效。
生产环境得配 Redis 集群,或者做降级策略,比如限流。
六、 综合实战演示
最后,咱们把前面说的全串起来。
写一个工具类,封装上下文管理和分布式锁。
调用方只管业务,不用管底层细节。
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.util.concurrent.TimeUnit; /** * 分布式上下文与锁管理器 * 封装了 ThreadLocal 清理和 Redisson 锁逻辑 */ public class DistributedContextManager { private static final ThreadLocal<String> currentUser = new ThreadLocal<>(); private final RedissonClient redissonClient; public DistributedContextManager(RedissonClient redissonClient) { this.redissonClient = redissonClient; } /** * 执行带锁的业务逻辑 * @param resourceKey 资源锁 Key * @param businessLogic 业务执行器 */ public void executeWithLock(String resourceKey, Runnable businessLogic) { RLock lock = redissonClient.getLock(resourceKey); try { // 尝试加锁 if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { businessLogic.run(); } else { throw new RuntimeException("获取分布式锁失败,系统繁忙"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("线程中断", e); } finally { // 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } // 清理上下文 currentUser.remove(); } } public void setCurrentUser(String user) { currentUser.set(user); } public String getCurrentUser() { return currentUser.get(); } }使用的时候,非常清爽。
// 在 Service 层调用 @Autowired private DistributedContextManager contextManager; public void processOrder(String orderId) { contextManager.setCurrentUser("某同事"); contextManager.executeWithLock("order:" + orderId, () -> { // 这里就是纯业务代码 System.out.println(contextManager.getCurrentUser() + " 正在处理订单"); // 扣库存、写流水... }); }闭环了。
资源竞争有锁管,内存泄漏有清理管,代码还干净。
总结
ThreadLocal 是好东西,但用不好就是毒药。
分布式环境下,千万别指望它做跨节点通信。
Redisson 锁是微服务的标配,但要注意锁粒度和异常释放。
核心就两点:
一是finally里必须remove()ThreadLocal。
