这篇文章复盘一次典型的“表象和根因不一致”的故障:

  • 表象:make coverage 执行到某个集成测试类附近就卡住。
  • 中间信号:日志里持续出现 timeout。
  • 实际情况:不是单点问题,而是两个问题叠加,且第二个问题才是“卡死”的直接原因。

为了避免泄露业务细节,本文仅保留通用技术路径与框架层结论。

现象与误导#

最初看起来像是某个测试类导致卡死,但单独跑该类测试是通过的。真正的卡点出现在后续测试阶段,并且并不稳定:有时卡在 A 类之后,有时卡在 B 类之后。

这类“位置漂移”的卡死通常意味着:

  1. 有共享资源被污染(连接、锁、线程、容器状态);
  2. 或有后台任务与测试并发竞争资源;
  3. 或两者同时存在。

第一层问题:连接池超时(不是最终卡死根因)#

先看到的是数据库连接获取超时。排查后发现,项目里存在若干 fire-and-forget 的异步调用模式(...subscribe() 直接触发,不挂回主响应链),在压测式的集成测试串行执行中会慢慢积压。

同时,测试环境里还有后台补偿任务定时运行,会进一步竞争连接与 Redis 资源。

第一轮修复动作:

  • 测试中关闭不必要的后台补偿任务;
  • 增加测试清表范围,避免历史数据触发额外补偿/扫描。

这一步明显降低了 timeout 频率,但测试仍会“卡死不退出”。说明还有第二层根因。

第二层问题:Lettuce SharedLock 自旋(最终卡死根因)#

通过线程栈定位到高 CPU 线程长期停留在 Lettuce 的 SharedLock.incrementWriters(),并且某个 reactor-tcp-nio 线程 CPU 接近 100%。

这是典型的共享原生连接争用异常:

  • 使用 Lettuce 默认共享连接模式时,多个命令通过同一底层连接发送;
  • 连接在特定时序下发生 reset/close,与并发命令流叠加,可能触发 SharedLock 进入异常状态;
  • 一旦进入该状态,后续 Redis 命令会持续卡在写入阶段,自旋耗 CPU,测试整体看起来“挂住”。

为什么这次会被放大#

几个因素叠加后,故障更容易稳定复现:

  • 集成测试一次跑全量,持续时间长、请求密度高;
  • 异步 fire-and-forget 调用没有统一收敛和错误治理;
  • 共享连接模式下,任何连接级异常都会影响整个命令面;
  • 部分 Redis 脚本调用链使用 FluxMono 的方式,如果上游时序不佳,更容易触发复杂取消/完成边界问题。

最终修复策略#

1) 测试环境关闭 Lettuce 共享连接(关键)#

在测试专用 Redis 配置中,将连接工厂改为非共享原生连接shareNativeConnection=false),并设置合理命令超时。

效果是把“单连接全局耦合”改为“每次操作独立连接”,从根上规避 SharedLock 把全局 Redis 操作一起拖死的风险。

2) 关闭测试中的后台补偿任务#

通过开关控制,测试环境不启动周期性补偿任务,减少与测试主流程的资源竞争与噪音。

3) 扩展测试清理策略#

补齐相关表清理,防止上一条测试残留状态触发额外异步行为。

4) 修正 Redis 脚本调用的消费方式#

对脚本返回流的消费方式进行统一,避免不必要的半消费/时序歧义(具体实现依项目风格而定)。

验证结果#

修复后进行了两层验证:

  1. 集成测试全量执行:全部通过,无卡死;
  2. 覆盖率流程(mvn verify / make coverage 核心链路):可完整结束,构建成功。

换句话说,问题从“执行到某处卡住”变为“全链路稳定完成”。

这次排查的经验总结#

1. 先区分“触发点”和“根因”#

“卡在某个测试类”只代表触发位置,不代表根因属于该测试类。单测可过、全量挂起时,优先怀疑共享资源与后台并发。

2. 两类信号要并行看#

  • 功能信号:哪个测试失败/超时;
  • 系统信号:CPU 热点线程、线程状态、连接池等待、Netty 事件循环状态。

只看功能日志容易陷入误判。

3. 对 fire-and-forget 模式保持克制#

subscribe() 直接触发很方便,但在复杂链路中会带来:

  • 生命周期不可控;
  • 错误传播不可控;
  • 资源占用不可控。

在高并发与长时测试场景里,问题会被放大。

4. 测试环境不等于生产环境,但必须“可控”#

测试中适度关闭非关键后台任务、隔离共享连接,是为了让主验证目标可重复、可收敛。这不是“偷懒”,是工程化测试的一部分。

可复用的排查清单#

下次遇到“make coverage / mvn verify 卡死”可以按这个顺序:

  1. 先确认是“失败退出”还是“挂住不退出”;
  2. 抓线程栈,定位 hottest thread 与事件循环线程;
  3. 检查共享连接/共享锁组件(Redis、HTTP 客户端、消息客户端);
  4. 盘点 fire-and-forget 异步调用与后台定时任务;
  5. 在测试环境做最小隔离(关闭非关键任务、连接去共享);
  6. 再回头做结构性治理(异步链收敛、错误处理统一化)。

如果你也在 Reactive + Redis + Integration Test 组合里遇到“偶发 timeout + 最终卡死”的问题,优先排查共享连接模式和异步调用治理,通常能更快收敛到根因。