上午十点,运营那边固定启动一批物流单号批量查询,平时一两分钟跑完。那天十点零二分,监控先报警"任务队列积压超过 5000",又过了半分钟,批量查询接口开始大面积超时,连正常的前台单笔查询都跟着卡了。
我第一反应是加机器,但看了线程池的监控数据后放弃了——问题不是机器不够,是并发治理的阀门全开错了。
批量查询走的是一个固定线程池,配置是这样的:
ThreadPoolExecutor(
corePoolSize = 8,
maximumPoolSize = 32,
keepAliveTime = 60s,
workQueue = new LinkedBlockingQueue<>(10000)
)这个配置有个经典的坑:LinkedBlockingQueue 无界(或极大)时,线程数永远不会从 8 涨到 32。线程池的扩容逻辑是"队列满了才新建线程",队列能装 10000 个任务,线程就一直停在 8 个。高峰期 8 个线程每人要处理几千个查询,单个查询再慢一点,队列就全线积压。
踩坑提醒:很多人以为设了 maximumPoolSize = 32 就是 32 个线程跑,实际上队列没满前线程池根本不会扩容。要看线程池真实并发数,监控 getActiveCount(),别只看配置。
修复第一步是把无界队列改成有界,让压力在入口就显现出来,而不是积压在内存里:
workQueue = new ArrayBlockingQueue<>(512)队列从 10000 压到 512 后,高峰期线程池会真的扩容到 32 个线程;队列再满,任务被拒绝,由拒绝策略决定怎么办。我们用的是 CallerRunsPolicy——被拒绝的任务由提交它的线程自己跑,这样既不丢任务,又天然给上游减速。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(512),
new ThreadPoolExecutor.CallerRunsPolicy()
);踩坑提醒:CallerRunsPolicy 有个隐蔽副作用——如果提交任务的线程本身就是批量任务主线程,被拒绝的任务会在主线程里串行执行,批量任务耗时会被拉长。我们在批量场景里给主线程也加了监控,发现执行耗时从 40 秒涨到 90 秒,排查后确认是这个原因,于是改成 AbortPolicy + 显式降级(把多余任务写进本地重试表,下一轮再跑),而不是在主线程硬扛。
批量查询的核心瓶颈不在我们自己的线程,而在上游物流渠道 API 的限流:对方网关对单 appkey 的并发上限是 20。线程池 32 个线程同时打过去,一半请求直接 429。
正确做法是用信号量把"对外并发"和"内部线程数"解耦:
Semaphore upstreamLimit = new Semaphore(18); // 留 2 个余量给重试
for (Task task : batchTasks) {
upstreamLimit.acquire(); // 拿不到就阻塞等待
pool.submit(() -> {
try {
callUpstream(task);
} finally {
upstreamLimit.release();
}
});
}信号量 18 保证对上游的并发永远不超过 18,线程池 32 只是内部并行度,两者不再互相打架。改完后 429 全部消失,批量查询耗时从 2 分半回到 1 分 20 秒。
踩坑提醒:acquire() 要放在提交任务前而不是任务内,否则高峰期 32 个线程全卡在 acquire 上,线程池被占满,其他任务也没法执行。
LinkedBlockingQueue 无界队列,线程池永不扩容,8 线程扛全部流量CallerRunsPolicy 让批量主线程串行执行被拒任务,耗时翻倍getActiveCount(),一直以为 32 线程在跑,实际只有 8批量查询平台是我们基于固乔快递批量查询助手的接口做的改造,我负责并发治理这部分,把无界队列、信号量和拒绝策略理顺后,十点高峰那波批量查询没有再全挂过。
getActiveCount(),配置的 maximumPoolSize 只是上限,不是常态以上是个人实践记录,各平台具体功能以官方实时信息为准。
留个开放问题:批量任务场景下,你们对 CallerRunsPolicy 和"重试表 + 下一轮补偿"这两种拒绝策略是怎么权衡的?
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。