
去年下半年帮一个团队看接口超时问题一个“订单详情”接口 P99 冲到 1.8 秒翻代码发现里面串了 5 个 RPC 调用一个接一个等返回每个 150 到 300 毫秒加起来正好对得上。当时的改法很朴素——把这 5 个调用塞进 CompletableFuture用 allOf 汇聚超时压到 800 毫秒P99 直接掉到 420 毫秒。但第二次上线就出事了所有并发任务都跑在默认的公共池上白天流量一上来公共池被打满连带别的业务一起变慢。这个经历基本概括了 CompletableFuture 线程异步编排的全部特点上手极快威力很大但默认行为里埋的雷也很多。它不是一个“用了就快”的魔法类而是一套编排 DSL——你写下的每一行 thenApply、thenCompose、allOf本质上都在描述两件事任务之间的依赖关系以及回调在哪个线程上跑。前者决定并发度后者决定稳定性。把这两件事想清楚了代码自然就稳。这篇内容适合三类人一是天天写业务接口想把串行调用改成并行的后端同学二是线程池参数一直靠拍脑袋想搞明白数字怎么算出来的三是接手了别人一堆 CompletableFuture 代码看着能跑但不知道哪里会炸的维护者。我会从 Future 的局限讲起把编排的三类基本形态讲透再把异步边界、异常超时、上下文传递、线程池隔离这些实战里最容易翻车的点一个个拆开最后给一份能直接照着用的检查清单。1. Future 的“取结果只能干等”才是编排需求的起点1.1 老 Future 的三个硬伤ExecutorService.submit返回一个 Future然后future.get()阻塞拿结果。这套 API 在只提交一个任务时够用一旦涉及多个任务的协作三个问题立刻暴露。第一是只能阻塞取结果没有回调机制。你想“任务完成后自动做下一件事”只能自己开个线程去 get或者用额外的队列传递结果代码很快变得难以阅读。第二是不能组合。两个 Future 想合并结果Java 8 之前只能自己写等待逻辑比如 CountDownLatch 加一个共享变量。如果需求是“A 和 B 都完成后执行 DC 单独执行 E”代码里的同步代码会和业务逻辑缠在一起改一次需求要重新推演一遍并发正确性。第三是异常被吞。任务里抛了异常如果没人调用 get这个异常就永远躺在 Future 对象里日志里什么都没有线上表现是“数据莫名不对”而不是“报错”。这类问题排查起来极其耗时因为没有任何显式信号。这三个硬伤指向同一个结论我们需要的不只是“一个能拿到结果的对象”而是一套能描述任务依赖关系、能自动触发下游、能把异常纳入流程的表达方式。1.2 CompletableFuture 是“结果容器”和“编排引擎”两副面孔CompletableFuture 同时实现了 Future 和 CompletionStage 两个接口这两者对应它的两副面孔。Future 那一面负责结果承载get、join、isDone、cancel 这些方法都在这一面。CompletionStage 那一面负责编排thenApply、thenCompose、thenCombine、allOf、anyOf 这些方法属于这一面。很多人只用了第一面把它当成“能手动完成的 Future”那是浪费了它百分之八十的价值。CompletionStage 的核心语义是“阶段”和“承诺”每一个阶段代表一个待完成的计算上游一旦完成下游会自动被触发。所以它的思维方式不是“我在这里等你”而是“你好了叫我我接着干”。这个视角的转换很关键因为它把并发关系从控制流里解放出来变成了可以阅读、可以组合的声明式代码。1.3 一张表看清两者的能力边界能力维度FutureCompletableFuture取结果只能 get 阻塞get、join、注册回调三种方式任务组合不支持需手工同步thenCombine、allOf、anyOf 等异常处理ExecutionException 包装不 get 不暴露exceptionally、handle、whenComplete手动完成不支持complete、completeExceptionally结果转换不支持thenApply、thenCompose 等一整套超时控制get 带超时参数Java 9 起有 orTimeout、completeOnTimeout需要强调的是最后两行之外的隐含差别Future 的 get(timeout) 只是“我不等了”任务本身还在跑CompletableFuture 的超时同样不会中断任务但它在语义上更清晰——异常会沿着链条往下传下游能感知到这件事。这个差别后面第 5 节会详细展开。1.4 先纠正一个普遍误解它不制造异步它只编排刚接触的人常有的误解是“用了 CompletableFuture 就并行了”。不成立。CompletableFuture 本身没有任何线程它只是把任务和线程池的关系声明出来。真正的异步只来自两个地方创建阶段的supplyAsync/runAsync以及所有带 Executor 参数的重载。如果你写的是不带 Async 的 thenApply回调就是在“上游完成的那个线程”上同步执行的跟你想象的完全不是一回事。所以评估一段异步代码只需要问两个问题任务提交到哪个池了回调在哪个线程跑把这两个问题的答案标出来绝大部分隐患就浮出水面了。2. 线程池归属不传 Executor 的那一步是最贵的默认值2.1 不传池子时任务到底去哪了看 JDK 源码就明白了supplyAsync内部的 AsyncSupply 和 AsyncRun如果传入的 executor 为 null会使用ASYNC_POOL也就是ForkJoinPool.commonPool()。这一步“省略参数”的写法结果是把任务丢进了一个全局共享的池子。这个池子有几个特性必须知道默认并行度是Runtime.getRuntime().availableProcessors() - 1。单核环境下是 1等于完全串行双核是 1四核是 3。它不是为你这种“阻塞等下游”的场景设计的。它是 JVM 全局共享的。你家的任务、框架里的 parallelStream、甚至某些第三方库的内部实现都在抢这几条线程。它用的是工作窃取队列。队首的线程忙其他线程会从队尾偷任务这在纯计算场景下效率极高但一旦有阻塞任务线程被占住池子会迅速饱和。池内线程是守护线程没有拒绝策略的概念。任务堆积起来不会报错只会让任务排队线上表现是“莫名其妙的延迟”。在容器化环境里还要多留个心眼。JDK 现在默认开启了容器感知UseContainerSupportavailableProcessors()会按 CPU 配额计算。但如果你在启动参数里手动覆盖过ActiveProcessorCount或者用的是较老的 JDK 版本读到的数字可能和预期不符。上线前实测打印一下最稳System.out.println(commonPool parallelism ForkJoinPool.commonPool().getParallelism());2.2 自建线程池的参数怎么算才不拍脑袋参数计算先按任务类型分类。IO 密集型任务的线程大部分时间在等常见经验公式是线程数 ≈ 核数 × (1 平均等待时间 / 平均计算时间)举例4 核机器一次下游调用耗时 200 毫秒其中本地处理只占 10 毫秒代入得 4 × (1 20) 84。84 这个数字显然不能直接写进配置因为真正约束你的不是本地 CPU而是下游能承受多少并发。更实用的做法是反过来推如果下游单机能扛 100 QPS、平均 RT 是 200 毫秒按 Littles Law稳态下的并发数大约是 100 × 0.2 20。你的核心线程数就不该显著超过这个量级否则你只是在给下游制造压力。CPU 密集型任务相反线程数超过核数只会增加上下文切换开销核数加一是个不错的起点。场景类型核心线程数参考队列选择关键说明纯计算、序列化核数 1有界小队列队列大只会掩盖容量问题RPC 聚合调用按下游容量倒推有界队列必须配超时和降级混合型业务拆成两个独立池各自有界别指望一个池通吃2.3 队列和拒绝策略比线程数更容易踩坑第一个原则不要用无界队列。LinkedBlockingQueue默认容量接近Integer.MAX_VALUE配上它等于把并发压力转化成内存里的任务堆积最终以 OOM 收场。用有界队列比如 200 或 500让系统在过载时快速给出信号。第二个原则想清楚拒绝策略。CallerRunsPolicy的含义是“队列满了就让提交任务的线程自己跑”。在 Web 场景里它其实是个不错的背压手段——Tomcat 的工作线程被占用新请求自然排队系统整体降速而不是直接崩掉。但如果你的提交线程不能被阻塞比如事件循环线程绝对不能用它否则会把整个网络层拖垮。ThreadPoolExecutor pool new ThreadPoolExecutor( 16, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(order-async-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy());第三个小细节但收益很大线程命名必须做。线程 dump 的时候看到order-async-3和看到pool-7-thread-3是完全不同的排查体验。前者一眼定位业务后者要靠翻调用栈猜。2.4 池子归属决定了“谁来等你”有人会问我在主线程 join等的是谁答案是——你等的只是“结果就绪”这个条件与池子无关。但如果池子饱和任务压根还没开始执行你就得一直等下去直到超时。所以超时要分两层来设计一层是单个任务的超时比如 orTimeout一层是接口整体的超时预算。两层都要有只做一层都不够。第 5 节会给出具体的分层方法。3. 串、并、汇聚编排的三类基本形态3.1 thenApply 与 thenCompose一字之差结构天壤之别这两个方法最容易混。看签名thenApply接收FunctionT, U返回CompletionStageUthenCompose接收FunctionT, CompletionStageU返回CompletionStageU。用函数式的语言说thenApply是 mapthenCompose是 flatMap。问题出在下面这种写法上// 错误示范 CompletableFutureCompletableFutureUser wrong CompletableFuture.supplyAsync(() - orderService.get(id), pool) .thenApply(order - userService.queryAsync(order.getUserId()));userService.queryAsync返回的是CompletableFutureUser用 thenApply 就会得到嵌套两层的类型。后续再链式调用时你拿到的是一个没完成的 Future而不是 User然后就是一脸问号加一堆空指针。正确写法是thenCompose(order - userService.queryAsync(order.getUserId()))。一个顺手的记忆法返回普通对象用 thenApply返回 CompletableFuture 用 thenCompose。如果两段逻辑有依赖关系第二步需要第一步的结果基本都该用 thenCompose。3.2 并行分支的几种接法各有适用场景方法依赖条件是否消费结果典型场景thenCombine两个都完成是拼装两个数据源的结果thenAcceptBoth两个都完成消费但不返回双写后统一打点runAfterBoth两个都完成不消费两个动作完成后触发通知applyToEither任一个完成是主备双活取先返回的thenCompose上游完成是串行依赖第二步依赖第一步allOf全部完成否批量并行收集anyOf任一完成返回 Object竞速、多来源兜底用的时候注意anyOf的返回值是CompletableFutureObject因为编译器不知道哪个任务会先完成。想拿到具体类型得自己强转强转前先判断完成的是哪个任务否则很容易 ClassCastException。3.3 allOf 返回 Void这才是真正的坑allOf只告诉你“都完成了”不给你结果所以必须自己从各个 Future 里把值取出来。市面上流传的写法大概有这么几种// 能跑但不优雅 ListCompletableFutureOrder futures new ArrayList(); // ... 填充 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); ListOrder orders futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList());这个写法逻辑上没错因为 allOf 完成时所有子任务都已结束后续 join 不会阻塞。但有个重要前提必须清楚allOf 的语义是“全成功才算成功任一失败立即失败”。某个子任务抛异常时allOf 会立刻以异常完成而不是等所有任务跑完。所以上面这段代码在出错时会直接抛出去后面的 collect 根本不会执行。如果业务需要“收集所有结果包括失败的那些”就必须在每个子任务上单独兜底。封装成一个工具方法会清爽很多static T CompletableFutureListT allOfList(ListCompletableFutureT futures) { return CompletableFuture .allOf(futures.toArray(new CompletableFuture[0])) .thenApply(v - futures.stream() .map(CompletableFuture::join) // 此处不会阻塞安全 .collect(Collectors.toList())); }注意注释那句“此处不会阻塞”这个结论依赖 allOf 已经完成这个前提。如果有人把这行代码单独拿出来用就会退化成一串串行等待。工具方法的好处就是把这个前提封在内部不给误用留机会。3.4 “部分成功”才是业务的常态真实业务里5 个下游挂了 1 个通常不应该让整个接口失败。用户只是看不到优惠券其他信息照样能看。所以每个非核心子任务都要单独兜底CompletableFutureCoupon couponFuture CompletableFuture .supplyAsync(() - couponService.query(userId), pool) .exceptionally(ex - { log.warn(coupon query failed, userId{}, userId, ex); metrics.counter(coupon.degrade).increment(); return Coupon.empty(); });这样 allOf 就永远不会因为单个下游失败而整体失败。代价是你必须在日志和监控里把降级次数打出来。否则下游挂了半年你都未必知道业务方看到的现象是“优惠券一直是空的”还以为是活动没配好。提示兜底只能用在“不影响主流程”的依赖上。像库存校验、支付状态查询这种挂了必须失败的场景绝对不能无脑兜底否则会出现超卖或者状态不一致。4. 异步边界thenApply 和 thenApplyAsync 差的不只是一个字母4.1 回调到底在哪个线程跑规则比想象的复杂基于 OpenJDK 的实现规则可以归纳成三条不带 Async 的 thenApply / thenAccept / thenRun如果上游已经完成回调在“调用这个方法的那个线程”上同步执行如果上游还没完成回调在“完成上游的那个线程”上执行。带 Async 但不传 Executor回调提交到 ForkJoinPool.commonPool。带 Async 且传了 Executor回调提交到你指定的池子。第一条规则最容易被忽视也最容易出问题。看这段代码CompletableFutureString f CompletableFuture.supplyAsync(() - data, pool); String r f.thenApply(s - s -processed).join();主线程调用 join 时如果 supplyAsync 已经完成比如它很快或者前面有点耗时操作thenApply 的回调就在主线程执行如果还没完成回调就在 pool 的线程上执行。同一个方法回调线程是不确定的。4.2 这种不确定性为什么是隐患第一ThreadLocal 丢失的时机变得不确定。如果回调依赖 MDC 里的 traceId有时候能拿到有时候拿不到测试环境并发低的时候往往测不出来上线后日志里的链路就是断的。第二“这段代码总是跑在我的业务池里”这个假设不成立。极端情况下回调跑在 Tomcat 的工作线程上直接霸占了请求线程。第三如果回调里是慢逻辑比如一次数据库查询你就在悄悄占用别人的线程。这个问题在低并发下完全不可见一旦并发上来会急剧恶化。所以有一条很实用的纪律任何非纯计算的回调都显式写成 xxxAsync(..., pool)。纯计算字符串拼接、DTO 字段转换、简单校验用同步版本反而更合适省掉线程切换的开销。判断标准就是这个操作会不会阻塞而不是它看起来“复不复杂”。4.3 join 与 get 的选择以及异常怎么剥get()抛受检的 InterruptedException 和 ExecutionException逼你写 try-catchjoin()抛非受检的 CompletionException写起来干净利落。在编排链内部join 更顺手在对外接口边界两者区别不大但要统一别一半 get 一半 join。更值得注意的是异常包装。join 抛出的异常是包装过的原始异常在getCause()里。经常看到有人只打ex.getMessage()日志里出现的是句不痛不痒的描述看不到真正的根因。稳妥的写法是递归解包static Throwable unwrap(Throwable t) { while ((t instanceof CompletionException || t instanceof ExecutionException) t.getCause() ! null) { t t.getCause(); } return t; }把这个方法放在日志工具类里所有异步链路的异常出口都调它排查效率会有明显提升。4.4 一段“回调没写 Async”引发的真实故障我见过一个案例某个接口的聚合回调里做了一次缓存查询用的是同步版本。压测时 QPS 怎么都上不去线程 dump 一看大量请求处理线程卡在网络 read 上。原因就是上游 Future 提前完成回调“顺手”在请求线程上跑了。改成thenApplyAsync(..., pool)之后请求线程立刻释放QPS 直接翻倍而业务逻辑一行没动。这类问题有个典型特征单机小流量压测数据看着还行并发一上来就雪崩式恶化。因为被抢占的请求线程数量随流量线性增长越忙越乱。5. 异常、超时与取消让链子不在半路断掉5.1 exceptionally、handle、whenComplete 的语义差别这三个方法名字很像行为差别不小。方法消费异常消费结果返回值是否会改写下游结果exceptionally是仅异常时触发否兜底值会提供兜底值handle是是任意值会总是执行whenComplete是是原结果或原异常不会whenComplete最容易被误用它像个回调钩子但返回值仍然是上游的结果或异常。所以你在里面 return 一个兜底值是没用的CompletableFutureString f CompletableFuture .supplyAsync(() - { throw new RuntimeException(boom); }, pool) .whenComplete((r, ex) - log.error(failed, ex)); // 异常依然会往下传whenComplete的正确用途是打日志、埋点、清理资源——只做副作用不改写结果。5.2 超时控制Java 8 和 Java 9 以后的两套做法Java 9 之后有现成的 APIorTimeout(3, TimeUnit.SECONDS)超时后以 TimeoutException 异常完成completeOnTimeout(defaultValue, 3, SECONDS)超时后用默认值完成不抛异常。Java 8 只能自己实现思路是借助一个调度器static T CompletableFutureT withTimeout(CompletableFutureT future, long timeout, TimeUnit unit, ScheduledExecutorService scheduler) { CompletableFutureT result new CompletableFuture(); ScheduledFuture? timer scheduler.schedule( () - result.completeExceptionally(new TimeoutException(timeout)), timeout, unit); future.whenComplete((v, ex) - { timer.cancel(false); if (ex ! null) { result.completeExceptionally(ex); } else { result.complete(v); } }); return result; }这里有个关键点必须讲清楚超时之后原来的任务并没有被打断。它还在池子里跑只是结果被丢弃了。如果你的任务会写数据库或者发消息超时加一层重试就可能造成重复执行。所以任务本身必须幂等这一点比超时机制本身更重要。另外那个 ScheduledExecutorService 建议单独建一个小的别和业务池混用。超时调度的任务本身很短但数量可能很大混在一起会互相干扰。5.3 cancel 为什么经常取消不掉future.cancel(true)对一个还在队列里排队的任务是有效的会把它从队列里移除。但对一个正在运行的任务如果它不响应中断大多数阻塞在网络 IO 上的任务都不响应除非底层客户端做了处理这个 true 参数基本没用。而且 cancel 之后下游阶段会以 CancellationException 完成容易引发意料之外的异常。所以不要指望用 cancel 去停掉一个正在跑的远程调用。真正有效的手段是给底层客户端设置连接超时和读取超时让阻塞自己结束。上层的 cancel 只是让调用方早点拿到结果、释放自己不等于“任务真的停了”。5.4 超时预算要分层做不能拍脑袋一个接口总预算 800 毫秒三个下游各给 300 毫秒看着挺合理。但如果这三个下游存在依赖关系就不能各给 300 毫秒加起来都 900 了。正确的算法要按结构分全并行结构单任务超时 ≤ 总预算 − 本地处理时间有依赖链的结构每一段超时之和 ≤ 总预算 − 本地处理时间留余量一般给单任务超时预留 20% 的缓冲我见过最常见的配置错误是网关超时设 1 秒但里面某个下游客户端的读取超时设了 3 秒。结果网关先掐断连接而那个客户端线程还在傻等白白占着线程池资源。这类问题一定要画一张“超时时间轴”从网关到最底层逐层递减每一层的数字都有明确依据。6. 上下文传递与线程池隔离并发代码的隐形资产管理6.1 ThreadLocal 为什么会在异步里消失ThreadLocal 的存储挂在 Thread 对象上线程换了值自然就没了。受影响的东西通常有MDC 里的 traceId、用户身份上下文、数据源路由标识、多租户 ID。表现是什么日志里的 traceId 变成空的链路追踪断成好几截排查问题时定位不到具体哪次调用出了问题。这类问题不会报错只会让排查变难所以往往拖很久才被发现。6.2 三种透传方案按侵入性排序方案一手动捕获加传递。在提交任务前把需要的值取出来作为参数传进 lambda。String traceId MDC.get(traceId); CompletableFuture.supplyAsync(() - { MDC.put(traceId, traceId); try { return orderService.query(id); } finally { MDC.remove(traceId); } }, pool);好处是无依赖、逻辑清晰坏处是每个异步入口都要写容易漏尤其是后来接手的人。方案二包装 Executor让上下文自动透传。这是更工程化的做法。static Executor mdcAware(ThreadPoolExecutor delegate) { return task - { MapString, String ctx MDC.getCopyOfContextMap(); // 提交线程的快照 delegate.execute(() - { MapString, String old MDC.getCopyOfContextMap(); if (ctx ! null) { MDC.setContextMap(ctx); } else { MDC.clear(); } try { task.run(); } finally { if (old ! null) { MDC.setContextMap(old); } else { MDC.clear(); } } }); }; }这里有个容易写错的细节快照必须在“任务提交那一刻”取也就是在 execute 方法内部取。如果图省事在构造包装器的时候取一次那所有任务就共用同一个快照了等于把上下文写死反而更糟。方案三使用链路追踪组件自带的上下文传播能力。核心思路和方案二一致区别在于它能传播的内容更多而且和追踪体系的集成更完整。如果你的项目已经接了追踪组件优先用现成的。6.3 池子要隔离不要一个大池子打天下所有业务共用一个池子会出现木桶效应某个慢下游把线程吃光健康的下游也跟着拿不到线程局部故障扩散成全局故障。隔离的维度我一般按这几个来分按依赖重要性核心链路一个池非核心推荐、埋点、日志上报一个池按资源类型远程调用一个池本地磁盘和计算任务另一个池按超时等级快接口一个池慢接口一个池代价是线程总数变多上下文切换和内存开销上升。所以池子数量要克制通常 3 到 5 个就够按业务域划分不要按接口划分否则会失控。参数上给一个起步参考池子用途核心线程最大线程队列容量说明主流程依赖调用3264300按下游容量校准非核心增强调用816200允许降级消息与埋点481000允许丢弃超时调度24无界小队列只放定时任务这些数字不是标准答案要通过压测和线上监控慢慢调最终目标是让池子在高负载下依然有余量。6.4 必须采集的三个指标不管用什么监控体系这三项一定要采活跃线程数与最大线程数的比值长期高于 80% 就说明池子偏小队列长度持续增长说明处理能力跟不上提交速度拒绝次数一旦非零立即告警我踩过一个坑值得说一下池子用了 CallerRunsPolicy队列满了不报错只是变慢。因为没有拒绝计数监控里看到的是“响应时间缓慢”而不是“线程池饱和”排查时绕了很远。后来在自定义拒绝策略里加了打点一眼就能看出来是哪一步卡住的。提示线程池一定要起名字并且把拒绝策略换成会打日志、带指标的自定义实现。默认的 AbortPolicy 只抛异常不带上下文排查时非常难受。7. 一次订单详情接口的改造实录7.1 改造前730 毫秒的串行调用原来的代码大概是这样public OrderDetailVO detail(Long orderId) { Order order orderService.get(orderId); // 约 80ms User user userService.get(order.getUserId()); // 约 120ms ListItem items itemService.list(orderId); // 约 200ms Coupon coupon couponService.best(orderId); // 约 150ms Logistics logi logisticsService.track(orderId); // 约 180ms return assemble(order, user, items, coupon, logi); }串行总耗时在 730 毫秒左右下游抖动时接口的 P99 能到 1.8 秒。这里有个关键观察这些调用之间只有 user 依赖 order其余四个互相独立。7.2 先画依赖图再动手写代码改造的第一步不是写代码是画依赖关系order 是根节点必须先拿到user 依赖 order 的 userId 字段items、coupon、logistics 只依赖 orderId互相独立结论是两段结构先取 order然后四个任务并行。这样理论耗时从 80 120 200 150 180 降到 80 max(200, 180, 150, 120) ≈ 280 毫秒。public OrderDetailVO detail(Long orderId) { Order order orderService.get(orderId); // 第一段必须串行 Long userId order.getUserId(); CompletableFutureUser userF CompletableFuture .supplyAsync(() - userService.get(userId), rpcPool) .exceptionally(ex - { log.warn(user fail, ex); return User.empty(); }); CompletableFutureListItem itemsF CompletableFuture .supplyAsync(() - itemService.list(orderId), rpcPool) .exceptionally(ex - { log.warn(items fail, ex); return Collections.emptyList(); }); CompletableFutureCoupon couponF CompletableFuture .supplyAsync(() - couponService.best(orderId), rpcPool) .exceptionally(ex - { log.warn(coupon fail, ex); return Coupon.empty(); }); CompletableFutureLogistics logiF CompletableFuture .supplyAsync(() - logisticsService.track(orderId), rpcPool) .exceptionally(ex - { log.warn(logistics fail, ex); return Logistics.empty(); }); CompletableFuture.allOf(userF, itemsF, couponF, logiF) .orTimeout(500, TimeUnit.MILLISECONDS) .join(); return assemble(order, userF.join(), itemsF.join(), couponF.join(), logiF.join()); }这里几个细节值得说每个子任务都带了 exceptionally 兜底所以 allOf 本身不会因为单个下游失败而整体失败超时给 500 毫秒比串行版本的单次调用余量还小因为是整体收口的超时join 放在 allOf 之后不会阻塞。7.3 压测数据对比指标改造前改造后平均 RT760ms290msP991.8s620ms单机 QPS180430请求线程数200200下游并发峰值14注意最后一行下游看到的并发从 1 变成 4。如果下游没做容量评估改完很可能把对方打挂。所以改造前一定要和下游负责人对一遍容量别自己闷头改。7.4 上线后踩的三个坑第一个坑第一版没传自定义线程池用的默认公共池。流量上来之后同一台机器上另一个用了 parallelStream 的报表导出功能跟着变慢。加独立池子后解决。这个坑的价值在于公共池是全局资源任何两处依赖它的代码都会互相影响。第二个坑有几次接口返回的订单详情里用户信息是空的。查日志发现是 user 查询超时被降级成了 User.empty()但前端没区分“真的没有”和“查询失败”直接展示成空白。后来在返回结构里加了降级标记字段前端对降级数据显示占位符。第三个坑比较隐蔽orTimeout 触发了但底层任务还在跑连接池里的连接被占着高峰期连接池也打满了。解决办法是给底层客户端设置读取超时让连接能及时归还。这印证了前面那句话——上层超时只是用户体验层的兜底不能替代底层超时。8. 排查手册那些一眼看不出原因的异步问题8.1 常见现象对照表现象大概率根因定位手段处理方式接口偶发变慢无报错公共池被其他业务占用jstack 看公共池线程栈换独立线程池日志里 traceId 为空ThreadLocal 没透传检查同一次请求的日志是否断链包装 Executor高并发下 QPS 反而下降回调跑在请求线程上jstack 看请求线程是否在执行回调改 Async 并指定池整体超时但下游 RT 正常线程池队列积压看队列长度与活跃线程数扩池或降并发任务异常但无人知晓没调用 get/join异常被吞搜索所有创建 Future 的地方统一异常出口数据重复写入超时重试加任务不幂等查库里是否有重复记录加幂等键去重8.2 几个我亲身踩过的具体坑坑一在循环里逐个 join。这种写法看起来用了异步实际上每次循环都在等并发度退化成 1还白白多了一堆线程切换开销// 错误示范 for (Long id : ids) { CompletableFutureFoo f CompletableFuture.supplyAsync(() - query(id), pool); result.add(f.join()); }正确做法是先收集所有 Future再统一 allOf 加 join。这个改动通常能把批量接口的耗时压到原来的几分之一。坑二在 thenApply 里写阻塞 IO。这等于把编排线程当业务线程用。如果池子里的线程都在等彼此的结果就会出现一种类似死锁的资源耗尽状态——谁都推进不了。记住这条边界编排回调里只做内存操作耗时操作一律用 xxxAsync 丢回业务池。坑三嵌套 parallelStream。parallelStream 也走公共池和 CompletableFuture 的默认行为叠在一起很容易把公共池打满而且出问题时的线程栈非常难读。要么改成显式的 CompletableFuture 加自定义池要么通过系统属性把并行流的池换掉。坑四把 Future 当缓存用。有的代码把 Future 存进 Map 复用结果一次失败的 Future 被反复 join同一个异常被抛很多次日志刷屏。要做结果缓存就老老实实用缓存组件别拿 Future 硬凑。8.3 一个快速判断“要不要异步化”的方法不是所有串行调用都值得改并行。我一般按三个条件筛调用之间确实没有数据依赖有依赖就必须串行单个调用的耗时明显大于线程切换成本一般超过 20 毫秒才有意义这些调用合计占总耗时的大头占比低于 30% 就不值得增加复杂度三条都满足再动手。我见过把一个总耗时 40 毫秒的接口拆成四个并行的最后因为线程切换和上下文传递跑到 55 毫秒纯属自找麻烦。异步化是有成本的编排本身、上下文透传、异常处理、监控埋点都要写代码收益不明显的时候就是在给自己挖坑。8.4 上线前照着过一遍的检查清单所有 xxxAsync 都显式指定了线程池没有留默认值每个子任务都有异常兜底或者有明确的失败语义超时时间从网关到底层逐层递减账算得清线程池有名字、有界队列、有拒绝计数日志上下文透传已经验证过日志里 traceId 完整下游已经确认过并发容量会写数据的任务确认过幂等压测覆盖了单下游故障、全下游故障、慢下游三种场景我个人在实际项目里的体会是与其在每个业务方法里手写一长串 CompletableFuture不如先封装一层“业务编排模板”把线程池、超时、兜底、日志、埋点一次性注入业务代码只负责声明“我要并行做这几件事”。这样后来接手的人即使不熟悉这套 API也不会因为漏了某个环节而埋雷。这套模板每个团队的结构都不一样但核心思路是一样的——把容易忘的东西固化成基础设施把剩下的注意力留给业务本身。还有一个小习惯推荐给你在日志里给每条编排链路打个标记记录“并行了几个任务、各自耗时多少、哪个降级了”。平时看不出价值线上出问题时这张耗时表能省下大半排查时间。