本节目标:把「线程池满了」从一条被吞掉的异常,变成调用方看得懂、运维查得到的 503。
适用版本:JDK 25(Temurin 25.0.5);Javalin 7.2.4;HikariCP 7.1.0。
承接的问题
9.1 给 libraryd 配了 libraryd-worker-* 线程池,队列 200,拒绝策略是 AbortPolicy。9.2 又把 HTTP 请求线程换成了虚拟线程。现在的问题是:队列满了以后会发生什么?
最直接的答案是抛 RejectedExecutionException。但异常抛给谁?如果它从 Javalin 的处理器里冒出去,第 8 章的全局异常映射会把它当成未预期异常,返回 500 INTERNAL。调用方看到 500,通常会认为服务有 bug,可能立刻重试;重试又会把已经满的队列压得更满。
过载不是 bug,是容量边界。它应该表现为:
- 一个明确的 503,而不是 500。
- 响应里带
Retry-After,告诉客户端什么时候可以再试。 - 日志里带池名、活跃线程数、队列深度和累计拒绝数。
- 指标里能看到拒绝在发生,而不是等用户投诉。
这一节要补上这三层:信号、语义、响应。
先看见:队列深度比拒绝更早
RejectedExecutionException 是最后一道门,等它出现时队列已经满了。更有用的信号是队列深度和活跃线程数,它们在拒绝之前就会变化。
ThreadPoolExecutor 提供几个只读快照:
package com.plume.library.app;
import java.util.concurrent.ThreadPoolExecutor;
public record PoolSnapshot(
String name,
int active,
int poolSize,
int queued,
int capacity,
long completed,
long rejected) {
public static PoolSnapshot of(String name, ThreadPoolExecutor pool, long rejected) {
int queued = pool.getQueue().size();
int capacity = queued + pool.getQueue().remainingCapacity();
return new PoolSnapshot(name, pool.getActiveCount(), pool.getPoolSize(),
queued, capacity, pool.getCompletedTaskCount(), rejected);
}
}
getActiveCount() 是近似值:它遍历 worker 判断是否锁住,返回的是一个瞬时估计。用它做严格准入会有一致性问题,但用来看趋势足够了。getQueue().size() 和 remainingCapacity() 加起来就是队列容量,这个值在池的生命周期里固定。
写一个每 100ms 打印一次快照的监控线程,配合 4 个线程、容量 10 的池,任务每次睡 120ms,每 20ms 提交一个,共 60 个。本机输出(截取中段):
active=4 queue=5/10 completed=14 rejected=0
active=4 queue=6/10 completed=17 rejected=0
active=4 queue=7/10 completed=20 rejected=0
active=4 queue=8/10 completed=24 rejected=0
active=4 queue=9/10 completed=28 rejected=0
active=4 queue=9/10 completed=34 rejected=2
active=4 queue=9/10 completed=38 rejected=3
active=4 queue=8/10 completed=42 rejected=4
accepted=56 rejected=4
可以看到队列先爬升到 9/10,然后才开始出现拒绝。监控如果只盯 rejected,就会在队列已经很深的时候还没有告警;盯 queue/capacity 和 active/poolSize 能提前看到压力。
这段实验的完整代码在一个文件里,核心是自定义拒绝处理器和监控线程:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 4, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(10),
r -> new Thread(r, "lib-worker"),
(task, executor) -> {
rejected.incrementAndGet();
throw new RejectedExecutionException("worker queue full");
});
拒绝处理器里只做计数和抛出,不做重试,也不做日志格式化。日志留给统一的 handler,避免在拒绝路径上再分配大量字符串。
拒绝策略的语义
四种内置策略在「任务是否丢失」和「调用方是否感知」上完全不同:
| 策略 | 任务会怎样 | 调用方是否感知 | 适合 libraryd 的场景 |
|---|---|---|---|
AbortPolicy | 不执行,抛 RejectedExecutionException | 是 | 用户请求的后台查询,映射成 503 |
CallerRunsPolicy | 在提交线程执行 | 变慢,不报错 | 不能丢、可以慢的审计或通知 |
DiscardPolicy | 静默丢弃 | 否 | 基本不用 |
DiscardOldestPolicy | 丢队列里最老的,再提交当前任务 | 否 | 基本不用 |
AbortPolicy 的关键是「调用方必须接住」。如果接不住,它就退化成 500。自定义 handler 可以在抛出前记录快照:
package com.plume.library.app;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.LongAdder;
public final class OverloadRejectionHandler implements RejectedExecutionHandler {
private static final System.Logger LOG =
System.getLogger("libraryd.executor");
private final String poolName;
private final LongAdder rejected = new LongAdder();
public OverloadRejectionHandler(String poolName) {
this.poolName = poolName;
}
@Override
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
rejected.increment();
PoolSnapshot snapshot = PoolSnapshot.of(poolName, executor, rejected.sum());
LOG.log(System.Logger.Level.WARNING,
"pool={0} active={1}/{2} queue={3}/{4} completed={5} rejected={6}",
snapshot.name(), snapshot.active(), snapshot.poolSize(),
snapshot.queued(), snapshot.capacity(),
snapshot.completed(), snapshot.rejected());
throw new RejectedExecutionException(poolName + " queue is full");
}
public long rejectedCount() {
return rejected.sum();
}
}
CallerRunsPolicy 在 9.1 已经看过一次真实输出:1 个线程、队列 1、提交 4 个任务,第 3 个任务在 main 线程上执行。放到 libraryd,main 就是 Jetty 的请求线程。它保住了任务,但把队列压力变成了请求延迟;如果任务里还要拿数据库连接,请求线程会被占住更久。它适合「不能丢、慢一点没关系」的场景,不适合所有请求都走它。
DiscardPolicy 和 DiscardOldestPolicy 在 libraryd 里没有位置。审计记录被丢掉,事后无法对账;最老的任务被丢掉,先到的请求可能永远没有响应。如果决定丢弃,必须显式计数并让调用方知道,而不是用静默策略。
把拒绝变成 503
第 8 章的 ErrorCode 已经有 400/404/409/500/503。这一章增加一个明确的过载码:
public enum ErrorCode {
// 前略:BOOK_NOT_FOUND、VALIDATION_FAILED、DB_UNAVAILABLE 等
SERVICE_OVERLOADED("E_SERVICE_OVERLOADED", 1, 503);
}
加这个常量不是「多一个错误码」那么简单,它把「队列满」和「数据库不可用」分开。两者的 HTTP 状态都是 503,但处置方式不同:队列满应该先扩容或限流,数据库不可用应该检查连接和 SQL。日志和指标里用不同的 code,排查时才能区分。
服务层把 RejectedExecutionException 翻译成领域异常:
package com.plume.library.app;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class BookSearchService {
private final ThreadPoolExecutor workers;
private final OverloadGuard guard;
private final BookSearchRepository repository;
public BookSearchService(ThreadPoolExecutor workers,
OverloadGuard guard,
BookSearchRepository repository) {
this.workers = workers;
this.guard = guard;
this.repository = repository;
}
public PageResponse<BookDto> search(String q, int page, int size) {
guard.check();
Future<PageResponse<BookDto>> future = workers.submit(
() -> repository.search(q, page, size));
try {
return future.get(2, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
throw new LibraryException(ErrorCode.SERVICE_OVERLOADED,
"worker queue is full", e);
} catch (TimeoutException e) {
future.cancel(true);
throw new LibraryException(ErrorCode.DB_UNAVAILABLE,
"book search timed out", e);
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new LibraryException(ErrorCode.INTERNAL,
"search interrupted", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof LibraryException libraryException) {
throw libraryException;
}
throw new LibraryException(ErrorCode.INTERNAL,
"book search failed", cause);
}
}
}
这段代码在 9.3 还是 Future.get(2s) 的阻塞写法,第 10 章会用 CompletableFuture 把它改成组合式。现在先关注拒绝路径:RejectedExecutionException 不再冒到全局 500,而是变成 SERVICE_OVERLOADED。
Web 层在异常映射里补一个 Retry-After 头。第 8 章的映射器已经按 ErrorCode.httpStatus() 返回状态码,这里只加响应头:
routes.exception(LibraryException.class, (e, ctx) -> {
if (e.errorCode() == ErrorCode.SERVICE_OVERLOADED) {
ctx.header("Retry-After", "1");
}
ctx.status(e.errorCode().httpStatus());
ctx.json(ApiError.from(e, ctx.attribute("requestId")));
});
Retry-After: 1 是一个保守值,意思是「一秒后再试」。它不能保证一秒后队列一定有空位,但能避免客户端在同一个瞬间疯狂重试。对非幂等的 POST 请求,客户端重试前还要带上第 5 章的幂等键,否则 503 之后的重复提交可能变成重复借阅。
在队列满之前快速失败
等队列 100% 满再拒绝,响应时间已经很难看:排在前面的任务要跑完,新任务才可能被接住。更好的做法是在队列到某个水位时就快速失败,把容量留给已经在排队的请求。
OverloadGuard 用队列占用率做准入:
package com.plume.library.app;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import java.util.concurrent.ThreadPoolExecutor;
public final class OverloadGuard {
private final ThreadPoolExecutor pool;
private final double highWatermark;
public OverloadGuard(ThreadPoolExecutor pool, double highWatermark) {
if (highWatermark <= 0 || highWatermark >= 1) {
throw new IllegalArgumentException("highWatermark 必须在 (0,1) 之间");
}
this.pool = pool;
this.highWatermark = highWatermark;
}
public void check() {
int queued = pool.getQueue().size();
int capacity = queued + pool.getQueue().remainingCapacity();
if (capacity == 0 || queued >= capacity * highWatermark) {
throw new LibraryException(ErrorCode.SERVICE_OVERLOADED,
"worker queue above " + (int) (highWatermark * 100) + "%");
}
}
}
这个检查是「尽力而为」:检查通过后、提交之前,队列可能又被别的线程填满。所以 submit 仍然要 catch RejectedExecutionException。准入控制只是把大部分过载挡在更早的位置,不能替代拒绝处理。
水位取多少要看任务时长。任务平均 50ms、队列 200,水位 0.8 意味着大约还有 40 个任务的空间,能吸收 2 秒的突发;任务平均 2 秒时,同样的水位会让请求等很久。水位不是固定值,应该和 P99 延迟目标一起调。
在 libraryd 里落地
把上面的类接进 AppMain:
OverloadRejectionHandler rejection = new OverloadRejectionHandler("libraryd-worker-");
ThreadPoolExecutor workers = new ThreadPoolExecutor(
config.workerThreads(), config.workerThreads(),
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(config.workerQueueCapacity()),
new NamedThreadFactory("libraryd-worker-"),
rejection);
OverloadGuard guard = new OverloadGuard(workers, 0.8);
BookSearchService searchService = new BookSearchService(workers, guard, repository);
SearchRoutes 只负责把服务结果写成 JSON,不处理线程池细节:
routes.get("/api/books", ctx -> {
String q = ctx.queryParamAsClass("q", String.class).getOrDefault("");
int page = ctx.queryParamAsClass("page", Integer.class).getOrDefault(0);
int size = ctx.queryParamAsClass("size", Integer.class).getOrDefault(20);
ctx.json(searchService.search(q, page, size));
});
OverloadGuard 抛出的 LibraryException 会走第 8 章的全局映射,返回 503 和 Retry-After。RejectedExecutionException 在 BookSearchService 里已经被翻译过,不会漏到全局 500。
运行时日志应该能回答三个问题:哪个池、多满、累计拒绝多少。本机拒绝路径的日志形态如下(示意格式,字段来自 PoolSnapshot):
WARNING libraryd.executor pool=libraryd-worker- active=4/4 queue=10/10 completed=56 rejected=5
这条日志里没有请求 ID,因为拒绝发生在任务提交之前,MDC 可能还没有绑定。第 12 章会把请求 ID 放进 MDC,第 10.3 节会讲异步线程里的上下文传递;在那之前,至少要把池名和队列深度打出来。
常见错误与边界
「捕获拒绝后立刻重试。」 队列满说明下游没有容量,立刻重试只会让队列更满。正确做法是返回 503 和 Retry-After,让客户端退避;服务内部也不要写「最多重试三次」的紧循环,那会把一次过载放大成三次。
「用 getActiveCount() 做严格准入。」 这个值是近似快照,而且检查与提交之间没有原子性。两个请求同时看到「还有空位」,一起提交,仍然可能一个被拒绝。准入控制可以用队列占用率做快速失败,但最终必须接住 RejectedExecutionException。
「只看线程池,不看连接池。」 工作线程 8 个、Hikari 连接 10 个,看起来线程池先满;但如果每个任务持有连接的时间很长,连接池会先耗尽,线程池的队列反而不深。两个池都要监控,Hikari 的 connectionTimeout 也要设成有限值,不能让请求无限等连接。
「把 CallerRunsPolicy 当成万能背压。」 它在提交线程执行任务,确实不丢任务,但会把队列压力变成请求线程的占用时间。如果提交线程是 Jetty 请求线程,用户看到的是延迟上升;如果任务里还有阻塞 I/O,请求线程可能被占几秒。它适合后台批处理,不适合所有 HTTP 请求。
「503 之后客户端可以直接重试 POST。」 借阅、归还这类写操作,重试必须带幂等键,或者由服务端用业务唯一键去重。否则一次 503 加一次重试,可能产生两条借阅记录。第 5 章的条件更新和第 11.3 节的任务幂等,解决的是同一类问题。
小结
- 过载要先用队列深度、活跃线程数和完成数看见,拒绝计数是最后一道信号。
AbortPolicy本身不是问题,问题是异常没有被翻译;libraryd 把它映射成SERVICE_OVERLOADED503。CallerRunsPolicy保住任务但把压力传回提交线程;在 HTTP 路径上会变成用户可见的延迟。DiscardPolicy和DiscardOldestPolicy静默丢任务,不适合需要审计的 libraryd。- 准入控制可以在队列到 80% 时快速失败,但它不能替代对
RejectedExecutionException的兜底。 - 503 响应要带
Retry-After;非幂等写操作重试前必须有幂等键。 - 线程池和 Hikari 连接池要一起看,任何一个先耗尽都会表现为过载。
拒绝路径已经能给出 503,但一次搜索仍然要串行等两次 SQL。下一章用 CompletableFuture 把独立查询并行起来,并把超时、异常和结果合并写清楚。
阅读导航:上一节:9.2 虚拟线程:在 libraryd 里替换平台线程 · 下一节:10.1 CompletableFuture:组合、异常与超时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。