1. 一个真实的生产问题被五个下游服务拖垮的聚合接口先讲个我实际遇到的场景。之前维护过一个订单详情接口逻辑不复杂拿到订单ID之后依次调用用户服务查买家信息、调用商品服务查商品快照、调用库存服务查当前库存、调用优惠服务算优惠价、再查一下物流轨迹。五个下游接口每一个单独看都不慢基本都在50ms到200ms之间但因为是同步串行调用接口总耗时稳定在600ms以上遇到下游抖动直接破1秒。压测时稍微加点并发Tomcat线程池就被占满后面排队的请求越积越多超时率飙升。这个问题的本质不是某个下游服务慢而是串行等待把延迟叠加了。五个服务之间没有依赖关系理论上可以同时发起请求总耗时应该由最慢的那个决定而不是五个时间的总和。当时第一反应是用线程池加Future但写出来发现代码很别扭用ExecutorService提交五个Callable再逐个future.get()虽然确实并行执行了但从主线程的角度看还是要等五个任务全部返回才能往下走而且Future只能阻塞式获取结果拿不到结果就无法做编排更别说处理其中一个失败其他怎么办这种逻辑。后来换成CompletableFuture代码结构完全不一样了。五个任务各自异步执行主线程用allOf()统一等待再用thenApply之类的编排方法把结果组装起来。同样是并行但可读性和扩展性好太多。这个标题我之所以想写就是因为从Future到CompletableFuture再到和Spring的Async结合中间有不少细节是光看文档体会不到的尤其是线程池配置、异常传播、上下文传递这些坑不实际踩一遍很难有感觉。如果你是刚接触异步编程的Java开发者或者项目中已经用了Async但心里没底不知道线程池参数怎么定、异常会不会丢、嵌套调用为什么失效这篇文章应该能帮你省不少时间。2. CompletableFuture的核心API从创建到串并行编排2.1 创建任务supplyAsync、runAsync与显式线程池CompletableFuture创建异步任务有两个入口supplyAsync()用于有返回值的场景runAsync()用于不需要返回值的场景。// 有返回值任务结果类型为UserInfo CompletableFutureUserInfo userFuture CompletableFuture.supplyAsync(() - { return userClient.getUserById(order.getUserId()); }); // 无返回值只执行动作 CompletableFutureVoid logFuture CompletableFuture.runAsync(() - { auditLogger.write(order.getOrderId()); });两个方法都有重载版本可以传入指定的ExecutorExecutorService bizPool Executors.newFixedThreadPool(10); CompletableFutureUserInfo userFuture CompletableFuture.supplyAsync(() - userClient.getUserById(order.getUserId()), bizPool);这里有个容易被忽略的点不传Executor时supplyAsync用的是ForkJoinPool.commonPool()。commonPool是JVM级别的公共线程池所有使用它的异步任务共享线程线程数默认是CPU核数减1。如果你的服务里到处都在用CompletableFuture又不指定线程池任务一多就会互相挤占而且commonPool里的线程都是守护线程应用关闭时任务可能直接被丢弃。所以我的建议很简单凡是业务代码里的CompletableFuture一律显式传线程池哪怕是复用Spring管理的线程池也比你裸用commonPool安全。2.2 串行编排thenApply、thenAccept、thenCompose创建任务只是第一步CompletableFuture真正的价值在编排。串行编排就是上一个任务完成之后把结果交给下一个任务继续处理这个链条上最常用的三个方法是thenApply、thenAccept、thenCompose。thenApply会把上一步的结果转换成新结果类似Stream里的mapCompletableFutureString future CompletableFuture .supplyAsync(() - userClient.getUserById(1001), bizPool) .thenApply(user - user.getUserName());thenAccept只消费结果不返回新值适合做副作用操作CompletableFutureVoid future CompletableFuture .supplyAsync(() - orderService.getOrderByNo(A1001), bizPool) .thenAccept(order - metricCenter.record(order.getAmount()));thenCompose稍微绕一点它用来扁平化嵌套的CompletableFuture。如果你在thenApply里返回一个CompletableFuture结果会变成CompletableFutureCompletableFutureT这显然不是我们想要的。thenCompose会把内层包装拆掉让链条保持CompletableFutureT的形态// 错误示范链上结果被套了一层 CompletableFutureCompletableFutureBigDecimal bad future .thenApply(user - CompletableFuture.supplyAsync(() - calcVipPrice(user))); // 正确示范用thenCompose保持扁平 CompletableFutureBigDecimal good future .thenCompose(user - CompletableFuture.supplyAsync(() - calcVipPrice(user), bizPool));实际开发中thenCompose多用于前一步的结果要作为参数去发起另一个异步请求的场景。还有一个值得提的是thenCombine它处理的是两个没有依赖的异步任务完成后把两个结果合并CompletableFutureBigDecimal priceFuture CompletableFuture .supplyAsync(() - priceService.getOriginalPrice(orderId), bizPool) .thenCombine( CompletableFuture.supplyAsync(() - discountService.getDiscountRate(orderId), bizPool), (price, rate) - price.multiply(rate) );2.3 并行编排allOf、anyOf与结果合并串行链条解决的是依赖问题但开头那个订单聚合场景五个下游接口互不依赖更合适的工具是allOf()和anyOf()。allOf会等待所有任务完成自己返回CompletableFutureVoid也就是说它不聚合结果只充当闸门。你需要自己在每个子任务里维护结果或者配合thenApply从各个future里取结果CompletableFutureUserInfo userFuture CompletableFuture.supplyAsync(() - userClient.getUserById(id), bizPool); CompletableFutureStockInfo stockFuture CompletableFuture.supplyAsync(() - stockClient.getStock(skuId), bizPool); CompletableFuturePriceInfo priceFuture CompletableFuture.supplyAsync(() - priceClient.getPrice(skuId), bizPool); CompletableFutureVoid all CompletableFuture.allOf(userFuture, stockFuture, priceFuture); CompletableFutureOrderDetail detailFuture all.thenApply(v - { OrderDetail detail new OrderDetail(); detail.setUser(userFuture.join()); // 此时join不会阻塞 detail.setStock(stockFuture.join()); detail.setPrice(priceFuture.join()); return detail; });注意这里的join()因为allOf已经保证所有任务完成了在thenApply里调用join是安全的不会阻塞等待。这是allOf的一个惯用套路allOf管等待join管取数。anyOf语义不同只要有一个任务先完成就返回返回值就是最先完成那个任务的结果CompletableFutureObject first CompletableFuture.anyOf( CompletableFuture.supplyAsync(() - couponService.queryByCache(orderId), bizPool), CompletableFuture.supplyAsync(() - couponService.queryByDb(orderId), bizPool) );这种模式适合缓存和DB谁先返回用谁的竞速场景。不过anyOf的返回类型是Object拿到的具体类型需要自己强转用的时候要注意。2.4 get()与join()取结果的细节差别从Future时代过来的同学第一反应可能是future.get()取结果。get()能抛受检异常ExecutionException和InterruptedException所以方法签名上要么throws要么try-catch写起来很啰嗦。join()不抛受检异常而是抛出CompletionExceptionRuntimeException的子类代码更干净。我强烈建议在CompletableFuture的编排链里统一用join()原因不只是省几个try-catch。get()在捕获异常之后异常原因被包在ExecutionException里你还要再去getCause()才能拿到真正的业务异常而join()抛出的CompletionException拆起来同样费劲。两者本质是半斤八两但从代码整洁度出发join在Stream和lambda场景里写起来舒服得多。还有一个细节get()有一个带超时参数的重载get(long timeout, TimeUnit unit)这在早期版本里是唯一的超时手段但超时后抛的是TimeoutException处理起来也比较繁琐。Java 9之后有了更好的orTimeout()后面讲异常处理时会展开。3. 异常处理与兜底CompletableFuture的自我保护机制3.1 exceptionally异常时的默认值兜底异步任务最大的隐患是异常被静默吞掉。Future时代的做法是try-catch包裹整个循环在阻塞获取时统一处理CompletableFuture则把异常处理编排进了任务链里。exceptionally()接收上一个阶段抛出的异常返回一个兜底结果。它的作用很像catch块CompletableFuturePriceInfo priceFuture CompletableFuture .supplyAsync(() - priceClient.getPrice(skuId), bizPool) .exceptionally(ex - { log.error(query price failed, skuId{}, skuId, ex); return PriceInfo.defaultPrice(); // 兜底默认价格 });这样设计之后priceFuture永远不会以异常状态结束要么拿到真实价格要么拿到默认值。在订单聚合这种查询类接口里这个模式非常实用——一个下游挂了不应该影响整个订单页的展示顶多优惠那块显示暂不可用。3.2 handle与whenComplete两种不同的收尾方式handle()和whenComplete()都用来处理任务结束这个事件但区别很关键。handle()接收两个参数上一步结果和异常对象。它像是一个不管成功失败都要执行的函数而且返回值会成为新的结果相当于把try-catch-finally里catch和return融合在一起CompletableFutureString result CompletableFuture .supplyAsync(() - remoteService.call(), bizPool) .handle((res, ex) - { if (ex ! null) { log.warn(call failed, use fallback, ex); return fallback; } return res; });whenComplete()同样能拿到结果和异常但它不改变结果。它更类似于finally块适合做释放资源、记录日志这类收尾动作CompletableFutureOrderInfo future CompletableFuture .supplyAsync(() - orderService.query(orderId), bizPool) .whenComplete((order, ex) - { if (ex ! null) { metricCenter.count(order_query_failed); } else { metricCenter.record(order_query_cost, order.getCost()); } });选型建议需要无论成败都返回一个值给下游时用handle需要观察但不干预结果时用whenComplete。两者可以组合使用比如whenComplete记录指标之后再接exceptionally或handle做兜底。3.3 超时控制Java 9后的orTimeout与completeOnTimeoutCompletableFuture在Java 8刚出来时有个很尴尬的问题没有内置超时控制。网上大量方案是future.get(3, TimeUnit.SECONDS)但get()超时只作用于等待的线程任务本身还在后台继续跑资源没有真正释放。Java 9开始提供了orTimeout()和completeOnTimeout()两个方法。前者在超时后让future以TimeoutException异常结束后者在超时后直接赋一个默认值// 3秒没返回就抛TimeoutException CompletableFuturePriceInfo future CompletableFuture .supplyAsync(() - priceClient.getPrice(skuId), bizPool) .orTimeout(3, TimeUnit.SECONDS); // 3秒没返回就给默认价 CompletableFuturePriceInfo future CompletableFuture .supplyAsync(() - priceClient.getPrice(skuId), bizPool) .completeOnTimeout(PriceInfo.defaultPrice(), 3, TimeUnit.SECONDS);这两个方法比get带超时的方案好在哪里它们是CompletableFuture自身的能力不依赖等待线程。即便没有人调用get或join任务超时后future也会自动进入完成状态后续编排链会继续往下走不会一直悬着。注意这两个方法都是Java 9才有的如果项目还停在Java 8只能用get超时或者自己用ScheduledExecutorService实现一个超时包装。4. Async与线程池的正确结合方式4.1 Spring默认异步执行器的致命短板单纯用CompletableFuture每个任务都得自己传线程池代码里到处是线程池的引用管理起来很分散。Spring的Async注解就是为了解决这个问题业务方法上加个注解调用时自动丢到线程池里执行调用方立即返回。但Spring默认的异步执行器有一个坑。EnableAsync之后Spring会找一个TaskExecutor类型的Bean如果找不到会退而使用SimpleAsyncTaskExecutor。这个执行器的实现逻辑简单粗暴每次提交任务都new一个线程完全不做线程复用。高并发场景下线程数量会失控最终导致频繁的线程创建销毁甚至OOM。而且Async还有一个隐藏行为如果方法返回类型是CompletableFutureSpring会特殊处理把异步结果封装成CompletableFuture返回给调用方如果返回void调用方拿到的就是null。这个特性正好适合我们做异步编排——被Async修饰的方法可以返回CompletableFuture然后在调用方组合它们。4.2 自定义线程池参数怎么定核心数、队列、拒绝策略既然默认执行器不靠谱实际项目中一定要自定义线程池。最常用的做法是定义一个ThreadPoolTaskExecutor的BeanBean(bizAsyncExecutor) public ThreadPoolTaskExecutor bizAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(biz-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }参数怎么定这是被问得最多的问题。我的经验是不要照抄网上的数字先明确你的场景类型。如果任务是IO密集型的比如调用下游HTTP接口、查数据库大部分时间在等待网络响应核心线程数可以设置得大一些参考公式是CPU核数 * 2或者更高。如果任务是CPU密集型的比如加解密、大数据量计算线程数超过CPU核数反而会因为上下文切换降低吞吐参考值是CPU核数 1。队列的选择也要注意。ThreadPoolTaskExecutor默认用的是无界LinkedBlockingQueue队列容量不设上限这会导致一个严重问题核心线程全忙时新任务不会创建非核心线程而是先进队列排队。如果下游一直故障任务在队列里堆积内存被撑爆只是时间问题。所以生产环境必须设置队列容量让它变成一个有界队列。线程池的执行顺序是核心线程满 - 任务进队列 - 队列满 - 创建非核心线程 - 线程数达到maxPoolSize - 触发拒绝策略。这个顺序很多人搞反以为先创建线程再进队列其实不是。拒绝策略推荐CallerRunsPolicy。它的含义是任务被拒绝后不丢弃而是由提交任务的线程自己执行。这样做的好处是系统过载时异步调用退化成同步执行起到天然限流的作用。代价是接口RT会变长但总比任务无声丢失好。4.3 Async失效的几种常见场景Async失效这是Spring异步开发里最经典的问题归纳起来主要有三种。第一种同类内部调用。Spring的Async基于AOP代理实现外部调用Bean方法时会走代理但类内部方法调用this.method()时this指向的是原始对象而不是代理对象注解自然不生效Service public class OrderService { public void process(Order order) { // 这里调的是thisAsync不生效异步会变成同步 this.sendNotify(order); } Async(bizAsyncExecutor) public CompletableFutureVoid sendNotify(Order order) { // ... } }解决方法是把异步方法拆到另一个Bean里注入后调用。这也是为什么实际项目中Async方法通常会独立放到AsyncService之类的类里。第二种方法不是public。CGLIB代理无法拦截私有方法private方法上的Async会被忽略。这个在编码阶段就要注意异步方法必须是public。第三种从其他线程调用。如果调用方自身已经在一个异步线程里又通过注入的Bean调用另一个Async方法正常情况下是能生效的因为代理依然在。但如果你绕过了Spring容器自己new了一个Bean实例那肯定失效。排查这类问题时先确认调用链路上的Bean是不是容器托管的。5. 实战用Async CompletableFuture重构聚合服务5.1 完整代码结构回到开头的订单聚合场景我用Async CompletableFuture做一次完整重构。先定义异步服务每个查询独立成一个方法。注意这里每个方法都用到了上面指定的线程池Service public class OrderAggregateQueryService { Async(bizAsyncExecutor) public CompletableFutureUserInfo queryUser(Long userId) { return CompletableFuture.completedFuture(userClient.getUserById(userId)); } Async(bizAsyncExecutor) public CompletableFutureStockInfo queryStock(String skuId) { return CompletableFuture.completedFuture(stockClient.getStock(skuId)); } Async(bizAsyncExecutor) public CompletableFuturePriceInfo queryPrice(String skuId) { return CompletableFuture.completedFuture(priceClient.getPrice(skuId)); } Async(bizAsyncExecutor) public CompletableFutureTrackInfo queryTrack(String orderNo) { return CompletableFuture.completedFuture(trackClient.getTrack(orderNo)); } }然后在业务聚合逻辑里把四个future组合起来Service public class OrderDetailService { private final OrderAggregateQueryService queryService; public OrderDetailService(OrderAggregateQueryService queryService) { this.queryService queryService; } public OrderDetail getOrderDetail(Long userId, String skuId, String orderNo) { CompletableFutureUserInfo userFuture queryService.queryUser(userId); CompletableFutureStockInfo stockFuture queryService.queryStock(skuId); CompletableFuturePriceInfo priceFuture queryService.queryPrice(skuId) .exceptionally(ex - PriceInfo.defaultPrice()); CompletableFutureTrackInfo trackFuture queryService.queryTrack(orderNo); CompletableFutureVoid all CompletableFuture.allOf(userFuture, stockFuture, priceFuture, trackFuture); return all.thenApply(v - { OrderDetail detail new OrderDetail(); detail.setUser(userFuture.join()); detail.setStock(stockFuture.join()); detail.setPrice(priceFuture.join()); detail.setTrack(trackFuture.join().getOrNull()); return detail; }).orTimeout(5, TimeUnit.SECONDS) .exceptionally(ex - buildDegradedOrderDetail(userId, skuId, orderNo)); } }这套结构有几个优点查询逻辑被拆分到独立的Service里每个方法职责单一组合逻辑在调用方完成一眼能看出依赖关系异常兜底在组合层统一处理个别下游失败不会拖垮整个接口。实测下来同样五个下游调用重构前平均耗时650ms重构后平均耗时220ms基本和最慢的那个下游接口持平。5.2 线程池隔离与命名刚上手的人往往把所有异步任务丢到同一个线程池里。短期没问题一旦某个下游出现慢查询任务积压占满队列和线程其他不相关的异步任务也跟着遭殃。这就是缺少线程池隔离的后果。我建议至少拆成两类线程池一类给外部IO调用比如查下游HTTP接口这类任务的特点是等待时间长、偶尔会超时可以单独配置线程数和超时策略另一类给内部计算比如数据转换、规则匹配这类任务相对快但可能出现CPU密集操作线程数要控制得保守一些。命名也要讲究。threadNamePrefix不只是给人看的线上排查问题时用jstack打线程快照如果所有异步线程都叫pool-3-thread-1根本分不清是哪个业务在跑。改成order-async-、user-notify-这种前缀一眼就能定位。5.3 上下文传递MDC与事务CompletableFuture的异步任务在线程池里执行意味着线程上下文不会自动传递。最常见的问题是日志追踪ID丢失。很多团队用MDC存放traceId请求进入时在拦截器里设置结束时清除。同步调用没问题因为整个调用链在一个线程里。但异步任务在线程池里运行时MDC是空的打印出来的日志没有traceId排查问题时无法串联整个请求链路。解决办法是用TaskDecorator包装提交到线程池的任务在任务执行前把主线程的MDC拷贝进去Bean(bizAsyncExecutor) public ThreadPoolTaskExecutor bizAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // ... 其他配置 executor.setTaskDecorator(runnable - { MapString, String contextMap MDC.getCopyOfContextMap(); return () - { try { MDC.setContextMap(contextMap); runnable.run(); } finally { MDC.clear(); } }; }); executor.initialize(); return executor; }这个模式可以扩展到其他上下文传递场景比如把用户信息、国际化Locale等放进ThreadLocal的数据都可以用同样的机制透传。要注意的是包装Runnable时捕获的ContextMap是提交任务那一刻的如果同一个线程池被多个请求共用一定要记得在finally里清理否则线程复用时会把上一个请求的上下文带到下一个请求造成数据串线。事务也要特别小心。Async方法在另一个线程里执行和调用方不在同一个事务里。如果异步方法内部有Transactional注解它会在自己的线程里开启新事务但前提是调用链走的是代理对象。更常见的坑是调用方在事务里更新数据后异步任务立刻去查询此时事务可能还没提交异步线程读不到刚写的数据。所以涉及事务的异步任务要么不做要么设计好延时和补偿机制不能想当然地认为我事务里写完了异步就能立刻看到。6. 踩坑记录与排查思路几个真实案例6.1 案例一无界队列把内存撑爆有一次线上告警某服务的堆内存持续上涨GC频率越来越高最终触发了Full GC告警。当时第一反应是代码里有大对象没释放用heap dump分析之后发现内存里堆积的全是任务对象进一步查堆栈发现都是同一个异步接口提交的任务。问题出在线程池配置。当时的代码里用了Executors.newFixedThreadPool(10)这个工厂方法内部用的是无界LinkedBlockingQueue。下游服务的一个接口偶尔抖动响应时间从200ms变成5秒但请求还在不断进来。核心线程全忙新任务全部塞进队列队列越来越大内存就爆了。排查链路其实不复杂先看监控确认内存趋势再看GC日志确认对象类型然后heap dump比对对象引用最后定位到线程池队列。修复方案也不难换成自定义ThreadPoolExecutor设置有界队列和拒绝策略。但我想强调一点无界队列在低并发、下游稳定的场景里可能永远不出问题但一旦并发上去或者下游故障它就是定时炸弹。生产环境的线程池队列必须有界。6.2 案例二异常被吞掉接口正常返回另一个案例更隐蔽。某个同步接口改成异步编排之后压测发现成功率100%但业务方反馈部分订单的优惠金额不对查日志又找不到错误记录。后来在测试环境复现发现是某个异步查询抛了NullPointerException但异常没有打印。原因是CompletableFuture的异常传播机制。当链上的某个任务抛异常如果下游没有接exceptionally或handle异常会一直向后传递到最终future。但问题在于最终调用方用的是allOf(...).join()allOf返回的CompletableFuture如果任何一个子任务异常结束join抛出的CompletionException会在allOf这一层就暴露出来可我们当时在join之前接了exceptionally做兜底导致异常被吞掉接口直接返回兜底数据。这个案例给我的教训是exceptionally兜底逻辑里必须记录完整日志。很多同学写兜底只写return默认值不打印异常栈出了问题根本无法排查。我的习惯是兜底逻辑一律先log.error(..., ex)哪怕你觉得这个异常不可能发生线上它就可能发生。6.3 案例三同类调用导致Async静默失效还有一个很经典的问题现象是接口变慢代码里明明加了Async但看监控发现方法还是同步执行。查线程日志没有出现预期的异步线程名。排查过程是这样的先确认注解配置正常EnableAsync有加线程池Bean也存在然后在异步方法里打了日志发现线程名是Tomcat的工作线程而不是自定义的biz-async-最终定位到问题出在同类内部调用——controller调用了OrderService的methodAmethodA内部又调用了同一个类里的methodBmethodB上有Async注解。前面讲过Async是AOP代理实现的同类调用走的是this引用不经过代理注解失效。但这个案例比我预想的更容易踩代码里把methodA和methodB写在同一个类里只是图方便没想到异步就失效了。我的建议是在代码规范层就规避掉这个问题异步方法必须放在独立的Service类中并且通过构造器注入使用禁止同类调用。如果你接手了老代码怀疑Async失效最快的验证方式是在方法里打一行日志输出Thread.currentThread().getName()看线程名是不是预期的前缀。最后补几句CompletableFuture和Async的组合本质上是把任务的执行和任务的编排分开。Async负责把任务放到合适的线程池里执行CompletableFuture负责把这些任务的完成时机、结果的合并、异常的处理组织起来。这两个工具的边界搞清楚之后大部分异步开发场景都能应付。个人经验是如果项目还在Java 8CompletableFuture能做的事情其实有限很多编排能力比如超时控制要Java 9以后才完整。如果条件允许尽早升级JDK版本省去不少自己造轮子的精力。另外一个建议是异步改造一定要搭配全链路的压测来做不要只看单接口的RT变化。异步任务会改变线程池的占用模式压测结果往往和预期有差距线程池参数要在真实流量下反复调几轮才能稳定。