ARTICLE DETAIL

资讯详情

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

CompletableFuture顺序工作流异步执行与异常传播策略详解

CompletableFuture顺序工作流异步执行与异常传播策略详解 顺序工作流异步执行是 Java 后端开发中非常常见的任务编排需求。它要求一组业务步骤按照严格顺序依次完成前一步的输出作为后一步的输入同时每个步骤本身又不能长时间占用调用线程。实现这种场景时CompletableFuture 提供了一种较优雅的异步编排方式但很多开发者在真正落地时都会遇到一个问题当链中的某个异步任务抛出异常后后续异步任务并没有继续执行有时甚至没有任何日志。实际上这背后是 CompletableFuture 基于依赖关系传播异常的默认行为并不是框架出错。如果理解了它的异常传播链路我们既能利用这种短路机制实现“异常后不执行其他异步任务”也能避免在真正需要继续降级时写错代码。下面从顺序工作流异步执行的核心问题开始分析然后基于 CompletableFuture 实现一个最小可运行案例重点讲清楚异常传播和终止策略最后给出生产环境下的参数选型、排错路径和优化建议。1. 顺序工作流异步执行到底在解决什么问题1.1 顺序工作流的典型形态顺序工作流可以理解为按依赖关系串联的业务步骤。步骤 A 完成后产生 dataA步骤 B 必须读取 dataA 才能完成 dataB步骤 C 又必须读取 dataB 才得到最终结果。典型的业务场景包括订单创建、支付对账、审批流转、数据导入导出等。在写代码时最直接的方式是同步调用DataA dataA stepA(); DataB dataB stepB(dataA); DataC dataC stepC(dataB);这种写法顺序清晰但有一个明显问题调用线程必须依次等待 stepA、stepB、stepC 全部执行完毕才能继续往下走。如果一个步骤耗时 200ms链路由 5 个步骤组成总耗时就是 1 秒。这个等待过程中调用线程不能处理其他请求线程池可用线程数量会被快速耗尽。顺序工作流异步执行要做的事情是把这一组有依赖关系的步骤放进异步模型中执行让调用线程提交任务后可以立即返回由后台线程池真正执行任务同时通过异步回调把步骤串联起来保持顺序语义不变。1.2 同步执行的问题与异步化的收益同步执行的直接代价是线程阻塞。在高并发场景下每个请求占用的线程时间越长系统需要创建的线程就越多线程切换和内存消耗都会明显上升。上游某个接口变慢时所有依赖它的链路都会被拖慢最终表现为接口超时、线程池队列积压。异步化之后调用线程不需要阻塞在耗时步骤上可以把线程资源释放出来继续处理其他请求。不过异步化的收益并不是自动获得的只有把步骤真正放到独立线程池中执行并且用异步回调组织后续步骤才能达到“不阻塞调用线程”的效果。如果只是把 synchronized 改成 CompletableFuture但仍然在 get 方法上阻塞等待本质上还是同步逻辑。1.3 异步顺序工作流的核心难点异步化之后真正的难点集中在四个方面。顺序控制步骤之间存在参数依赖前一步未完成时后一步不能被触发。异常传播步骤 B 失败时步骤 C 是否继续执行取决于业务规则。多数业务场景希望在异常后终止后续步骤。超时控制异步任务可能长时间不结束不能无限等待。可观测性异步执行会跨线程日志打印和监控需要能串联到同一个请求上下文中。很多团队在引入 CompletableFuture 时只关注了顺序控制异常、超时和可观测性往往出现问题后才发现。本文会围绕这些难点展开。2. CompletableFuture 为什么适合做顺序异步编排2.1 从 Future 到 CompletableFutureJava 5 引入的 Future 可以提交异步任务并获取结果但能力有限。Future 只能通过 get 阻塞等待结果无法直接表达“结果出来后自动执行下一步”。如果多个步骤有依赖关系使用 Future 时要么在一个线程里循环调用 get要么把下一步逻辑写在上一步任务内部。CompletableFuture 在 Java 8 引入核心改进是支持依赖式异步回调。它允许把多个异步操作串成一条链每个操作依赖前一个操作的结果前一个操作完成后自动触发下一个操作。同时CompletableFuture 还会把异常状态保存在内部后续依赖节点能够感知并跳过执行。2.2 链式调用与依赖关系CompletableFuture 的 thenApply、thenCompose、thenAccept 等方法会生成一个新的 CompletableFuture并且这个新 future 依赖前一个 future 的完成状态。只有前一个 future 正常完成后续回调才会被调用。这里最重要的概念是“依赖”。看下面这个示例CompletableFuture.supplyAsync(() - step1(), executor) .thenApplyAsync(result1 - step2(result1), executor) .thenApplyAsync(result2 - step3(result2), executor);step2 和 step3 在同一个异步链上step3 依赖于 step2 的结果。如果 step2 抛出异常step2 对应的 future 会以异常状态结束step3 的回调不会被执行异常会继续向后传播。如果换一种写法不把任务串成链而是先创建多个独立 future再用 allOf 或 join 组合异常行为就会不同。比如下面这段代码两个 future 彼此独立一个失败并不能阻止另一个继续执行CompletableFutureInteger f1 CompletableFuture.supplyAsync(() - step1(), executor); CompletableFutureInteger f2 CompletableFuture.supplyAsync(() - step2(), executor); CompletableFutureInteger f3 CompletableFuture.supplyAsync(() - step3(), executor); CompletableFuture.allOf(f1, f2, f3).join();所以判断“异常后不执行其他异步任务”是否成立不能只看是否用了 CompletableFuture而是要看任务之间是否形成了依赖链。2.3 异常在异步链中的传播方式当 CompletableFuture 的执行体抛出异常时异常会被包装成 CompletionException并作为该 future 的完成状态保存下来。后续依赖这个 future 的方法在执行前会先检查前置 future 是否异常完成。如果前置 future 异常完成后续回调不会执行异常会继续向后传播直到遇到 exceptionally、handle 或 whenComplete 这样的处理节点。如果在异常链的末端没有处理节点调用方执行 get 或 join 时就会抛出异常。异常传播有几个容易被忽略的细节。异常一旦被 exceptionally 捕获并返回了新值该节点的 future 会变成正常完成状态后续 thenApply 会继续执行。handle 和 whenComplete 都会拿到 throwable但 whenComplete 不改变结果它对应 future 的完成状态仍然是异常。如果在某个步骤内部自己 try-catch 并返回默认值异常不会进入异步链后续步骤会正常执行。理解这些细节才能写出符合业务预期的工作流。3. 最小可运行示例用 CompletableFuture 串联三个业务步骤3.1 环境准备与依赖示例基于 Java 8 及以上版本使用 Maven 管理项目CompletableFuture 是 JDK 自带能力不需要额外引入第三方依赖。在 pom.xml 中可以配置编译器版本properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target /properties如果使用 Java 9 及以上版本后面的超时示例会更容易写。本文代码兼容 Java 8。3.2 定义工作流步骤先定义三个业务步骤模拟一个简单数据链路step1 返回初始值step2 对输入加 1step3 把输入乘 10。import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SequentialWorkflowExample { public static void main(String[] args) { ExecutorService executor Executors.newFixedThreadPool(4); CompletableFutureInteger future CompletableFuture .supplyAsync(() - step1(), executor) .thenApplyAsync(result1 - step2(result1), executor) .thenApplyAsync(result2 - step3(result2), executor); Integer result future.join(); System.out.println(final result result); executor.shutdown(); } static int step1() { return 1; } static int step2(int input) { return input 1; } static int step3(int input) { return input * 10; } }运行这段代码输出为final result 20这个结果说明step3 收到了 step2 的处理结果链式调用保持了步骤之间的顺序。3.3 异常后自动终止后续任务的编排代码现在人为让 step2 抛异常并在 step3 中打印日志观察 step3 是否执行。static int step2(int input) { throw new IllegalStateException(step2 failed); } static int step3(int input) { System.out.println(step3 execute, input input); return input * 10; }同时修改 main 方法捕获异常并打印原因try { Integer result future.join(); System.out.println(final result result); } catch (Exception e) { System.out.println(caught exception: e.getClass().getName()); System.out.println(cause: e.getCause()); }运行后可以看到caught exception: java.util.concurrent.CompletionException cause: java.lang.IllegalStateException: step2 failed并且没有输出step3 execute。这个结果说明当 step2 异常时step3 没有被执行。这正是 CompletableFuture 依赖链的短路机制异常沿链向后传播后续步骤自动终止。3.4 运行验证与预期输出验证方式验证顺序工作流异步执行是否成功除了看最终结果还要关注以下几点。正常路径三个步骤是否按顺序执行结果是否符合预期。异常路径中间步骤失败后后续步骤是否被终止。异常信息通过 join 或 get 抛出时异常原因是否能还原到业务异常。线程信息打印执行线程名称可以验证任务确实在线程池中执行而不是调用线程同步执行。建议在真实项目中在步骤入口和出口增加日志方便观察链路执行情况。4. 关键 API 与异常控制策略详解4.1 thenApply、thenCompose、thenAccept 如何选择顺序工作流中每个步骤的执行体类型不同选择不同的串联方法会影响代码结构和可读性。方法作用前置结果下一步输入典型场景thenApply把前一步结果转换为新结果可用新结果步骤返回普通对象thenCompose把前一步结果转换为新的 CompletableFuture可用新 CompletableFuture步骤本身就是异步操作thenAccept消费前一步结果不返回新值可用无日志、通知、落库等结束操作thenRun前一步完成后执行 Runnable不可用无不关心前一步结果的收尾动作thenApply 和 thenCompose 的区别很关键。如果 step2 内部需要再调用一个返回 CompletableFuture 的方法thenApply 会导致方法嵌套外层类型变成 CompletableFutureCompletableFuture 代码中需要额外 join 展开。使用 thenCompose 可以让返回类型保持扁平也更容易表达异步依赖。推荐在每一步都返回 CompletableFuture 时使用 thenCompose 串联CompletableFuture.supplyAsync(() - step1(), executor) .thenComposeAsync(result1 - step2Async(result1), executor) .thenComposeAsync(result2 - step3Async(result2), executor);4.2 异常后不执行其他异步任务的三种实现方式“异常后不执行其他异步任务”在实际代码中可以有三种实现方式使用场景不同。第一种也是最推荐的方式使用依赖式异步链。只要后续步骤是通过 thenApply、thenCompose、thenAccept 等方法挂在同一个链上的前置异常会自动终止后续任务。前面示例已经展示了这一点。第二种手动状态检查。如果代码结构无法完全避免多个独立 future可以在每个步骤执行前检查前一步的完成状态。CompletableFutureInteger step1Future CompletableFuture.supplyAsync(() - step1(), executor); CompletableFutureInteger step2Future step1Future.thenApply(result1 - { if (step1Future.isCompletedExceptionally()) { return null; } return step2(result1); });这种写法不推荐因为需要为每个步骤手动增加判断容易遗漏也不利于维护。第三种在链的末端使用 handle 统一判断异常。handle 可以拿到 result 和 throwable适合做统一出口处理例如记录日志、发送告警或返回降级结果。CompletableFutureInteger future CompletableFuture.supplyAsync(() - step1(), executor) .thenApplyAsync(result1 - step2(result1), executor) .thenApplyAsync(result2 - step3(result2), executor) .handle((result, throwable) - { if (throwable ! null) { System.out.println(workflow failed: throwable.getMessage()); return -1; } return result; });注意handle 返回的新 future 会被视为正常完成。如果业务要求异常后终止且调用方不关心返回值可以把 handle 放在链的末端不要在其后再依赖原始结果继续业务。4.3 exceptionally、handle、whenComplete 的适用场景这三个方法都能处理异常但语义区别很大。方法能拿到结果能拿到异常是否改变链的完成状态适用场景exceptionally否是改变变成正常完成提供降级值或默认值handle是是改变需要根据是否异常分别处理whenComplete是是不改变只记录日志或监控不影响结果exceptionally 的入参只有 throwable它在前置异常时返回一个替换结果之后链可以继续执行。如果业务要求异常后停止不要在 exceptionally 后继续挂业务步骤。whenComplete 不改变结果。即使 whenComplete 里记录了异常后续调用 join 仍然会抛出 CompletionException。它更适合做日志和监控不适合做流程控制。实际项目中建议把业务逻辑和异常处理分层。链上只保留业务步骤链末端统一使用 handle 处理结果和异常。这样每个步骤不需要自己捕获异常异常可以自然传播到出口。5. 常见现象与排查链路异常后为什么没有继续执行5.1 现象排查顺序遇到 CompletableFuture 相关工作流问题时不要一开始就怀疑框架建议按下面的顺序排查。先确认步骤之间是否构成依赖链。检查代码中后续步骤是挂在 thenApply/thenCompose 上还是在链外独立创建。确认异常是否真的被抛出。在步骤内部打断点或加日志确认是否进入了异常分支。确认异常是否被 try-catch 吞掉。吞掉异常后后续步骤会正常执行且不会留下异常痕迹。确认链上是否存在 exceptionally 或 handle将异常恢复为正常状态。确认线程池是否发生拒绝异常。RejectedExecutionException 发生在任务提交阶段和链内异常表现不同。5.2 常见问题速查表问题现象可能原因检查方式处理建议前一步异常但后续仍在执行后续任务不是依赖前一步链被拆散检查是否使用 thenApply/thenCompose 串联改为依赖式异步链异常后没有日志也没有异常步骤内 try-catch 吞掉异常检查步骤实现搜索 catch 块记录日志后重新抛出或不要吞异常join 抛出 CompletionException 但找不到业务异常异常被多次包装循环调用 getCause 逐层展开打印完整堆栈使用根异常判断配置 exceptionally 后后续步骤仍不执行exceptionally 返回结果未继续挂载后续依赖检查链的拼接顺序如果确实要恢复执行把后续步骤挂在 exceptionally 后面线程池队列积压任务被拒绝线程池参数不合理查看线程池活跃线程数和队列容量增大线程数或调整队列策略5.3 线程池和异常被吞掉的坑常见的坑有三个。第一个坑是步骤内部使用 try-catch 捕获异常后返回 null。这样异常不会进入 CompletableFuture 的异常链路后续步骤会拿到 null 继续执行。比如 step2 内 catch 后返回 nullstep3 对 null 做计算时可能抛出 NullPointerException干扰问题定位。处理方式是不吞异常或者记录异常后重新抛出。第二个坑是误用 exceptionally 后继续执行。exceptionally 的语义是“异常时返回一个替代结果”它会把异常状态覆盖为正常完成。如果业务要求异常后终止却把降级值传给后续步骤就会出现意料之外的继续执行。第三个坑是使用默认 ForkJoinPool.commonPool。多个业务链路如果共用 commonPool一个慢任务可能影响其他链路。推荐在服务启动时创建独立命名的线程池按业务场景隔离线程资源。ThreadFactory threadFactory new ThreadFactory() { private final AtomicInteger index new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, workflow-exec- index.getAndIncrement()); } }; ExecutorService executor new ThreadPoolExecutor(4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), threadFactory, new ThreadPoolExecutor.CallerRunsPolicy());CallerRunsPolicy 会退化为调用线程执行任务虽然能阻止任务丢失但会造成调用线程阻塞。生产环境选择拒绝策略时要结合业务容忍度。6. 生产级顺序工作流异步执行的优化建议6.1 线程池、超时与回滚生产环境不建议直接使用 Executors 提供的固定线程池。固定线程池使用无界队列任务积压时容易出现内存增长。更稳妥的方式是使用 ThreadPoolExecutor 显式配置核心线程数、最大线程数、队列容量和拒绝策略。超时控制同样不能忽略。Java 9 之后可以在 CompletableFuture 上直接使用 orTimeoutCompletableFutureInteger future CompletableFuture.supplyAsync(() - step1(), executor) .thenApplyAsync(result1 - step2(result1), executor) .orTimeout(3, TimeUnit.SECONDS);Java 8 中可以使用 get(timeout) 处理超时try { Integer result future.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { System.out.println(workflow timeout); // 根据业务需求做降级或告警 } catch (Exception e) { // 处理其他异常 }顺序工作流还可能涉及回滚。因为步骤之间存在依赖前几步成功、后面失败时需要补偿已完成的步骤。生产环境要记录每一步的执行状态结合事务消息或定时任务做最终一致性处理。6.2 可观察性与监控异步链路的问题排查必须依赖完整日志。建议在每个步骤入口和出口打印步骤名、输入、输出、耗时和线程名。如果有链路追踪系统可以把 traceId 放入 MDC让异步任务继承调用方上下文。下面是一个步骤包装的示例思路用于记录耗时和执行情况static int traceStep(String stepName, SupplierInteger action) { long start System.currentTimeMillis(); try { int result action.get(); System.out.println(stepName success, cost (System.currentTimeMillis() - start) ms); return result; } catch (RuntimeException e) { System.out.println(stepName failed, cost (System.currentTimeMillis() - start) ms); throw e; } }使用时把真正业务逻辑放入 traceStep即使异常也能记录失败位置。6.3 可复用检查清单在将顺序工作流异步执行代码发布到生产环境之前建议逐项确认这个清单。步骤之间是否都使用了依赖式异步调用形成了完整异步链。异常后后续任务是否按业务预期终止或者按预期进入降级逻辑。是否对整条链路设置了总超时或对关键步骤设置了单独超时。是否使用独立命名线程池线程参数是否根据业务压测结果确定。是否在链末端有统一异常兜底避免调用方直接收到裸异常。是否记录每个步骤的耗时和执行状态日志中是否包含 traceId。是否设计了失败后的补偿或回滚机制。是否验证过正常路径和异常路径的测试用例。这套清单可以放在项目发布文档中也可以作为异步任务代码评审的检查项。回到最开始的问题CompletableFuture 异常后不执行其他异步任务并不是需要额外开发的功能而是依赖链天然具备的短路行为。理解依赖关系和异常传播方式之后我们才能决定是让异常终止整条链还是通过降级值让链继续走完。顺序工作流异步执行核心不在于堆砌异步 API而在于用清晰、可观测、可超时、可补偿的方式组织这些异步步骤。建议从最小示例开始先跑通正常情况下 1 - 2 - 3 的顺序再人为注入异常观察短路行为。这个简单实验能帮你避免很多关于异步编排的直觉误区。
返回列表