ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CompletableFuture异常处理全攻略:从传播机制到降级兜底

CompletableFuture异常处理全攻略:从传播机制到降级兜底 1. 为什么CompletableFuture的异常处理让人头疼先聊个实际场景。你接手一个订单系统需要同时调用用户服务、库存服务、优惠券服务最后聚合结果。用CompletableFuture串起来确实爽链式调用写起来行云流水但一旦某个服务超时或者返回异常整个链路的错误处理就变得非常棘手。日志里常见CompletionException、ExecutionException、CancellationException混在一起异常堆栈还经常丢失原始cause排查问题时一头雾水。我在实际项目里见过太多类似情况异步任务失败后错误信息被吞掉、线程池被异常打满、回调链中断后主流程还傻傻等待。这些问题归根结底是因为很多人只用了CompletableFuture的“正常路径”忽略了它在异常路径上的设计细节。CompletableFuture本身是一套非常完善的异步编程工具只不过异常处理相关的API比较多、行为也比较微妙用不好就会踩坑。这篇文章不打算从thenApply、thenAccept这些基础用法开始讲而是直接从“失败”这个视角切入把CompletableFuture的异常传播机制、常见异常类型、各类恢复手段、线程池交互、超时控制全部过一遍。适合那些已经会用supplyAsync、thenApply但还没把异常处理真正吃透的Java开发者。2. CompletableFuture的异常传播机制2.1 异常到底会在哪里被“包一层”CompletableFuture的异常传播逻辑其实很清晰但它和传统的try-catch思维有一个关键差异异常不是直接抛给调用方而是被封装成CompletionException存进future内部状态里。只有当你调用get()、join()或者getNow()显式取值时这个封装异常才会被解开抛出。举个最简单的例子CompletableFutureString future CompletableFuture.supplyAsync(() - { if (true) { throw new RuntimeException(库存服务调用失败); } return ok; }); // 这里不会抛异常future内部状态是COMPLETED_EXCEPTIONALLY Thread.sleep(100); // 调用join时才会抛CompletionException String result future.join();join()会抛出CompletionException它的cause才是原始的RuntimeException。所以你在捕获时如果只catch了RuntimeException是接不住这个CompletionException的。这是我见过最多的一个坑。2.2 exceptionally、handle、whenComplete有什么区别这三个是处理异常的常用API很多新手分不清。我直接说结论exceptionally只在异常时执行参数是原始异常已经被解包后的cause需要返回一个恢复值。它不会收到正常结果。handle无论成功失败都会执行参数是(结果, 异常)两个值可以同时处理两种分支返回新的结果。whenComplete无论成功失败都会执行但它只负责“旁观”返回值和原来的结果一致不能改变最终结果。看个对比代码更直观// exceptionally异常时提供兜底值 CompletableFutureString f1 CompletableFuture .supplyAsync(() - { throw new RuntimeException(boom); }) .exceptionally(ex - fallback); // handle正常和异常统一处理返回新类型 CompletableFutureInteger f2 CompletableFuture .supplyAsync(() - 123) .handle((result, ex) - { if (ex ! null) return -1; return Integer.parseInt(result); }); // whenComplete只做记录不改变结果 CompletableFutureString f3 CompletableFuture .supplyAsync(() - success) .whenComplete((result, ex) - { if (ex ! null) { log.error(任务失败, ex); } else { log.info(任务成功: {}, result); } });实际项目里我的习惯是需要兜底值、并且异常分支不需要关心原始流程的优先用exceptionally。需要统一出口做结果转换、且成败分支逻辑交织的用handle。只需要记录日志、埋点、告警不改变结果的用whenComplete。2.3 异常链式传播一个环节坏了后面全“短路”CompletableFuture的链式调用有个很反直觉的地方中间某个节点出现异常后续的thenApply、thenAccept都不会执行异常直接沿着依赖链向下游传递直到被某个exceptionally或handle截获。看这个例子CompletableFuture.supplyAsync(() - { throw new RuntimeException(第一步就挂了); }) .thenApply(v - v 第二步) .thenApply(v - v 第三步) .exceptionally(ex - 兜底结果);如果第一步挂了后面的两个thenApply逻辑都不会执行直接跳到exceptionally拿到“兜底结果”。这个机制保证了异常不会被静默吞掉但也意味着你要想清楚你希望异常在哪里被截获是在每一步都单独处理还是在链路末尾统一兜底我的建议是关键节点用handle单独处理非关键节点的异常往链路末尾抛统一由出口兜底。这样代码不会到处散落exceptionally排查问题时也只需要看链路出口的处理逻辑。3. 常见异常类型与排查思路3.1 CompletionException、ExecutionException、CancellationExceptionCompletableFuture涉及的异常主要有三类我整理了一个对照表异常类型抛出场景常见误解CompletionExceptionjoin()取结果时任务执行阶段抛出的异常会被包装成它认为它就是业务异常本身ExecutionExceptionget()取结果时任务执行阶段抛出的异常会被包装成它和CompletionException混淆CancellationException调用了cancel()或者依赖的上游任务被取消认为只要捕获RuntimeException就够了这里有个很关键的细节同样的异常用join()和get()取结果抛出的包装类型不一样。join()抛CompletionExceptionget()抛ExecutionException受检异常需要显式处理。所以你的代码里如果混用了get()和join()捕获逻辑就要区分清楚。另外CancellationException是个RuntimeException但它的cause是null堆栈信息也很有限。遇到这个异常时排查方向不再是“哪个逻辑抛错了”而是“谁调用了cancel或者上游的future是否被超时取消了”。3.2 如何从包装异常里拿到真正的根因排查问题最烦的就是异常信息被层层包装日志里只看到CompletionException真正的业务异常埋在cause链里。一个实用的处理姿势是写一个工具方法递归解包public static Throwable unwrap(Throwable throwable) { if (throwable instanceof CompletionException || throwable instanceof ExecutionException) { return unwrap(throwable.getCause()); } // 这里也可以把CancellationException单独处理 return throwable; }然后在统一出口用这个工具方法记录日志CompletableFuture .supplyAsync(task) .exceptionally(ex - { Throwable rootCause unwrap(ex); log.error(异步任务失败根因: {}, rootCause.getMessage(), rootCause); return fallback; });这样日志里就能直接看到原始的业务异常不用再一层层去翻cause。这个工具方法我在多个项目里复用过稳定可靠。4. 异常恢复的实战套路4.1 失败兜底从exceptionally到fallback模式最常见的需求是异步任务失败时返回一个默认值或降级结果而不是让异常一路传播到上层导致接口报错。这就像电商大促时优惠券服务挂了不能让整个下单流程直接失败而是给用户返回无优惠的默认状态保证主流程能走完。用exceptionally做兜底是最直接的方式CompletableFutureCouponInfo couponFuture CompletableFuture .supplyAsync(() - couponService.getCoupon(userId), couponThreadPool) .exceptionally(ex - { log.warn(优惠券服务异常使用默认优惠: {}, ex.getMessage()); return CouponInfo.empty(); });这里有个容易被忽略的点exceptionally里返回的兜底值类型必须和原任务的返回类型一致。如果原来返回CouponInfo兜底也得返回CouponInfo或者用handle把它转成另外的类型。4.2 重试机制别在exceptionally里直接递归调用有些失败是瞬时性的网络抖动、连接超时直接兜底有点浪费合理的做法是重试。但CompletableFuture本身不支持重试需要自己控制。一个容易写错的做法是直接在exceptionally里递归调用自己// 不建议这么写 CompletableFutureString retryFuture CompletableFuture.supplyAsync(task) .exceptionally(ex - retryFuture.join());这么写的问题是闭包捕获了尚未初始化的变量而且递归调用会占用调用线程重试次数多了还可能栈溢出。更稳重的做法是写一个通用的重试方法public static T CompletableFutureT withRetry( SupplierT task, int maxRetries, Executor executor) { CompletableFutureT future CompletableFuture.supplyAsync(task, executor); for (int i 0; i maxRetries; i) { future future.thenApply(CompletableFuture::completedFuture) .exceptionally(ex - { log.warn(任务执行失败第{}次重试, i 1); return CompletableFuture.supplyAsync(task, executor); }) .thenCompose(Function.identity()); } return future; }每次重试都是在新的任务里重新执行不会阻塞当前线程重试间隔可以通过thenCompose拿到结果后再thenApplyAsync加延时。不过要注意重试策略要结合业务场景设计幂等接口才适合重试非幂等的下单、支付接口千万别无脑重试否则会造成重复扣款、重复下单。4.3 handle实现“成功也处理、失败也处理”的统一出口我参与过一个风控系统的改造里面有个聚合接口需要同时获取用户画像、设备指纹、历史行为三个维度的数据。当时的需求是即使历史行为数据查询失败也要基于另外两个维度的数据给出“低风险”判断而不能让整个接口报错。这种场景用handle最合适CompletableFutureRiskResult resultFuture userProfileFuture .thenCombine(deviceFingerprintFuture, (profile, device) - buildRiskResult(profile, device)) .handle((partialResult, ex) - { if (ex ! null) { log.warn(部分数据获取失败降级为低风险: {}, ex.getMessage()); return RiskResult.lowRisk(); } return partialResult; });handle的灵活性在于它可以同时看到正常结果和异常这样你可以在异常分支里决定是返回兜底值还是抛出一个业务异常。如果想在异常分支里继续保持异步链可以结合handlethenCompose不过这种写法可读性会下降我一般只在确实需要时使用。5. 超时控制与线程池交互5.1 orTimeout和completeOnTimeout的正确用法Java 9开始CompletableFuture增加了超时控制能力核心是orTimeout和completeOnTimeout。这两个方法的效果完全不同很多人混用。orTimeout超时后把future变成COMPLETED_EXCEPTIONALLY状态抛TimeoutException。等于是“超时即失败”。completeOnTimeout超时后把future变成COMPLETED状态填充你给的默认值。等于是“超时即兜底”。看个代码示例// 超时抛TimeoutException交给exceptionally处理 CompletableFutureString f1 CompletableFuture .supplyAsync(task) .orTimeout(3, TimeUnit.SECONDS) .exceptionally(ex - timeout fallback); // 超时直接返回默认值不抛异常 CompletableFutureString f2 CompletableFuture .supplyAsync(task) .completeOnTimeout(default value, 3, TimeUnit.SECONDS);我的建议是默认优先用orTimeout把超时当作一种显式异常处理。因为超时往往意味着外部服务状态不确定返回一个默认值可能会掩盖问题让上层以为调用一切正常实际服务已经假死。相反显式抛异常能触发告警让运维及时介入。5.2 为什么用了默认的ForkJoinPool会导致线程池被打满这算是CompletableFuture使用中非常经典的坑。如果你没有显式传线程池supplyAsync、thenApplyAsync这些方法默认使用ForkJoinPool.commonPool()。这个线程池的并行度默认是CPU核心数 - 1而且它不只是你一个应用在用其他依赖CompletableFuture的框架也可能在共用它。当你的异步任务发生阻塞比如调用了future.get()等待另一个异步任务或者执行了同步的HTTP调用commonPool的线程就会被占住线程池很快被打满后续所有异步任务的提交都会排队等待甚至造成整个JVM的异步处理能力瘫痪。我在排查一个生产故障时遇到过一模一样的情况一个网关服务通过CompletableFuture.supplyAsync批量调用外部接口外部接口响应变慢commonPool的线程全部被阻塞导致另外几个本来不相关的异步任务也全部卡住用户请求大量超时。最后就是把所有业务任务都换成独立线程池并设置核心线程数、最大线程数、队列容量才彻底解决。所以这里有个实操铁律所有带有阻塞I/O、外部调用的异步任务必须显式传入自定义线程池不要用默认的commonPool。5.3 自定义线程池的正确配置思路自定义线程池也没有那么玄乎核心看业务场景。我一般用ThreadPoolExecutor配置思路是ThreadPoolExecutor ioThreadPool new ThreadPoolExecutor( 20, // 核心线程数 50, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue(200), // 队列 new ThreadFactoryBuilder() .setNameFormat(async-io-pool-%d) .build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );几个关键参数的选择逻辑核心线程数参考QPS、单个任务耗时、目标RT来算。比如QPS100单个任务耗时200ms为了不让任务堆积理想并发线程数大概在20以上。公式可以粗略这样算核心线程数 ≥ QPS × 单任务耗时。队列用有界队列不要用无界的Executors.newFixedThreadPool那种默认无限队列。无界队列会在任务积压时吃掉大量内存而且线程数永远不会超过核心线程数失去调整弹性。拒绝策略用CallerRunsPolicy适合IO密集型场景线程池满了就让调用线程也就是提交任务的线程直接执行这样还能天然起到背压作用。它的合理之处在于不会丢弃任务代价是调用线程可能会被阻塞但对IO任务来说通常可以接受。5.4 父子任务编排时的线程切换问题还有一个和线程池相关的细节thenApply和thenApplyAsync的区别。thenApply在调用线程上执行也就是说谁调用了它就在谁的线程里跑。这个“调用线程”可能是父任务所在线程也可能是主线程。thenApplyAsync一定在提交给线程池后执行需要显式或默认指定线程池。这意味着如果你在自定义线程池A里跑了supplyAsync然后用thenApply做结果转换转换逻辑可能在A线程里同步执行也可能在主线程里执行这取决于链式调用的时机。如果转换逻辑包含阻塞操作这种不确定性很危险。所以我在项目中有一条约定CPU密集型的轻量转换用thenApply包含阻塞I/O或较重逻辑的一律用thenApplyAsync并显式指定线程池。这条约定帮我避免了很多“怎么就卡住了”的诡异问题。6. 完整案例一个聚合接口的异常处理实战6.1 需求背景与整体设计最后用一个完整的例子把这些知识点串起来。假设我们有个移动端首页聚合接口需要同时获取以下几类数据用户基础信息调用用户服务用户今日订单数调用订单服务用户优惠券列表调用优惠券服务这三个接口互相独立适合用CompletableFuture并行调用。需求方要求用户信息必须拿到拿不到就直接报错订单数和优惠券属于非关键数据服务异常时返回默认值即可但不能让整个接口失败。我设计了两组线程池一组给关键的用户服务调用一组给非关键的订单和优惠券服务调用。这样即使非关键服务把线程池打满也不影响关键服务的调用。6.2 核心代码实现Service public class HomePageAggregationService { private final ThreadPoolExecutor userServicePool; private final ThreadPoolExecutor secondaryServicePool; public HomePageAggregationResult getHomePageData(String userId) { // 1. 关键服务失败必须上抛 CompletableFutureUserInfo userInfoFuture CompletableFuture .supplyAsync(() - userClient.getUserInfo(userId), userServicePool) .orTimeout(2, TimeUnit.SECONDS); // 2. 非关键服务失败/超时都走兜底 CompletableFutureInteger orderCountFuture CompletableFuture .supplyAsync(() - orderClient.getTodayOrderCount(userId), secondaryServicePool) .orTimeout(1, TimeUnit.SECONDS) .exceptionally(ex - 0); CompletableFutureListCouponInfo couponFuture CompletableFuture .supplyAsync(() - couponClient.getCoupons(userId), secondaryServicePool) .orTimeout(1, TimeUnit.SECONDS) .exceptionally(ex - Collections.emptyList()); // 3. 通过thenCombine聚合关键服务异常会自然向上传播 return userInfoFuture .thenCombine(orderCountFuture, (userInfo, orderCount) - HomePageAggregationResult.builder() .userInfo(userInfo) .orderCount(orderCount) .build()) .thenCombine(couponFuture, (partial, coupons) - { partial.setCoupons(coupons); return partial; }) .whenComplete((result, ex) - { if (ex ! null) { log.error(首页聚合接口异常 userId{}, userId, unwrap(ex)); metricsClient.increment(homepage.aggregate.error); } else { metricsClient.increment(homepage.aggregate.success); } }) .join(); } }这段代码里有几个细节值得单独说用户服务的orTimeout(2, TimeUnit.SECONDS)设成2秒订单和优惠券设成1秒是因为用户服务直接决定页面可用性等得起非关键数据等不起宁可早点降级。非关键服务用exceptionally返回默认值订单数0、空优惠券列表关键用户服务没有兜底异常会沿着链路传播到join()最终由外层try-catch统一转成HTTP错误响应。whenComplete用来做监控埋点无论成功失败都会执行。这里没有用handle就是因为不想要改变结果。6.3 几个设计细节的复盘这个方案并不是一上来就长这样中间踩过一些坑复盘出几个有价值的设计原则。第一超时时长不能全都一样。最早我图省事统一设了2秒结果订单服务只要一慢整个接口的RT就跟着涨到2秒。后来把非关键服务的超时改成1秒RT明显降下来了。超时时间要按业务对数据的依赖程度分级而不是一刀切。第二join()放在最后统一获取不要在中途用get()。中途get()会强制等待阻塞当前线程如果某个future还没完成你就白白浪费了并行执行的意义。正确的姿势是先把所有future都组合好让它们并行飞最后再一次性获取结果。第三whenComplete里的ex是CompletionException包装类型记录日志时一定要先解包否则日志里大量出现CompletionException运维同事根本看不出真正的根因。我在项目里强制统一使用解包工具方法。6.4 这个线程池配置方案的可复用性上面这个聚合服务里两组线程池的配置也有讲究。用户服务Pool我用的是核心线程16、最大线程32、队列容量200二级服务Pool用的是核心线程8、最大线程16、队列容量100。为什么这么分配核心原因是从流量规模和响应时间倒推出来的。假设这个接口QPS是200用户服务和二级服务各占一次下游调用用户服务单次耗时约150ms那么并发中的服务调用量大概是200 × 0.15 30所以核心线程至少要30附近。设成32留一点余量。二级服务单次耗时约80ms并发中的调用量大概是200 × 0.08 16核心线程设成16刚好。当然这个计算是基于平均耗时的粗略估算实际还要结合TP99耗时和下游服务的承压能力来调整。但思路是对的核心线程数跟“在途请求量”挂钩而不是拍脑袋随便设。另一个值得强调的点是线程池参数要支持动态调整。我在配置中心里把这几个参数核心线程数、最大线程数、队列容量都做成了动态配置每次大促前可以根据预估流量临时调大活动结束后再调回来。这样既避免了平时资源浪费也扛得住流量峰值。7. 常见问题与排查技巧实录7.1 为什么日志里只有CompletionException看不到真正的业务异常这是最频繁的求助问题。原因前面分析过CompletableFuture会把业务异常包装成CompletionException日志如果直接打印ex只能看到包装类的堆栈信息真正的cause被埋在里面看不到。解决方式是写一个解包工具方法在日志埋点处统一调用。实际排查时还有一个更隐蔽的问题如果你在exceptionally里打印异常时用了ex.getMessage()拿到的消息可能也是包装后的而不是原始cause的消息。所以无论是getMessage()还是打印完整堆栈都要先解包。7.2 为什么我用CompletableFuture后接口变慢了接口变慢大概率不是CompletableFuture本身的问题而是线程池配置或者使用方式出了问题。常见原因有这么几个一是使用了默认commonPool被其他任务挤占。这是最常见的情况改法就是显式传线程池。二是在thenApply里做了阻塞操作。前面说过thenApply是在调用线程上执行的如果你在转换逻辑里同步调用了另一个HTTP接口这个线程会被一直占着拖慢整个链路。三是使用get()在循环里逐个等待future。别人写的是并行你写的其实是串行等待。正确姿势是把所有future都用thenCombine或allOf组合起来再统一等待。四是线程池的拒绝策略是AbortPolicy任务一多直接抛RejectedExecutionException。这个异常还不是普通的业务异常排查起来很容易绕弯路。解决方式是换成CallerRunsPolicy或提前设置更大的队列容量。7.3 如何调试异步任务里的异常异步调试比同步代码麻烦因为堆栈信息是断裂的很多线程上下文已经丢失。我常用的调试手段有这几个首先是给每个线程池设置清晰的线程名比如user-service-pool-1、secondary-service-pool-2。这样线程转储能直接定位到是哪个业务线程卡住了。强烈建议所有生产环境的自定义线程池都这么做。其次是借助whenComplete在关键节点打点记录任务的执行耗时、成功/失败标记、以及原始异常信息。不要等到任务失败后才去看日志而是提前埋好观测点。我在聚合服务里埋的success/error计数器直接接入了监控大盘出了故障能第一时间发现。最后是尽量把异常信息打印完整包括unwrap()之后的根因堆栈同时带上业务上下文比如userId、orderId。独立的一套上下文信息可以大幅缩短排查链路。7.4 异步任务里的事务问题怎么处理这个问题问的人也不少。很多人想用CompletableFuture做并行事务提交比如同时向两张表插入数据然后等两个future都成功再一起提交事务。但这里有个严重的设计问题Java的本地事务是和线程绑定的CompletableFuture的每个异步任务跑在单独的线程上事务管理器根本管不到子线程里的事务。我见过有人试图在supplyAsync里写Transactional方法结果事务要么没有生效要么提交了一半失败一半成功数据一致性完全没法保证。处理这类问题的正确思路是如果两个子任务确实必须保持一致性就不应该拆成独立的异步任务而是保持同步执行或者把事务边界上移到协调者比如引入分布式事务组件。如果只是希望“并行执行提升性能”可以在每个子任务内部各自管理自己的事务并通过future.join确保所有子任务都完成后再返回。但要注意这种方式只能保证“每个子任务要么成功要么回滚”不能保证跨任务的一致提交。7.5 单元测试里如何模拟异常测异常路径比测正常路径更费劲但恰恰更需要测试覆盖。我的测试套路是用Mockito mock掉依赖的服务让它在特定参数下抛出异常然后验证CompletableFuture链路上是否走到了exceptionally或handle分支。一个很实用的小技巧是给测试对象注入一个专用线程池避免使用commonPool。因为commonPool的线程是全局共享的测试之间可能互相污染导致测试结果不稳定。Test void testOrderServiceTimeoutFallback() { when(orderClient.getTodayOrderCount(anyString())) .thenThrow(new RuntimeException(timeout)); HomePageAggregationResult result homePageService.getHomePageData(user123); assertEquals(0, result.getOrderCount()); assertNotNull(result.getUserInfo()); }这个测试隐含验证了两件事订单服务异常被exceptionally正确兜底用户服务正常返回所以主流程没被中断。如果兜底逻辑写错这个测试就会失败。7.6 一个容易被忽略的坑allOf和anyOf的异常行为如果你用allOf聚合多个future它的join()在任一future异常时就会抛异常但你拿不到是哪一个future出的错。allOf返回的是CompletableFutureVoid没有保存子任务的结果或异常信息。排查时需要遍历所有子future调用join()才能找到异常源。CompletableFutureVoid all CompletableFuture.allOf(f1, f2, f3); // 这里会抛异常但不知道是谁 try { all.join(); } catch (CompletionException e) { // 需要遍历所有future才能定位异常源 for (CompletableFuture? f : new CompletableFuture[]{f1, f2, f3}) { try { f.join(); } catch (CompletionException ex) { log.error(子任务异常: {}, unwrap(ex).getMessage(), ex); } } }anyOf也有类似的问题它返回Void而且拿到的是最快完成的那个future的结果不保证是“感兴趣的”那个。所以我的实操建议是不要只依赖allOf/anyOf的返回值做异常判断每个子future都要有自己的exceptionally兜底把异常转换成一个业务上可辨识的结果或者提前在每个子future上挂whenComplete把异常收集到外部容器里。我在一个用户画像项目里就是这么做的子任务各自把成功结果或异常信息写进ConcurrentHashMap主流程等allOf完成后统一检查map这样既能定位失败的子任务又能拿到原始异常。8. 从失败中沉淀我的一些实操心得这套CompletableFuture异常处理的东西我并不是一开始就全懂也是在项目里踩了无数次坑才慢慢形成一套相对稳定的惯例。一条非常重要的心得是不要在业务代码里到处散落exceptionally和handle。链路一旦长了每个节点都加异常处理代码可读性会变得极差而且异常边界会变得模糊——你不知道某个异常是被谁吞掉的排查时只能从头捋到尾。我的惯例是只在两个地方做异常处理一个是链路的关键分支节点用于处理可恢复的业务降级另一个是链路的最末尾用于统一记录日志、埋点和转换最终结果。中间的节点一律让异常自然传播不拦截。第二条心得是关于“失败”的语义。CompletableFuture把“超时”、“任务被取消”、“任务内部异常”都表现为future的异常完成状态但它们的业务含义完全不同。我在实践中会用不同的错误码和监控指标区分这三类失败超时进timeout告警任务异常进exception告警取消进cancel计数。如果混在一起统计你在监控面板上根本看不出来系统到底出了什么问题。第三条心得是异步代码的边界一定要收口。不要在一个方法里开一条很长的CompletableFuture链然后return出去让调用方来控制。更好的做法是封装成独立的Service方法内部完成所有异常处理和结果转换对外只暴露同步或异步的入口调用方不需要关心底层用了什么异步机制。这样你的异步逻辑可测试、可替换、可复用。最后说一个小技巧CompletableFuture的completeExceptionally方法在开发调试时非常有用。你可以在代码里手动把一个future变成异常完成状态模拟下游故障验证链路上的降级逻辑是否生效。我在本地调试时经常这么干比真正去依赖一个容易报错的外部服务方便得多。CompletableFutureString future new CompletableFuture(); future.completeExceptionally(new RuntimeException(模拟故障));只要理解了CompletableFuture的内部状态机和异常传播的核心机制再复杂的异步编排最终都能被拆成“正常分支”和“异常分支”的清晰映射这个思路能复用到任何异步场景。
返回列表