一次批量循环调用分布式锁导致线程池队列告警的排查与复盘
本文记录一次真实的后端性能问题:单任务完成接口增加分布式锁后,批量完成任务仍在循环中复用这个入口。每个任务都会经历加锁、注册续期任务、执行、取消续期和释放锁,最终触发线程池队列告警。
这次问题表面上是线程池队列增长,根因却不在线程池参数,而在调用层级、锁实现和批量场景之间的组合。修复也不是简单删除所有锁,而是重新划分单任务入口、批量入口和核心业务逻辑的边界。
1. 问题背景
项目中有两个任务完成入口:
- 单任务完成:用户一次提交一个任务,需要防止重复点击和并发完成同一个任务。
- 批量完成:一次最多处理 30 个任务,代码会在一个循环中逐条完成。
单任务入口使用任务 ID 作为锁粒度:
String lockKey = RedisConstant.TASK_COMPLETE
+ RedisConstant.LOCK_KEY
+ taskFormCompleteBo.getTaskId();
LockResult lock = distributedLock.lock(
lockKey,
30,
35,
TimeUnit.MINUTES
);这个设计单独看是合理的:同一个任务只能被一个请求处理,不同任务之间互不阻塞。
问题出在批量入口继续调用了这个带锁的单任务入口:
for (KdbProjectTaskInfo taskInfo : taskInfoListByFactorId) {
TaskFormCompleteBo taskFormCompleteBo = buildCompleteBo(taskInfo);
doCompleteTask(taskFormCompleteBo);
}一次批量请求因此不再是一次业务操作,而是最多 30 次完整的分布式锁生命周期。
2. 告警链路
事故链路可以概括为:
批量完成请求
-> 循环 N 个任务
-> 每个任务调用带锁入口
-> 每次成功加锁都注册一个续期调度任务
-> 业务很快执行完并取消续期任务
-> 已取消任务仍可能暂留在线程池延迟队列
-> 多批次并发后队列长度快速增长
-> 触发线程池队列告警这次定位的关键,不是只看业务代码中有没有显式使用线程池,而是继续追到 DistributedLock 的具体实现。
3. 锁参数的真实含义
DistributedLock 的方法签名是:
LockResult lock(String key, int lockTime, int timeout, TimeUnit timeUnit);两个整数很容易被误读。它们的真实含义分别是:
| 参数 | 含义 | 事故版本的值 |
|---|---|---|
lockTime | 锁在 Redis 中的租期 | 30 分钟 |
timeout | 获取不到锁时的最长等待时间 | 35 分钟 |
timeUnit | 前两个参数共同使用的时间单位 | 分钟 |
因此下面这行代码不是“最多等 30 分钟,锁 35 分钟”,而是“锁租期 30 分钟,最多等待 35 分钟”:
distributedLock.lock(lockKey, 30, 35, TimeUnit.MINUTES);后续提交将参数调整为:
distributedLock.lock(lockKey, 30, 2, TimeUnit.SECONDS);也就是锁租期 30 秒,最长等待 2 秒。本文分析事故时使用的是历史参数,不能把它和当前参数混为一谈。
3.1 长等待时间的额外风险
锁实现并不是把等待交给一个完全独立的后台任务。获取失败后,它会按退避策略重试 Redis,并让当前调用线程等待。
这意味着 35 分钟的 timeout 在锁冲突时可能产生以下问题:
- Web 请求线程长时间被占用。
- Redis 锁脚本被反复调用。
- 上游请求容易先超时,但服务端线程仍可能继续等待。
- 并发请求持续进入后,Tomcat、业务线程池或网关侧都可能出现排队。
本次直接出现的是续期线程池队列告警,但请求线程被长时间占用同样是事故版本中的高风险点。
4. 为什么释放了锁,队列仍然会增长
项目依赖的分布式锁默认使用高级模式,并且默认开启自动续期。它内部维护:
private final Map<String, ScheduledFuture<?>> renewalTasks;
private final ScheduledExecutorService renewalExecutor;成功获取一把锁后,会通过 scheduleWithFixedDelay 注册续期任务:
ScheduledFuture<?> future = renewalExecutor.scheduleWithFixedDelay(
renewalTask,
renewalInterval,
renewalInterval,
TimeUnit.SECONDS
);
renewalTasks.put(lockKey, future);释放锁时,业务实现会从 renewalTasks 中移除记录并调用:
future.cancel(false);但调度线程池由 Executors.newScheduledThreadPool(...) 创建,当前实现没有看到以下配置:
scheduledThreadPoolExecutor.setRemoveOnCancelPolicy(true);ScheduledThreadPoolExecutor 默认不会因为 cancel 就立即把延迟任务从内部队列物理移除。任务虽然已经取消,不会再执行,但通常仍要等到原定触发时间到达后才从延迟队列中被清走。
这就解释了为什么业务日志显示锁已经成功释放,监控中的线程池队列却仍然增长:
renewalTasks这个业务 Map 已经删除记录。- Redis 锁也已经释放。
- 但调度线程池的内部延迟队列中,可能还留着已取消的任务节点。
“锁已释放”和“线程池队列已清理”不是同一件事。
5. 30 分钟租期为什么会放大问题
高级锁会根据锁租期计算首次续期时间。默认续期阈值约为租期的三分之一,单机模式下还会做额外调整。
当锁租期为 30 分钟时:
- 集群模式下,首次续期大约在 9 分 54 秒后。
- 单机模式下,首次续期大约在 15 分钟后。
如果业务只执行几百毫秒,续期任务刚进入队列就被取消,但它可能继续在延迟队列里停留接近 10 至 15 分钟。
一次批量最多 30 条,假设在这段时间内有 B 个批次进入,那么队列的瞬时放大量近似为:
队列新增量 ≈ B × N
B:尚未等到已取消任务自然出队的批次数
N:每个批次实际完成的任务数,最大为 30例如 100 个满批请求,在旧任务尚未自然出队前,理论上就可能产生约 3000 个续期调度任务节点。业务方法明明是串行执行,队列仍会因为跨请求、跨时间窗口累积而膨胀。
将批量上限限制为 30 只能限制单次放大量,不能消除并发批次的乘法效应。
6. 为什么单任务加锁合理,批量复用却有问题
单任务入口的职责通常包括:
- 防重复点击。
- 防止多个实例同时完成同一任务。
- 串行化同一个任务的状态检查和写入。
批量入口的上下文则不同:
- 一次请求已经收集出一组任务。
- 任务 ID 通常互不相同,任务级锁之间没有竞争。
- 外层已经有批量校验和统一事务。
- 逐条复用带锁入口,会重复执行基础设施动作。
问题不是 for 循环本身,也不是分布式锁本身,而是把一个包含基础设施副作用的“外部入口”当成了纯业务函数复用。
一个可复用的方法,除了返回值和数据库写入,还要关注它隐藏的副作用:
- 是否申请锁。
- 是否注册定时任务。
- 是否占用连接。
- 是否开启或加入事务。
- 是否发消息、写日志或调用远程服务。
7. 实际修复方式
修复前,批量循环调用带锁入口:
for (KdbProjectTaskInfo taskInfo : taskInfoListByFactorId) {
TaskFormCompleteBo taskFormCompleteBo = buildCompleteBo(taskInfo);
doCompleteTask(taskFormCompleteBo);
}8. 事务边界中的另一个隐患
单任务方法如果同时标记了 @Transactional,并在方法体的 finally 中释放锁,会出现一个容易忽略的时间窗口:
Spring 开启事务
-> 方法体获取锁
-> 执行业务 SQL
-> finally 释放锁
-> 方法返回
-> Spring 提交事务锁释放发生在事务提交之前。另一个请求可能在第一个事务尚未提交时拿到锁,读取到旧状态或遇到数据库锁等待。
更清晰的结构是让锁包在事务外层:
获取分布式锁
-> 调用另一个 Bean 的事务方法
-> 事务提交完成
-> 返回外层
-> 释放分布式锁9. 线上排查步骤
遇到类似线程队列告警,可以按以下顺序排查:
- 根据告警中的线程池名称定位创建位置和用途。
- 查看队列里具体是什么
Runnable或ScheduledFuture。 - 从任务类型反查是谁在批量生产它们。
- 核对取消任务是否会立即从队列移除。
- 核对线程池队列类型、容量、拒绝策略和告警阈值。
- 核对分布式锁的租期、等待时间、续期开关和重试策略。
- 对照接口流量,验证队列增长速度是否接近
QPS × 批量大小。 - 通过灰度或压测验证修复后队列是否能稳定回落。
线程 dump 只能看到当时正在运行的线程。对于本次这类问题,还必须查看调度线程池的延迟队列,因为大量任务可能处于“已取消但尚未出队”的状态,并不会表现为活跃线程很多。
10. 总结
这次问题的核心教训有四点:
- 带锁入口不是纯业务方法,不能在批量循环里无成本复用。
- 释放分布式锁,不等于立即清空续期线程池里的延迟任务。
- 长租期和长等待时间会同时放大调度队列、Redis 重试和请求线程占用风险。
- 修复时要移除不合适的基础设施行为,同时完整保留校验、事务和业务顺序。