摘要
接口超时是分布式系统中最常见的故障之一,但根因可能藏在意想不到的地方。本案例记录一次支付系统的接口超时问题排查:表面上请求堆积、线程池耗尽,但真正的问题是两个服务之间的循环等待死锁。A 服务持有锁 X 等锁 Y,B 服务持有锁 Y 等锁 X,形成经典的"ABBA"死锁。通过 jstack 的死锁检测功能快速定位,再用 Arthas trace 命令追踪到具体的加锁代码,最终通过调整加锁顺序解决了问题。文中还介绍了 ThreadLocal 泄漏、锁升级机制导致的"伪死锁"等常见并发陷阱。
一、问题背景
1.1 业务场景
某支付系统的账务模块,包含两个核心服务:
AccountService(账户服务): - lockAccount(userId) → 加锁账户 - debit(amount) → 扣款 - unlockAccount() → 解锁 TransactionService(交易服务): - lockTransaction(txId) → 加锁交易 - confirm(amount) → 确认交易 - unlockTransaction() → 解锁1.2 故障现象
告警: [14:30] 账务接口 TP99 响应时间 > 10s(正常 < 500ms) [14:31] 线程池活跃线程数:200/200(完全耗尽) [14:32] 开始出现请求超时,支付失败率上升 监控面板: - 线程池队列积压:10,000+ 请求 - 死锁告警:检测到潜在死锁 - CPU 使用率:正常(30%) - 内存使用率:正常1.3 初步分析
接口超时但 CPU/内存正常的常见原因: ┌──────────────────────────────────────────────────────────────────┐ │ 原因 │ 特征 │ 排查工具 │ ├──────────────────┼──────────────────────────────┼──────────────┤ │ 死锁 │ 线程池耗尽,CPU 正常 │ jstack -l │ │ 数据库锁等待 │ 慢查询,连接池耗尽 │ show process │ │ 外部服务超时 │ 有超时日志,偶发 │ 日志分析 │ │ GC STW │ CPU 高,GC 日志有 Full GC │ jstat │ │ 线程池配置不当 │ 线程数少,请求堆积 │ jinfo │ └──────────────────────────────────────────────────────────────────┘二、问题排查
2.1 Step 1:jstack 死锁检测
# 使用 -l 参数查看详细锁信息(包含死锁检测)$ jstack-l12345>/tmp/threaddump_deadlock.txt# 查看输出中的死锁部分$grep-A30"Found one Java-level deadlock"/tmp/threaddump_deadlock.txt# 输出:Found one Java-level deadlock:============================="pool-1-thread-15":waitingforownable synchronizer 0x00007f1234567890,(a java/util/concurrent/locks/ReentrantLock),whichis held by"pool-1-thread-8""pool-1-thread-8":waitingforownable synchronizer 0x00007f1234567898,(a java/util/concurrent/locks/ReentrantLock),whichis held by"pool-1-thread-15"Java stack informationforthe threads listed above:=================================================="pool-1-thread-15":at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:...)- waiting to lock<0x00007f1234567890>(a java/util/concurrent/locks/ReentrantLock)- locked<0x00007f1234567898>(a java/util/concurrent/locks/ReentrantLock)at com.example.AccountService.lockAccount(AccountService.java:45)"pool-1-thread-8":at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:...)- waiting to lock<0x00007f1234567898>(a java/util/concurrent/locks/ReentrantLock)- locked<0x00007f1234567890>(a java/util/concurrent/locks/ReentrantLock)at com.example.TransactionService.lockTransaction(TransactionService.java:67)2.2 死锁分析
死锁原因(经典的 ABBA): ┌──────────────────────────────────────────────────────────────────┐ │ │ │ Thread-15 Thread-8 │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ lockAccount(X) │ │ lockTrans(Y) │ │ │ │ 持有锁 X │ │ 持有锁 Y │ │ │ │ ↓ │ │ ↓ │ │ │ │ 等待锁 Y ←───┼──┼────────┼──→ 等待锁 X │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ 循环等待,死锁! │ │ │ └──────────────────────────────────────────────────────────────────┘2.3 定位问题代码
// AccountService.javapublicclassAccountService{privatefinalReentrantLockaccountLock=newReentrantLock();publicvoidtransfer(Accountfrom,Accountto,longamount){// 问题:先锁账户 A,再锁账户 BaccountLock.lock();try{// 在持有 accountLock 的情况下调用 transactionServicetransactionService.confirm(from.getUserId(),to.getUserId(),amount);// 等待中...}finally{accountLock.unlock();}}}// TransactionService.javapublicclassTransactionService{privatefinalReentrantLocktransLock=newReentrantLock();publicvoidconfirm(StringfromUserId,StringtoUserId,longamount){// 问题:先锁交易,再可能调用 accountServicetransLock.lock();try{// ...// 如果这里需要更新账户余额,会调用 accountService.lockAccount()// 等待中...}finally{transLock.unlock();}}}// 死锁场景还原:// T1: transfer(A, B) → lock(A) → 调用 confirm → 等待 lock(B)// T2: transfer(B, A) → lock(B) → 调用 confirm → 等待 lock(A)三、解决方案
3.1 方案一:固定加锁顺序(最简单)
// 解决方案:使用账户 ID 作为排序依据,保证加锁顺序一致publicclassAccountService{publicvoidtransfer(Accountfrom,Accountto,longamount){// 始终先锁 ID 较小的账户Accountfirst=from.getUserId().compareTo(to.getUserId())<0?from:to;Accountsecond=from.getUserId().compareTo(to.getUserId())<0?to:from;first.getLock().lock();// 先锁 firsttry{second.getLock().lock();// 再锁 secondtry{doTransfer(from,to,amount);}finally{second.getLock().unlock();}}finally{first.getLock().unlock();}}}3.2 方案二:使用 TryLock + 重试
// 解决方案:使用 tryLock + 随机等待,避免死锁publicclassTransferResulttryTransfer(Accountfrom,Accountto,longamount){while(true){if(from.getLock().tryLock(100,TimeUnit.MILLISECONDS)){try{if(to.getLock().tryLock(100,TimeUnit.MILLISECONDS)){try{doTransfer(from,to,amount);returnTransferResult.SUCCESS;}finally{to.getLock().unlock();}}}finally{from.getLock().unlock();}}// 随机等待后重试(避免多个线程同时重试)Thread.sleep(random.nextInt(100));// 最大重试次数if(retryCount++>MAX_RETRY){returnTransferResult.RETRY_EXHAUSTED;}}}3.3 方案三:使用并发框架(推荐)
// 解决方案:使用 StampedLock(乐观读)或 ConcurrentHashMappublicclassAccountService{privatefinalStampedLockstampedLock=newStampedLock();publicvoidtransfer(Accountfrom,Accountto,longamount){// StampedLock 支持乐观读,性能更好longstamp=stampedLock.writeLock();try{doTransfer(from,to,amount);}finally{stampedLock.unlockWrite(stamp);}}}// 或使用数据库分布式锁(最可靠)publicclassAccountService{@AutowiredprivateRedissonClientredisson;publicvoidtransfer(StringfromId,StringtoId,longamount){RLocklock1=redisson.getLock("account:"+min(fromId,toId));RLocklock2=redisson.getLock("account:"+max(fromId,toId));try{// 按顺序获取锁lock(lock1);lock(lock2);doTransfer(fromId,toId,amount);}finally{unlock(lock2);unlock(lock1);}}}四、效果验证
4.1 修复后监控
# 修复后连续监控 24 小时$ jstack-l12345|grep-c"Java-level deadlock"0# 无死锁$ jstat-gcutil12345100086400|awk'{print $10}'|sort-n|tail-5# 查看 Full GC 次数(应该为 0 或极少)00001# 只有 1 次(正常)4.2 性能对比
修复前后对比: ┌──────────────────────────────────────────────────────────────────┐ │ 指标 │ 修复前 │ 修复后 │ 改善 │ ├────────────────────┼─────────────┼─────────────┼────────────┤ │ 线程池使用率 │ 100% (200/200) │ 15% (30/200) │ 85% ↓ │ │ 接口 TP99 │ > 10s │ < 500ms │ 95% ↓ │ │ 死锁发生次数 │ 持续 │ 0 │ 完全消除 │ │ 支付成功率 │ 70% │ 99.9% │ 30% ↑ │ └──────────────────────────────────────────────────────────────────┘五、并发陷阱扩展
5.1 ThreadLocal 内存泄漏
// ThreadLocal 泄漏的典型场景publicclassUserContextFilter{privatestaticfinalThreadLocal<User>currentUser=newThreadLocal<>();publicvoiddoFilter(...){try{Useruser=authenticate(request);currentUser.set(user);// 设置用户chain.doFilter(request,response);}finally{// 忘记清理!→ 内存泄漏// currentUser.remove(); ← 应该加上}}}// 线程池场景下的泄漏:// ThreadPool 的线程是复用的// 如果不清理 ThreadLocal,下一个请求可能拿到上一个请求的用户数据// → 数据泄露 + 内存泄漏5.2 锁升级机制(偏向锁 → 自旋锁 → 重量锁)
// synchronized 的锁升级过程publicclassLockUpgradeDemo{privatefinalObjectlock=newObject();publicvoidprocess(){synchronized(lock){// 第一次进入:偏向锁(无开销)// 多次进入:轻量级锁 CAS// 竞争激烈:升级为重量级锁(系统调用,阻塞)}}}// JDK 15+ 废弃偏向锁(因为现代服务器多核竞争激烈)// 启动参数:// -XX:-UseBiasedLocking # 禁用偏向锁(JDK 15+ 默认)六、经验总结
6.1 死锁预防 checklist
代码审查必查项: 1. 多把锁的代码:检查加锁顺序是否一致 2. tryLock 后是否正确释放 3. ThreadLocal 是否在 finally 中清理 4. 锁的范围是否过大(持有锁时调用外部服务) 5. 是否可以使用更高层的并发工具(ConcurrentHashMap, StampedLock)6.2 jstack 线程状态解读
jstack 线程状态速查: ┌──────────────────────────────────────────────────────────────────┐ │ 状态 │ 含义 │ 正常/危险 │ ├───────────────────┼────────────────────────────┼──────────────┤ │ RUNNABLE │ 正在执行 │ 正常 │ │ BLOCKED │ 等待获取 Monitor 锁 │ 危险 │ │ WAITING │ 无限等待(Object.wait) │ 需分析 │ │ TIMED_WAITING │ 限时等待(Thread.sleep) │ 需分析 │ │ PARKING │ LockSupport.park │ 需分析 │ └──────────────────────────────────────────────────────────────────┘系列导航
- 上一篇:【JVM深度解析】第16篇:JVM配置优化案例三:CPU 100%排查(线程死循环)
- 下一篇:【JVM深度解析】第18篇:JVM配置优化案例五:GC停顿优化与低延迟改造
- 系列目录:JVM深度解析系列全集
参考资料
- Oracle Java Concurrency Tutorial
- Deadlock Prevention with StampedLock
- Arthas Thread Commands
- JVM Lock Internals
- Understanding Java Thread Deadlock