并发 13 · 异步编排

秒杀下单成功之后,系统要做好几件事:生成订单、扣减库存(如果放在下单接口内已经做过,这里可能是记账/对账)、给用户发一张优惠券、给下游推送一条消息通知发货系统。这几件事有的必须先后串起来 (订单生成依赖扣库存成功),有的互不依赖可以并行跑(发优惠券和推消息谁也不用等谁),如果全部塞进一个方法里顺序同步执行,接口响应时间就是这几步耗时的总和------用户会觉得"下单怎么这么慢"。

这就是异步编排 要解决的问题:把一堆有依赖关系、又有并行机会的任务组织起来,尽量缩短总耗时,同时让代码读起来还是一条清晰的逻辑线,而不是一堆嵌套回调纠缠在一起(这正是早期 Future 被吐槽最多的地方)。这一篇按"Future 的局限 → CompletableFuture 怎么补上 → Fork/Join 框架"这条线索展开,最后用秒杀下单场景把常用的编排方法串成一个完整例子。

目录

  1. [Future 的局限:只能等,不能编排](#Future 的局限:只能等,不能编排)
  2. [CompletableFuture 入门:创建异步任务与默认线程池的坑](#CompletableFuture 入门:创建异步任务与默认线程池的坑)
  3. [串联任务:thenApply / thenApplyAsync / thenCompose](#串联任务:thenApply / thenApplyAsync / thenCompose)
  4. [合并与等待:thenCombine / allOf / anyOf](#合并与等待:thenCombine / allOf / anyOf)
  5. [异常处理:exceptionally / handle / whenComplete](#异常处理:exceptionally / handle / whenComplete)
  6. [Fork/Join 框架:分治任务与工作窃取](#Fork/Join 框架:分治任务与工作窃取)
  7. 实战:秒杀下单的异步编排流水线
  8. 选型小结

一、Future 的局限:只能等,不能编排

JDK 5 引入的 Future 是异步编程最早的抽象:把任务提交给线程池,立刻拿到一个 Future,主线程可以先去做别的事,需要结果时再调用 get()

java 复制代码
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Order> future = pool.submit(() -> createOrder(userId, productId));

// 主线程可以先做别的事......

Order order = future.get();   // 阻塞,直到任务完成才能拿到结果

问题就出在 get() 上:它是阻塞的,一旦调用就只能傻等,没有"任务完成时自动回调我"的机制。这带来几个实际的麻烦:

  • 无法优雅地串联多个异步任务。 如果"扣库存"完成后要接着"生成订单",用 Future 只能是 future1.get() 拿到结果后再手动 submit() 下一个任务------本质上还是阻塞等待,只是把等待的位置往后挪了一下,没有真正的异步链路。
  • 无法表达"等多个任务都完成"或"等任意一个完成"。 想等发优惠券和推消息两个并行任务都结束,用 Future 得手写 future1.get(); future2.get();,挨个等,没有内置的批量等待语义。
  • 异常处理别扭。 任务里抛的异常会包装成 ExecutionExceptionget() 时才抛出来,想在异步链路的中间步骤处理异常也没有直接的钩子。

FutureTaskFuture 的一个具体实现(同时实现了 Runnable),本质上没有解决上面的问题,只是提供了一种可以手动 run() 触发计算的写法。真正解决这些问题的是 JDK 8 引入的 CompletableFuture ,它同时实现了 Future 接口和 CompletionStage 接口,后者才是支持链式编排的关键。

二、CompletableFuture 入门:创建异步任务与默认线程池的坑

创建一个异步任务最常用的是两个静态方法:

java 复制代码
// 有返回值,异步执行一个 Supplier
CompletableFuture<Integer> stockFuture =
    CompletableFuture.supplyAsync(() -> queryStock(productId));

// 无返回值,异步执行一个 Runnable
CompletableFuture<Void> notifyFuture =
    CompletableFuture.runAsync(() -> sendNotification(userId));

这里有一个容易踩的坑:如果不显式传 Executor 参数,supplyAsync/runAsync 默认用的是 ForkJoinPool.commonPool() 这是一个 JVM 全局共享的线程池(默认并行度是 CPU 核数 - 1),Stream 的并行流(parallelStream())、CompletableFuture 的所有异步方法,只要不指定线程池,全都挤在这一个池子里跑。

java 复制代码
// ✗ 用默认线程池:如果任务里有阻塞IO(查数据库、调HTTP接口),
//   会占用commonPool的线程,间接拖慢应用里所有依赖它的并行操作
CompletableFuture.supplyAsync(() -> queryStockFromDB(productId));

// ✓ 生产代码里传自己的业务线程池,跟公共池隔离
private static final ExecutorService BIZ_POOL = Executors.newFixedThreadPool(20);
CompletableFuture.supplyAsync(() -> queryStockFromDB(productId), BIZ_POOL);

这跟第 8 篇讲的线程池隔离思想是同一个道理:commonPool 是整个 JVM 共用的公共资源,业务代码里凡是涉及阻塞 IO(数据库、RPC、HTTP 调用)的异步任务,都应该传入自己独立的业务线程池,不要让一个慢查询拖累了这个进程里所有依赖公共池的其他功能。只有纯 CPU 计算、不会阻塞的任务,用默认池问题不大。

三、串联任务:thenApply / thenApplyAsync / thenCompose

thenApply :拿到上一步的结果,做一次转换,返回新的 CompletableFuture

java 复制代码
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> queryStock(productId), BIZ_POOL)   // 返回 Integer(库存数)
    .thenApply(stock -> stock > 0 ? "有货" : "无货");       // 转换成 String

thenApply 默认会在"完成上一阶段的那个线程"里继续执行下一步(如果上一阶段已经完成,调用 thenApply 的线程直接同步跑完;如果还没完成,则由触发完成的那个异步线程接着跑)。如果想强制这一步也在指定的线程池里异步执行 ,用 thenApplyAsync(同样支持传 Executor 参数,不传则回落到 commonPool)。

java 复制代码
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> queryStock(productId), BIZ_POOL)
    .thenApplyAsync(stock -> stock > 0 ? "有货" : "无货", BIZ_POOL);  // 显式指定在哪个池子跑

thenCompose :当"下一步"本身也是一个返回 CompletableFuture 的异步操作时,该用 thenCompose 而不是 thenApply

java 复制代码
// ✗ 用 thenApply,会得到嵌套的 CompletableFuture<CompletableFuture<Order>>,很别扭
CompletableFuture<CompletableFuture<Order>> nested = CompletableFuture
    .supplyAsync(() -> deductStock(productId), BIZ_POOL)
    .thenApply(success -> createOrderAsync(userId, productId));   // createOrderAsync 本身返回 CompletableFuture<Order>

// ✓ 用 thenCompose,自动"拍平"成 CompletableFuture<Order>
CompletableFuture<Order> flat = CompletableFuture
    .supplyAsync(() -> deductStock(productId), BIZ_POOL)
    .thenCompose(success -> createOrderAsync(userId, productId));

thenComposeStream 里的 flatMap 是同一个心智模型 :普通的转换函数返回"裸值"用 map/thenApply,转换函数本身返回"一个容器"(StreamCompletableFuture)时用 flatMap/thenCompose 把嵌套的容器拍平成一层。判断该用哪个的标准很简单:看你传进去的函数返回的是不是 CompletableFuture------是就用 thenCompose,不是就用 thenApply

四、合并与等待:thenCombine / allOf / anyOf

上面讲的都是"串行依赖"------下一步必须等上一步的结果。但开头提到的"发优惠券"和"推消息"是互相独立、可以并行跑的两个任务,这时候需要的是合并而不是串联。

thenCombine :两个独立的 CompletableFuture 都完成后,用一个 BiFunction 把两者的结果合并成一个新结果。

java 复制代码
CompletableFuture<Coupon> couponFuture = CompletableFuture.supplyAsync(() -> issueCoupon(userId), BIZ_POOL);
CompletableFuture<Boolean> notifyFuture = CompletableFuture.supplyAsync(() -> sendNotification(userId), BIZ_POOL);

// 两个任务并行跑,都完成后把结果合并成一个字符串
CompletableFuture<String> combined = couponFuture.thenCombine(notifyFuture,
    (coupon, notified) -> "优惠券:" + coupon.getId() + ", 通知已发送:" + notified);

allOf :等待多个 CompletableFuture 全部完成 ,但它返回的是 CompletableFuture<Void>,不会自动帮你收集每个任务的结果,需要自己再挨个 .get()(因为已经全部完成,这里的 get() 不会阻塞)。

java 复制代码
CompletableFuture<Void> all = CompletableFuture.allOf(couponFuture, notifyFuture);
all.join();   // 等全部完成(join()和get()语义一样,但不抛受检异常,Lambda里更好用)
Coupon coupon = couponFuture.join();      // 全部完成后直接取,不会阻塞
Boolean notified = notifyFuture.join();

anyOf :多个任务里任意一个先完成 就返回,返回类型是 CompletableFuture<Object>(因为不知道是哪一个任务先完成、类型也可能不一样),常见场景是"多个数据源查同一份数据,谁先返回用谁的"(比如本地缓存和远程缓存同时查,哪个先给结果就用哪个)。

五、异常处理:exceptionally / handle / whenComplete

异步链路上任何一步抛异常,都会导致异常沿着链路往下传(后续的 thenApply/thenCompose 都不会执行了),直到遇到能处理异常的节点或者被 get()/join() 取出来。三个处理异常的方法容易搞混,按"能不能兜底改结果""能不能同时看到异常和正常结果"来区分:

java 复制代码
CompletableFuture<Integer> future = CompletableFuture
    .supplyAsync(() -> deductStock(productId))
    // 只处理异常,返回一个替代值,正常情况完全不触发
    .exceptionally(ex -> {
        log.error("扣库存失败", ex);
        return -1;    // 用-1表示失败,替代原本应有的返回值
    });

CompletableFuture<Integer> future2 = CompletableFuture
    .supplyAsync(() -> deductStock(productId))
    // 正常/异常都会走到这里,二选一非null,可以据此决定返回什么
    .handle((result, ex) -> {
        if (ex != null) {
            log.error("扣库存失败", ex);
            return -1;
        }
        return result;
    });

CompletableFuture<Integer> future3 = CompletableFuture
    .supplyAsync(() -> deductStock(productId))
    // 正常/异常都会走到这里,但只是"观察",不能改变最终结果,异常会继续往下传
    .whenComplete((result, ex) -> {
        if (ex != null) {
            log.error("扣库存失败", ex);
        }
    });

区别一句话记住:exceptionally 只在异常时触发、用来兜底给替代值;handle 正常/异常都触发、可以决定最终返回什么(相当于 thenApply + exceptionally 的合体);whenComplete 正常/异常都触发,但只能"看"不能"改"------原来的结果或异常原样往下传,适合做日志记录、埋点这种旁路操作而不想干扰主链路。

六、Fork/Join 框架:分治任务与工作窃取

CompletableFuture 编排的是"业务上语义不同的多个任务"(扣库存、生成订单、发券),而 Fork/Join 框架解决的是另一类问题:把一个大的计算任务递归拆成许多足够小的子任务并行算,再把子任务的结果合并起来------典型的分治算法思路,比如大数组求和、归并排序、递归遍历文件目录统计大小。

核心是两个抽象类(继承自 ForkJoinTask):

  • RecursiveTask<V> :有返回值的分治任务,实现 compute() 方法。
  • RecursiveAction:无返回值的分治任务(比如原地排序),用法一样只是没有返回值。
java 复制代码
public class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;   // 拆到多小才不再拆,直接算
    private final long[] array;
    private final int start, end;

    public SumTask(long[] array, int start, int end) {
        this.array = array; this.start = start; this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) sum += array[i];   // 足够小,直接算
            return sum;
        }
        int mid = (start + end) / 2;
        SumTask left = new SumTask(array, start, mid);
        SumTask right = new SumTask(array, mid, end);
        left.fork();                       // 把左半部分丢给别的线程异步算
        long rightResult = right.compute(); // 右半部分自己接着算(避免多fork一次线程切换)
        long leftResult = left.join();      // 等左半部分的结果
        return leftResult + rightResult;
    }
}

// 使用
ForkJoinPool pool = new ForkJoinPool();     // 也可以直接用 ForkJoinPool.commonPool()
long total = pool.invoke(new SumTask(bigArray, 0, bigArray.length));

fork() 把子任务提交给线程池异步执行,join() 等待子任务完成并拿到结果------这一拆一合就是"Fork/Join"这个名字的来源。

它的性能关键在 ForkJoinPool 内部的调度策略:工作窃取(work-stealing)。 每个工作线程维护自己的一个双端队列存放待执行的子任务,正常情况下从队列头部取自己的任务执行;当一个线程把自己队列里的任务都做完了,会去随机挑一个别的线程的队列,从队列尾部"偷"一个任务来执行,而不是空闲等待。这样即使分治拆出来的子任务大小不均匀,整体的 CPU 利用率依然很高,不会出现有些线程闷头干活、有些线程闲着没事的情况。

版本提示ForkJoinPool.commonPool() 是 JDK 8 引入的全局共享池,parallelStream()CompletableFuture 默认异步方法都用它。JDK 9 之后 CompletableFuture 增加了 orTimeout()completeOnTimeout()delayedExecutor() 等超时相关的便捷方法,本篇聚焦 JDK 8 的核心 API,这几个新增方法用法直观,需要时查文档即可。

七、实战:秒杀下单的异步编排流水线

回到开头的场景:扣库存 → 生成订单 必须串行(订单依赖扣库存成功),发优惠券和推消息可以并行(互不依赖),扣库存失败要短路后面所有步骤直接返回失败:

java 复制代码
public class SeckillOrderPipeline {
    private static final ExecutorService BIZ_POOL = Executors.newFixedThreadPool(20);

    public CompletableFuture<OrderResult> placeOrder(Long userId, Long productId) {
        return CompletableFuture
            // 第一步:扣库存
            .supplyAsync(() -> deductStock(productId), BIZ_POOL)
            // 第二步:扣库存成功才生成订单,用thenCompose串联(createOrderAsync本身返回CompletableFuture)
            .thenCompose(deductSuccess -> {
                if (!deductSuccess) {
                    // 失败短路,直接返回一个已完成的失败结果,后面的优惠券/通知步骤都不会执行
                    return CompletableFuture.completedFuture(OrderResult.fail("库存不足"));
                }
                return createOrderAsync(userId, productId)
                    .thenCompose(order -> {
                        // 第三步:订单生成后,发优惠券和推消息并行跑,用thenCombine合并
                        CompletableFuture<Coupon> couponFuture =
                            CompletableFuture.supplyAsync(() -> issueCoupon(userId), BIZ_POOL);
                        CompletableFuture<Boolean> notifyFuture =
                            CompletableFuture.supplyAsync(() -> notifyShipping(order.getId()), BIZ_POOL);

                        return couponFuture.thenCombine(notifyFuture,
                            (coupon, notified) -> OrderResult.success(order, coupon));
                    });
            })
            // 兜底:链路上任何一步抛异常,都在这里统一处理,不让异常裸露到调用方
            .exceptionally(ex -> {
                log.error("下单异步流水线异常, userId={}, productId={}", userId, productId, ex);
                return OrderResult.fail("系统异常,请重试");
            });
    }
}

这个流水线的耗时模型:总耗时 ≈ 扣库存耗时 + 生成订单耗时 + max(发优惠券耗时, 推消息耗时)------最后两步是并行的,取两者中较长的那个,而不是简单相加。如果同步顺序执行这四步,耗时是四者相加,异步编排在这里能省下"发优惠券"和"推消息"里较短那个的全部耗时。

八、选型小结

需求 方法 关键点
转换上一步的结果(同步值) thenApply/thenApplyAsync 转换函数返回裸值,不是CompletableFuture
串联下一步也是异步操作 thenCompose 转换函数返回CompletableFuture,自动拍平,避免嵌套
合并两个独立并行任务的结果 thenCombine 类似"zip",两者都完成后用BiFunction合并
等待多个任务全部完成 allOf 返回Void,需要自己逐个.join()取结果
多个任务任意一个先完成即可 anyOf 返回Object,常用于多数据源竞速查询
只处理异常给替代值 exceptionally 正常情况不触发
正常/异常都要处理并决定最终结果 handle 相当于thenApply+exceptionally合体
正常/异常都要"观察"但不改变结果 whenComplete 日志埋点等旁路操作,异常照常往下传
大计算任务递归拆分并行算 Fork/JoinRecursiveTask/RecursiveAction 工作窃取调度,适合CPU密集型分治算法

带走两句话:① 遇到"下一步返回的是不是CompletableFuture"时用thenCompose而不是thenApply,这是避免嵌套 Future 的关键分辨点;CompletableFuture 的默认线程池是全局共享的commonPool,涉及阻塞IO的任务一定要传自定义线程池,不要占用公共资源。② Fork/JoinCompletableFuture 不是竞争关系而是分工------CompletableFuture 编排"业务上不同的多个任务",Fork/Join 拆分"同一个大计算任务的子问题",两者甚至可以配合,CompletableFuture 默认异步方法本身就跑在ForkJoinPool.commonPool()上。

下一篇是本系列的收官篇,我们讲虚拟线程与结构化并发------JDK 21 的 Loom 项目带来的虚拟线程,用极低的成本创建数百万个"线程",让阻塞式编程重新变得高效,某种程度上是对这一篇讲的"异步编排"复杂度的一种釜底抽薪式的解法。会讲清楚虚拟线程和平台线程的关系、它解决了传统线程池的什么痛点、结构化并发这个新概念,以及整个系列 14 篇怎么串起来看。

相关推荐
SamDeepThinking1 小时前
警惕那些很长时间没有编写任何代码、却在设计系统的人
java·后端·架构
TizzyGoodhealth1 小时前
从零到一搭建Spring‑AI原生Tool‑Calling AI Agent|架构对比+完整实战源码+踩坑实录
java·ai·知识库·rag·ai agent·spring ai
mmsx1 小时前
置灰按钮为什么自己又“亮“了?一次状态缓存“双写冲突“的排查记录
java·缓存·bug·livedata
rannn_1111 小时前
【力扣hot100】图论专题+模板|DFS、BFS、拓扑排序...
java·算法·leetcode·深度优先·图论
用户3126874877201 小时前
ReentrantLock 到底怎么排队的?AQS 源码拆解
java
Zane19941 小时前
ArrayList 插入慢,LinkedList 一定快吗
java·后端
吃饱了得干活1 小时前
从类爆炸到协作——DDD战略设计登场
java·后端·架构
wno7042 小时前
Spring Boot异常处理
java·spring boot·后端
君顾12 小时前
智慧场馆解决方案小程序系统开发实战指南
java·开发语言·智慧场馆