Java 批量并发请求:从“能并发“到“结果按完成顺序可用“的三种写法与选型

一句话结论:FutureCompletionServiceCompletableFuture 都能把串行请求变并发,但只有后两者能让你按完成顺序拿到结果。而在真实线上系统里,决定这段代码是否可靠的,往往不是选了哪个 API,而是线程池、超时预算、异常语义和可观测性这四件事有没有做对。


一、从一个真实场景说起

1.1 场景与约束

一个很常见的需求:需要从下游服务拉取一批数据,数据量大到一次拉不完,接口只提供分页。

串行分页的耗时模型是这样的:

复制代码
总耗时 ≈ 页数 × 单页 RT

100 页、单页 200ms,就是 20 秒。这个耗时放在定时任务里勉强能接受,放在一个同步接口里基本不可用。

于是自然想到:既然每一页的拉取彼此独立,为什么不并发?并发之后的耗时模型变成:

scss 复制代码
总耗时 ≈ ceil(页数 / 并发度) × 单页 RT + 调度开销

1.2 真正要解决的问题:结果什么时候"可用"

批量任务的耗时几乎不可能一致:有的页命中缓存 10ms 返回,有的页穿透到 DB 走了慢查询 3s 返回。这时有两个完全不同的诉求:

  • 诉求 A:我要拿到全部结果再统一处理。 比如要对全量数据做排序、聚合、去重。
  • 诉求 B:谁先完成我就先处理谁。 比如边拉边写库、边拉边推 MQ、边拉边流式返回给前端;或者在超时预算内能拿多少算多少,剩下的降级。

诉求 A 用 Future 就够了。诉求 B 用 Future 会很别扭------这也是原文想说清楚的核心点。

这里要先纠正一个容易混淆的地方:"能优先拿到最快完成的结果"改善的是首个结果的可用时间和处理的流水化程度,不等于总耗时会变短。 如果代码最终还是要等所有任务结束,那么三种写法的墙钟耗时都约等于最慢任务的耗时。下面的示例会刻意把这一点暴露出来。

为了让示例可复现,先准备一个统一的辅助方法(注意 InterruptedException 的正确处理,后面会解释为什么不能只 printStackTrace):

java 复制代码
static void sleepSeconds(long seconds) {
    try {
        TimeUnit.SECONDS.sleep(seconds);
    } catch (InterruptedException e) {
        // 不要吞掉中断信号:恢复中断标记,让上层有机会感知取消
        Thread.currentThread().interrupt();
        throw new IllegalStateException("task interrupted", e);
    }
}

三个耗时不等的任务(用秒是为了在控制台肉眼可辨,生产代码里当然是毫秒级 RPC):

java 复制代码
static List<Callable<Integer>> buildTasks() {
    return Arrays.asList(
            () -> { sleepSeconds(3); System.out.println("task-3s done"); return 3; },
            () -> { sleepSeconds(1); System.out.println("task-1s done"); return 1; },
            () -> { sleepSeconds(2); System.out.println("task-2s done"); return 2; }
    );
}

二、方案一:invokeAll + Future

2.1 代码

java 复制代码
public static void byInvokeAll() throws InterruptedException {
    long start = System.currentTimeMillis();
    ExecutorService pool = Executors.newFixedThreadPool(3);   // 仅为示例,生产别这么写,见 6.1
    try {
        List<Future<Integer>> futures = pool.invokeAll(buildTasks());
        for (Future<Integer> f : futures) {
            try {
                System.out.println("result: " + f.get());
            } catch (ExecutionException e) {
                System.out.println("task failed: " + e.getCause());
            }
        }
    } finally {
        pool.shutdown();
    }
    System.out.println("cost=" + (System.currentTimeMillis() - start) + "ms");
}

输出(顺序稳定):

makefile 复制代码
task-1s done
task-2s done
task-3s done
result: 3
result: 1
result: 2
cost=3005ms

2.2 为什么拿不到"最先完成"的结果

关键不在 Future,而在 invokeAll 的语义:

invokeAll阻塞调用线程,直到所有任务全部完成(或被取消) ,然后返回一个与入参集合迭代顺序一致的 Future 列表。

也就是说,invokeAll 返回的那一刻,所有 Future 都已经是 isDone() == true 了。后面那个 for 循环里的 f.get() 不会再阻塞,它只是按提交顺序把已经躺在那儿的结果读出来。

所以"先完成先返回"在这个写法里根本没有机会发生------你在拿第一个结果之前,就已经等完了最慢的那个任务。日志里 task-1s done 出现在最前面,但 result: 3 出现在最前面,这个错位正好说明了问题:任务的完成顺序和结果的消费顺序被解耦开了,而消费顺序被固定成了提交顺序。

一个常见的"自己造轮子"补救方案是轮询:

java 复制代码
// 反面示例:忙轮询 isDone()
while (!futures.isEmpty()) {
    futures.removeIf(f -> {
        if (f.isDone()) { consume(f); return true; }
        return false;
    });
}

这段代码能"跑通",但它会把一个 CPU 核心打满去做无意义的空转,还容易在任务数多时引入明显的调度抖动。CompletionService 存在的意义就是把这件事做对。

2.3 Future 的能力边界

Future 是 JDK 5 的产物,它的接口只提供了四种能力:查完成、取消、阻塞取结果、带超时地阻塞取结果。它没有的能力:

  • 没有完成回调(只能主动问,不能被动通知);
  • 没有组合能力(无法表达"A 完成后接 B"、"A、B 都完成后合并");
  • 没有按完成顺序获取的入口;
  • 异常必须通过 get() 抛出的 ExecutionExceptiongetCause() 剥一层。

2.4 什么时候用它反而是对的

不要因为它"老"就否定它。以下场景 invokeAll 是最简洁、最不容易写错的选择:

  • 语义上就是"全部拿到才有意义",比如批量校验、全量聚合;
  • 任务数量固定且不多,耗时方差不大;
  • 你需要一个整体超时并自动取消未完成任务------这时用带超时的重载,非常好用:
java 复制代码
// 3 秒整体预算,到点未完成的任务会被 cancel
List<Future<Integer>> futures = pool.invokeAll(buildTasks(), 3, TimeUnit.SECONDS);
for (Future<Integer> f : futures) {
    if (f.isCancelled()) {
        System.out.println("timeout, skipped");     // 超时被取消
        continue;
    }
    try {
        System.out.println("result: " + f.get());
    } catch (ExecutionException e) {
        System.out.println("failed: " + e.getCause());
    }
}

这个重载常被忽略,但它把"整体超时 + 未完成任务取消"两件事一次做了,比自己拿 CountDownLatch 拼要稳妥。

需要留意的是:cancel(true) 只是给线程发中断信号。如果任务体内部是不响应中断的阻塞(例如某些 HTTP 客户端只认自己的 socket timeout),线程不会立刻退出。取消是"请求取消",不是"保证停止",这一点在排查"任务超时了但线程池还满着"时非常关键。


三、方案二:CompletionService

3.1 代码

java 复制代码
public static void byCompletionService() {
    long start = System.currentTimeMillis();
    ExecutorService pool = Executors.newFixedThreadPool(3);
    try {
        List<Callable<Integer>> tasks = buildTasks();
        // 注意:CompletionService 要每批次新建,不要做成共享单例,见 3.4
        CompletionService<Integer> cs = new ExecutorCompletionService<>(pool);
        tasks.forEach(cs::submit);

        for (int i = 0; i < tasks.size(); i++) {
            try {
                Integer r = cs.take().get();     // 按完成顺序返回
                System.out.println("result: " + r);
            } catch (ExecutionException e) {
                System.out.println("task failed: " + e.getCause());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    } finally {
        pool.shutdown();
    }
    System.out.println("cost=" + (System.currentTimeMillis() - start) + "ms");
}

输出:

makefile 复制代码
task-1s done
result: 1
task-2s done
result: 2
task-3s done
result: 3
cost=3006ms

结果按完成顺序出现,第一个结果在 1 秒时就已经可以处理了。总耗时依然约 3 秒------因为循环还是要取满三个结果。这正好印证 1.2 节的判断。

3.2 原理:多了一个完成队列

ExecutorCompletionService 的实现思路很直白,没有任何魔法:它把你提交的 Callable 包装成一个 QueueingFuture,重写了 FutureTaskdone() 钩子------任务完成时,把自己塞进一个内部的 BlockingQueue

于是:

  • submit():提交到底层 Executor,同时登记到完成队列;
  • take():从完成队列阻塞取一个已完成Future
  • poll() / poll(timeout, unit):非阻塞 / 限时获取。

理解了这一点,就能理解它的所有约束:它的能力完全来自那个队列,它不知道你提交了多少个任务。

3.3 关键用法:整体超时预算 + 部分成功

CompletionService 真正的工程价值不在"顺序好看",而在于它天然适合"在给定的时间预算内,能拿多少算多少"。这是接口层做并发聚合时最实用的一个模式:

java 复制代码
public class BatchFanOut {

    public static class BatchResult<T> {
        public final List<T> succeeded = new ArrayList<>();
        public int failed;
        public int unfinished;
        public boolean isPartial() { return failed > 0 || unfinished > 0; }
    }

    /**
     * 在 budgetMs 的整体预算内并发执行 tasks,返回已完成的结果,未完成的取消。
     */
    public static <T> BatchResult<T> execute(List<Callable<T>> tasks,
                                             ExecutorService pool,
                                             long budgetMs) {
        BatchResult<T> result = new BatchResult<>();
        CompletionService<T> cs = new ExecutorCompletionService<>(pool);

        List<Future<T>> submitted = new ArrayList<>(tasks.size());
        for (Callable<T> task : tasks) {
            submitted.add(cs.submit(task));
        }

        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs);
        try {
            for (int i = 0; i < tasks.size(); i++) {
                long remaining = deadline - System.nanoTime();
                Future<T> f = remaining <= 0 ? null : cs.poll(remaining, TimeUnit.NANOSECONDS);
                if (f == null) {                       // 预算用尽
                    result.unfinished = tasks.size() - i;
                    break;
                }
                try {
                    result.succeeded.add(f.get());     // 此处不会再阻塞
                } catch (ExecutionException e) {
                    result.failed++;
                    // 生产代码请打日志并带上任务标识,见第七章
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 必须收尾:提前 break 后,未完成任务仍在占用线程池
            submitted.forEach(f -> f.cancel(true));
        }
        return result;
    }
}

这段代码里有三个容易漏掉的细节,值得单独强调:

  1. 超时预算是"整体 deadline",不是"每个任务 timeout"。 如果给每个任务各自 500ms,10 个任务串在一起最坏就是 5 秒;用整体 deadline,总耗时才真正可控。上游接口的 SLA 是整体的,超时预算也应该是整体的。
  2. 提前退出后一定要 cancel 剩余任务。 否则这批任务会继续占用线程池,下一次请求进来时线程池已经被上一批的"僵尸任务"占满------这是并发聚合类代码最常见的雪崩诱因。
  3. f.get() 放在 poll 之后是安全的。 从完成队列里取出来的 Future 一定已经 done,get() 不会阻塞。

3.4 有哪些坑

现象 正确做法
take() 次数多于实际提交数 线程永久阻塞,接口一直不返回 严格用提交数计数;能提前退出的场景用 poll(timeout)
CompletionService 做成共享单例/成员变量 请求 A 取到了请求 B 的结果,数据串批 每批次新建一个实例 ,只共享底层 ExecutorService
提前 break 却不 cancel 线程池被上一批任务占满,后续请求排队甚至拒绝 finally 中统一 cancel(true)
submitRejectedExecutionException 后继续 take 提交数与计数不一致 → 阻塞 提交在 try 内,用实际成功提交的数量作为循环上界
吞掉 InterruptedException 上层取消/超时后线程仍在跑 Thread.currentThread().interrupt() 后退出

CompletionService 的能力边界也很清楚:它解决了"按完成顺序消费",但不解决编排 。"A 完成后触发 B,B、C 完成后合并成 D"这类依赖关系,用它写出来就是一堆嵌套的 take 循环。这就是 CompletableFuture 的地盘。


四、方案三:CompletableFuture

4.1 代码

CompletableFuture 提供的是回调式模型:结果就绪时由完成它的线程回调你的处理逻辑,天然就是"先完成先处理"。

java 复制代码
public static void byCompletableFuture(ExecutorService pool) {
    long start = System.currentTimeMillis();

    List<Integer> seconds = Arrays.asList(3, 1, 2);
    List<CompletableFuture<Integer>> futures = seconds.stream()
            .map(s -> CompletableFuture.supplyAsync(() -> {
                sleepSeconds(s);
                System.out.println("task-" + s + "s done on " + Thread.currentThread().getName());
                return s;
            }, pool))                                   // 显式指定线程池,见 4.2
            .collect(Collectors.toList());

    // 完成即消费(谁先完成谁先进来)
    futures.forEach(f -> f.thenAccept(r -> System.out.println("result: " + r)));

    // 等全部结束;任一异常会在这里抛 CompletionException
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

    System.out.println("cost=" + (System.currentTimeMillis() - start) + "ms");
}

输出:

vbnet 复制代码
task-1s done on batch-pool-2
result: 1
task-2s done on batch-pool-3
result: 2
task-3s done on batch-pool-1
result: 3
cost=3011ms

4.2 第一个坑:不要用默认线程池

原文示例用的是 supplyAsync(supplier) 单参版本。它的执行器是 ForkJoinPool.commonPool(),这在业务代码里通常是错的,原因有三个:

其一,它是整个 JVM 共享的。 你的批量拉取、别人的 parallelStream()、某个库内部的异步逻辑,全都挤在同一个池子里。一段慢的阻塞任务会把不相关的功能一起拖死,而且极难定位------线程栈上只会看到 ForkJoinPool.commonPool-worker-N,看不出是谁提交的。

其二,它的默认并行度是 可用处理器数 - 1 在 4 核容器里就是 3。这个池子被设计用来跑 CPU 密集的计算任务,而不是跑一堆 IO 阻塞任务。更隐蔽的是:当计算出的并行度为 0 时(例如容器只分到 1 核),commonPool 会退化成"每个任务新建一个线程"的模式。这意味着你的并发行为会随部署环境的 CPU 配额而变------同一份代码在 8 核机器和 1 核容器里表现完全不同。

其三,它不响应你的治理手段。 你没法给它设置有界队列、拒绝策略、监控指标和有意义的线程名。

正确做法是自己建池并显式传入:

java 复制代码
ThreadPoolExecutor batchPool = new ThreadPoolExecutor(
        8, 8,
        60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(200),                              // 有界
        new CustomizableThreadFactory("batch-fetch-"),              // Spring 提供,线程名可辨识
        new ThreadPoolExecutor.AbortPolicy());                      // 快速失败优于悄悄堆积
batchPool.allowCoreThreadTimeOut(true);

顺便说一句:parallelStream() 同样跑在 commonPool 上,且不支持指定执行器。在业务代码里对 IO 操作用 parallelStream(),属于同一类问题。

4.3 第二个坑:join 和 get 的异常语义不一样

这是 code review 里高频出现的混淆点:

方法 抛出的异常 是否受检
get() ExecutionExceptionInterruptedException 是,必须处理
join() CompletionException 否,运行时异常

两者包装的原始异常都要通过 getCause() 取。join() 写起来干净(尤其在 lambda 里),代价是编译器不会提醒你处理失败 。原文示例中 futures.forEach(CompletableFuture::join) 如果第一个任务抛异常,整个方法直接抛出,后面的任务既没被消费也没被取消------异常路径上的行为和正常路径完全不同。

另外,thenAccept 注册的回调不会 捕获上游异常。上游失败时,thenAccept 会被跳过,异常沿链向下传递。要处理失败必须显式接上 exceptionally / handle / whenComplete

java 复制代码
CompletableFuture.supplyAsync(() -> client.fetchPage(pageNo), batchPool)
        .thenAccept(this::consume)                 // 成功路径
        .exceptionally(ex -> {                     // 失败路径,返回兜底值让链继续
            log.warn("fetch page {} failed", pageNo, ex);
            return null;
        });

三者的区别:exceptionally 只在异常时触发并可替换结果;handle 无论成功失败都触发且可改结果;whenComplete 无论成功失败都触发但不改变结果(异常会继续传递)。

4.4 组合能力:allOf / anyOf / 超时

这是 CompletableFuture 相对前两者真正的增量价值。

全部完成后合并 (注意 allOf 返回 CompletableFuture<Void>,结果要自己捞):

java 复制代码
CompletableFuture<List<PageResult>> all = CompletableFuture
        .allOf(futures.toArray(new CompletableFuture[0]))
        .thenApply(v -> futures.stream()
                .map(CompletableFuture::join)      // 此时都已完成,join 不阻塞
                .collect(Collectors.toList()));

任一完成即返回(典型场景:多个数据源竞速取最快的那个):

java 复制代码
CompletableFuture<Object> fastest = CompletableFuture.anyOf(fromCache, fromDb);

超时(Java 9+):

java 复制代码
CompletableFuture<PageResult> f = CompletableFuture
        .supplyAsync(() -> client.fetchPage(pageNo), batchPool)
        .orTimeout(500, TimeUnit.MILLISECONDS)                   // 超时则异常完成
        .exceptionally(ex -> PageResult.empty(pageNo));           // 降级为空页

// 或者直接给默认值,不走异常路径
CompletableFuture<PageResult> f2 = CompletableFuture
        .supplyAsync(() -> client.fetchPage(pageNo), batchPool)
        .completeOnTimeout(PageResult.empty(pageNo), 500, TimeUnit.MILLISECONDS);

这里有一个必须知道的语义细节orTimeout / completeOnTimeout 只是让这个 CompletableFuture 提前进入完成状态,它不会中断正在执行的那个任务。任务体会继续跑到自然结束,继续占着线程池的一个位置。也就是说,超时保护的是调用方的响应时间,不保护后端资源。真正的资源保护要靠:任务内部自己的 socket/read timeout、连接池上限、以及有界队列 + 拒绝策略。

如果你用 Java 8,没有 orTimeout,就需要自己拿 ScheduledExecutorServicecompleteExceptionally,或者干脆用 CompletionServicepoll(timeout) 模式(3.3 节),后者在 Java 8 项目里通常更简单。

4.5 第三个坑:回调在哪个线程执行

Async 后缀的方法(thenAcceptthenApply...)的执行线程是不确定的:

  • 如果注册时上游尚未完成 ,回调由完成上游的那个线程执行;
  • 如果注册时上游已经完成 ,回调由当前调用线程执行。

这带来两个实际后果:

  1. 在回调里做重活或阻塞操作(写库、发 MQ、再发一次 RPC),会占用业务线程池的工作线程,甚至可能占用主线程/Web 容器线程。要隔离就用 thenAcceptAsync(action, anotherPool),把不同性质的工作放到不同的池子里。
  2. 调试时看到的线程名可能和你预期完全不同,别据此推断"任务跑在哪个池"。

一个容易踩的死锁场景:在同一个固定大小的线程池里,任务 A 内部又提交任务 B 并 join() 等待 B。当池被 A 类任务占满时,B 永远排不上队,A 永远等不到 B。不要在同一个池内做嵌套等待------这是"接口偶发性完全无响应、线程栈全是 WAITING"这类问题的典型成因。


五、三种方案怎么选

5.1 对比表

维度 invokeAll + Future CompletionService CompletableFuture
结果获取顺序 提交顺序 完成顺序 完成顺序(回调)
编程模型 阻塞式 阻塞式(队列驱动) 回调式 / 声明式
首个结果可用时间 等最慢任务 最快任务完成即可用 最快任务完成即可用
任务编排(串/并/合并) 不支持 不支持 支持(thenCompose / thenCombine / allOf / anyOf
整体超时 invokeAll(tasks, timeout, unit) poll(timeout) + 手动 cancel orTimeout / completeOnTimeout(Java 9+)
异常处理 ExecutionException.getCause() 同左,可按任务粒度捕获 链式:exceptionally / handle / whenComplete
默认线程池 必须自己传 必须自己传 单参重载用 commonPool(陷阱)
心智负担 中偏高(回调线程、异常传播)
最低版本 Java 5 Java 5 Java 8(超时 API 需 9+)

5.2 选型判断

按"业务语义"而不是"API 新旧"来选:

  • 只要全量结果、任务不多、耗时接近invokeAll。代码最短,最不容易写错。需要整体超时就用带 timeout 的重载。
  • 需要边完成边处理,或需要"预算内能拿多少算多少"的部分成功CompletionService。它是这个场景下最直白的工具,尤其适合 Java 8 项目。
  • 任务之间有依赖、需要编排,或要接入响应式/异步链路CompletableFuture。前提是你能管住线程池和异常传播。
  • Spring 项目里的常规异步@Async 配合自定义 ThreadPoolTaskExecutor,返回 CompletableFuture。注意 @Async 走的是代理,同类内部方法直接调用不会生效,这是最常被问的一个坑。

反过来,有几种情况不适合做批量并发:

  • 下游是共享资源且没有做隔离(同一个 DB 实例、同一个 Redis 分片)。你把串行改并发,只是把压力从自己身上转移到了下游,很可能换来一批慢查询和连接池耗尽。
  • 任务之间有强顺序或事务语义。并发会破坏顺序性,且跨线程后本地事务不共享------@Transactional 不会传播到异步线程,回滚边界会碎掉。
  • 单个任务本身很快(微秒级),任务数极多。线程调度和上下文切换开销可能超过收益,批量合并请求(batch API)通常比并发更有效。

六、工程化落地:让这段代码上线后不出事

前面讲的是 API,这一章讲的是"为什么线上出问题"。

6.1 不要用 Executors 的工厂方法

Executors.newFixedThreadPool(n) 用的是无界 LinkedBlockingQueue。下游变慢时,任务不会被拒绝,而是无声地在队列里堆积,直到内存被占满触发频繁 GC 乃至 OOM。newCachedThreadPool 是另一个极端:线程数上限接近 Integer.MAX_VALUE,下游变慢时会疯狂创建线程,直到无法再创建原生线程。

结论:ThreadPoolExecutor 构造函数显式声明七个参数,队列必须有界。这也是《阿里巴巴 Java 开发手册》里明确要求的一条。

线程数怎么定?给一个可用的起点,而不是精确公式:

  • CPU 密集型:接近核心数;
  • IO 密集型:核心数 × (1 + 平均等待时间 / 平均计算时间) 是一个粗略上界,实践中更常见的做法是先按"期望 QPS × 平均 RT"估算并发量,再压测调整;
  • 无论哪种,最终要靠压测和线上指标校准,公式只是初值。

拒绝策略的选择也有讲究:CallerRunsPolicy 常被当作"温柔的降级",但它会让提交者线程 去执行任务。如果提交者是 Tomcat 的工作线程,你就是在用 HTTP 线程去跑批量任务------反压确实产生了,但反压对象是你的接口吞吐量。对于可丢弃的旁路任务,AbortPolicy + 记录指标 + 告警通常更可控。

6.2 超时预算要"自上而下"分配

一个接口的总 RT 预算(比如 1 秒)应该向下拆解:

bash 复制代码
接口预算 1000ms
  ├── 参数校验 + 本地缓存      ~10ms
  ├── 批量并发拉取             预算 600ms   ← 整体 deadline,不是单任务 timeout
  └── 聚合、序列化、返回        剩余

批量拉取拿到的是 600ms 的整体 预算。落到代码上就是 3.3 节的 deadline 模式。同时别忘了任务内部也要有超时------HTTP 客户端的 connectTimeout / socketTimeout、数据库的 queryTimeout。上层超时管的是响应时间,下层超时管的是资源释放,两者不能互相替代。

6.3 明确"部分成功"是什么语义

批量并发一旦引入超时和降级,返回结果就可能是不完整的。这时必须由业务来回答三个问题,而不是让代码默默决定:

  1. 部分成功算成功还是失败?返回体里要不要带 partial 标记和缺失范围?
  2. 缺失的数据是补拉(异步重试 / 落到延时队列)还是直接丢?
  3. 调用方能否感知?如果调用方拿到一个"看起来完整"的残缺列表,后续的聚合、对账、库存计算都可能出错。

这类问题不解决,"并发优化"就会变成"偶发性数据不一致"。在我的经验里,批量并发上线后的事故,绝大多数不是并发本身写错了,而是部分成功的语义没定义清楚。

6.4 保护下游

  • 限制并发度 :并发度不是越大越好,要匹配下游的承载能力。除了控制线程池大小,也可以用 Semaphore 在任务内部做二次限流。
  • 给下游连接池留余量:8 个线程并发查库,就意味着瞬时占用 8 个数据库连接。线程池大小超过连接池大小时,多出来的线程只是在等连接。
  • 失败要有边界:熔断/隔离(Sentinel、Resilience4j 等)应该作用在下游调用上,避免一个慢下游把整个批量任务的线程池长期占满。

七、可观测性与线上排查

并发代码最大的成本不在写,在出问题时看不见。

7.1 线程名是排查的起点

默认线程名 pool-1-thread-3 没有任何信息量。给每个池起一个业务可辨识的名字,抓一次 thread dump 就能立刻定位是谁在阻塞:

java 复制代码
// Spring 项目
new CustomizableThreadFactory("order-batch-fetch-");

// 或用 JDK 原生方式
ThreadFactory factory = r -> {
    Thread t = new Thread(r, "order-batch-fetch-" + counter.incrementAndGet());
    t.setDaemon(false);
    t.setUncaughtExceptionHandler((th, e) -> log.error("uncaught in {}", th.getName(), e));
    return t;
};

顺带说一句 submitexecute 的差别:用 submit 提交时,任务抛出的异常会被封进 Future如果你不调用 get(),异常就被彻底吞掉了UncaughtExceptionHandler 也不会触发。这是"任务好像没执行,但也没有任何报错日志"的常见原因。

7.2 traceId 必须跨线程透传

异步化之后,子线程的日志会丢掉 MDC 里的 traceId,链路直接断在这里。Spring 的 ThreadPoolTaskExecutor 提供了 TaskDecorator 扩展点:

java 复制代码
public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> parent = MDC.getCopyOfContextMap();   // 提交线程的上下文
        return () -> {
            try {
                if (parent != null) {
                    MDC.setContextMap(parent);
                }
                runnable.run();
            } finally {
                MDC.clear();                                       // 池化线程必须清理,防止污染下一个任务
            }
        };
    }
}

@Bean("batchExecutor")
public ThreadPoolTaskExecutor batchExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("batch-fetch-");
    executor.setTaskDecorator(new MdcTaskDecorator());
    executor.initialize();
    return executor;
}

注意 finally 里的清理:线程是复用的,不清理会导致下一个任务打出上一个请求的 traceId,比没有 traceId 更容易误导排查。

如果需要透传的不只是 MDC,还有自定义的 ThreadLocal 上下文(用户身份、灰度标记、租户 ID),可以考虑 TransmittableThreadLocal(alibaba/transmittable-thread-local),它提供了 TtlExecutors 来包装线程池。

7.3 线程池要有指标

线程池是黑盒,必须暴露出来。ThreadPoolExecutor 自带这些可读属性:

方法 含义 关注点
getActiveCount() 正在执行任务的线程数 长期贴着 maxPoolSize → 容量不足或任务变慢
getQueue().size() 队列积压 持续增长 → 下游变慢,即将触发拒绝
getCompletedTaskCount() 累计完成数 增速停滞 → 任务卡死
getLargestPoolSize() 历史峰值线程数 判断 maxPoolSize 是否被打满过

接入 Micrometer 只需要一行包装,之后线程池指标就能进 Prometheus / Grafana:

java 复制代码
ExecutorService monitored = ExecutorServiceMetrics.monitor(
        meterRegistry, batchPool, "batch-fetch", Tags.of("biz", "order"));

再补三个业务维度的指标,排查效率会完全不同:批次总耗时、单任务耗时分布(P99)、部分成功率 / 超时任务数。

7.4 排查对照表

线上现象 优先看什么 常见根因
接口 RT 从毫秒级跳到超时 线程池队列长度、activeCount 队列积压,下游变慢;线程池被占满
接口偶发完全无响应,线程栈大量 WAITING thread dump 中的 park 位置 同池嵌套等待死锁(4.5);take() 次数多于提交数(3.4)
任务像没执行,但没有异常日志 是否用 submit 且从未 get() 异常被封进 Future 后吞掉(7.1)
老年代持续增长直至 OOM 队列类型是否无界 Executors.newFixedThreadPool 的无界队列(6.1)
日志里 traceId 丢失或串了 是否配置 TaskDecoratorfinally 清理 MDC 未透传 / 未清理(7.2)
超时已生效但线程池仍满 任务是否响应中断、下游 socket timeout orTimeout 不中断任务(4.4);cancel 只是发中断(2.4)
同一份代码在不同环境并发表现不一致 是否用了 commonPool / parallelStream 并行度随 CPU 配额变化(4.2)

八、总结

回到原文的那个问题------"如何让先完成的任务先返回结果",答案分三层:

  1. API 层面invokeAll 的语义就是"等全部完成",所以它做不到;CompletionService 通过完成队列做到了;CompletableFuture 通过回调做到了,并且额外提供了编排能力。
  2. 收益层面 :按完成顺序消费改善的是首个结果的可用时间处理的流水化程度,不等于总耗时变短。只有配合超时预算、提前退出、流式输出,它才会转化为可感知的 RT 收益。
  3. 可靠性层面:这段代码上线后能不能扛住,取决于线程池是否有界、超时预算是否自上而下、部分成功语义是否定义清楚、traceId 和线程池指标是否可观测。这四件事比选哪个 API 重要得多。
相关推荐
苏生Susheng2 小时前
【软件实施】Linux企业运维常用命令手册
java·linux·运维·服务器·springboot·springcloud·软件实施
想要成为老金高手2 小时前
Kubernetes 调度器详解:从 nodeName 到污点容忍
java·容器·kubernetes
吴长建先生重名了2 小时前
ElasticSearch 检索系统性能优化实战:基准测试
java
西索斯coding2 小时前
doubao-seed-2.1-turbo 调用一直 401 怎么办?pro 版同样的 Key 却正常——5 分钟排查定位指南
java·服务器·数据库·ai
旧梦95272 小时前
Java SortedMap 接口详解:从入门到实战
java·开发语言
莫陌尛.2 小时前
Java_this构造方法
java·开发语言
2601_962297252 小时前
C# vs Java vs Python:YOLO工业部署性能对比实战
java·python·c·工业视觉·性能对比
计算机毕设定制辅导-无忧学长2 小时前
《基于SpringBoot的马术俱乐部管理系统》
java·spring boot·后端
Dreams°1233 小时前
【Java后端+Vue前后端分离:内网正常、公网访问异常|5个高频经典踩坑完整复盘】
java·开发语言·vue.js