ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

线程过多导致系统性能崩坏?从线程池参数到死锁排查全解析

线程过多导致系统性能崩坏?从线程池参数到死锁排查全解析 你有没有遇到过这种情况系统上线前压测一切正常上线后跑个把小时线程数噌噌往上涨几百上千个线程堆在那儿CPU占用率飙到 90% 以上接口响应从几十毫秒变成几十秒最后连健康检查都挂了整个服务像死了一样。我之前排查过不少这种问题根子往往不在业务代码有多复杂而是线程开得太随意了。很多人有个错觉觉得“多开线程 多核并行 性能更好”实际上线程是操作系统里的重量级资源每个线程都有独立的栈空间、内核调度实体线程一多光上下文切换就能把 CPU 吃干净。这篇文章我就围绕“如何避免线程过多导致的系统性能问题”这个核心把线程开销的来源、线程池参数怎么配、跨语言场景下的线程管理、死锁与互斥的排查这些内容一次讲透。适合后端开发、客户端开发、运维和所有被线上线程问题折磨过的朋友。1. 线程一多系统就卡先弄清楚线程开销花在哪1.1 线程不是免费的内存与内核资源开销先说一个最容易被忽略的事实线程是操作系统管理的资源不是你想要就能随便造的。Java 里new Thread()创建一个线程底层会调用系统线程创建接口在 Linux 上就是一个pthread这个线程有自己独立的栈空间、线程局部存储、内核栈还有一块用于保存线程上下文的内存。具体占多大Java 线程默认栈大小在 64 位 JVM 上一般是 1MBLinux 上的原生线程栈默认是 8MBC# 的线程栈默认 1MBWindows 上可调。你算一下如果 Java 服务开了 1000 个线程光栈空间就预留了 1GB 虚拟内存虽然不会全部驻留物理内存但线程真正使用到的栈深度一旦涨上去物理内存的消耗就实打实了。更关键的是每个线程对应一个内核调度实体无论是 1:1 还是 M:N 模型现代主流 JVM 基本上都是 1:1 映射到内核线程内核需要为每个线程维护调度信息线程数一多内核本身的开销就上去了。网上那些“线程数开个几千没问题”的说法在内存充足、线程空转的场景下也许能撑住但只要线程真正干活栈内存和内核对象的压力立刻显现。我自己测过一个空转线程大概占 300KB 左右的内存10000 个空转线程就是 3GB 内存消失不见这还没算线程之间交互带来的锁开销。1.2 上下文切换是隐藏的性能杀手线程太多最大的坑其实是上下文切换。CPU 核心数是有限的假设你机器有 8 核同时只有 8 个线程能真正执行其他线程都在等待。操作系统要不停地在这些线程之间切换每次切换都要保存当前线程的寄存器状态、程序计数器、栈指针然后加载下一个线程的状态这个过程叫上下文切换。上下文切换的成本有多高一次切换大概是微秒级别的开销听起来不多但如果线程数量远超 CPU 核数每秒会发生成千上万次切换这些切换本身消耗的 CPU 时间就非常可观。有个经验数据当线程数是 CPU 核数的 10 倍以上时线程调度和切换开销可能占到 CPU 总消耗的 30% 甚至更高。更隐蔽的是缓存失效问题。线程切换后CPU 的一级缓存、二级缓存、TLB页表缓存里存的都是上一个线程的数据新线程的数据不在缓存里就要频繁去内存里取这就是“缓存冷启动”开销。线程切换越频繁缓存命中率越低程序实际执行速度越慢。还有一个连锁反应线程多了锁竞争也会加剧。多个线程同时访问共享数据时为了线程安全需要加锁线程越多抢锁的等待时间越长甚至出现“线程多到都在等锁活没干多少”的现象。可以说线程数和性能之间不是线性关系而是一条先上升后急剧下降的曲线。1.3 线程数量控制在多少才算合适既然线程太多不行那多少个才算合理这取决于任务类型。一般分两类CPU 密集型任务比如大量计算、编解码、图像处理这类任务几乎不阻塞线程数设置为CPU 核数 1是比较经典的配置。IO 密集型任务比如网络请求、数据库操作、文件读写线程大部分时间在等待 IO可以设置更多的线程常见经验值是CPU 核数 * 2或者更高具体可以用公式线程数 CPU 核数 * (1 平均等待时间 / 平均计算时间)来估算。举个例子假设一个任务平均计算 20ms然后要等待数据库查询返回 80ms那1 80/20 58 核机器就可以开 40 个左右的线程。这个数不是拍脑袋定的是有依据的线程数太少CPU 在等待 IO 的时候空转线程数太多上下文切换和锁竞争又把 CPU 消耗掉所以存在一个最优点。我之前调过一个服务接口里要做一次远程 RPC 和一次数据库查询平均耗时 150ms其中计算只有 10ms。最初线程池配了 64 个线程压测 QPS 只有 850我按公式调成线程池 24 个线程以后CPU 占用下降 40%QPS 反而涨到了 1200。原因很简单线程少了上下文切换少了CPU 都在干正事。2. 线程池先选型再启动参数怎么配才算合理2.1 为什么线程池能解决线程过多问题靠“自觉控制线程数”在开发阶段还行但一到生产环境就失控了——有的是并发量突增有的是代码里漏了回收线程数直接失控。要根治“线程过多导致的性能问题”最主流的手段就是线程池。线程池的思路很简单提前创建一批线程放在池子里任务来了直接交给池里的线程执行执行完线程不销毁继续接下一个任务。它解决了三个核心问题限制线程总数线程池有最大线程数上限超过这个数的新任务进队列等待或者被拒绝线程数不会无限膨胀。复用线程线程执行完任务后不销毁省去了反复创建和销毁线程的巨大开销。统一管理线程池可以统一配置核心线程数、队列容量、拒绝策略还能监控线程池状态。可以这样理解线程池就像一个固定的工人团队生意再好也不临时招几千人而是把订单放进排队区等工人空闲了再处理。2.2 核心参数逐个拆解Java 的ThreadPoolExecutor是使用最广泛的线程池实现它有 6 个核心参数参数作用备注corePoolSize核心线程数常驻线程不被回收即使空闲也保留maximumPoolSize最大线程数线程池允许创建的最大线程数量必须大于等于 corePoolSizekeepAliveTime非核心线程的空闲存活时间超过这个时间没任务就会被回收workQueue任务阻塞队列核心线程都在忙时新任务进队列threadFactory线程工厂用于创建线程可以用来给线程命名、设置优先级handler拒绝策略队列也满时的处理方式默认是抛异常ThreadPoolExecutor处理任务的流程有一个经典的“四步走”逻辑先看核心线程有没有空闲没有就尝试往任务队列里放如果队列也满了尝试创建新线程直到达maximumPoolSize最后还不行就走拒绝策略。理解这个流程特别重要因为很多参数配置错误都出在没搞懂“核心线程”和“队列”的配合顺序上。举个例子核心线程数设置 10队列容量设置 200最大线程数设置 20。当任务量在 10 个以内时核心线程处理任务增加到 10210 个时核心线程全部忙新任务先进队列排队任务量超过 210 个时才会创建第 1120 个线程来处理如果连 20 个线程都处理不过来队列也满了后面来的任务就触发拒绝策略。很多人有个误区以为先开满最大线程数再进队列实际上线程池的默认流程恰恰相反优先用队列缓冲任务队列满了才增加线程数。这一点直接决定了你在突发流量下是“排队等”还是“多开线程跑”要根据业务特性来取舍。2.3 参数配置经验别照搬网上的公式线程池参数没有银弹必须结合任务类型来配。网上常见的公式可以作为起点但一定要结合压测结果调优。我自己的经验是这样核心线程数corePoolSize根据“平时大多数时间”的并发量来设置让绝大多数请求在线程池常态运行时不触发额外的线程创建。一般取CPU 核数 * 2作为起点如果是 IO 密集型且等待时间占比高可以适当加大。最大线程数maximumPoolSize根据“高峰期最大能接受多少并发”来设置一般不超过核心线程数 * 2再往上增加的吞吐收益很小反而会增加上下文切换。队列容量这是很多人忽略的参数。如果用的是无界队列比如默认的LinkedBlockingQueue不设容量意味着任务永远不会满拒绝策略永远不会触发maximumPoolSize形同虚设。我强烈不建议生产环境用无界队列一旦任务积压线程池里的任务会越堆越多内存被打爆服务直接 OOM。拿一个真实项目举例一个消息推送服务平时每秒推送 200 条消息每条消息要做一次远程 HTTP 调用耗时约 100ms。当时的配置是new ThreadPoolExecutor( 16, // 核心线程数 32, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲 60 秒后回收 new ArrayBlockingQueue(1000), // 有界队列容量 1000 new NamedThreadFactory(push-thread), // 自定义线程工厂 new CallerRunsPolicy() // 拒绝策略调用者执行 );这个配置计算逻辑是要支撑每秒 200 条消息每条耗时 100ms需要并发线程数为200 * 0.1 20个左右所以核心线程 16 略低但配合队列容纳突发流量是足够的。最大线程 32 是为了应对突然的流量高峰如果再高就会因为 HTTP 连接数限制而失去意义。队列 1000 是为了给突发流量一个缓冲同时也有限流作用超过 1000 就触发拒绝策略。2.4 阻塞队列怎么选这是个关键决策线程池的性能和稳定性很大程度上取决于队列的选择。这往往是新手最容易踩的坑我单独拿出来详细说。ArrayBlockingQueue有界数组阻塞队列容量固定必须指定大小。适合用来限制任务积压数量防止内存爆炸。默认使用非公平锁可以通过构造函数指定公平锁策略但会牺牲一部分吞吐量。这是我最推荐的生产环境队列。LinkedBlockingQueue链表阻塞队列默认容量是Integer.MAX_VALUE也就是无界。如果用它作为线程池队列意味着线程池永远不会触发拒绝策略任务会一直堆积直到 OOM。如果你一定要用请务必在构造时指定容量。SynchronousQueue同步队列不存储任务每个插入操作必须等待另一个线程的移除操作相当于“手递手”交接。配合比较大的maximumPoolSize使用可以实现“无线程可复用就直接创建新线程”的效果。适合任务本身比较重、不希望在队列中等待的场景。PriorityBlockingQueue优先级队列任务可以按优先级执行但要注意同一个线程池里放不同优先级的任务低优先级任务也有可能被饿死。队列的选择直接决定了线程池面对突发流量时的表现。如果你的业务不希望请求在队列里等待太久可以用SynchronousQueue 大最大线程数如果希望削峰填谷就选较大的有界队列如果宁可丢弃一些非核心任务也不愿意拖垮系统就选无界队列配拒绝策略——但这里说的无界是“容量很大的有界”不是完全无界。2.5 拒绝策略的取舍拒绝策略在线程池里有四种AbortPolicy默认直接抛RejectedExecutionException异常。坏处是调用方可能没处理这个异常导致任务丢失甚至业务报错。CallerRunsPolicy任务直接在调用方的线程里执行相当于“谁提交的谁执行”。好处是不丢任务而且能起到反向压力作用——调用方被占用了自然会放慢提交速度。坏处是影响调用方自身的响应时间。DiscardPolicy静默丢弃任务不报错。坏处是任务无声无息地丢了排查问题非常困难。DiscardOldestPolicy丢弃队列里最老的任务然后尝试重新提交新任务。适合希望保留最新任务的场景。实际项目中我几乎不用默认的AbortPolicy因为生产环境的接口调用方不会每个都处理这个异常很容易变成 500 错误。我用的最多的是CallerRunsPolicy它不像丢弃策略那样丢失任务又能通过调用线程被占用来实现天然限流。但要注意它的一个副作用如果调用方是热路径上的请求线程响应时间会被拉长所以要在压测时确认这个影响在可接受范围内。3. 线程池配置完只是开始任务拆分、隔离与监控3.1 按业务类型拆分多个线程池而不是一个大而全的池很多人图省事整个应用只创建一个全局线程池所有业务任务往里丢。这在并发量低时没太大问题一旦某个业务出现慢查询或者依赖的接口变慢所有使用同一个线程池的业务都会被拖垮。这就是“线程池污染”也叫“雪崩效应”。正确的做法是按业务类型隔离线程池。举个例子一个电商系统里订单查询接口依赖数据库支付回调接口依赖第三方 HTTP日志上报接口依赖本地磁盘 IO。这三个接口的延迟特征完全不同如果混用一个线程池支付回调的阻塞会占满线程池里的线程导致订单查询被阻塞甚至日志上报也受影响。我负责过的一个后台管理服务就遇到过这种情况用户导出报表的任务特别重一旦有人导出大批量数据线程池里几乎所有的线程都被导出任务占住其他列表查询接口全部超时。后来把导出任务单独放到一个线程池核心线程只有 2 个最大线程 4 个队列容量 100其他接口用另一个线程池。之后再出现大导出也只影响导出本身其他接口稳如老狗。3.2 给线程起好名字配好异常处理这是个小技巧但对排查问题帮助极大。用ThreadFactory给线程池里的线程命名比如order-pool-thread-1、push-pool-thread-2。这样出问题时用jstack或者 Arthas 查看线程栈一眼就能看出是哪个业务池子出了问题不需要去对照线程 ID 猜。ThreadFactory namedThreadFactory new ThreadFactory() { private final AtomicInteger idx new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(order-pool-thread- idx.incrementAndGet()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, throwable) - { // 记录异常日志可以做告警 }); return t; } };同时一定要给线程设置UncaughtExceptionHandler否则线程执行过程中抛出的未捕获异常会被线程池吞掉任务失败了你根本不知道。3.3 线程池监控不监控的池子等于裸奔线程池配好了如果没有监控就像一个没装仪表盘的发动机。线程池运行到瓶颈、任务积压、拒绝策略触发这些都需要监控才能及时发现。常见的监控指标有这些getPoolSize()当前线程池中的线程数量可以观察线程数是否一直处于最大线程数。getActiveCount()正在执行任务的线程数量如果长期接近线程池大小说明线程不够用。getQueue().size()阻塞队列中积压的任务数增长过快说明处理能力跟不上。getTaskCount()累计任务数可以用来计算吞吐量。getCompletedTaskCount()已完成任务数和上面的差值就是未完成任务数。拒绝次数这个指标需要自己统计在拒绝策略里加计数器。可以起一个后台定时任务每 30 秒打印一次这些指标或者接入监控系统发送告警。我习惯的告警规则是队列积压数超过队列容量的 80%告警拒绝策略触发立即告警说明系统快撑不住了活跃线程数长期超过最大线程数的 80%告警。有一次线上服务出现轻微超时我就是通过监控发现线程池队列积压从 0 涨到了 700而最大线程数的活跃度只有 40%。这说明不是线程不够而是某个任务的执行时间变长了——查下去果然发现依赖的下游接口响应从 50ms 涨到了 2 秒。3.4 参数动态调整不要重启就能调优线上环境改线程池参数是需要重启的重启意味着短暂的服务中断这在核心业务上没法接受。所以如果你的系统允许可以预留参数动态调整的能力。比如在线程池外面包一层ThreadPoolExecutor的管理器暴露调整corePoolSize和maximumPoolSize的方法。Java 的ThreadPoolExecutor原生支持通过setCorePoolSize()和setMaximumPoolSize()动态调整改完立即生效。我在生产环境一般预留一个内部的管理接口运维或开发同学通过控制台就能调整线程池参数不需要发布代码。调整的原则是“小步慢调”每次调整幅度不超过 20%观察几分钟再决定是否继续调避免一次调过头引起新的问题。4. Java 之外C、C#、Python、Linux 下的线程管理4.1 C 场景线程池与大数组读写C 里写多线程比 Java 更贴近底层线程管理没有现成ThreadPoolExecutor可以用需要自己封装或者用第三方库如ThreadPool、BS::thread_pool。但线程过多的问题是一样的甚至更严重因为 C 线程默认栈大小更大没有 JVM 帮忙做任何回收优化。C 里常见的一个场景是“两个线程分别读写一个大数组”。比如一个线程负责从网卡收包写入数组另一个线程负责从数组读取数据进行计算。这里最容易出问题的是数据竞争需要在读写之间做同步。经验做法用std::mutex加锁保护共享索引或者用无锁队列如boost::lockfree::spsc_queue做单生产者单消费者模型。做大数组分块处理时注意“伪共享”问题。两个线程操作同一个缓存行上的不同数据会导致缓存行频繁失效性能断崖式下降。解决方法是让两个线程操作的数据每隔 64 字节错开。struct alignas(64) PaddedCounter { std::atomicint value{0}; };这段代码用alignas(64)强制按缓存行对齐避免伪共享。很多 C 性能问题不一定在锁而是锁背后隐藏的缓存一致性问题。4.2 C# 场景Task、WaitOne 与线程安全集合C# 里现在一般用Task而不是直接操作ThreadTask默认跑在线程池上线程调度由运行时管理但依然要小心线程过多的问题。C# 开发者经常会遇到一个场景多个任务并行执行需要等待所有任务完成。用Task.WaitAll()可以等待一组任务全部完成但如果等待的任务太多占用的线程也多主线程阻塞等待时如果线程池排队较长也可能出现死锁。更安全的做法是Task.WhenAll()用异步方式等待不阻塞任何线程。C# 里还有一个常见的坑是“线程安全 List”。很多人写多线程程序时图方便直接用ListT做共享集合高并发下会出各种奇奇怪怪的问题。正确的选择是这样ConcurrentBagT适合高频添加、偶尔取出的场景ConcurrentQueueT适合先进先出的队列场景ConcurrentDictionaryTKey, TValue适合键值对存储或者用ListT加lock自己控制同步。简单说来不要用裸集合做跨线程共享这是所有语言的通病C# 的WaitOne和互斥体Mutex也是常用的同步手段多个线程需要按顺序访问资源时使用AutoResetEvent/ManualResetEvent要注意设置超时避免某个线程卡死导致所有等待线程永久阻塞。4.3 Python 场景GIL 下的线程真相Python 的多线程和 Java、C 有着本质区别因为 CPython 解释器有 GIL全局解释器锁同一时刻只有一个线程能执行 Python 字节码。这意味着 Python 多线程对 CPU 密集型任务是无效的还会因为线程切换增加开销。对于 I/O 密集型任务爬虫、接口调用、文件操作Python 多线程依然有用因为线程在等待 I/O 时会让出 GIL其他线程可以执行。Python 的concurrent.futures.ThreadPoolExecutor用法和 Java 的线程池类似同样要注意线程数不能开太多线程切换开销一样存在。from concurrent.futures import ThreadPoolExecutor def fetch_url(url): # 模拟 IO 操作 pass with ThreadPoolExecutor(max_workers20) as executor: list(executor.map(fetch_url, urls))Python 适合 CPU 密集型的正确姿势是多进程如ProcessPoolExecutor/multiprocessing因为每个进程有独立的 GIL可以并行执行。但要记住进程比线程更重进程数一般取 CPU 核数即可不要盲目多开。4.4 Linux 下的线程条件变量与守护线程在 Linux 环境下无论用什么语言写的服务器最终都跑在pthread之上。Tomcat 里一个线程对应一个 Linux 线程这就是最典型的 1:1 线程模型。所以 Tomcat 配置的maxThreads如果设得太大比如 1000那操作系统里就会有 1000 个线程等着被调度性能反而上不去。Linux 多线程编程中线程条件变量pthread_cond_t是常用的同步原语它配合互斥锁使用让线程可以等待某个条件成立再继续执行。条件变量设计得好能避免线程忙等浪费 CPU但如果使用不当比如没有正确加锁就调用pthread_cond_signal会导致未定义的行为卡死或者错过唤醒。Linux 里还有守护线程的概念pthread_detach把线程设为分离状态后线程结束时会自动释放资源不需要pthread_join。进程主线程退出时进程内所有线程都会被终止所以守护线程的设计要格外注意生命周期管理。5. 线程互斥与死锁多线程“打架”的排查与预防5.1 死锁是怎么产生的线程调度和资源竞争不仅带来性能问题还会带来正确性问题其中最典型的就是死锁。死锁一旦发生线程永久阻塞CPU 占用可能不高但系统功能完全瘫痪。死锁的产生需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。这个概念教科书上都讲了但实际项目中还是反复出现最常见的就是“两个锁的获取顺序不一致”。经典场景是线程 A 持有锁 1 等待锁 2线程 B 持有锁 2 等待锁 1两者互不相让陷入死锁。用代码表示就是这样// 线程 A synchronized(lockA) { // 做一些事 synchronized(lockB) { ... } } // 线程 B synchronized(lockB) { // 做一些事 synchronized(lockA) { ... } }这种问题还常常跟业务代码纠缠在一起比如一个方法内部先调用 A 服务再调用 B 服务另一个方法先调用 B 再调用 A两个服务内部又有锁。排查起来特别费劲。5.2 Java 环境下的死锁排查实操Java 中排查死锁最直接的手段是jstack。拿到卡住的 Java 进程 PID执行jstack PID输出文件里如果发现类似这样的信息Found one Java-level deadlock: thread-B: waiting to lock monitor 0x00007f8a3000b800 (object 0x00000000d5bf1a48, a java.lang.String), which is held by thread-A thread-A: waiting to lock monitor 0x00007f8a3000b800 (object 0x00000000d5bf1b50, a java.lang.String), which is held by thread-B这就说明 JVM 已经检测到了死锁并且明确指出了参与死锁的线程和锁对象。拿到这个信息之后再结合业务代码去分析锁获取顺序问题就很好定位了。除了jstack用jconsole或jvisualvm也可以看到死锁检测界面里会直接标红提示。如果生产环境不能随便用这些工具可以提前在代码里做监控用ThreadMXBean定时检测死锁发现死锁后输出线程栈并告警。ThreadMXBean tmb ManagementFactory.getThreadMXBean(); long[] deadlockedThreads tmb.findDeadlockedThreads(); if (deadlockedThreads ! null) { // 输出死锁线程信息 }5.3 避免死锁的设计策略死锁的排查成本远高于预防成本所以把预防做在前面是最划算的。第一统一锁的顺序。所有需要获取多个锁的地方必须按照同一个全局顺序获取锁。比如规定必须先获取锁 A 再获取锁 B就不允许出现先获取 B 再获取 A 的代码。这个规则要用代码评审来卡不能只靠自觉。第二使用超时锁。ReentrantLock支持tryLock(timeout)获取锁时设置超时时间超时后主动释放已持有的锁避免无限等待。数据库连接池的获取连接、分布式锁的获取都应该设置超时时间。第三减少锁粒度。锁的范围越小发生死锁的概率越低。比如只锁共享数据的那一小段操作不要把一个方法整体锁住。JDK 8 之后可以通过 CAS、原子变量、读写锁等手段减少锁竞争。第四合理设计线程池。线程池里如果同一个任务嵌套提交到同一个线程池而线程池已经没有空闲线程就可能出现“线程池饥饿”导致的死锁。遇到这类问题要么用无界队列要么对嵌套任务单独开一个线程池总之不能让父任务占着线程等子任务而子任务永远没机会执行。5.4 线程互斥的其他注意点互斥锁在保证安全的同时也会引入性能损耗。锁的粒度越粗线程并行的程度越低锁的竞争越激烈线程等待的时间越长。所以在设计共享数据访问时可以考虑无锁或者读写锁数据量不大且读多写少用ReadWriteLock或者 JDK 的StampedLock单个变量的更新用AtomicInteger、AtomicLong等无锁原子类多个变量需要保持一致的更新再考虑加锁保护。还有一种常见问题是“锁内做了耗时操作”。比如锁内发了个 HTTP 请求其他所有需要这个锁的线程都被迫等待吞吐量直线下降。这里可以做的是把锁内的耗时操作移出去先复制一份数据做快照释放锁后再处理耗时操作保证锁不会成为系统的性能瓶颈。6. 踩坑实录常见线程性能问题排查速查表6.1 我的几个线上案例案例一线程数暴涨导致 CPU 飙升。某个服务上线后运行三个小时线程数从 200 涨到 2000CPU 从 30% 飙升到 95%。用jstack一看大量线程堆积在某个 HTTP 客户端的调点上再查代码发现这个 HTTP 客户端没有设置连接池和超时时间每次请求都新建连接而且连接一直不释放线程全部阻塞在等待连接上。解决方法是给 HTTP 客户端配置合理的连接池、连接超时和读取超时同时把执行任务的线程池按业务拆细避免互相影响。案例二CallerRunsPolicy引发的响应时间恶化。线上一个支付回调接口线程池满了之后走了CallerRunsPolicy回调请求直接在 Tomcat 的线程里执行导致 Tomcat 线程被占满其他接口全部超时。后来改成了自定义的拒绝策略任务进入一个降级队列异步落库记录后续再由定时任务补处理。核心思路是“高峰可以延迟处理但不能把主流程阻塞掉”。案例三线程池队列无界导致 OOM。这个是最典型的一个后台导出任务使用了默认的LinkedBlockingQueue没有指定容量一个运营同学点了大批量导出瞬间几万条任务全部堆积在队列里内存被任务对象占满服务 OOM。后来改成有界队列 丢弃策略并对导出类任务做并发控制单个用户同时只能有一个导出任务再也没出现过问题。案例四死锁隐藏得很深。一个消息队列消费服务用了两个线程池分别在处理消息时获取同一个分布式锁两个线程池的任务互相等待对方的锁服务整体卡死。jstack显示线程全部处于WAITING状态。解决方法是统一锁的获取顺序并且给分布式锁加超时时间获取不到就直接抛异常中止任务让消息重新进入队列。6.2 速查表从现象到解决方案现象可能原因排查手段解决方案CPU 占用率高但 QPS 不高线程数过多上下文切换频繁jstack看线程数top -H看线程 CPU 占比限制线程池大小检查是否有线程空转或忙等线程数持续增长不下降任务执行时间过长或线程泄漏jstack定位线程堆积位置检查阻塞调用是否有超时线程池使用后是否正确回收接口响应变慢任务积压队列积压线程池不够用查看线程池活跃线程数、队列大小合理加大线程数或减小任务耗时必要时扩容OOM 异常无界队列任务堆积查看堆转储统计任务对象数量改为有界队列设置容量上限系统整体无响应死锁或线程阻塞jstack检测死锁统一锁顺序加超时锁数据错乱或抛出异常线程安全问题代码评审加锁验证使用线程安全集合加锁保护共享数据6.3 一些独家避坑技巧线程池的最大线程数不要拍脑袋设很大压测时一定测“线程数翻倍”的场景。我曾经帮人排查过一个系统线程池最大线程数设了 500压测时发现 500 线程跑起来的吞吐量只有 200 线程的一半原因就是上下文切换和缓存失效把收益吃掉了。线程池的keepAliveTime不要设成 0。如果你设成 0意味着非核心线程一旦空闲就立刻回收这样在流量有波动的时候线程反复创建销毁的开销非常大。一般设成 60 秒比较合适。用监控工具观察线程池的状态。我在所有核心线程池上都加了指标采集每 10 秒记录一次线程池大小、活跃线程数、队列积压数。这样做的好处是问题发生后不用翻日志找证据直接看监控曲线就能定位到是什么时候开始恶化的。线程池里的任务一定要做超时控制。不管是 HTTP 调用、数据库操作还是 RPC 调用都加上超时时间。否则一个下游服务挂了所有线程都会阻塞在那里线程池很快被耗尽这就是“线程池耗尽雪崩”。写代码时不要图方便直接new Thread().start()。像 Java 的Executors.newCachedThreadPool()这种快捷方法也尽量别在生产用因为它是无界线程池线程数可以无限增长。要用就用ThreadPoolExecutor显式地配置所有参数确保你能掌控线程总数。最后再分享一个很容易被忽略的细节如果你们用的是 Tomcat 这类 Web 容器server.tomcat.threads.max这个参数也要关注。它决定了 Web 容器能处理的最大并发请求数如果设得太大Tomcat 的线程数也会成为系统瓶颈和业务线程池的问题如出一辙。我习惯把 Tomcat 的线程数控制在 200 以内再结合业务线程池做分层限制这样任何一个层面的问题都不会把系统整体拖垮。
返回列表