一、问题起点:偶发的 “30 秒卡点”

某 MQTT 消息处理服务中,核心接口PortMessageProcessorImpl.processMessage出现诡异现象:

  • 正常耗时仅 1-4ms,符合业务预期;
  • 偶发耗时飙升至 30 秒,且无固定规律,日志仅记录 “处理完成”,无异常堆栈;
  • 影响范围:该接口负责设备消息接收,30 秒阻塞会导致后续消息堆积,引发连锁反应。

由于是偶发问题,传统日志打印难以捕获上下文,最终选择Arthas 无侵入诊断,直接在生产环境实时追踪调用链路。

二、排查过程:从 “链路拆解” 到 “锁逻辑破壁”

排查核心思路是 **“先定位慢方法→再深挖子调用→最后核对锁逻辑”**,全程用 Arthas 工具层层剥茧,避开 “方法名迷惑性” 和 “锁逻辑隐藏” 两大陷阱。

1. 第一步:用trace定位顶层慢方法

先从接口入口processMessage切入,追踪完整调用链路,确认耗时集中在哪一层:

# 追踪processMessage的10次调用,展示子方法耗时分布
trace

关键结果processMessage总耗时 30 秒,其中sendForRemote()方法占比 99.9%(约 29.9 秒),其他操作(如 Redis 查询、对象初始化)均为毫秒级。→ 初步结论:耗时瓶颈在sendForRemote()

2. 第二步:逐层trace,锁定tryLock耗时点

# 聚焦sendForRemote,定位其内部耗时节点
trace 

新发现sendForRemote()的耗时来自setDataToCache()(约 29.8 秒),而setDataToCache()的耗时又全部集中在saveSendMsgtoRedis()中的LockUtils.tryLock()方法(约 29.7 秒)。

→ 核心怀疑:tryLock()获取分布式锁时阻塞。

3. 第三步:用watch验证锁 Key 与耗时关联

为确认锁阻塞原因,用watch查看tryLock()的入参(锁 Key)和耗时,判断是否存在锁竞争:

# 查看tryLock的锁Key、耗时,展开参数深度3
watch 

关键数据

  • 慢调用的锁 Key 均为210000000810BA007544(某设备标识);
  • 同一 Key 的tryLock()耗时差异极大:快调用 0.1-0.3ms,慢调用 29-30 秒;
  • 其他 Key(如210000000810BA007540)无耗时,直接获取成功。

    → 初步判断:210000000810BA007544存在锁竞争。

4. 第四步:核对锁逻辑,发现 “自我阻塞” 真相

到这里,看似是 “单 Key 锁竞争”,但进一步核对调用链路的加锁顺序后,发现更隐蔽的问题:

  1. 外层加锁sendForRemote()第 141 行先调用LockUtils.tryLock(key=210000000810BA007544),获取锁后执行后续逻辑;
  2. 内层再抢同一锁sendForRemote()调用saveSendMsgtoRedis(),后者第 295 行再次调用LockUtils.tryLock(key=210000000810BA007544)
  3. 自我阻塞:由于外层锁未释放(需等saveSendMsgtoRedis()执行完才解锁),内层锁只能阻塞,直到外层锁 30 秒超时自动释放,内层才得以获取锁。

最终根因:同一调用链路中,外层和内层重复获取同一把不可重入的分布式锁,导致内层锁 “等自己释放”,形成 30 秒自我阻塞。

三、解决方案:从 “修复” 到 “避坑”

针对 “内外层重复锁 + 不可重入” 的问题,采取两步优化,彻底解决耗时异常:

1. 核心优化:移除冗余内层锁

分析业务逻辑后发现,saveSendMsgtoRedis()的锁是冗余的 —— 外层sendForRemote()已加锁,内层无需再重复加锁。直接删除内层锁相关代码:

// 优化前:saveSendMsgtoRedis内的冗余加锁
public void saveSendMsgtoRedis(RemoteInfo remoteInfo) {
    String key = remoteInfo.getDeviceId();
    // 冗余加锁,与外层sendForRemote的锁Key相同
    if (LockUtils.tryLock(key, 30000, 10000)) { 
        try {
            remoteDeviceRedisService.saveWaitSendMsg(remoteInfo);
        } finally {
            LockUtils.unlockRedisKey(key);
        }
    }
}

// 优化后:删除内层锁,依赖外层锁保证线程安全
public void saveSendMsgtoRedis(RemoteInfo remoteInfo) {
    // 直接执行业务逻辑,无需重复加锁
    remoteDeviceRedisService.saveWaitSendMsg(remoteInfo);
}

2. 补充优化:提升锁设计健壮性

为避免后续再出现类似问题,补充两项基础优化:

优化后,processMessage接口耗时稳定在 1-5ms,30 秒耗时异常彻底消失,消息堆积问题解决。这次排查不仅修复了一个性能 bug,更重要的是建立了 “分布式锁耗时异常” 的标准化排查思路 —— 用 Arthas 穿透链路,用 “锁 Key + 加锁顺序” 定位根因,比盲目优化更高效。

六、最终效果

  • 锁可重入性保障:若业务需多层加锁,将自定义LockUtils替换为支持可重入的分布式锁组件(如 Redisson 的RLock);
  • 加锁日志增强:在tryLockunlock处打印线程 ID、调用链路(如Thread.currentThread().getStackTrace()),方便快速定位锁持有关系。
  • 四、排查方法论:分布式锁问题的 “5 步闭环法”

    基于本次排查经验,总结出一套针对 “分布式锁导致耗时异常” 的标准化排查流程,适用于大多数分布式系统场景:

  • 五、经验教训:分布式锁设计的 3 个 “红线”

    本次排查暴露了分布式锁设计中容易忽略的细节,总结 3 个必须规避的 “红线”:

  • 禁止 “同一链路重复加同一锁”:设计加锁逻辑前,必须梳理完整调用链路,确认是否已有外层锁覆盖,避免 “自我阻塞”;
  • 明确锁的 “可重入性”:自定义锁或选择组件时,先确认是否支持可重入;若不支持,严格控制 “同一线程的加锁次数”;
  • 拒绝 “锁粒度一刀切”:避免用 “全局 Key”(如固定设备 ID)加锁,优先按 “设备 ID + 消息类型”“设备 ID + 时间窗口” 拆分锁粒度,降低竞争概率。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐