看到一个程序员吐槽:隔壁工位同事刚拿N+1离开,第二天 leader 就把对方 Q3 的目标全部划给了他,团队人数少了,总目标不变,他个人的目标直接变成原来的1.8倍。
![]()
这种“资源减少、吞吐目标不变”的场景,我在线上系统里也遇到过。业务不会因为机器少了就自动少发请求。问题是,一个原本按照固定容量设计的 Java 服务,如果实例缩容30%,流量不变,最先出问题的经常不是 CPU,而是线程池、连接池和下游调用一起进入排队状态。
之前有个订单聚合服务,接口需要并行查询商品、营销和库存。最初机器数量比较充足,为了降低接口 RT,代码直接使用线程池并发执行:
CompletableFuture priceFuture =
CompletableFuture.supplyAsync(
-> priceClient.query(skuId), executor);
CompletableFuture promoFuture =
CompletableFuture.supplyAsync(
-> promotionClient.query(userId, skuId), executor);
CompletableFuture stockFuture =
CompletableFuture.supplyAsync(
-> stockClient.query(skuId), executor);
CompletableFuture.allOf(
priceFuture, promoFuture, stockFuture
).join;return assemble(
priceFuture.join,
promoFuture.join,
stockFuture.join
);
当时这么写没什么问题。单机流量不高,三个 RPC 平均耗时都在50ms以内,线程池配置也比较宽松:
new ThreadPoolExecutor(
32,
64,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.AbortPolicy
);
后来因为资源调整,实例从20台缩到12台,入口总流量基本没变化。第二天接口 P99 从300ms涨到2秒以上,偶尔还能看到 RejectedExecutionException 。
我第一反应是下游接口变慢了,因为聚合接口自己基本没有重逻辑。查了一轮链路,三个下游的耗时却都正常:
priceClient avg=42ms p99=81ms
promotionClient avg=55ms p99=103ms
stockClient avg=38ms p99=76msaggregateApi avg=311ms p99=2187ms
这就不对了。下游只跑几十毫秒,聚合层为什么能卡两秒?
继续看线程池指标,问题才暴露出来:
poolSize=64
activeCount=64
queueSize=873
completedTaskCount=18492031poolSize=64
activeCount=64
queueSize=996
completedTaskCount=18492802
线程已经全部占满,队列也快满了。请求进来以后,三个 RPC 任务并没有马上执行,而是先进入 LinkedBlockingQueue 排队。
这里其实很容易误判。链路平台记录的 RPC 耗时通常从真正发起调用开始计算,任务在线程池里等待的500ms并不会算进 priceClient 的42ms,所以看起来下游完全正常。
可以给任务增加一个最简单的排队时间监控:
long submitTime = System.nanoTime;
executor.execute( -> {
long queueCost =
TimeUnit.NANOSECONDS.toMillis(
System.nanoTime - submitTime);
if (queueCost > 100) {
log.warn("executor queue delay={}ms", queueCost);
}priceClient.query(skuId);
});
加完以后日志很快刷出来:
executor queue delay=327ms
executor queue delay=514ms
executor queue delay=891ms
executor queue delay=1243ms
问题到这里才算定位清楚:缩容之后单机入口 QPS 上升,每个请求又拆成三个异步任务,线程池实际承受的是入口流量的数倍。线程数没有同步调整,任务开始排队,排队又抬高接口 RT;RT 上升以后,在途请求继续增加,最后把队列也顶满。
当时有人提议把队列扩大。这是线上很常见的处理方式,因为 RejectedExecutionException 会立刻减少,但它只是把拒绝变成等待。
假设线程池稳定处理能力只有800 task/s,而高峰持续进入1000 task/s,每秒净积压200个任务。5000长度的队列只是允许它多积压25秒。
对HTTP接口来说,这些任务25秒以后即使执行也没意义,因为上游可能早就超时了。
所以我们没有扩大队列,而是先把线程池改成更明确的过载保护:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
48,
48,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(200),
new ThreadFactoryBuilder
.setNameFormat("aggregate-rpc-%d")
.build,
new ThreadPoolExecutor.AbortPolicy
);
核心不是48这个数字,而是取消“超大缓冲区”的思路。线程池达到处理极限以后尽快暴露过载,不让请求在队列里无意义等待。
调用侧同时增加超时降级。营销数据允许缺失,就不能因为它拖死整个订单接口:
CompletableFuture promoFuture =
CompletableFuture.supplyAsync(
-> promotionClient.query(userId, skuId),
executor
).completeOnTimeout(
Promotion.empty, 120, TimeUnit.MILLISECONDS
).exceptionally(ex -> {
log.warn("promotion degraded, sku={}", skuId, ex);
return Promotion.empty;
});
库存属于核心数据,不能直接吞异常;营销信息则允许降级。三个并行任务不能使用完全相同的失败策略,否则所谓“异步并行”只是把三个下游的故障域一起带进来了。
只改线程池仍然不够,因为实例继续减少时,同样的问题还会回来。我们后来按照单实例能够稳定处理的并发量,在入口增加了并发限制:
privatefinal Semaphore permits = new Semaphore(180);
public OrderView query(OrderRequest request){
if (!permits.tryAcquire) {
thrownew ServiceBusyException(
"aggregate service overloaded");
}try {
return doQuery(request);
} finally {
permits.release;
}
}
180不是拍脑袋配置,而是压测出来的。我们逐级增加并发,记录接口吞吐和P99:
Concurrency QPS P99
100 612 184ms
150 817 236ms
180 901 291ms
220 927 684ms
260 931 1437ms
并发从180增加到260,吞吐只增加30 QPS,P99却从291ms恶化到1.4秒。继续放请求进来已经不能明显提高处理量,只会制造更多排队,因此限流点最终放在性能曲线开始明显拐弯的位置附近,并预留了一部分安全余量。
回到开头那个案例,人少了,目标可以在表格里直接乘1.8;但系统资源少了,吞吐量不会跟着KPI重新计算。Java服务遇到这种情况,先把线程池队列、单机QPS、P99和下游超时翻出来,往往比单纯“加线程”更有用。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.