一次批量循环调用分布式锁导致线程池队列告警的排查与复盘

本文记录一次真实的后端性能问题:单任务完成接口增加分布式锁后,批量完成任务仍在循环中复用这个入口。每个任务都会经历加锁、注册续期任务、执行、取消续期和释放锁,最终触发线程池队列告警。

这次问题表面上是线程池队列增长,根因却不在线程池参数,而在调用层级、锁实现和批量场景之间的组合。修复也不是简单删除所有锁,而是重新划分单任务入口、批量入口和核心业务逻辑的边界。

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. 线上排查步骤

遇到类似线程队列告警,可以按以下顺序排查:

  1. 根据告警中的线程池名称定位创建位置和用途。
  2. 查看队列里具体是什么 Runnable 或 ScheduledFuture。
  3. 从任务类型反查是谁在批量生产它们。
  4. 核对取消任务是否会立即从队列移除。
  5. 核对线程池队列类型、容量、拒绝策略和告警阈值。
  6. 核对分布式锁的租期、等待时间、续期开关和重试策略。
  7. 对照接口流量,验证队列增长速度是否接近 QPS × 批量大小。
  8. 通过灰度或压测验证修复后队列是否能稳定回落。

线程 dump 只能看到当时正在运行的线程。对于本次这类问题,还必须查看调度线程池的延迟队列,因为大量任务可能处于“已取消但尚未出队”的状态,并不会表现为活跃线程很多。

10. 总结

这次问题的核心教训有四点:

  1. 带锁入口不是纯业务方法,不能在批量循环里无成本复用。
  2. 释放分布式锁,不等于立即清空续期线程池里的延迟任务。
  3. 长租期和长等待时间会同时放大调度队列、Redis 重试和请求线程占用风险。
  4. 修复时要移除不合适的基础设施行为,同时完整保留校验、事务和业务顺序。