异步任务与线程池治理实践
线程池问题最容易伪装成“偶发超时”
很多系统里的异步能力不是一次设计出来的,而是慢慢叠上去的:
1、先有一个公共线程池
2、后面补批量任务
3、再加补偿、消息处理、外部调用
4、最后所有异步任务都往里面塞
这时线上最常见的表现不是线程池报错,而是接口慢、任务堆、偶发超时、日志里还看不出来是谁把池子打满了。
真正该先拆的,是任务模型,不是线程数
我现在更倾向按职责拆池,而不是按“项目里有异步”拆池。
比较实用的拆法通常是:
1、规则计算线程池
2、批量查询线程池
3、补偿任务线程池
4、消息消费后处理线程池
5、外部调用线程池
因为这几类任务的资源模型完全不同:
1、有的偏 CPU
2、有的偏 IO
3、有的容忍堆积
4、有的必须及时完成
如果混在一个池子里,出了问题基本只能看到“这个池满了”,但看不到是谁干的。
一套能进生产的线程池配置,至少要把边界定清楚
1 |
|
这里我更关心的不是参数具体值,而是它们表达的边界:
1、正常流量靠核心线程吃
2、突发流量先排队还是先扩容
3、打满以后是回压、丢弃还是报错
4、线程名能不能一眼看出归属
参数不是拍脑袋定的,应该对应任务语义。
拒绝策略本质上是业务策略,不是技术细节
很多线程池问题最后不是线程数不够,而是拒绝策略和业务语义不匹配。
1 | executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); |
比如:
1、任务不能丢,但允许主线程回压,可以考虑 CallerRunsPolicy
2、任务允许放弃,且不能拖垮主链路,才考虑 DiscardPolicy
如果一个必须执行的补偿任务配了丢弃策略,线上出问题时通常会非常隐蔽。
批量任务别直接一把梭进线程池
批量任务最容易犯的错,就是几千条任务直接丢进池子里,剩下全交给线程池兜。
我更愿意先做批次拆分,再把每批交给线程池:
1 | CountDownLatch latch = new CountDownLatch(batchList.size()); |
这样做至少有三个好处:
1、单次压入线程池的任务数可控
2、每批失败和成功更容易统计
3、收口点更明确,后续能补超时和异常统计
线程池最少要补哪些监控
如果线程池已经进生产,我现在至少会看:
1、活跃线程数
2、队列长度
3、任务拒绝次数
4、平均执行耗时
5、最大执行耗时
6、异常数量
没有这些指标时,很多线程池问题只能等到接口超时、补偿积压以后才暴露。
我更容易踩到的几个坑
1、不同类型任务共用一个池
最后谁都跑不快。
2、队列无限大
问题没有消失,只是从线程数转移到延迟和内存。
3、线程池里再起线程池
外层任务已经异步,内层又自己开池,资源模型很快就会乱。
4、任务本身时间不可控
慢 SQL、远程调用、重 CPU 逻辑都塞进一个任务里,参数调得再漂亮也没用。
如果线程池已经出问题,我一般怎么收
我自己的顺序通常是:
1、先按职责拆池
2、再补线程池监控
3、再改任务批次和超时控制
4、最后才微调核心线程数、最大线程数、队列长度
因为线程池问题大多数时候不是参数问题,而是任务边界问题。
小结
线程池治理不是把数字调大,而是把任务模型收清楚。
职责拆池决定资源隔离,拒绝策略决定流量打满时的业务语义,批次拆分决定线程池怎么被使用,监控决定你能不能在问题扩大前发现它。把这几层收住,异步链路才会稳。



