在接口聚合、批量查询和后台任务中,异步代码可以减少线程等待带来的浪费。但异步并不等于更快,也不等于把每一步都包进supplyAsync。真正可靠的异步程序,需要先区分串行依赖与并行依赖,再明确线程池、超时、异常和结果收集策略。
一、CompletableFuture 解决的是什么问题
同步代码会在当前线程等待:查询用户、查询订单、查询推荐依次发生。若后两项彼此独立,可以同时发起,等两者都完成后再组装结果。CompletableFuture用一个“未来会产生的结果”表示这段计算,并提供了继续处理、组合和异常恢复的 API。
CompletableFuture<User>userFuture=CompletableFuture.supplyAsync(()->userService.load(userId),pool);CompletableFuture<List<Order>>orderFuture=CompletableFuture.supplyAsync(()->orderService.list(userId),pool);CompletableFuture<Dashboard>dashboard=userFuture.thenCombine(orderFuture,Dashboard::new);thenCombine表达的是“两个结果都完成后再组合”。如果第二步依赖第一步,就不应强行并行,而应使用thenCompose:
CompletableFuture<List<Order>>orders=userFuture.thenCompose(user->CompletableFuture.supplyAsync(()->orderService.list(user.id()),pool));这两个 API 的区别很重要:thenApply适合把T转成R;thenCompose适合把T转成另一个CompletionStage<R>,避免出现嵌套的CompletableFuture<CompletableFuture<R>>。
二、不要默认使用公共线程池
无参supplyAsync会使用默认执行器。示例代码中它很方便,但业务服务通常应该根据任务性质明确线程池:CPU 密集计算不应与阻塞式数据库调用混在一起,外部调用也不应无边界地占满公共资源。
ExecutorServiceioPool=Executors.newFixedThreadPool(32);try{CompletableFuture<String>result=CompletableFuture.supplyAsync(this::callRemote,ioPool);System.out.println(result.join());}finally{ioPool.shutdown();}线程池大小不是越大越好。要结合任务是否阻塞、下游连接池大小、超时时间和机器资源压测。线程池本身也要可观测:活跃线程数、队列长度、拒绝次数和任务耗时都比“我设置成 200 线程”更有价值。
在服务器程序中,线程池一般由应用生命周期统一管理,而不是每次请求创建和销毁。对于短示例,try-finally能表达资源归属;在长期运行服务里,应在关闭阶段停止接收新任务,并等待在途任务结束。
三、thenApply、thenCompose 和 thenAccept
可以把常见动作理解为三类:
thenApply:有输入、有转换后的输出。thenCompose:有输入,继续启动一个异步阶段。thenAccept:消费结果,不再产生业务结果。
CompletableFuture<String>name=loadUser(userId).thenApply(User::displayName);CompletableFuture<Profile>profile=loadUser(userId).thenCompose(user->loadProfile(user.id()));loadUser(userId).thenAccept(user->audit("loaded",user.id()));还有thenRun,它不关心前一步的结果,只在前一步完成后执行动作。选择 API 时不要只看方法名,要看类型:如果回调返回CompletableFuture,大概率应该考虑thenCompose。
带Async后缀的方法表示后续动作会提交给执行器执行;不带后缀的方法通常由完成前一阶段的线程继续执行。不要把“异步 API”误解成“每一个回调都自动获得独立线程”。执行上下文应通过代码和线程池设计明确,而不是依赖偶然的调度结果。
四、超时、取消和异常必须显式设计
异步调用最危险的状态不是失败,而是无限等待。可以使用orTimeout给阶段设置超时,也可以使用completeOnTimeout在超时时提供默认值:
CompletableFuture<Price>price=loadPrice(sku).orTimeout(800,TimeUnit.MILLISECONDS).exceptionally(error->Price.unavailable(sku));二者语义不同:orTimeout让阶段以异常完成;completeOnTimeout则用指定值完成。默认值不能掩盖核心业务失败,例如支付结果不能因为超时就直接当作“支付失败后可重试”,必须先确认下游是否已经受理。
异常处理常用三个 API:exceptionally将异常转换为备用结果;handle无论成功或失败都会执行;whenComplete适合记录日志和指标,但不会自动吞掉异常。
future.handle((value,error)->{if(error!=null){metrics.recordFailure(error);returnFallback.empty();}returnFallback.of(value);});回调中的异常会让后续阶段异常完成。日志必须记录业务标识和原始原因,不要只输出“异步任务失败”。join()抛出未检查异常,get()还涉及受检异常;在边界层统一转换成项目约定的错误响应,避免把实现细节泄露给调用方。
取消也要正确理解。取消一个CompletableFuture并不自动撤销已经发出的网络请求、数据库查询或下游业务操作。真正的中断能力取决于底层客户端和任务实现。超时之后要不要重试,更要依据操作是否幂等,而不是看到异常就重试。
五、并行收集不能忽略失败语义
批量任务常见写法是把多个 future 收集起来,再用allOf等待:
List<CompletableFuture<Item>>tasks=ids.stream().map(id->CompletableFuture.supplyAsync(()->loadItem(id),pool)).toList();CompletableFuture<Void>all=CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new));CompletableFuture<List<Item>>items=all.thenApply(ignored->tasks.stream().map(CompletableFuture::join).toList());allOf只返回Void,结果仍需要从原 future 读取。更关键的是,要先定义“一个失败是否让整个批次失败”。如果所有项目必须成功,统一失败是合理的;如果允许部分成功,应在每个任务边界处理错误,并返回带状态的结果对象,而不是在最后join时才发现异常。
并行数量也应受控。一次把几百万个 ID 转成 future 可能先耗尽内存,再耗尽下游连接。生产代码通常需要分批、信号量、限流或有界队列。异步只是改变等待方式,不会消除外部系统的容量上限。
六、可测试性与可观测性
异步代码难测,常见原因是把执行器、时钟和外部客户端都藏在静态方法里。更容易测试的方式是把执行器作为依赖传入,在测试中使用小型直接执行器或受控线程池,并为超时、异常、空结果和取消分别写用例。
日志应携带请求 ID、业务 ID、阶段名称和耗时;指标至少区分排队时间、执行时间、下游响应时间、成功数、超时数和重试数。不要只在最外层打印一条“future failed”,否则无法判断到底是哪个并行分支失败。
还要防止在异步回调里调用阻塞操作。若回调运行在处理器线程或公共池中,阻塞数据库、文件或网络调用可能形成线程饥饿。要么把阻塞操作明确放到专用执行器,要么采用支持异步的客户端。代码中的Async后缀不是性能保证,资源模型才是。
七、何时不该使用 CompletableFuture
如果流程本身是线性的,且没有并行等待,普通同步代码往往更易读、更易调试。如果任务需要复杂的结构化生命周期、子任务取消和资源自动回收,可以评估 Java 21 的虚拟线程与结构化并发方向;不要为了“看起来现代”把简单的三行逻辑改成十层回调。
8. 一个更接近生产的异步聚合模板
接口聚合通常同时面对三个约束:总截止时间、单个下游的失败隔离,以及最终结果的降级策略。可以把它们拆开表达,而不是在一个回调里同时完成所有事情:
CompletableFuture<Profile>profile=loadProfile(userId).orTimeout(500,TimeUnit.MILLISECONDS).exceptionally(error->Profile.unavailable());CompletableFuture<List<Order>>orders=loadOrders(userId).orTimeout(700,TimeUnit.MILLISECONDS).exceptionally(error->List.of());CompletableFuture<Dashboard>dashboard=profile.thenCombine(orders,Dashboard::new).orTimeout(900,TimeUnit.MILLISECONDS);这里的“个人资料不可用”和“订单查询失败”被设计成不同的降级结果,最终聚合仍可能成功。实际项目中还应把错误状态带到结果中,例如ProfileResult同时包含数据、是否降级和失败原因的分类。单纯返回空列表可能让调用者误以为真的没有订单,而不是下游超时。
总截止时间和单分支超时不能混为一谈。两个分支各自 700 毫秒,并不意味着整个接口可以等待 1400 毫秒;聚合层还需要自己的 deadline。超时值应根据用户体验、下游 SLA 和连接池容量确定,并在日志和指标中记录真实耗时,而不是只记录“完成”或“失败”。
9. 取消、重试与幂等性
CompletableFuture.cancel(true)会让 future 以取消状态结束,但不保证已经开始执行的底层任务真的停止。若任务正在等待 HTTP 响应,是否能释放连接取决于 HTTP 客户端;若任务正在执行数据库语句,是否能中断取决于驱动和数据库。取消必须沿着任务所使用的资源链路传递,不能只改变最外层对象的状态。
重试也要单独设计。对于读请求,短暂网络失败有时可以有限重试;对于创建订单、扣款或发送消息,重试前必须确认幂等键、去重表或状态查询机制。指数退避要加随机抖动,避免大量任务在同一时间再次冲击下游。对明确的参数错误和权限错误重试没有意义,只会放大流量。
一个可靠的结果模型通常至少区分:成功、业务拒绝、可重试的技术失败、不可重试的技术失败和超时未知状态。尤其是未知状态,不能简单地当成失败后再次执行;应提供查询接口或交给补偿任务确认最终状态。
10. 生命周期、背压与代码审查
线程池需要跟随应用生命周期管理。服务停止时,先停止接收新请求,再让在途任务在限定时间内完成,超过期限才强制取消。否则应用看似退出,后台线程仍可能持有连接、文件或业务锁。线程池的队列要有上限;无界队列会把过载压力转化为延迟和内存增长。
如果一个请求会派生几十个下游任务,应设置并发上限或信号量。并发度必须同时受线程池、HTTP 连接池、数据库连接池和对方限流策略约束。任何一层无界放大,都会让整体系统在高峰时失去背压。
代码审查时可以逐项询问:
- 哪些任务彼此独立,哪些任务存在数据依赖?
- 每个阻塞调用使用哪个执行器,是否可能跑在公共池中?
- 超时后下游操作是否真的会停止,还是仅仅不再等待?
- 失败结果是否区分降级、重试和未知状态?
- future、线程池、连接和定时器由谁关闭?
- 在峰值流量下,任务数量和队列长度是否有上限?
当这些问题都有明确答案时,CompletableFuture才是可维护的异步编排工具;否则它只是把同步问题隐藏在更多回调之后。异步代码的价值不在于方法名带不带Async,而在于资源、时间和失败都能被控制。
总结:先画出依赖图,再决定thenApply、thenCompose或thenCombine;明确使用哪个执行器;给外部调用设置超时;按业务定义异常恢复和部分成功;最后通过有界并发与指标保证系统可控。异步代码的高级之处不是回调数量,而是失败时仍然知道发生了什么、资源由谁负责、结果是否可信。
参考资料:CompletableFuture API、ExecutorService API。