使用 Arthas 排查 MQTT 接口分布式锁 “自我阻塞”
一、问题起点:偶发的 “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 锁竞争”,但进一步核对调用链路的加锁顺序后,发现更隐蔽的问题:
- 外层加锁:
sendForRemote()第 141 行先调用LockUtils.tryLock(key=210000000810BA007544),获取锁后执行后续逻辑; - 内层再抢同一锁:
sendForRemote()调用saveSendMsgtoRedis(),后者第 295 行再次调用LockUtils.tryLock(key=210000000810BA007544); - 自我阻塞:由于外层锁未释放(需等
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); - 加锁日志增强:在
tryLock和unlock处打印线程 ID、调用链路(如Thread.currentThread().getStackTrace()),方便快速定位锁持有关系。 -
四、排查方法论:分布式锁问题的 “5 步闭环法”
基于本次排查经验,总结出一套针对 “分布式锁导致耗时异常” 的标准化排查流程,适用于大多数分布式系统场景:
-
五、经验教训:分布式锁设计的 3 个 “红线”
本次排查暴露了分布式锁设计中容易忽略的细节,总结 3 个必须规避的 “红线”:
- 禁止 “同一链路重复加同一锁”:设计加锁逻辑前,必须梳理完整调用链路,确认是否已有外层锁覆盖,避免 “自我阻塞”;
- 明确锁的 “可重入性”:自定义锁或选择组件时,先确认是否支持可重入;若不支持,严格控制 “同一线程的加锁次数”;
- 拒绝 “锁粒度一刀切”:避免用 “全局 Key”(如固定设备 ID)加锁,优先按 “设备 ID + 消息类型”“设备 ID + 时间窗口” 拆分锁粒度,降低竞争概率。
更多推荐




所有评论(0)