从零构建一个高性能异步任务编排框架

后续项目会放到gitee。完善后会把地址放到评论区。

这篇文章想和你一起做成一件事:从一个半夜被告警叫醒的下单接口出发,先把它拆到最底层,再从最底层一点点搭回去,最后搭出一个可用的异步任务编排框架。

先说清楚"框架"到底指什么,它没那么神秘。说到底就是能做到这几件事的一段代码:互不依赖的任务自己并行跑,有依赖的任务等上游好了自动触发,中间谁挂了能重试、能熔断、能兜底,跑完还能把整个过程汇报清楚。这些能力听起来复杂,但每一样背后都对应一个具体的、让人头疼的问题。我们会把这些问题一个个摆出来,然后一起想明白该怎么解决。

我不会并发,这篇文章里不少坑我也是真踩过的。所以线程、线程池、CompletableFuture 这些东西,我不会一上来就抛给你,而是等它们"实在绕不开了"才拿出来——就像我们平时真写代码那样,先遇到问题,再找办法。

如果你手边有环境,建议跟着把代码敲一遍跑一下,看懂和敲过确实是两回事。另外这些章节是环环相扣的,尽量按顺序读,前后因果才接得上。

大致会走这么一条路:

序章    一个真实的编排难题

第一篇  并发地基(先把零件磨好,暂时还不搭框架)
   1  顺序执行的墙
   2  第一次让两件事同时发生
   3  线程很贵
   4  等待的困境
   5  CompletableFuture:把等待换成订阅
   6  给任务画一张依赖地图

第二篇  框架诞生(用上面的零件,把框架一步步搭出来)
   7   最小能跑的编排器
   8   依赖失败会"人间蒸发"
   9   给单个任务装上超时
   10  失败自动重试,但不许霸占线程
   11  熔断:连续失败就别再打了
   12  降级:给失败一个台阶下
   13  洋葱模型合拢
   14  运行时拐弯:条件路由
   15  一图失败,结果该留多少
   16  回调可叠加,且不许炸穿主链
   17  让调度器会自报家门
   18  收尾的三件事:关闭、取消、自检

终章    回头看一眼我们一起搭了个什么东西

序章:一个真实的编排难题

故事从凌晨两点的监控群开始,这一幕相信不少人都熟悉。

告警指向"下单接口",P99 响应时间 1.8 秒,网关已经开始限流。我们把接口代码翻出来,是一串规规矩矩的同步调用:

public OrderResult placeOrder(String userId, String skuId) {
    User   user   = queryUser(userId);      // 查用户,大概 200ms
    Sku    sku    = querySku(skuId);        // 查商品,大概 200ms
    Stock  stock  = deductStock(user, sku); // 扣库存,大概 150ms,要用到 user 和 sku
    Promo  promo  = calcPromo(stock);       // 算优惠,大概 100ms,要用到 stock
    notify(user, promo);                    // 发通知,大概 50ms,要用到 promo
    return buildResult(stock, promo);
}

老老实实,一行跑完才轮到下一行。顺利的时候,总耗时大约就是每一步加起来:

200 + 200 + 150 + 100 + 50 = 700ms

盯着这五行,我们会发现一个很朴素、但当时未必细想过的点:查用户和查商品之间并没有依赖关系,凭什么查商品的时候,要干等着查用户回来?

这两件事完全可以同时做。光是让它们并行,这两步就能从 400ms 缩到 200ms。

不过"让它俩同时跑"只是个开头。我们真正想要的,是这么一套东西:不相干的任务自动并行;有依赖的任务,上游一完事就自动把下游叫醒,谁也不用傻等;中间哪个环节挂了、超时了,能自己重试,反复挂就及时拉闸熔断,实在救不回来还能给个兜底值;到最后,整条链跑成什么样、慢在哪、挂在哪,还能给你说明白。

这么一套东西,就是我们说的异步任务编排框架。接下来这段路,我们会从最笨的 new Thread 写起,每遇到一个问题就学会一个新招,直到把上面这套机制亲手搭出来。

得先给这套机制找个练手的靶子。下面这张依赖图会从头跟到尾,每一节的代码都围着它转,我们下面就以它为例:

   ┌──────────┐        ┌──────────┐
   │ user     │        │ sku      │   查用户、查商品:互不依赖,可以并行
   │ 查用户   │        │ 查商品   │
   └────┬─────┘        └────┬─────┘
        │                   │
        └─────────┬─────────┘
                  ▼
            ┌──────────┐
            │ stock    │                扣库存:要等 user 和 sku 都完成
            │ 扣库存   │
            └────┬─────┘
                 ▼
            ┌──────────┐
            │ promo    │                算优惠:要等 stock
            │ 算优惠   │
            └────┬─────┘
                 ▼
            ┌──────────┐
            │ notify   │                发通知:要等 promo
            │ 发通知   │
            └──────────┘

五个任务,几处依赖,一处能并行。

有个约定先说好:后面我会用 Thread.sleep(毫秒) 来代表一次远程调用的耗时,比如 sleep(200) 就当是一次 200ms 的网络往返。真实项目里那当然是网络请求,只是用 sleep 演示"慢"最干净可控。


第一篇 · 并发地基

这一篇先不急着搭框架,把手要用的零件一个个磨顺手。磨完,我们手里会有一套能用的并发工具,只是还没图纸把它们拼起来——图纸留给第二篇。地基这活儿急不得,前面慢一点,后面才稳当。

第 1 节 · 顺序执行的墙

眼下我们手里只有最普通的方法调用。先把序章那段串行代码跑一遍,看看它到底慢在哪:

public class SerialDemo {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();          // 记下开始时间

        String user = queryUser();                        // 这一步睡 200ms
        String sku  = querySku();                         // 这一步又睡 200ms

        long cost = System.currentTimeMillis() - start;   // 算总耗时
        System.out.println("串行总耗时 = " + cost + "ms");  // 大约 400
    }

    static String queryUser() { sleep(200); return "user:张三"; }  // 模拟一次 200ms 的调用
    static String querySku()  { sleep(200); return "sku:iPhone"; }
    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { /* 教学用的,忽略 */ }
    }
}

跑出来,总耗时约 400ms。

问题出在等待上。queryUser() 执行的那 200ms 里,CPU 其实是闲着的,它纯粹在等网络回包。而这段时间,querySku() 明明完全可以凑上来一起做,却被排在了后面干等。

画个时间轴会更直观:

串行:  |---- user 200ms ----|---- sku 200ms ----|   合计 400
并行:  |---- user 200ms ----|
        |---- sku  200ms ----|                       合计 200

结论很清楚:只要让互不依赖的事同时发生,它们的耗时就从"相加"变成了"取最长的那个"。道理不复杂,难的是后面怎么把它做出来、还做得靠谱。

要"同时发生",Java 里最直接的工具就是线程。下一节我们就从它开始。

(这一节记住一点:串行让本可以同时做的事白白排队,而我们想要的是让它们一起做。)


第 2 节 · 第一次让两件事同时发生

要让两件事同时发生,最基础的工具就是线程 Thread。动手写之前,先把 Thread 和 Runnable 这两个概念对齐一下,免得看代码时心里犯嘀咕。

Thread 是一个线程对象,代表一条独立的执行流。调它的 start(),JVM 就会向操作系统申请一条真线程,让它跟主线程各跑各的。Runnable 是个接口,里面就一个 void run(),代表"一件要去做的事"。你把一件"事"交给一个 Thread,这个 Thread 就在它的线程里把这件事跑一遍。

这里有个很容易搞混、也很容易中招的地方:thread.start() 才是真的开一条新线程去并发执行;而 thread.run() 并不开线程,它就像手动调了个普通方法,在当前线程里把 run 跑一遍,等于白忙活。一个字母的差别,结果却完全不同。

还有个更关键的限制:Runnable 的 run() 返回类型是 void。也就是说,你在子线程里算出来的东西,没法像普通方法那样 String user = xxx 直接接住。

这个限制会带来多大麻烦,我们不妨先不下结论,动手写一版,让问题自己暴露出来。

先用 Thread 加 Runnable,把 user、sku 并行起来。这一版先不纠结"拿结果",只求让它们同时跑:

public class ParallelWithThread {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        new Thread(() -> {
            System.out.println(queryUser());   // 只是打印,结果没往回传
        }).start();

        new Thread(() -> {
            System.out.println(querySku());
        }).start();

        long cost = System.currentTimeMillis() - start;
        System.out.println("主线程走到这只是 = " + cost + "ms");   // 几乎是 0
    }

    static String queryUser() { sleep(200); return "user:张三"; }
    static String querySku()  { sleep(200); return "sku:iPhone"; }
    static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ignored) {} }
}

跑一下,主线程走到这只是 ≈ 0ms——主线程把两条子线程一启动,自己就往下走了,并没有等它们。

但这并不代表一切就绪。实际输出很可能是这样:

主线程走到这只是 = 0ms
user:张三
sku:iPhone

主线程都收工了,两个结果才姗姗来迟,顺序也未必对。序章里定的第一件事——把结果拿回来——落空了。

这一版把问题摊得挺清楚。第一,run() 是 void,子线程算出来的字符串只能就地打印,没法当返回值递回主线程。第二,主线程和子线程"会合"没人管,start() 不会在任务结束时通知谁,主线程只能自顾自往前冲。第三,结果到底落在哪、谁先写谁后写,主线程一无所知。

第二个问题,Thread 留了个办法叫 join()——让主线程在某个子线程门口守着,等它跑完再走:

Thread t1 = new Thread(() -> System.out.println(queryUser()));
Thread t2 = new Thread(() -> System.out.println(querySku()));
t1.start();
t2.start();
t1.join();   // 主线程卡在这,等 t1 结束
t2.join();   // 再等 t2 结束

这样顺序总算对了。但 join() 只管"等它结束","把结果取回来"那个老问题它没帮上。真想取结果,就得自己在 Runnable 外面架个共享变量,还得操心可见性、线程安全……越写越别扭。

说到底,病根在 Runnable 那个 void run() 上:它天生不给你返回值。

所以我们真正缺的,是某种"能返回结果、又能异步执行、还能把结果取回来"的任务。这正好是后面要讲的东西。不过在请它出场之前,有一堵更现实、代价也更大的墙横在前面,比"怎么拿结果"还急——线程太贵。下一节我们就会撞上它。

(这一节记住一点:Thread 能让事情同时发生,但 Runnable 的 run 没有返回值,结果拿不回来;join 只能等结束、不能取结果。我们缺一个能返回值的异步任务。)


第 3 节 · 线程很贵

上一节为了并行,我们 new Thread 用得很随手。但设想一下:如果这个接口一秒被调 1000 次,每次都开好几条线程,机器会怎么样?

答案是线程并不是免费的。每 new Thread().start() 一次,操作系统都要给它划一块栈内存、在内核里建一张线程表、登记调度信息,用完还得销毁,这一开一关是笔固定开销。更要命的是线程数不能无限涨。CPU 核就那么几个,几千条线程挤在一起互相抢,光是来回切换(上下文切换)就能把机器拖垮,再狠一点直接 unable to create new native thread 报错。

绕了一圈我们会明白:线程得省着用、反复用,而不是开一个扔一个。这正是线程池 ThreadPoolExecutor 的用途——提前养一批线程,任务来了丢给空闲线程去做,做完归还,线程不销毁,下回接着用。

要用它,得先看懂它的构造函数。ThreadPoolExecutor 的构造有七个参数,它们几乎决定了线程池的所有行为,理解了这七个参数,基本就理解了线程池:

new ThreadPoolExecutor(
    int corePoolSize,               // 核心线程数:池里常驻的线程,一般不回收
    int maximumPoolSize,            // 最大线程数:池里最多同时能有多少条线程
    long keepAliveTime,             // 空闲存活时间:非核心线程闲够这个时间就被回收
    TimeUnit unit,                  // 上面那个时间的单位
    BlockingQueue<Runnable> workQueue,      // 工作队列:核心线程忙不过来时,新任务先来这排队
    ThreadFactory threadFactory,            // 线程工厂:负责怎么造一条线程(起名、设为守护等)
    RejectedExecutionHandler handler        // 拒绝策略:队列也满了、线程也到顶了,再来的任务怎么处置
);

七个参数光背没用,更好的理解方式是换个角度:去看"一个任务丢进来之后走的那条路线"。理解路线,比记名字重要:

  1. 当前线程数还没到核心线程数?那就直接新建一条核心线程跑它。
  2. 核心线程满了?任务先进队列排队,只要队列还没满。
  3. 队列也满了,但线程数还没到最大线程数?破例再开"非核心"线程顶上。
  4. 线程数到顶、队列也满?触发拒绝策略。

这条路线记成一句话:先填核心,再排队,再突破到最大,最后拒绝。

拿下单场景代入,数字会更有实感。假设服务线程池配成核心 2、最大 4、队列容量 100:

  • 来 2 个任务,两条核心线程直接开跑。
  • 第 3 到第 102 个任务,核心忙,全进队列排队。
  • 第 103 个,队列满了,破例开第 3、第 4 条线程,最多开到 4 条。
  • 第 105 个,4 条线程全满、队列也满,触发拒绝。

参数不是抽象数字,它们合起来决定了这系统能扛多大并发、从哪一步开始说"不"。

有了这条路线,我们就能写出后面一直要用的那个线程池。每一行配什么、为什么这么配,都标在注释里:

import java.util.concurrent.*;

public class Pools {
    // 编排框架的服务线程池,后文所有任务默认都在它上面跑
    public static ExecutorService newOrchestrationPool() {
        return new ThreadPoolExecutor(
            2,                                  // 核心线程 2,常驻,扛常态流量
            4,                                  // 最大线程 4,突发时最多扩到这
            60L, TimeUnit.SECONDS,              // 非核心线程闲 60 秒就回收,不长期占资源
            new ArrayBlockingQueue<>(100),      // 有界队列,这里特意这么选,原因见下
            new ThreadFactory() {
                private int seq = 0;
                @Override
                public Thread newThread(Runnable r) {
                    // 给线程起个有意义的名字,线上排查时一眼认出这是我们框架的线程
                    return new Thread(r, "orch-" + (++seq));
                }
            },
            new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略,也是有讲究的,见下
        );
    }
}

有三处是特意选的,值得说道一下。

队列为什么要"有界"。很多人图省事直接用默认的无界队列(比如不带容量的 LinkedBlockingQueue)。问题是任务会无限往里堆、永远堆不满,于是"最大线程数"形同虚设——第 3 步那条"破例开线程"的路永远走不到,最后全堆在队列里堆到内存溢出。有界队列其实是把压力提前顶出来,相当于一个安全阀。

线程名为什么要自己起。线上真出事时,你 dump 出一屏线程,orch-1 这种名字一眼就知道是自己人;pool-3-thread-7 这种还得翻半天。名字是排查问题的第一道线索。

拒绝策略为什么挑 CallerRunsPolicy。它的意思很直接:队列满了,谁提交的任务谁自己拿回去跑。这样一来提交方越快就越会被自己拖住,天然形成一种"慢下来"的压力反馈(行话叫背压),既不丢任务也不抛异常。对编排框架这种"任务不能随便扔"的场合,比较合适。

到这儿,"任务由谁去跑"我们算是有了交代。但你可能还记得第 2 节那道没做完的题——线程池解决了"谁来跑",可"跑完的结果怎么取回来"、以及更要紧的"跑完之后怎么自动把依赖它的下一个任务叫醒",它一个都没管。

这两件事怎么解,得先看线程池对外提供的、用来提交任务和拿结果的那对接口:Callable 和 Future。下一节我们正面来看它。

(这一节记住一点:线程贵且数量有限,得复用,这就是线程池;它的行为由七个参数决定,路线是填核心、排队、扩到最大、拒绝。我们定下了后文通用的服务线程池。但结果怎么取、跑完怎么通知下游,还没着落。)


第 4 节 · 等待的困境

线程池有了,但"结果怎么取"这道题还挂着。ThreadPoolExecutor 提供了一个 submit 方法:丢一个任务进去,它还你一个 Future。这里绕不开 Callable 和 Future 这一对,先分别说清楚。

Callable<V> 长得像 Runnable,也是"一件要做的事",但它的 call() 有返回值、类型是 V,还能声明抛异常。可以理解成"会返回东西的 Runnable"。这恰好补上了第 2 节那个 void run() 的缺口。

Future<V> 是 submit 的返回物,代表"这个任务将来会有一个 V 类型的结果,现在可能还没好"。它给几个方法:get() 拿结果,注意结果没好时它会阻塞、就在原地干等;isDone() 问一句好了没、但不等;cancel() 取消任务。

那我们把第 2 节没拿回来的结果,用 submit + get 取回来试试:

public class FutureDemo {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Pools.newOrchestrationPool();
        long start = System.currentTimeMillis();

        Future<String> userF = pool.submit(() -> queryUser());   // 提交,拿到 Future
        Future<String> skuF  = pool.submit(() -> querySku());    // 两条任务同时在池里跑

        String user = userF.get();   // 卡在这,直到 user 回来
        String sku  = skuF.get();    // 再卡在这,直到 sku 回来

        System.out.println("耗时 " + (System.currentTimeMillis() - start)
            + "ms,拿到:" + user + " / " + sku);
        pool.shutdown();
    }
    static String queryUser() { sleep(200); return "user:张三"; }
    static String querySku()  { sleep(200); return "sku:iPhone"; }
    static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ignored) {} }
}

结果确实拿到了,耗时也是 200ms 出头——因为 user 和 sku 在池里同时跑,我 get user 等了 200ms,这期间 sku 早跑完了,再 get sku 就不用等。看起来挺好。

但 get() 这个动作本身值得琢磨。它把当前线程(这儿是主线程)摁在原地,直到结果回来。任务是并行了,可"取结果"这一步又变回了一件干等的事。更要紧的是——

回到下单场景。stock 要等 user 和 sku 都回来才能开始。按现在这套写法,只能让主线程 userF.get()、skuF.get(),两个都拿到了,再手动去提交 stock。也就是说,user 和 sku 完成的那一刻,并没有任何东西"自动"去触发 stock,全靠主线程站在那儿 get、等齐了、再亲手推一把。

把这种不爽说穿,是三层:

  • 主线程不该陪着任务干等。它更合适的角色是"把活派下去就走",结果回来自然有人处理。
  • 任务之间"上游一完成、下游立刻开跑"这层关系,现在没地方表达,只能靠人肉安排 get 的顺序。
  • 依赖一复杂(比如 stock 要同时等 user 和 sku 两个),"谁等谁、谁等齐了叫谁"这套调度就得自己用 get 一点点拼,稍不留神就漏、就顺序错。

根子在于,Future 是被动的——它只躺在那儿等你 get,自己不会主动通知任何人。而我们想要的是主动:结果一好,立刻把后续该做的事拽起来。

Java 后来在 Future 之上补了一整套"能自己往下串、完成时自动触发回调"的能力,这就是 CompletableFuture。名字里 future 是"未来",completable 是"可完成",合起来可以理解成"一个能被完成、且完成时能自动做点别的"的未来。它是这篇教程真正的主角,下一节我们把它好好认识一遍。

(这一节记住一点:submit 给你 Future,Future 能取回结果,但 get 是干等,且任务间"上游完成就触发下游"没法自动表达,全靠主线程人肉调度。我们需要一个会主动往下流的 Future。)


第 5 节 · CompletableFuture:把等待换成订阅

CompletableFuture(下面简称 CF)方法不少,一次性认全反而容易晕。这一节定个规矩:每个方法都先讲清"它是干什么的、参数什么意思、跟旁边那个长得像的有啥区别",再上例子;而且一次只引入两三个新动作,等它们各自站稳了再往下走。先只学两个最核心的动作——发起一个会返回结果的异步任务、以及等它好了接着做。

第一招:supplyAsync——派一个会返回结果的活

签名大致是 CompletableFuture.supplyAsync(Supplier<U> supplier, Executor executor)。Supplier 是"给我一个值"的函数(无入参,吐一个结果);executor 指定这活派到哪个线程池去跑。它立刻返回一个 CompletableFuture<U>,代表这个将来会有的 U 结果。跟 pool.submit 几乎一样,区别只在返回的是 CF,而 CF 值钱的地方是它身上能接后续动作。

ExecutorService pool = Pools.newOrchestrationPool();
CompletableFuture<String> userF = CompletableFuture.supplyAsync(() -> queryUser(), pool);
CompletableFuture<String> skuF  = CompletableFuture.supplyAsync(() -> querySku(),  pool);

第二招:thenApply——结果好了,顺手加工一下

cf.thenApply(Function):等 cf 好了,把它的结果丢进这个 Function 加工,产出一个新的 CF。就是"上游给个值,我接着做个转换"。

CompletableFuture<String> nameF = userF.thenApply(u -> u.toUpperCase());  // 拿到 user 转成大写

这里有个容易混的点先记下:CF 上很多动作有"孪生兄弟",thenApply 和 thenApplyAsync 是一对。区别只在加工这步在哪跑——thenApply 可能直接复用刚跑完上游的那条线程顺手做掉;thenApplyAsync 则把加工重新丢回线程池、另用一条线程。教学里先用 thenApply,够用。

第三招:thenCompose——结果好了,接着做下一件异步事

加工是"结果 → 值"。但更多时候,下游本身也是一件要异步做的事,比如 user 回来了才去发起扣库存这个远程调用。这种时候用 thenApply 会别扭地套出个 CF<CF<T>>,正是 thenCompose 要解决的。

cf.thenCompose(Function):这个 Function 接收上游结果、返回一个新的 CF,thenCompose 会自动把这层新 CF 展开接上,不会双层嵌套。一句话区分:thenApply 接一个普通转换,thenCompose 接一个异步任务。

// user 回来后,再异步去查这个用户的地址
CompletableFuture<String> addrF = userF.thenCompose(u ->
        CompletableFuture.supplyAsync(() -> queryAddress(u), pool));  // 直接是 CF<String>,不嵌套

这两招合起来,就能把"先 A 再 B 再 C"顺着写下来。可下单里 stock 要同时等 user 和 sku 两个,两个怎么汇成一个都好了的时刻?这需要下一招。

第四招:allOf——等多个都好了

CompletableFuture.allOf(CF...):接收一堆 CF,返回一个新 CF,这个新 CF 会在所有传入 CF 都完成时完成。

有个坑得提醒:allOf(...) 返回的是 CompletableFuture<Void>,它不携带任何一个上游的结果,只告诉你都完了。结果还得你拿着原来那些 CF 各自去取。

// 等 user 和 sku 都完成,再开扣库存
CompletableFuture<String> stockF = CompletableFuture
        .allOf(userF, skuF)                                    // 只看都好了,不带结果
        .thenCompose(v -> CompletableFuture.supplyAsync(         // v 永远是 null,这里不关心它
                () -> deductStock(userF.join(), skuF.join()), pool)); // 要结果就从原始 CF join 取

join() 是 get() 的 CF 版,拿结果、不抛受检异常。因为 allOf 已保证到这一步 user、sku 都完成了,join 不会阻塞。

第五招:thenCombine——两个都好了,拿到各自结果再合流

既然 allOf 拿不到结果,CF 直接给了个更顺手的:a.thenCombine(b, BiFunction)。它等 a、b 两个都完成,把两者的结果一起交给这个 BiFunction,产出一个新 CF。刚才 stock 那段可以写得更干净:

CompletableFuture<String> stockF =
    userF.thenCombine(skuF, (u, s) ->
        deductStock(u, s));   // u、s 直接是 user 和 sku 的结果,不用 join

一句话对比:allOf 只告诉你都完了、thenCombine 还顺手把结果给你合流。

第六招:handle / whenComplete——成功失败都要看一眼

任务不只会成功,也会失败。thenApply/thenCompose/thenCombine 这些只在成功时走。CF 有一对双通道方法,成功失败都会经过:

  • cf.handle((result, exception) -> newResult):拿到结果或异常,返回一个新结果。它是唯一能把异常转成正常值的方法。
  • cf.whenComplete((result, exception) -> {...}):长得像 handle,但它只看一眼、做个副作用,处理完仍原样往下传,不能把失败洗成成功。
stockF.whenComplete((r, ex) -> {
    if (ex != null) System.out.println("扣库存挂了:" + ex);
    else            System.out.println("扣库存成功:" + r);
});

记住这条分水岭——能改写结果(包括把失败变成功)的是 handle,只能旁观做点事的是 whenComplete。讲降级时会重提它。

第七招:exceptionally——只处理失败那条路

有时你只关心挂了怎么办,成功时不想多此一举。cf.exceptionally(ex -> fallback) 只在 cf 失败时执行、返回一个兜底值补上;成功时原样跳过。相当于专门给失败修的一条旁路。

先歇口气,认两个预备役

还有两个方法,这一节先混个脸熟,第二篇会重点用到,现在不展开细节:

  • cf.orTimeout(时长, 单位):到点了如果 cf 还没好,就把它置为异常完成,异常是 TimeoutException。留一句关键的:orTimeout 只标记这个 CF 超时,并不会去打断正在跑的任务——第 9 节会围绕这点做文章。
  • CompletableFuture.delayedExecutor(时长, 单位):返回一个晚点再派活的特殊 Executor。它不占你的业务线程池,JDK 内部只养一条守护线程管计时。第 10 节非阻塞重试的主角就是它。

把这几招串起来:整条下单链

现在用 CF 把整条下单流程顺下来,注意主线程从头到尾没有阻塞过一次:

ExecutorService pool = Pools.newOrchestrationPool();

CompletableFuture<String> userF = CompletableFuture.supplyAsync(() -> queryUser(), pool);
CompletableFuture<String> skuF  = CompletableFuture.supplyAsync(() -> querySku(),  pool);

userF.thenCombine(skuF, (u, s) -> deductStock(u, s))       // 两个都好了,拿到结果合流扣库存
     .thenApplyAsync(stock -> calcPromo(stock), pool)      // 算优惠
     .thenAcceptAsync(promo -> notifyUser(promo), pool)    // 发通知(thenAccept:拿结果做件事、不返回)
     .orTimeout(1, TimeUnit.SECONDS)                        // 整链兜个超时
     .exceptionally(ex -> { System.out.println("链路挂了:" + ex); return null; }); // 失败旁路

pool.shutdown();

主线程派完 user、sku 就往下走了,剩下 stock、promo、notify 全靠完成时自动触发,没有任何人 get 干等。这就是 CF 的精髓:把等待换成了订阅。

不过有件事得留个心眼。这段链子一旦中间 deductStock 抛了异常,后面的 thenApplyAsync、thenAcceptAsync 会怎样?先记下这条 CF 的脾气:

CF 的链上,只要有一环异常完成,后面所有 thenApply/thenCompose/thenCombine 这类只在成功时走的环节会被整体跳过,异常一路传到链尾,直到遇到 exceptionally/handle/whenComplete 才被处理。

这个跳过特性,既是好事(异常自动往上传),也是大坑(你想在中间环节做点事反而做不了)。第二篇第 8 节会专门把这个坑踩个明白。

现在 CF 我们会用了,但还有更根本的问题——上面这条链是我一行行手写死的。真实系统里任务几十个、依赖交叉,不可能给每个场景手搓一遍链子。得有一种方式,把谁依赖谁描述出来,让框架自己去生成这条链。下一节就来画这张依赖地图。

(这一节记住一点:CF 能 supplyAsync 派活、thenApply/thenCompose/thenCombine 串接、allOf 汇合、handle/whenComplete 双通道、exceptionally 修失败、orTimeout 设 deadline、delayedExecutor 延迟。它把干等变成完成时自动触发。但链子还是手写的,尚不能按依赖自动生成。)


第 6 节 · 给任务画一张依赖地图

第 5 节那条链,是我把 user、sku、stock、promo、notify 的顺序在代码里写死的。要是明天加一个风控任务插在 promo 前、后天把 notify 改成同时发短信和邮件,就得重新捯这条链,改一处怕碰坏另一处。

任务一多,手写链子这件事本身就不现实。我们需要一种能声明谁依赖谁的描述方式,然后让框架自己推出执行顺序、自动生成那条异步链。

描述谁依赖谁,数学里正好有个现成的东西:有向无环图,简称 DAG。名字听着唬人,其实就是有方向、不会绕回来的依赖关系,当成一张流程图看即可:有向指每条边有箭头,A→B 表示 B 要等 A;无环指不能出现 A→B→C→A 这种绕回来自己等自己的情况。

我们下单场景就是一张 DAG(序章那张图)。一旦把任务画成 DAG,就有两个问题要回答:一,怎么保证它真的无环?二,怎么从这张图里算出一个合法执行顺序?

先说环为什么必须先查。如果 A 等 B、B 又等 A,这俩永远凑不齐上游都完成的条件,一个都不会开始,整张图卡死。所以任何编排框架开工第一件事,就是确认这张图没有环。查环有标准算法:DFS 深度优先遍历,带一个当前路径上的节点集合,一旦又走回集合里的节点就说明有环。代码第 7 节会写,这节先记住结论——必须先确认无环,才能往下排。

再说合法执行顺序。对下单这张图,一种顺序是 user、sku、stock、promo、notify(先两个无依赖的,再按依赖一层层往下)。注意 user 和 sku 谁先谁后无所谓、甚至可同时——合法顺序往往不止一个,我们要的是任一个都能保证每个任务出现时,它依赖的上游都在它前面。

这种给 DAG 排出一个不违背依赖的顺序,有个经典算法叫拓扑排序,用到入度这个概念。入度很简单:一个节点被几条箭头指着,入度就是几。下单图里 user 入度 0(没人依赖它),stock 入度 2(user、sku 都指向它)。

拓扑排序的思路也直白:入度为 0 的节点没有任何前置,可以先定它(下单里就是 user、sku)。定了一个节点,就把它对所有下游的贡献销掉——相当于把下游入度各减 1。谁入度减到 0,它前置齐了,进下一轮。这样一浪浪推下去,全部节点被定完就是拓扑序。若推到最后还剩节点入度降不到 0,说明剩下的之间有环。

这里给一个诚实的提醒,免得后面困惑:等看到第 7 节你会发现,框架运行时其实并不真靠先算出拓扑序再挨个执行。CF 那套依赖完成自动触发本身就已经按依赖顺序在跑了。拓扑排序真正的用武之地是在建图阶段做合法性校验(有没有环、依赖齐不齐)。我先把这个经典算法讲透,一是它能把依赖顺序这件事想扎实,二是它是循环依赖检测最好的参照系。到第 7 节会让两者各司其职。

现在依赖地图的骨架想清楚了:用一张图存任务和依赖;开工前先查有没有环;再顺着依赖关系靠 CF 自动触发把任务跑起来。

零件到这里算磨得差不多了。第二篇我们从三件套最小版开始,把这张依赖地图变成真能自动跑的编排器,再一层层把超时、重试、熔断、降级加上去。

(这一节记住一点:任务间依赖用 DAG 描述;无环要事先查,合法顺序可由拓扑排序(入度为 0 逐层剥)得出;但真正执行会靠 CF 的完成自动触发,拓扑排序主要用在校验。)


第二篇 · 框架诞生

零件磨好了,开始装配。这一篇我们从一张依赖地图出发,先搭一个最小能跑的编排器,再顺着一个个真实痛点,把超时、重试、熔断、降级、条件路由、监控、生命周期一层层加上去。我会尽量让每节只比上一节多一小块,让你看到它是连续长出来的。

第 7 节 · 最小能跑的编排器

先给这节要造的东西一个直观画面。所谓编排,其实很像工厂的流水线:每个工位(任务)前面摆一个"还差几个上游没到"的计数牌,只要牌清零,这个工位立刻开工;它干完,就把下游工位的牌各减一。没有中央调度员挨个喊话,全靠"完成即触发"。CF 的链式回调,天生就是这个画面。

我们要三样东西配合:

  • ResultContext:所有任务共享的一张结果板,谁算完了把结果写上去,下游从上面取。
  • AsyncNode:一个工位的描述——它做什么、依赖谁。
  • TaskGraph:整张流水线图,负责存节点、建图时查环。

再从这三样长出一个 AsyncOrchestrator:把图跑起来。这一节先不加任何容错,纯让它转起来。

ResultContext:一张线程安全的结果板

先说它为什么必须线程安全。CF 的每个任务在不同线程上完成,它们会并发地往这块板上写结果。用普通 HashMap 并发写会出问题(丢数据、甚至结构损坏)。所以我们用 ConcurrentHashMap 打底。先写最小版:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ResultContext {
    // 每个任务名 -> 它的结果;用并发 map,允许多线程同时写
    private final Map<String, Object> results = new ConcurrentHashMap<>();

    public void put(String taskName, Object result) {
        results.put(taskName, result);                 // 任务把结果写回板上
    }

    @SuppressWarnings("unchecked")
    public <T> T get(String taskName) {
        return (T) results.get(taskName);              // 下游按上游任务名取结果
    }

    public boolean isCompleted(String taskName) {
        return results.containsKey(taskName);          // 判断某任务是否已有结果
    }

    public Map<String, Object> getAll() {
        return Map.copyOf(results);                    // 对外给一份只读快照
    }
}

get 那个 (T) 强转是有代价的:它靠调用方自己保证类型对,运行时并不检查。眼下先能跑就行,等框架成熟些可以再加个 get(name, Class) 做类型约束。

热身验证一下它会存取:

ResultContext ctx = new ResultContext();
ctx.put("user", "张三");
System.out.println(ctx.get("user"));   // 张三

AsyncNode:一个工位的描述

一个节点需要:名字、要做的事、以及依赖哪些上游。要做的事我们用一个"能读结果板、产出结果"的函数来表达,这样 stock 才能从板子上读到 user、sku 的结果。

import java.util.*;

public class AsyncNode<T> {
    // 任务体:接收共享的结果板,产出类型 T 的结果
    @FunctionalInterface
    public interface Task<T> {
        T run(ResultContext context);
    }

    private final String name;
    private final Task<T> task;
    private final List<AsyncNode<?>> dependencies = new ArrayList<>();

    private AsyncNode(String name, Task<T> task) {
        this.name = name;
        this.task = task;
    }

    public static <T> AsyncNode<T> of(String name, Task<T> task) {
        return new AsyncNode<>(name, task);
    }

    // 流式声明依赖:.dependsOn(a, b) 可反复调用累加
    public AsyncNode<T> dependsOn(AsyncNode<?>... deps) {
        this.dependencies.addAll(Arrays.asList(deps));
        return this;
    }

    public String getName()           { return name; }
    public Task<T> getTask()          { return task; }
    public List<AsyncNode<?>> getDependencies() { return dependencies; }
}

这里 of(...) 静态工厂加 dependsOn(...) 返回 this 的链式写法,是为了让声明依赖读起来接近自然语言:AsyncNode.of("stock", ...).dependsOn(user, sku)。

TaskGraph:存节点 + 建图时查环

图先把节点收着,并提供两件事:查有没有环、算拓扑序。先写骨架和查环。第 6 节说过,运行时其实靠 CF 自动触发,拓扑排序主要用在校验,但我们还是把它写全,因为它能把依赖这件事坐实。

import java.util.*;

public class TaskGraph {
    // 用 LinkedHashMap 保持插入顺序,输出更稳定
    private final Map<String, AsyncNode<?>> nodes = new LinkedHashMap<>();

    public static TaskGraph of(AsyncNode<?>... arr) {
        TaskGraph g = new TaskGraph();
        for (AsyncNode<?> n : arr) g.addNode(n);
        return g;
    }

    public void addNode(AsyncNode<?> node) {
        if (nodes.containsKey(node.getName())) {
            throw new IllegalArgumentException("任务名重复:" + node.getName()); // 重名直接建图失败
        }
        nodes.put(node.getName(), node);
    }

    public AsyncNode<?> getNode(String name)   { return nodes.get(name); }
    public Collection<AsyncNode<?>> getNodes()  { return nodes.values(); }

    // 循环依赖检测:DFS + 三色标记
    public boolean hasCycle() {
        Set<String> visited = new HashSet<>();    // 已确定无环的节点
        Set<String> inPath  = new HashSet<>();    // 当前递归路径上的节点
        for (String name : nodes.keySet()) {
            if (dfsHasCycle(name, visited, inPath)) return true;
        }
        return false;
    }

    private boolean dfsHasCycle(String name, Set<String> visited, Set<String> inPath) {
        if (inPath.contains(name)) return true;   // 绕回了路径上的节点,有环
        if (visited.contains(name)) return false; // 之前已验过且无环,跳过
        visited.add(name);
        inPath.add(name);                          // 进入这个节点:加入路径
        for (AsyncNode<?> dep : nodes.get(name).getDependencies()) {
            if (dfsHasCycle(dep.getName(), visited, inPath)) return true;
        }
        inPath.remove(name);                       // 离开这个节点:移出路径
        return false;
    }

    // 拓扑排序:入度为 0 逐层剥(Kahn 算法)
    public List<AsyncNode<?>> topologicalSort() {
        if (hasCycle()) throw new IllegalStateException("存在循环依赖,无法排序");

        Map<String, Integer> inDegree = new HashMap<>();          // 每个节点入度
        Map<String, List<String>> downstream = new HashMap<>();   // 上游 -> 下游列表
        for (AsyncNode<?> n : nodes.values()) inDegree.putIfAbsent(n.getName(), 0);
        for (AsyncNode<?> n : nodes.values()) {
            for (AsyncNode<?> dep : n.getDependencies()) {
                downstream.computeIfAbsent(dep.getName(), k -> new ArrayList<>()).add(n.getName());
                inDegree.merge(n.getName(), 1, Integer::sum);     // n 依赖 dep,n 入度 +1
            }
        }

        Deque<String> queue = new ArrayDeque<>();
        inDegree.forEach((name, deg) -> { if (deg == 0) queue.offer(name); }); // 入度 0 先入队

        List<AsyncNode<?>> order = new ArrayList<>();
        while (!queue.isEmpty()) {
            String name = queue.poll();
            order.add(nodes.get(name));
            for (String next : downstream.getOrDefault(name, List.of())) {
                if (inDegree.merge(next, -1, Integer::sum) == 0)  // 消掉这条依赖,下游入度 -1
                    queue.offer(next);                            // 减到 0 就进下一轮
            }
        }
        return order;
    }
}

inPath 这个当前路径集合是查环的关键。普通 visited 只能记住访问过,但访问过不代表有环(可能只是多条边指向同一节点);只有沿着某条路径往下走、又撞回这条路径自己身上的节点,才说明成环。inPath 就是专门记当前这条路径的。

热身验证一下建图和拓扑序:

AsyncNode<String> user  = AsyncNode.of("user",  c -> "u");
AsyncNode<String> sku   = AsyncNode.of("sku",   c -> "s");
AsyncNode<String> stock = AsyncNode.of("stock", c -> "stock").dependsOn(user, sku);
TaskGraph g = TaskGraph.of(user, sku, stock);
System.out.println(g.hasCycle());                                    // false
g.topologicalSort().forEach(n -> System.out.print(n.getName() + " ")); // user sku stock(前两个顺序任意)

AsyncOrchestrator:把图跑起来

三件套齐了,现在写最核心的调度器。这一版只有一个方法,逻辑就一句话:给每个节点懒建一个 CF,这个 CF 等它所有依赖的 CF 都好了、再执行自己的任务并把结果写板。最后把全部节点的 CF 用 allOf 汇一个头,就是整张图。

先补一个马上要用的 Map 方法。computeIfAbsent(key, mappingFunction):如果 map 里还没有这个 key,就用函数算一个值放进去并返回;如果已经有了,直接返回现成的。关键在于它对同一个 key 是原子的——即便多个线程几乎同时为同一个节点名调用,也只会计算、放入一次。这恰好保证菱形依赖里同一个节点只被建一个 CF、不会重复跑。

import java.util.*;
import java.util.concurrent.*;

public class AsyncOrchestrator {
    private final ExecutorService executor;

    public AsyncOrchestrator(ExecutorService executor) {
        this.executor = executor;
    }

    // 对外入口:异步执行整张图,返回代表最终结果板的 CF
    public CompletableFuture<ResultContext> executeAsync(TaskGraph graph) {
        // 开工先查环,有问题直接返回一个异常完成的 CF(快速失败)
        if (graph.hasCycle()) {
            return CompletableFuture.failedFuture(new IllegalStateException("存在循环依赖"));
        }

        ResultContext context = new ResultContext();
        // 节点名 -> 该节点的 CF;ConcurrentHashMap 配 computeIfAbsent 保证原子构建
        Map<String, CompletableFuture<Void>> futures = new ConcurrentHashMap<>();

        // 给每个节点建(或复用)它的 CF,构建会顺着依赖递归展开
        for (AsyncNode<?> node : graph.getNodes()) {
            getOrCreate(node, context, futures);
        }

        // 把全部节点的 CF 汇成一个总 CF;都完成后返回结果板
        CompletableFuture<?>[] all = futures.values().toArray(new CompletableFuture[0]);
        return CompletableFuture.allOf(all).thenApply(v -> context);
    }

    // 取该节点的 CF:没有就现场建,有就直接复用
    private CompletableFuture<Void> getOrCreate(AsyncNode<?> node, ResultContext context,
                                                Map<String, CompletableFuture<Void>> futures) {
        return futures.computeIfAbsent(node.getName(), name -> buildFuture(node, context, futures));
    }

    // 核心:一个节点的 CF = 等所有依赖 CF 完成 -> 再执行本节点任务 -> 写结果
    private <T> CompletableFuture<Void> buildFuture(AsyncNode<T> node, ResultContext context,
                                                    Map<String, CompletableFuture<Void>> futures) {
        List<AsyncNode<?>> deps = node.getDependencies();
        // 递归拿到每个依赖的 CF
        CompletableFuture<?>[] depFutures = deps.stream()
                .map(dep -> getOrCreate(dep, context, futures))
                .toArray(CompletableFuture[]::new);

        return CompletableFuture.allOf(depFutures)     // 等所有上游完成(无依赖时这里立即完成)
                .thenRunAsync(() -> {                   // 上游齐了,异步跑本节点任务
                    T result = node.getTask().run(context);
                    context.put(node.getName(), result);// 把结果写上结果板,供下游读取
                }, executor);
    }
}

几个值得点一下的设计:

  • 用 CompletableFuture<Void> 而不是 CompletableFuture<T> 作为节点 CF。因为结果我们统一写进 ResultContext 这块共享板上,CF 只负责表达"这个节点完事了没",不负责搬运结果。这样依赖几个上游、取它们的结果,都从板上按名字拿,链子不必把类型传来传去。
  • 无依赖的节点,depFutures 是空数组,allOf() 立即完成,于是它一建好就开跑——这正好实现了根节点马上并行启动。
  • getOrCreate 的递归会在需要时才去建上游的 CF,天然把整张图从被依赖者到依赖者串起来,且每个节点只建一次。

让下单图真跑起来

现在把序章那五个任务接上,看它自己并行、自己按依赖触发:

public class OrderMain {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Pools.newOrchestrationPool();

        AsyncNode<String> user  = AsyncNode.of("user",  c -> { sleep(200); return "user:张三"; });
        AsyncNode<String> sku   = AsyncNode.of("sku",   c -> { sleep(200); return "sku:iPhone"; });
        AsyncNode<String> stock = AsyncNode.of("stock", c -> {
            String u = c.get("user");                 // 从结果板读上游 user 的结果
            String s = c.get("sku");                  // 读上游 sku 的结果
            sleep(150); return "扣库存(" + u + " + " + s + ")";
        }).dependsOn(user, sku);
        AsyncNode<String> promo = AsyncNode.of("promo", c -> {
            sleep(100); return "优惠(" + c.get("stock") + ")";
        }).dependsOn(stock);
        AsyncNode<String> notify = AsyncNode.of("notify", c -> {
            sleep(50);  return "已通知(" + c.get("promo") + ")";
        }).dependsOn(promo);

        TaskGraph graph = TaskGraph.of(user, sku, stock, promo, notify);
        AsyncOrchestrator orchestrator = new AsyncOrchestrator(pool);

        long start = System.currentTimeMillis();
        // 这里用 .get() 只是为了让 main 等到全部跑完好打印;框架内部并没有阻塞调度
        ResultContext result = orchestrator.executeAsync(graph).get();

        result.getAll().forEach((k, v) -> System.out.println(k + " => " + v));
        System.out.println("总耗时 = " + (System.currentTimeMillis() - start) + "ms");
        pool.shutdown();
    }

    static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ignored) {} }
}

输出大致是:

user => user:张三
sku => sku:iPhone
stock => 扣库存(user:张三 + sku:iPhone)
promo => 优惠(扣库存(user:张三 + sku:iPhone))
notify => 已通知(优惠(扣库存(user:张三 + sku:iPhone)))
总耗时 = 500ms

这里有一处值得停下来看一眼:总耗时是 500ms,而不是各步相加的 700ms。差别就出在 user 和 sku 那 200ms——它们并行跑了,只算了一次。我们没写任何"让它俩并行"的代码,是 CF 的依赖链天然把它们同时推了出去。这就是编排框架的价值:你只声明依赖,并行交给它。

再强调一遍那个 .get()。它出现在 main 里,只是为了演示"等整张图跑完再打印"。框架内部从头到尾没用它,真正的调度全靠 thenRunAsync 自动触发。这个区别后面会越来越重要。

到这里,一个最小能跑的编排器就成立了。但我们埋了个雷:如果 stock 中途抛异常,promo、notify 会怎么样?第 5 节末尾提过 CF 那个异常时整体跳过下游的脾气,下一节就专门去踩这颗雷。

(这一节我们造出了:ResultContext、AsyncNode、TaskGraph,和一个靠 computeIfAbsent 加 allOf().thenRunAsync() 懒建 CF 链、能让下单图自动并行跑通的最小编排器。但它还没有任何容错——任务失败时会发生什么,下一节见分晓。)


第 8 节 · 依赖失败会"人间蒸发"

上一节末尾我留了颗雷。现在把它引爆:让 stock 抛个异常,看看会发生什么。

先给 stock 加一句 throw:

AsyncNode<String> stock = AsyncNode.of("stock", c -> {
    String u = c.get("user");
    String s = c.get("sku");
    sleep(150);
    throw new RuntimeException("库存不足");   // 模拟扣库存失败
}).dependsOn(user, sku);

跑起来,main 里那句 executeAsync(graph).get() 直接抛了 ExecutionException。这个能预料。真正让人意外的是结果板:stock 挂了,promo 和 notify 怎么样?答案是——板子上关于它俩的任何记录都没有。它们没执行(对),但也没被标记失败、没写任何错误、什么都没留下。我把这叫作被跳过任务的"三无"状态:不跑、不记、不报。

为什么会这样?回想第 5 节埋的那条 CF 脾气,以及第 7 节我们的写法:

CompletableFuture.allOf(depFutures).thenRunAsync(() -> { 执行本节点 })

stock 抛异常 → stock 的 CF 异常完成 → 依赖它的 promo,其 allOf(depFutures) 也异常完成 → thenRunAsync 的那个 lambda 被整体跳过(CF 只在成功时才走 thenRunAsync)→ promo 的 CF 也因此异常完成 → notify 同理。于是 promo、notify 的 lambda 从头到尾没进过那扇门,自然不会往结果板上写任何东西。

这里有个反直觉但很重要的点:跳过不是 bug,恰恰是 CF 异常传播的设计——上游炸了,下游那些只在成功时才做的加工就不该做。问题只在于:框架对外汇报时,"这个任务是因为依赖挂了才没跑"和"这个任务根本没被安排"这两件事,我们在结果板上没留下任何区分痕迹。等下要写监控、要给用户看执行报告,就会是一笔糊涂账。

我们先把"被跳过"这件事记录下来。第一步,给 ResultContext 补上错误和跳过两张板:

private final Map<String, Throwable> errors = new ConcurrentHashMap<>();   // 任务名 -> 失败原因
private final Map<String, Boolean> skipped = new ConcurrentHashMap<>();    // 任务名 -> 是否被跳过

public void putError(String taskName, Throwable error) { errors.put(taskName, error); }
public Throwable getError(String taskName) { return errors.get(taskName); }
public boolean isFailed(String taskName) { return errors.containsKey(taskName); }

public void markSkipped(String taskName) { skipped.put(taskName, true); }
public boolean isSkipped(String taskName) { return skipped.getOrDefault(taskName, false); }

isCompleted 也要改成"有结果或有错误都算处理过":

public boolean isCompleted(String taskName) {
    return results.containsKey(taskName) || errors.containsKey(taskName);
}

第二步,引入一个专门的异常,用来讲清"这个任务不是自己挂的,是上游挂拖累的":

public class DependencyFailedException extends RuntimeException {
    public DependencyFailedException(String message, Throwable cause) {
        super(message, cause);   // cause 里放上游那个原始异常,保留根因
    }
}

第三步,也是关键:在依赖被跳过时补一个失败哨兵。既然 thenRunAsync 的 lambda 进不去,我们就把"记录跳过"这件事挂在失败通道上——exceptionally。它只在 CF 异常完成时执行,正好接住"因依赖失败而被跳过"的这一路。

给 buildFuture 的返回链尾巴加一段:

return CompletableFuture.allOf(depFutures)
        .thenRunAsync(() -> {
            T result = node.getTask().run(context);
            context.put(node.getName(), result);
        }, executor)
        .exceptionally(ex -> {
            // 走到这里,说明本节点没能正常完成:要么自己执行失败,要么被上游拖累
            if (!context.isCompleted(node.getName())) {
                // 板子上没本节点的记录 → 是依赖失败导致根本没执行 → 标记跳过
                Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
                context.markSkipped(node.getName());
                context.putError(node.getName(),
                        new DependencyFailedException("依赖失败,跳过:" + node.getName(), cause));
            }
            throw new CompletionException(ex);   // 把异常原样继续往上传,让下游也能感知
        });

这里有个新手很容易踩的反模式,我想特别说一下:既然要"发现依赖失败就记录跳过",直觉上很多人会想在 thenRunAsync 那个 lambda 的最前面写一段"先检查我的依赖是不是都成功了,有失败的我就记录跳过"。但那段代码永远不会执行——因为一旦依赖失败,allOf 异常完成,整个 thenRunAsync 的 lambda 就被跳过了,你写在它肚子里的检查自然也到不了。教训是:失败时的副作用,必须挂在 exceptionally 或 handle 这种失败也会经过的通道上,而不是塞在"只在成功时执行"的正向 lambda 里。

现在再让 stock 抛异常跑一遍,结果板就清楚多了:stock 记录了真实失败,promo、notify 各有一条 DependencyFailedException 且被标记为跳过。虽然它们仍然不会执行,但至少"为什么没跑"有据可查。

留一个尾巴:这一节我们只是把跳过记进了板子,但下游任务被跳过时并没有触发任何"某某失败了"的通知。这个我们放到第 16 节讲回调时一起补上,眼下先专注把失败传播的机制理顺。

(这一节记住一点:上游失败会让下游的 thenRunAsync 被整体跳过,造成不跑、不记、不报的三无状态;这是 CF 异常传播的正常结果,但要给被跳过的任务留记录就得在 exceptionally/handle 这种失败通道上补,别塞进正向 lambda。)


第 9 节 · 给单个任务装上超时

stock 挂了会拖累下游,这我们已经能记录和传播。但还有另一种更磨人的情况:stock 没抛异常,只是卡住了——比如它调的那个远程服务迟迟不返回。它既不成功也不失败,就那么占着一条线程干耗,下游 promo、notify 也就一直等不到。

我们得能给单个任务设一个最多等多久的界限。这一节有意思的地方在于:我们会先写一个能用的版本,跑起来却发现它悄悄浪费了一倍线程,然后再把它修正。这个先错后对的过程,本身就是理解超时最好的路。

先写第一版,很符合直觉:既然任务是异步跑的,那我给它的 Future 加个最多等 timeout。用第 4 节的 submit + get(timeout):

private <T> CompletableFuture<Void> executeWithTimeout_V1(AsyncNode<T> node, ResultContext context) {
    Future<T> f = executor.submit(() -> node.getTask().run(context));        // 占线程 A
    return CompletableFuture.supplyAsync(() -> {
        try {
            T r = f.get(node.getTimeout(), node.getTimeoutUnit());           // 这行又占线程 B 在干等
            context.put(node.getName(), r);
            return null;
        } catch (TimeoutException e) {
            f.cancel(true);
            throw new CompletionException(new TimeoutException(node.getName()));
        } catch (Exception e) {
            throw new CompletionException(e);
        }
    }, executor);                                                            // 这个 supplyAsync 占线程 B
}

逻辑没错,功能也能成。但请你数一数它一次要占几条线程:executor.submit(task) 让真正干活的任务占了线程 A;为了让等结果这件事也异步,我又 supplyAsync 了一段,这段里 f.get(timeout) 阻塞着,占了线程 B。一个任务,两条线程——而且线程 B 啥正事没干,纯粹在 get 上干等。并发一高,线程全被陪等占满,真正干活的反而排不上。

在 JDK 9 之前,这几乎是标准写法。但既然有 CompletableFuture,我们值得追求更省的方案。问题出在等这个动作上。能不能不去等,而是让 CF 到点自己判超时?能,这就是第 5 节认识的 orTimeout。重写:

private <T> CompletableFuture<Void> executeWithTimeout(AsyncNode<T> node, ResultContext context) {
    // 任务直接 supplyAsync 跑,只占一条线程
    CompletableFuture<T> taskFuture = CompletableFuture.supplyAsync(
            () -> node.getTask().run(context), executor);

    CompletableFuture<T> timed = node.hasTimeout()
            ? taskFuture.orTimeout(node.getTimeout(), node.getTimeoutUnit())  // 到点自动置为 TimeoutException
            : taskFuture;

    return timed.thenAccept(r -> context.put(node.getName(), r));            // 成功才写板
}

现在一个任务只占一条线程。orTimeout 不额外占用你的业务线程池——它借用 JDK 内部一条专门计时的守护线程,到点把这个 CF 标记成 TimeoutException。计时这件事不再需要一条人肉陪等的线程,浪费就没了。表述上要抠字眼:它省的是不占你的线程池,而不是完全没有线程,计时那条守护线程还是存在的。

不过 orTimeout 有个必须知道的脾气:它只把这个 CF 判成超时,并不会去打断那个还在跑的任务体。也就是说,stock 的 supplyAsync 里那个卡住的动作,可能还在后台耗着,只是它的结果不再有人要了。要真想让它停下来,得手动中断。中断靠 Thread.interrupt。问题是怎么拿到正在跑 stock 的那条线程。我们在任务体一开始就把当前线程记下来,超时了再去中断它:

private <T> CompletableFuture<Void> executeWithTimeout(AsyncNode<T> node, ResultContext context) {
    AtomicReference<Thread> running = new AtomicReference<>();   // 记住跑这个任务的线程

    CompletableFuture<T> taskFuture = CompletableFuture.supplyAsync(() -> {
        running.set(Thread.currentThread());                      // 任务体一开头,把当前线程登记下来
        return node.getTask().run(context);
    }, executor);

    if (node.hasTimeout()) {
        CompletableFuture<T> timed = taskFuture.orTimeout(node.getTimeout(), node.getTimeoutUnit());
        timed.whenComplete((r, ex) -> {
            if (ex instanceof TimeoutException) {
                Thread t = running.get();
                if (t != null) t.interrupt();                     // 超时了,去中断那条线程
            }
        });
        return timed.thenAccept(r -> context.put(node.getName(), r));
    }
    return taskFuture.thenAccept(r -> context.put(node.getName(), r));
}

interrupt 能不能真停下那个任务,取决于它在忙什么。这里有个常被误解的点:interrupt 只是给目标线程打了个"你被要求中断"的标记(设置中断标志位)。它对正处于 sleep、wait、阻塞 IO 这些可中断等待里的线程有效——这些方法会抛 InterruptedException、把线程唤醒。但如果任务在闷头算一个死循环(纯 CPU),它根本不看中断标志,interrupt 对它毫无作用。所以中断是补强,不是万能刹车;真要任务能被停,得任务自己配合。

配套的 AsyncNode 开关也补上:

private long timeout = 0;
private TimeUnit timeoutUnit = TimeUnit.MILLISECONDS;
public AsyncNode<T> withTimeout(long t, TimeUnit unit) { this.timeout = t; this.timeoutUnit = unit; return this; }
public boolean hasTimeout() { return timeout > 0; }
public long getTimeout() { return timeout; }
public TimeUnit getTimeoutUnit() { return timeoutUnit; }

于是 stock 就能这么声明"我最多给你 100ms":

AsyncNode.of("stock", c -> { ... }).dependsOn(user, sku).withTimeout(100, TimeUnit.MILLISECONDS);

回头看这一节绕的这个小弯:先写了功能正确却一任务两线程的版本,看清浪费在哪,再用 orTimeout 收到一条线程,最后补上 interrupt 处理 orTimeout 管不到的任务体本身。这条修正路径,比直接给最终答案更能说明为什么最终要长这样。

(这一节记住一点:submit+get(timeout) 能让超时生效,但一个任务占两条线程、一条纯陪等;orTimeout 把等待换成 CF 到点自判,只占一条线程、计时借 JDK 内部守护线程;但 orTimeout 不打断任务体,需要用 AtomicReference 记线程 + interrupt 补强,而 interrupt 只对可中断的等待有效。)


第 10 节 · 失败自动重试,但不许霸占线程

远程调用偶尔抽风一下、过会儿又好了,这种事我们都遇过。对这种抖一下,最管用的招就是重试一下。这一节我们想把重试加到框架里,而它正好是全篇最能体现 CF 威力的地方,我会多花点笔墨。

先说清楚我们要的重试长什么样。一个 RetryPolicy 就三个参数:最多重试几次(maxRetries)、首次退避多久(backoffMs)、每次退避乘以多少(backoffMultiplier,让重试间隔越来越宽,即指数退避)。

public class RetryPolicy {
    private final int maxRetries;            // 最多重试次数(不含首次执行)
    private final long backoffMs;            // 首次退避时间
    private final double backoffMultiplier;  // 退避倍数,2.0 就是每次翻倍

    public RetryPolicy(int maxRetries, long backoffMs, double backoffMultiplier) {
        this.maxRetries = maxRetries;
        this.backoffMs = backoffMs;
        this.backoffMultiplier = backoffMultiplier;
    }
    public int getMaxRetries() { return maxRetries; }
    public long getBackoffMs() { return backoffMs; }
    public double getBackoffMultiplier() { return backoffMultiplier; }
}

配套的开关也好加:withRetry(RetryPolicy)、hasRetry()、getRetryPolicy()。具体实现略,和前几节的 withTimeout 一个模式。

先写一个能想得到的版本

重试最直接的写法就是循环:试一次,失败就睡一会儿再试。我们把每个"尝试"包在一个异步任务里:

private <T> CompletableFuture<T> executeWithRetry_V1(AsyncNode<T> node, ResultContext context) {
    return CompletableFuture.supplyAsync(() -> {
        RetryPolicy p = node.getRetryPolicy();
        RuntimeException last = null;
        for (int i = 0; i <= p.getMaxRetries(); i++) {
            try {
                return node.getTask().run(context);          // 试一次
            } catch (RuntimeException e) {
                last = e;
                try { Thread.sleep(p.getBackoffMs()); }      // 失败:就地睡一会——坑就在这
                  catch (InterruptedException ie) { break; }
            }
        }
        throw last;                                          // 全失败,抛出最后一次
    }, executor);
}

这版能跑,逻辑也对。但把第 9 节的教训拿过来一镜照:supplyAsync 已经把任务放到了一条工作线程上,而这个线程在 Thread.sleep(backoffMs) 期间,其实什么都没干,就是抱着一条线程在睡。退避 3 次、每次睡 200ms,就等于一条线程白白被占 600ms。高并发下,这些"在睡的重试"会把线程池占满,真正能干活的任务反而排不上。跟那个陪等的超时一个毛病,只是换了个地方。

想要的状态:退避时不占任何线程

我们理想中的重试,是:失败了,把"等一会儿再试"这件事登记给一个定时器,然后当前线程立刻释放去干别的;时间一到,定时器再把下一次尝试重新投进线程池。等待期间,没有一条业务线程在被霸占。

这恰好就是 delayedExecutor 的用武之地——还记得第 5 节那个只混了脸熟的方法吗?CompletableFuture.delayedExecutor(delay, unit) 返回一个"到点才执行"的 Executor。交给它的任务不会立即跑,而是等满时长后开始;计时本身由 JDK 内部一条守护线程负责,不占你的业务线程池。用它做退避,等待就不再霸占工作线程。

先理解 Promise:一张取货号码牌

把重试接上异步链,有个绕不开的设计得先讲透,不然后面 code 会看起来像魔法。我拿一个生活中的东西类比:去餐馆取餐,你拿号下单时会拿到一张取货号码牌。这张牌代表"你的餐将来会好"。后厨可能炒糊一锅、重来、又炒糊、又重来(对应重试),但这些都发生在牌子的背后。你只看着那张牌,它一旦被"完成"(要么可以取餐、要么告诉你做不了),你就拿到最终结果,完全不需要知道中间炒了几次。

在 Java 里,new CompletableFuture<>()(不带任何上游、自己构造的空 CF)就是这样一张牌:它一开始没结果,你在任意时刻调它的 complete(值) 或 completeExceptionally(异常),它就定下来。这就是 Promise 模式:对外递一张"承诺",内部拿多条异步路径去竞争完成它。调用方只拿到最终态,不必知道重试了几次。

重试的逻辑就变成:先造一张承诺牌 promise;每次尝试完,成功就 promise.complete;失败且还有次数就退避后发起下一次尝试、继续用同一张牌去收敛;次数用尽就 promise.completeExceptionally。

非阻塞重试的实现

先声明一下:为了能把重试叠在超时之上,我把第 9 节那个会直接写结果板的 executeWithTimeout 拆了一下——拆成一个只负责产结果、先不碰板的 executeOnce(超时+中断补强都在它里),写板统一放到最外层做。这样重试时每一次尝试都是干净的一次产出,不会各自抢着写板:

// 单次尝试:负责超时和中断,只产出结果、不写板
private <T> CompletableFuture<T> executeOnce(AsyncNode<T> node, ResultContext context) {
    AtomicReference<Thread> running = new AtomicReference<>();
    CompletableFuture<T> f = CompletableFuture.supplyAsync(() -> {
        running.set(Thread.currentThread());
        return node.getTask().run(context);          // 仍可读板拿上游结果
    }, executor);
    if (node.hasTimeout()) {
        CompletableFuture<T> timed = f.orTimeout(node.getTimeout(), node.getTimeoutUnit());
        timed.whenComplete((r, ex) -> {
            if (ex instanceof TimeoutException) { Thread t = running.get(); if (t != null) t.interrupt(); }
        });
        return timed;
    }
    return f;
}

现在是非阻塞重试的核心。chainRetry 把一张承诺牌和一个"本次尝试的 CF"绑在一起,根据本次成败决定牌的命运,失败就用 delayedExecutor 退避后递归接下一次:

private <T> CompletableFuture<T> executeWithRetry(AsyncNode<T> node, ResultContext context) {
    CompletableFuture<T> promise = new CompletableFuture<>();   // 那张取货号码牌
    chainRetry(promise, executeOnce(node, context), node, node.getRetryPolicy(), 1, context);
    return promise;                                             // 对外只递这张牌
}

private <T> void chainRetry(CompletableFuture<T> promise, CompletableFuture<T> attempt,
        AsyncNode<T> node, RetryPolicy policy, int nextAttempt, ResultContext context) {
    attempt.whenComplete((r, ex) -> {
        if (ex == null) { promise.complete(r); return; }              // 成功:兑现承诺,牌定下来
        if (nextAttempt > policy.getMaxRetries()) {                    // 重试次数用尽
            promise.completeExceptionally(ex); return;
        }
        long delay = (long) (policy.getBackoffMs()
                * Math.pow(policy.getBackoffMultiplier(), nextAttempt - 1));  // 指数退避
        System.out.println("  [重试] 第 " + nextAttempt + " 次,延迟 " + delay + "ms");
        // 关键:runAsync 的空任务体只为拿到延迟效果,等待不占业务线程
        CompletableFuture.runAsync(() -> {},
                CompletableFuture.delayedExecutor(delay, TimeUnit.MILLISECONDS))
            .whenComplete((v, e) -> {
                // 延迟到点,重新发起一次尝试(又走超时那套),并递归接上下一次
                chainRetry(promise, executeOnce(node, context), node, policy, nextAttempt + 1, context);
            });
    });
}

逐段看一下为什么这么写:

  • 整个重试不靠循环,而靠 whenComplete 回调一层层自己接自己(递归)。第一次失败后在回调里发起第二次,第二次失败又在它自己的回调里发起第三次……没有一条线程在循环里干睡。
  • runAsync(() -> {}, delayedExecutor(delay)) 那个空任务体看着很怪,其实是惯用写法:我们不需要它做什么,只需要它"延迟 delay 再完成"这个效果;真正的重投放在它后面的 whenComplete 里。
  • 每次重试都重新调 executeOnce,意味着每次都重新经过超时层——所以超时是对"单次尝试"计时,而不是对"整串重试"计时。这个区别很关键,第 13 节会正面讲。

口径上补一句容易误会的:说"退避不占线程",准确说法是不占你的业务线程池;JDK 内部那条延迟调度守护线程还是存在的,它只负责掐点,到点了把活投回你的池。它不会为每个重试各开一条线程,全局就那么一条计时线程。

现在给 stock 挂上重试,配合超时,它就能表达"先给 100ms,不行就退避重试最多 3 次":

AsyncNode.of("stock", c -> { ... })
    .dependsOn(user, sku)
    .withTimeout(100, TimeUnit.MILLISECONDS)
    .withRetry(new RetryPolicy(3, 200, 2.0));   // 退避依次约 200ms, 400ms, 800ms

这一节我们把第 5 节埋的两个伏笔一次性收成了:orTimeout 让超时不占线程,delayedExecutor 让重试不占线程。再加上 Promise 这张号码牌把"多次尝试"藏在一张对外承诺背后,异步重试就这么立起来了。

(这一节记住一点:循环+Thread.sleep 的重试会在退避期间霸占工作线程;改用 delayedExecutor 做非阻塞退避,用 Promise(一个对外 complete/completeExceptionally 的空 CF)藏住内部多次尝试,靠 whenComplete 递归自己接自己;退避不占的是业务线程池,JDK 内部只一条计时守护线程。)


第 11 节 · 熔断:连续失败就别再打了

上一节的重试,治的是"偶尔抽一下"——抖一抖的下游,多试两次大概率就通了。但有种情况重试不但帮不上忙,还会火上浇油:下游是真挂了,比如服务宕了、一直在返回 500。这时候你还按着指数退避一遍遍去踹这扇坏掉的门,结果是每个请求都得先耗满"重试全部失败"的时间才作罢,用户等得更久;更要命的是,这些重试流量全压在一个已经奄奄一息的下游身上,它本来缓一缓或许还能活,被这么反复捶打,很可能直接雪崩,连带着依赖它的别的服务一起倒。

我们想要的是另一种反应:能识别出"对方大概率是真不行了",于是暂时别再打过去,给下游一条生路,也给自己一条生路——别把线程都耗在注定失败的重试上。这就是熔断。

生活里最贴的类比是家里的保险丝,或者空气开关。电流正常时它导通;一旦持续过载,它主动断开,整条回路停下来,避免线路烧毁起火;等故障排除了,再合上恢复供电。熔断器要做的,就是给每个容易挂的下游配一根可编程的保险丝。

它的行为用三个状态就能说清楚,这是个典型的状态机:

   连续失败达到阈值
CLOSED ───────────────▶ OPEN
  ▲  正常放行、边放行边数失败      拒绝一切请求、任务不启动
  │                                │
  │ 探测成功                        │ 过了恢复期
  └────── HALF_OPEN ◀──────────────┘
          偷偷放行一次请求去探测
          (探测失败就立刻退回 OPEN)
  • CLOSED(关闭):正常态,请求照常放行,同时数着连续失败次数。
  • OPEN(打开):连续失败到了阈值就"跳闸"。这个状态下所有请求直接被拒绝,连任务都不启动——这正是我们要的:别再捶下游,也别浪费自己的线程。
  • HALF_OPEN(半开):OPEN 不是判了死刑。过了一个"恢复期",熔断器会偷偷放一个请求过去探探路。这一次要是成功了,说明下游多半缓过来,就回到 CLOSED;要是还失败,说明没戏,立刻退回 OPEN,再等下一个恢复期。

这里的关键是"半开只放行一次探测"。它给了系统一个自我了断式试探的机会,不至于下游明明恢复了、你却永远把它锁在门外。

先把它单独写出来

这一节没有新的 CompletableFuture API,要用到的 whenComplete 第 5 节就认识了。所以我们先把熔断器本身写成一个纯同步的小对象,脱离框架先把它单独跑通——先用几行代码看它状态怎么翻转,确认逻辑对了,第 13 节再把它接进执行链。别一上来就把它塞进异步链,那样状态流转和异步回调搅在一起,很难看清。

先定义状态这个枚举,三个值对应上面那三种:

public class CircuitBreaker {

    public enum State { CLOSED, OPEN, HALF_OPEN }   // 关闭(放行) / 打开(拒绝) / 半开(探测一次)

    private State state = State.CLOSED;             // 初始为正常
    private int failureCount = 0;                   // 当前连续失败计数
    private final int failureThreshold;             // 连续失败到多少就跳闸
    private final long resetTimeoutMs;              // 跳闸后过多久进入半开探测
    private long lastFailureTime = 0;               // 上次失败时刻,用来判断恢复期到没到

    public CircuitBreaker(int failureThreshold, long resetTimeoutMs) {
        if (failureThreshold <= 0) throw new IllegalArgumentException("failureThreshold 必须 > 0");
        if (resetTimeoutMs   <= 0) throw new IllegalArgumentException("resetTimeoutMs 必须 > 0");
        this.failureThreshold = failureThreshold;
        this.resetTimeoutMs   = resetTimeoutMs;
    }

两个方法决定"能不能放行"和"跑完后怎么记账"。注意它们都加 synchronized——熔断器的状态是多个线程共享的,同一下游可能被并发调用,计数和状态翻转必须串行化,否则会数错、跳错闸:

    /** 请求进来时先问一句:现在放行吗? */
    public synchronized boolean allowRequest() {
        if (state == State.OPEN) {
            // 打开状态:先看恢复期过了没
            if (System.currentTimeMillis() - lastFailureTime >= resetTimeoutMs) {
                state = State.HALF_OPEN;   // 过了恢复期,转半开,放这一次去探测
                return true;
            }
            return false;                  // 还在恢复期内,拒绝
        }
        return true;                        // CLOSED 或 HALF_OPEN 都放行
    }

    /** 一次执行结束后,把结果记进熔断器 */
    public synchronized void recordSuccess() {
        failureCount = 0;                  // 成功就把连续失败清零
        state = State.CLOSED;              // 无论之前半开还是关闭,成功一律回归正常
    }

    public synchronized void recordFailure() {
        failureCount++;                    // 失败:连续计数 +1
        lastFailureTime = System.currentTimeMillis();
        if (state == State.HALF_OPEN) {
            state = State.OPEN;            // 半开探测失败,立刻重新跳闸
        } else if (failureCount >= failureThreshold) {
            state = State.OPEN;            // 连续失败到阈值,跳闸
        }
    }
}

把它单独跑一遍,看状态怎么翻转。我们配一个"连续失败 2 次就跳闸、恢复期 200ms"的熔断器,然后模拟调用结果:

public class CBPreview {
    public static void main(String[] args) throws Exception {
        CircuitBreaker cb = new CircuitBreaker(2, 200);   // 阈值2、恢复期200ms

        cb.recordFailure();                              // 第1次失败,还没到阈值
        cb.recordFailure();                              // 第2次失败,到阈值 → 跳闸
        System.out.println("两次失败后放行吗?" + cb.allowRequest());   // false(OPEN,拒绝)

        Thread.sleep(250);                                 // 等过恢复期
        System.out.println("恢复期后放行吗?" + cb.allowRequest());     // true(转 HALF_OPEN,放一次探测)

        cb.recordSuccess();                               // 探测成功 → 回归 CLOSED
        System.out.println("探测成功后放行吗?" + cb.allowRequest());   // true
    }
}

这段脱离框架的小程序,把 CLOSED→OPEN→HALF_OPEN→CLOSED 一整圈走完了。确认它对,我们再往下接。

接进异步编排:前置检查、后置记录

接到执行链上时,有个顺序上的讲究:熔断检查得发生在任务启动之前。如果等任务都跑完了才发现"其实早该熔断",那这次对下游的捶打、这条被占的线程,还是没省下。所以要在构建这个任务的执行链之前,先同步问一句 allowRequest(),一旦被拒绝,就直接返回一个异常完成的 Future,任务体根本不提交、不启动:

// 在构建链的最前面(同步执行,早于任何 supplyAsync)
if (node.hasCircuitBreaker()) {
    CircuitBreaker cb = node.getCircuitBreaker();
    if (!cb.allowRequest()) {
        // 被熔断:直接造一个已失败的 Future,任务压根不跑
        return CompletableFuture.failedFuture(
                new CircuitBreakerOpenException("熔断器已打开,拒绝任务:" + node.getName()));
    }
}

CircuitBreakerOpenException 就是个普通的运行时异常,用来标记"这次失败是被熔断挡的,不是任务真跑挂了":

public class CircuitBreakerOpenException extends RuntimeException {
    public CircuitBreakerOpenException(String message) { super(message); }
}

放行之后,任务照常跑;等它有了结果,再在 whenComplete 里把成败记账。这一步是后置的,因为只有跑完才知道成没成:

chain = chain.whenComplete((result, ex) -> {
    if (ex == null) cb.recordSuccess();   // 成功:清零连续失败
    else            cb.recordFailure();   // 失败:累加,可能触发跳闸
});

配套给 AsyncNode 补个开关(老套路):withCircuitBreaker(CircuitBreaker)、hasCircuitBreaker()、getCircuitBreaker()。

AsyncNode.of("stock", c -> { ... })
    .dependsOn(user, sku)
    .withCircuitBreaker(new CircuitBreaker(5, 3000));   // 连续失败5次熔断、恢复期3s

一个必须抠清楚的口径

这里有个很容易算错的地方,值得单独拎出来。熔断器记的失败,是"冒泡到熔断这一层的那一次最终失败"。而第 13 节我们会看到,熔断这一层是裹在重试外层——所以一个任务哪怕内部重试了 3 次、真实尝试了 4 次全失败,冒泡到熔断层时也只是一个"失败",recordFailure() 只调一次、failureCount 只 +1。

代入具体数字感受一下:熔断阈值设成 5,任务又配了 maxRetries=3(即每个请求内部最多真实尝试 4 次)。那么要让熔断跳闸,需要的是 5 个请求周期各自失败,最坏情况下对应的是 5×4 = 20 次真实失败——而不是你以为的"5 次就跳"。反过来说,如果你想的是"下游连续挂 5 次我就赶紧熔断止血",那就别把熔断放在重试外面,或者把阈值按重试次数换算一下。口径对不上,熔断的开闸时机就会和你的预期差着一截,这个坑调参时最容易踩。

(这一节记住一点:熔断是给易挂下游配的一根可编程保险丝,靠 CLOSED/OPEN/HALF_OPEN 三态流转,半开时放行一次探测来自我恢复;接入异步链要"前置 allowRequest 拦截、后置 whenComplete 记账";记账口径是"冒泡到熔断层的最终一次失败",配了重试时 N+1 次真实尝试只算 1 次,阈值要按这个口径换算。)


第 12 节 · 降级:给失败一个台阶下

重试能救抖一下的,熔断能挡住持续挂的、顺便歇着等恢复。但有些环节你根本不能允许它就这么失败下去。比如下单链路末尾的 notify 发通知:短信网关抽风了、重试几次还不行、甚至熔断拒绝了——可订单本身该给用户一个回执,总不能因为通知发不出去就让整单在用户端崩掉。这种时候得给失败一个台阶下:实在救不回来,就返回一个兜底值,让下游拿着这个"退而求其次"的结果继续走。

这就是降级,它是整个容错洋葱里唯一一层"把失败变成功"的机制。前面超时、重试、熔断都是在想法子让任务成功或尽快止损,它们失败的结果仍然是失败;只有降级,会在最外层把那个失败的 Future 接住,转化成一个正常的返回值。

先用 handle 把失败接住

第 5 节讲过 handle:它是双通道回调,成功和失败都会经过它,而且它的返回值会作为下游的输入——也就是说,你在 handle 里给失败返回一个正常值,异常就被"消化"掉了,下游拿到的是一个成功结果。这正好是降级要的语义。先脱离框架看一眼这个转换效果:

CompletableFuture.supplyAsync(() -> { throw new RuntimeException("网关挂了"); })
    .handle((r, ex) -> ex != null ? "默认回执" : r)   // 有异常就换成兜底值
    .thenAccept(r -> System.out.println("下游拿到:" + r));  // 打印"下游拿到:默认回执"

上游明明抛了异常,下游却一点没受影响,稳稳收到了"默认回执"。这就是 handle 把失败变成功的瞬间。

把它包成一个策略

每个任务的兜底逻辑不一样:有的返回固定值,有的要根据异常类型给不同值,有的要从缓存里捞个旧值。所以我们把"怎么兜"抽成一个策略对象。它内部就存一个函数——接收异常、产出一个兜底值:

public class FallbackPolicy<T> {

    private final java.util.function.Function<Throwable, T> fallback;   // 异常 -> 兜底值

    public FallbackPolicy(java.util.function.Function<Throwable, T> fallback) {
        if (fallback == null) throw new IllegalArgumentException("fallback 不能为空");
        this.fallback = fallback;
    }

    /** 最常见的场景:不管什么异常,都返回一个固定值 */
    public static <T> FallbackPolicy<T> ofValue(T defaultValue) {
        return new FallbackPolicy<>(ex -> defaultValue);
    }

    public java.util.function.Function<Throwable, T> getFallback() { return fallback; }
}

接进执行链时,它永远在最外层,用一个 handle 兜住内层任何一环冒出来的失败:

if (node.hasFallback()) {
    chain = chain.handle((result, ex) -> {
        if (ex != null) {
            // 内层(任务/超时/重试/熔断)任一环失败了,在这里被兜住
            return node.getFallbackPolicy().getFallback().apply(unwrap(ex));
        }
        return result;                       // 内层成功则原样放行
    });
}

那个 unwrap(ex) 是把 CompletableFuture 包异常的壳(CompletionException)剥掉,拿到里面真正的异常再交给兜底函数——否则用户在兜底逻辑里看到的会是一个包装壳,判断不了到底出了什么事。它在第 13 节会正式出场,这里先记住它的作用。

有个特别容易误会的点要说清楚:降级不是"一进来就返回兜底值"。它是先让内层任务、超时、重试、熔断全都跑完,确确实实都失败了,才由最外层的它接手兜底。所以它是洋葱的最外层——最后才被碰到,也最先接触到最终冒泡出来的那个异常。如果一上来就返回兜底,那前面几层容错就全白搭了。

配套开关照旧:withFallback(FallbackPolicy)、hasFallback()、getFallbackPolicy()。

AsyncNode.of("notify", c -> { ... })
    .dependsOn(promo)
    .withFallback(FallbackPolicy.ofValue("发送失败,已转站内信"));   // 发不出去也给个台阶

(这一节记住一点:降级是容错洋葱里唯一把失败变成功的一层,用 handle 双通道在最外层把内层冒泡的异常接住、换成兜底值让下游继续;它是先让内层跑完、真失败才兜底,不是一进来就返回兜底值。)


第 13 节 · 洋葱模型合拢

到这里,超时、重试、熔断、降级四件东西各自都单独讲清楚、也各自跑通了。但它们是叠在一起的:一个 stock 可能同时配了超时、重试、熔断、降级。这一节要做的,就是把这四层按正确的次序裹成一条链——这个"一层裹一层"的结构,我们叫它洋葱模型。

先找个生活直觉。天冷出门穿衣服,你总有固定顺序:贴身的秋衣在最里,然后是毛衣,再是外套,最后围巾。你从里往外穿,脱的时候从外往里脱。洋葱模型一模一样:越靠近任务核心的层,越早构建;异常往外冒泡时,越外层的逻辑越先接手。我们要把这条穿衣顺序定死,因为一旦穿反,行为就全乱了。

由内到外一层层裹上

我们把单个任务的执行链构建收敛成一个方法 buildExecutionChain。它内部严格按"任务/超时 → 重试 → 熔断 → 降级"从内往外裹:

private <T> CompletableFuture<Object> buildExecutionChain(AsyncNode<T> node, ResultContext context) {

    // 第 0 步(前置、同步):熔断器放行检查,OPEN 就直接拒绝,任务压根不启动
    CircuitBreaker cb = null;
    if (node.hasCircuitBreaker()) {
        cb = node.getCircuitBreaker();
        if (!cb.allowRequest()) {
            return CompletableFuture.failedFuture(
                    new CircuitBreakerOpenException("熔断器已打开,拒绝任务:" + node.getName()));
        }
    }

    // 第 1 层(最内):单次尝试 = 任务体 + 超时 + 中断补强(第 9、10 节的 executeOnce)
    CompletableFuture<Object> chain = executeOnce(node, context);

    // 第 2 层:重试。把"单次尝试"包进非阻塞重试链,失败就退避后再来(第 10 节)
    if (node.hasRetry()) {
        chain = executeWithRetry(node, context);
    }

    // 第 3 层:熔断记账。裹在重试外层,因此只对最终冒泡出的那一次成败计数
    if (cb != null) {
        final CircuitBreaker cbRef = cb;
        chain = chain.whenComplete((result, ex) -> {
            if (ex == null) cbRef.recordSuccess();
            else            cbRef.recordFailure();
        });
    }

    // 第 4 层(最外):降级。兜住内层任何一环的失败,把异常换成兜底值
    if (node.hasFallback()) {
        chain = chain.handle((result, ex) -> ex != null
                ? node.getFallbackPolicy().getFallback().apply(unwrap(ex))
                : result);
    }

    return chain;
}

这里要注意,第 10 节我们说过"写板统一放最外层",指的就是:buildExecutionChain 只管产出最终结果或最终异常,不负责往结果板写东西、不负责触发回调。这些收尾工作放在它的调用方 executeTask 里,一次任务的计时、写板、回调都收在一处:

private <T> CompletableFuture<Object> executeTask(AsyncNode<T> node, ResultContext context) {
    long start = System.currentTimeMillis();
    return buildExecutionChain(node, context).whenComplete((result, ex) -> {
        context.putExecutionTime(node.getName(), System.currentTimeMillis() - start);  // 记整个容错过程耗时
        if (ex != null) {
            context.putError(node.getName(), unwrap(ex));        // 记失败原因
        } else {
            context.put(node.getName(), result);                 // 记成功结果
        }
        // 成功/失败回调在第 16 节补,这里先留位
    });
}

至于剥壳的 unwrap,实现很简单——CF 失败时习惯把真异常套在一个 CompletionException 里,我们把它取出来:

private Throwable unwrap(Throwable ex) {
    if (ex instanceof CompletionException && ex.getCause() != null) {
        return ex.getCause();      // 拆开壳,拿里面真正的异常
    }
    return ex;
}

为什么顺序不能反:两个反例

穿衣顺序反了会难受,这四层顺序反了会出真 bug。我们用两个反例来把顺序钉死,而不是背结论。

反例一:把超时放到重试外面。 假设顺序变成"重试在最内、超时在最外",超时值设成 100ms。那么 orTimeout 计的就是整串重试的总时长——第一次尝试万一慢了点,或者刚失败正准备退避重试,100ms 一到,整串直接被超时砍掉,后面的重试根本没机会发生。可我们本意是"每次尝试最多给 100ms,失败了退避后再给 100ms 重试几次"。所以超时必须在重试内层,对单次尝试计时;重试在超时外层,正是为了让每一层新尝试都能重新拿到一个完整的超时预算。代入数字:单次超时 100ms、重试 3 次、退避约 200/400/800ms,整串最坏墙钟是 100×4 + (200+400+800) ≈ 1800ms——远大于单个 100ms,这也说明超时只能卡每一次、不能卡总长。

反例二:把熔断放到重试里面。 假设熔断记账裹在单次尝试外面、却在重试里面,那么每次重试失败都会经过熔断记录,recordFailure() 被反复调。配 maxRetries=3 时,一个业务调用内部就 +4,你设的阈值会被远远提前击穿,在下游其实还能用的时候就误判跳闸、错误拒绝。所以熔断必须在重试外层,只对最终冒泡出的那一次成败计数——这正好和上一节末尾那条"N+1 只算 1 次"的口径对上了:口径能成立,全靠熔断裹在重试外面这个位置。

两个反例说的是同一件事:这四层不是随便叠的,从内到外固定是"任务/超时 → 重试 → 熔断 → 降级",每一层的位置都由它要解决的语义决定。

合拢到这一步,一个任务的完整容错链就成型了。把 stock 配上全套,它就是"单次给 100ms、失败退避重试最多 3 次、连续 5 轮都挂就熔断 3 秒、实在救不回就走兜底库存":

AsyncNode.of("stock", c -> { ... })
    .dependsOn(user, sku)
    .withTimeout(100, TimeUnit.MILLISECONDS)
    .withRetry(new RetryPolicy(3, 200, 2.0))
    .withCircuitBreaker(new CircuitBreaker(5, 3000))
    .withFallback(FallbackPolicy.ofValue("降级:查缓存库存"));

(这一节记住一点:四层容错按洋葱模型由内到外固定为"任务/超时 → 重试 → 熔断 → 降级",构建从内往外、异常冒泡从外往内接手;超时必须在重试内层(否则总时长被单个超时砍断、重试失去意义),熔断必须在重试外层(否则每次重试都累加计数导致过早跳闸误拒);写板与回调统一收在最外层 executeTask,兜底前用 unwrap 剥掉 CompletionException 壳。)


第 14 节 · 运行时拐弯:条件路由

到这里,一张图的结构是构建时就定死的:stock 完成了一定接 promo,promo 完成了一定接 notify。但真实业务里经常有“跑到一半才发现该走哪条岔路”的情况。比如扣完库存一看:存量归零了,那就不该走后面的正常发货,而该转一个缺货登记流程。这种“根据上游的实际结果,运行时才决定下一步走哪个分支”的能力,就是条件路由。

先把它要解决的场景说具体。假定 stock 跑完会往结果板里放一个库存数。我们希望在 stock 之后放一个“分流节点”:它自己不干业务,只负责看一眼 context 里的库存数,然后决定接下来执行 ship(正常发货)还是 shortage(缺货登记)。这个“看一眼决定走哪条”的拍板,就叫路由。

先用一个函数把拍板逻辑写清

路由的核心就是一个函数:输入当前上下文,输出一个分支 key。这一步先别想异步、先想框架,单独把它写明白:

// 输入结果板,输出一个分支标识:有货走 ship,没货走 shortage
java.util.function.Function<ResultContext, String> router = c -> {
    Integer qty = (Integer) c.get("stockQty");        // 从板上拿上游 stock 写下的库存数
    return (qty != null && qty > 0) ? "ship" : "shortage";
};

路由要能跑,还需要一张 key 到目标节点的对照表:"ship" 对应哪个节点、"shortage" 对应哪个节点。两样东西合起来,就是一个条件节点。

ConditionalNode:会自己选路的节点

我们让一个新的节点类型继承之前的 AsyncNode,因为它本质上还是一个节点,只是多了“路由函数 + 分支表”:

public class ConditionalNode<T> extends AsyncNode<T> {

    private final java.util.function.Function<ResultContext, String> router;   // 拍板函数
    private final Map<String, AsyncNode<?>> routes = new HashMap<>();           // key -> 目标节点

    private ConditionalNode(String name, java.util.function.Function<ResultContext, String> router) {
        super(name, ctx -> null);        // 条件节点自己不干活,任务体是个空壳,真正执行的是选中目标
        this.router = router;
    }

    public static <T> ConditionalNode<T> of(String name, java.util.function.Function<ResultContext, String> router) {
        return new ConditionalNode<>(name, router);
    }

    /** 注册一条分支:key 命中时就去执行 target */
    public ConditionalNode<T> route(String key, AsyncNode<?> target) {
        routes.put(key, target);
        return this;
    }

    public String resolve(ResultContext context) { return router.apply(context); }   // 跑一下拍板
    public AsyncNode<?> getRouteTarget(String key) { return routes.get(key); }        // 查对照表
}

它用起来很直白,先把分支表配好:

ConditionalNode<Void> dispatch = ConditionalNode.of("dispatch", c -> {
        Integer qty = (Integer) c.get("stockQty");
        return (qty != null && qty > 0) ? "ship" : "shortage";
    })
    .dependsOn(stock)
    .route("ship",     AsyncNode.of("ship",     c -> { ... }))     // 有货走发货
    .route("shortage", AsyncNode.of("shortage", c -> { ... }));    // 缺货走登记

调度器怎么执行一个条件节点

普通节点是直接 executeTask 自己;条件节点得先等依赖跑完、再拍板、再执行选中的目标。这里没有新 API,用的还是第 5 节的 thenCompose(上游结果→下一个异步任务)。但第 8 节埋的那个坑在这儿同样成立:一旦条件节点的依赖(比如 stock)失败,它的 allOf 会异常完成,thenCompose 的 lambda 被整体跳过——所以依赖失败时“标记跳过”这件事,照样得挂在尾部的 exceptionally 哨兵上,而不是塞在 thenCompose 肚子里:

private CompletableFuture<Object> buildConditionalFuture(ConditionalNode<?> node,
        ResultContext context, Map<String, CompletableFuture<Object>> futures) {

    List<AsyncNode<?>> deps = node.getDependencies();
    CompletableFuture<Object>[] depFutures = deps.stream()
            .map(dep -> getOrCreateFuture(dep, context, futures))
            .toArray(CompletableFuture[]::new);

    return CompletableFuture.allOf(depFutures)
        .thenCompose(v -> {
            // 能进到这里,说明依赖都成功了(否则 lambda 早被跳过)
            String key = node.resolve(context);                 // 拍板:算出该走哪个分支
            AsyncNode<?> target = node.getRouteTarget(key);     // 查表:拿到目标节点
            if (target == null) {
                // key 在分支表里没注册,这是一条错误路径,也得写清楚
                context.putError(node.getName(),
                        new RuntimeException("路由失败:未知 key=" + key));
                return CompletableFuture.<Object>failedFuture(
                        new RuntimeException("路由失败:未知 key=" + key));
            }
            // 执行选中的目标,并把它的结果回写到条件节点名下(对外看,dispatch 也有结果)
            return executeTask(target, context).thenApply(r -> {
                context.put(node.getName(), r);
                return r;
            });
        })
        .exceptionally(ex -> {                 // 依赖失败哨兵:第 8 节同一条准则
            if (!context.isCompleted(node.getName())) {
                context.markSkipped(node.getName());
                context.putError(node.getName(), new DependencyFailedException(
                        "依赖失败,跳过条件节点:" + node.getName(), unwrap(ex)));
            }
            throw new CompletionException(ex);
        });
}

这里多一句关于错误路径的:resolve 完全可能算出一个你没注册过的 key(比如库存数据脏了、返回个意外值)。这种时候不能默默吞掉,得往板上写一条清楚错误、并让节点异常完成,下游才能感知到“在这个岔路口出事了”,而不是卡在那不明不白。

最后,调度器构建一个节点的 Future 时,要先判断它是不是条件节点,走不同的构建分支:

if (node instanceof ConditionalNode) {
    return buildConditionalFuture((ConditionalNode<?>) node, context, futures);
}
// 否则走普通节点的 buildFuture

(这一节记住一点:条件路由把“运行时根据上游结果选分支”抽成一个 ConditionalNode(拍板函数 resolve + key到目标节点的分支表 route),调度时先等依赖完成再拍板再执行选中目标;它同样是 thenCompose,所以依赖失败仍靠尾部 exceptionally 哨兵记录跳过,路由 key 未注册也是一条要写清楚的错误路径。)


第 15 节 · 一图失败,结果该留多少

上一节条件节点能根据结果选路了。这一节回到一个更早的问题:一张图里只要有一个任务失败了,整体到底该给你什么结果?

先想想我们最早(第 7 节)的写法。当时把所有节点 Future 聚成整体,用的是:

CompletableFuture.allOf(all).thenApply(v -> context);   // 朴素聚合

allOf 的脾气是:只要此中任何一个 Future 异常完成,allOf 就异常完成,于是后面的 thenApply 被整体跳过(还是第 8 节那条规律)。结果就是:哪怕 user、sku、stock 都跑成功了,只要 notify 最后挂了,整个 executeAsync 返回的 Future 就是异常的,你 get() 拿不到 context,那些已经成功的结果就这么白白丢了。明明手里有一堆可用的结果,却因为一角塔了就当全塔没用——这不太公平。

两种语义,自己选

其实“一图失败时结果留多少”本质上是个业务选择,没有绝对对错,所以我们把它做成一个开关,叫 FailureMode(失败模式):

public enum FailureMode {
    FAIL_ALL,          // 任一失败则整体失败(沿用 allOf 的朴素语义)
    PRESERVE_PARTIAL   // 保留已经成功的结果,失败也返回原始 context
}

两者的区别只在聚合那一层的尾钩子。先拿两个独立 CF 热个身,直观感受一下 thenApply 与 handle 在“一个成功一个失败”时的差别:

CompletableFuture<String> ok  = CompletableFuture.completedFuture("成功结果");
CompletableFuture<String> bad = CompletableFuture.failedFuture(new RuntimeException("挂了"));
CompletableFuture<Void> all = CompletableFuture.allOf(ok, bad);

// FAIL_ALL:thenApply,allOf 异常完成 → lambda 跳过 → 整体拿不到结果
// PRESERVE_PARTIAL:handle,成功失败都进 lambda → 直接把原始 ok/bad 的结果板带出去
all.handle((v, ex) -> "结果板");   // 不管成没成,都返回一个东西

接回框架,就是把 executeAsync 聚合尾部按模式分个流(相比之前只多了这几行判断):

CompletableFuture<?> all = CompletableFuture.allOf(allFutures);
if (failureMode == FailureMode.PRESERVE_PARTIAL) {
    return all.handle((v, ex) -> context);   // 失败也返回原始 context,保留已成功结果
} else {
    return all.thenApply(v -> context);      // 任一失败则整体异常
}

handle 双通道都经过、且能转换结果(第 5 节就讲过),所以就算 allOf 异常完成,这个 handle 照样会把那个 context 递出来——上面哪个任务成功了、哪个失败了、哪个被跳过了,全在板子上好好待着。我们把它作为默认推荐:真实系统里,一个下单流程走到 notify 挂了,前面算好的价格、扣好的库存这些信息对排错和后续补偿都很有用,没理由因为末尾一个失败就全抹掉。

不过有个提醒,这里很容易踩一个和第 8 节有关的坑:即便选了 PRESERVE_PARTIAL,那些因依赖失败而被“人间蒸发”的下游任务,仍然是第 8 节说的那个三无状态(不跑、不记、不报)。PRESERVE_PARTIAL 保的是“板上已经写进去的那些成功/失败记录”,而一个被整体跳过、压根没进 lambda 的下游,板上本来就没它的记录。所以当你读取或写断言时,别默认“失败的图里每个未完成节点都被标了失败”——有些节点可能就是安安静静地没出现在板上。

(这一节记住一点:一个任务失败时,默认的 allOf().thenApply 会因 allOf 异常完成而把已成功结果一并丢掉;FailureMode 把选择权交回业务:FAIL_ALL 沿用该语义、PRESERVE_PARTIAL 用 allOf().handle 在失败时仍返回原始 context 保留成功结果(默认推荐);但即使保留,被整体跳过的下游仍是三无,板上呚不到它。)


第 16 节 · 回调可叠加,且不许炸穿主链

第 13 节我们在 executeTask 里给回调留了个位,现在把它填上。回调是干什么的?一个任务跑完了(无论成败),你可能想顺手做点事:打个监控埋点、记一行日志、发个事件。这些不该混进任务体本身,而是挂在节点上,“完事了通知你一下”。

先定义回调接口,三个钩子都是 default 方法,用户想实现哪个实现哪个:

public interface NodeCallback<T> {
    default void onSuccess(T result) {}          // 成功时
    default void onFailure(Throwable ex) {}      // 失败时
    default void onComplete() {}                 // 无论成败,最后都走
}

一个节点想挂多个回调

需求很自然:同一个 stock 节点,既想埋点又想写日志,那就是两个 onSuccess。但如果 withSuccessCallback 每调一次就覆盖前一个,那只能留一个。我们要的是“叠加”而不是“覆盖”。做法是装饰器:每次注册新回调时,不直接替换,而是把新逻辑和已有回调包成一个新回调,串起来。

// AsyncNode 内部持有回调;注册新回调时包裹旧的,而不是覆盖
public AsyncNode<T> withCallback(NodeCallback<T> extra) {
    NodeCallback<T> old = this.callback;
    this.callback = new NodeCallback<>() {
        @Override public void onSuccess(T r) {
            if (old != null) old.onSuccess(r);   // 先转给已有的回调
            extra.onSuccess(r);                  // 再跑新加的
        }
        @Override public void onFailure(Throwable ex) {
            if (old != null) old.onFailure(ex);
            extra.onFailure(ex);
        }
        @Override public void onComplete() {
            if (old != null) old.onComplete();    // onComplete 沿链往下各触发一次
            extra.onComplete();
        }
    };
    return this;
}

这里有个很容易双触发的坑,我特意多写几句:onComplete 的语义是“终态之后走一次”。如果你在 onSuccess 里顺手调一次 onComplete、又在 onFailure 里也调一次,那没问题(成功和失败互斥);真正会出问题的是叠加时把 onComplete 塞进每一层的 onSuccess/onFailure 里,导致同一个终态触发多遍 onComplete。正确做法是让 onComplete 保持成一个独立的钩子、沿装饰链往下逐层委托一次,终态触发时只走这一条 onComplete 链。上面那段写法就是在守这个规矩。

用户回调不能炸穿主链

更关键的一条是隔离。回调是用户写的代码,他可能在 onSuccess 里一个 NPE 抛出来。如果这个异常不加防护地冒上去,后果很难看:一个“只是想把结果记个日志”的回调挂了,竟然能把本来成功的任务拖成失败,甚至把整条调度链搞中断。这在设计上完全不能接受——回调是旁路观测,不能反过来左右主流程。

所以触发回调时,要在外面包一层 try-catch,把用户回调的异常吃掉、只打个告警,绝不让它往上冒:

private <T> void fireSuccess(AsyncNode<T> node, Object result) {
    NodeCallback<T> cb = node.getCallback();
    if (cb == null) return;
    try {
        cb.onSuccess((T) result);
        cb.onComplete();
    } catch (Exception e) {
        // 用户回调自己抛了异常:只告警,不影响主链、不掩盖任务真正的成败
        System.err.println("[警告] 任务 '" + node.getName() + "' 的成功回调抛出异常:" + e.getMessage());
    }
}

private <T> void fireFailure(AsyncNode<T> node, Throwable error) {
    NodeCallback<T> cb = node.getCallback();
    if (cb == null) return;
    try {
        cb.onFailure(error);
        cb.onComplete();
    } catch (Exception e) {
        System.err.println("[警告] 任务 '" + node.getName() + "' 的失败回调抛出异常:" + e.getMessage());
    }
}

现在把第 13 节留位的 executeTask 补上回调(终态触发一次),也把第 8 节、第 14 节那些“依赖失败哨兵”里缺的失败回调一并补上:

return buildExecutionChain(node, context).whenComplete((result, ex) -> {
    context.putExecutionTime(node.getName(), System.currentTimeMillis() - start);
    if (ex != null) {
        context.putError(node.getName(), unwrap(ex));
        fireFailure(node, unwrap(ex));            // 失败:写板 + 触发失败回调
    } else {
        context.put(node.getName(), result);
        fireSuccess(node, result);                // 成功:写板 + 触发成功回调
    }
});

(这一节记住一点:同一个节点的多个回调要“叠加”而非覆盖,用装饰器把新回调包裹旧回调;onComplete 保持为独立钩子、沿链逐层只触发一次,避免双触发;触发用户回调必须在外面 try-catch 隔离,防止一个旁路回调的异常掩盖任务真实成败或中断调度。)


第 17 节 · 让调度器会自报家门

框架到这儿已经能跑、能容错、能选路了。但还有个现实问题:它跑起来之后到底有多忙?现在一共成了几个任务、失败几个、线程池是不是快挤满、队列堆积了多少——如果你一句话都答不上来,那线上出事时就只能猜。这一节我们让调度器能把这些数字自报家门。

先想清楚要观察什么

要监控的东西分两类。一类是任务计数:总共提交了多少、成功多少、失败多少。另一类是线程池健康度:当前多少条线程、多少条在忙、队列里堆了多少待处理的。两类合起来,才能回答“现在这个编排器顶不顶得住”。

计数这件事难点在于并发。回想第 7 节,多个 CF 的回调是在不同线程上并发跑的,一个 completedCount++ 不是原子操作(它是“读-改-写”三步,并发下会丢数)。所以计数必须用线程安全的自增,这就是 AtomicLong 的用武之地:

private final AtomicLong submittedCount = new AtomicLong(0);   // 已提交
private final AtomicLong completedCount = new AtomicLong(0);   // 已成功
private final AtomicLong failedCount   = new AtomicLong(0);    // 已失败

第 13、16 节的 executeTask 里,我们把计数补上(相比之前只是多了三行 incrementAndGet):

private <T> CompletableFuture<Object> executeTask(AsyncNode<T> node, ResultContext context) {
    submittedCount.incrementAndGet();                 // 一提交就 +1
    long start = System.currentTimeMillis();
    return buildExecutionChain(node, context).whenComplete((result, ex) -> {
        context.putExecutionTime(node.getName(), System.currentTimeMillis() - start);
        if (ex != null) {
            context.putError(node.getName(), unwrap(ex));
            failedCount.incrementAndGet();            // 失败:failedCount +1
            fireFailure(node, unwrap(ex));
        } else {
            context.put(node.getName(), result);
            completedCount.incrementAndGet();         // 成功:completedCount +1
            fireSuccess(node, result);
        }
    });
}

线程池那半边指标,不用自己维护——ThreadPoolExecutor 本来就提供了一批实时 getter:getPoolSize()、getActiveCount()、getQueue().size()、getQueue().remainingCapacity() 等,直接读就行。

把指标装进一张快照

这些数字单独看没意义,我们想一次性拿全套。做法是把它们装进一个不可变快照对象 OrchestratorStats。“快照”的意思就像拍照:把按快门那一瞬间的各项数字固定下来,之后线程池再怎么变,这张照片里的值不会跟着变。不可变,才能安全地把一个观测结果递给外面任意人去读,不怕拿着拿着数据变了。

public class OrchestratorStats {
    private final int corePoolSize, maxPoolSize, currentPoolSize, activeCount;
    private final int queueSize, queueRemainingCapacity;
    private final long submittedCount, completedCount, failedCount;

    // 全参构造:由调度器把实时指标传进来,字段均 final → 一旦造就不可变
    public OrchestratorStats(int corePoolSize, int maxPoolSize, int currentPoolSize, int activeCount,
                             int queueSize, int queueRemainingCapacity,
                             long submittedCount, long completedCount, long failedCount) {
        this.corePoolSize = corePoolSize; this.maxPoolSize = maxPoolSize;
        this.currentPoolSize = currentPoolSize; this.activeCount = activeCount;
        this.queueSize = queueSize; this.queueRemainingCapacity = queueRemainingCapacity;
        this.submittedCount = submittedCount; this.completedCount = completedCount;
        this.failedCount = failedCount;
    }

    // 由原始指标衍生的“一眼能看懂”的健度指标
    public double getUtilizationPercent() {          // 线程利用率 = 活跃/当前
        return currentPoolSize == 0 ? 0.0 : (double) activeCount / currentPoolSize * 100.0;
    }
    public double getQueueUsagePercent() {           // 队列使用率 = 已用/(已用+剩余)
        int total = queueSize + queueRemainingCapacity;
        return total == 0 ? 0.0 : (double) queueSize / total * 100.0;
    }
    // ……各 getter 略
}

调度器提供一个 getStats(),负责在调用那一刻读齐两边的数据、组装成快照:

public OrchestratorStats getStats() {
    if (executor instanceof ThreadPoolExecutor tpe) {
        return new OrchestratorStats(
            tpe.getCorePoolSize(), tpe.getMaximumPoolSize(),
            tpe.getPoolSize(), tpe.getActiveCount(),
            tpe.getQueue().size(), tpe.getQueue().remainingCapacity(),
            submittedCount.get(), completedCount.get(), failedCount.get());
    }
    // 非 ThreadPoolExecutor(比如包装过的池)拿不到池指标,只能给任务计数
    return new OrchestratorStats(0, 0, 0, 0, 0, 0,
        submittedCount.get(), completedCount.get(), failedCount.get());
}

最后给个 toText(),把一堆数字排成人能一眼读完的面板,方便直接打到日志里:

public String toText() {
    return "线程[核心" + corePoolSize + " 最大" + maxPoolSize + " 当前" + currentPoolSize + " 活跃" + activeCount + "] "
         + "队列[等待" + queueSize + " 剩余" + queueRemainingCapacity + " 使用率" + String.format("%.1f%%", getQueueUsagePercent()) + "] "
         + "任务[提交" + submittedCount + " 成功" + completedCount + " 失败" + failedCount + "]";
}

跑完一批下单,System.out.println(orchestrator.getStats().toText()) 就能拿到类似这样一行:

线程[核心2 最大4 当前4 活跃1] 队列[等待0 剩余100 使用率0.0%] 任务[提交5 成功5 失败0]

一眼就能看出:池没挤满、队列没堆、五个任务全成了。这就是“自报家门”的意义。

(这一节记住一点:监控分任务计数与线程池健康两类;并发自增必须用 AtomicLong 而非普通 ++;把按快门那一刻的各项指标装进一个字段全 final 的不可变快照 OrchestratorStats,才能安全地交出去读;线程池指标直接读 ThreadPoolExecutor 的实时 getter,最后用 toText() 排成人能看懂的面板。)


第 18 节 · 收尾的三件事:关闭、取消、自检

功能基本齐了,最后把生命周期和入口健壮性这些“收尾但重要”的事补齐。它们不出彩,但少了它们,框架就只能跑在一个受控的小演示里,上不了真实环境。

关闭:让线程池体面退场

调度器手里握着一个线程池,而线程池里的非守护线程会让 JVM 不肯退出。所以得有个地方把池关掉。我们让调度器实现 AutoCloseable,把关闭逻辑收进 close():

public class AsyncOrchestrator implements AutoCloseable {
    @Override
    public void close() {
        executor.shutdownNow();                       // 不再接新任务,并尝试中断在跑的
        try {
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {  // 最多再等 5 秒收尾
                // 超时仍未终止:这里可按需要记一条告警
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();                   // 等待期间被打断,再发一次中断
            Thread.currentThread().interrupt();       // 补回中断标志(第 9 节讲过的礼貌)
        }
    }
}

实现 AutoCloseable 的最大好处,是能直接用 try-with-resources,用完自动 close,不怕忘:

try (AsyncOrchestrator orchestrator = new AsyncOrchestrator(executor)) {
    orchestrator.executeAsync(graph).get();
}   // 离开作用域自动调 close(),线程池不会泄漏

取消:一键扫掉未完成的任务

有些场景需要中途作罢,比如用户关掉了页面、或者上游整体超时了。我们得能把当前还在飞的 Future 统一取消。难点是要知道“哪些还在飞”。做法是用一张 ConcurrentHashMap 登记活跃 Future,谁完成了谁自己销账:

private final Map<String, CompletableFuture<?>> activeFutures = new ConcurrentHashMap<>();

// 每构建一个节点 Future,就登记它,并挂一个“完成即销账”的回调
private <T> CompletableFuture<Object> getOrCreateFuture(AsyncNode<T> node, ...) {
    CompletableFuture<Object> future = futures.computeIfAbsent(node.getName(), name -> buildFuture(node, context, futures));
    activeFutures.put(node.getName(), future);                          // 登记
    future.whenComplete((r, ex) -> activeFutures.remove(node.getName())); // 完成自动摘除
    return future;
}

/** 取消所有未完成任务(true 表示同时尝试中断在跑的任务体,呼应第 9 节 interrupt 语义)*/
public void cancelAll() {
    activeFutures.values().forEach(f -> f.cancel(true));
    activeFutures.clear();
}

把“完成即销账”这一步交给 whenComplete 回调,是因为并发下手动判断“哪些该删”很容易出错;让每个 Future 完成后自己从登记表里摸掉,表里就一直只留“还在飞的”。

另外给监控配套的 resetStats() 也一并写上,把三个计数器归零(常用于一个周期统计完后重置):

public void resetStats() {
    submittedCount.set(0); completedCount.set(0); failedCount.set(0);
}

图级超时与入口自检

第 9 节做的是单任务超时。其实还有一种需求:整张图最多给多少时间,超了就整体判超时。这个很容易实现——在聚合 Future 上挂一个 orTimeout 就行(还是那个不占业务线程的 orTimeout):

public CompletableFuture<ResultContext> executeAsync(TaskGraph graph, long timeout, TimeUnit unit) {
    return executeAsync(graph).orTimeout(timeout, unit);   // 整图级超时,叠在聚合 Future 上
}

最后是入口自检。一个图送进来,有两件事必须先查,而且要在真正开跑前就快速失败:

public CompletableFuture<ResultContext> executeAsync(TaskGraph graph) {
    // 自检 1:图有循环依赖就立刻返回异常 Future,不往下编排
    if (graph.hasCycle()) {
        return CompletableFuture.failedFuture(new IllegalStateException("任务图存在循环依赖"));
    }
    // 自检 2:节点声明的依赖必须真在图里,否则也是直接返回异常 Future
    for (AsyncNode<?> node : graph.getNodes()) {
        for (AsyncNode<?> dep : node.getDependencies()) {
            if (graph.getNode(dep.getName()) == null) {
                return CompletableFuture.failedFuture(new IllegalArgumentException(
                    "任务 '" + node.getName() + "' 依赖的 '" + dep.getName() + "' 不在图中"));
            }
        }
    }
    // ………通过自检后,才进入真正的懒构建与编排
}

这两道自检为什么重要?循环依赖(第 6 节讲过)一旦发生,相互等的任务永远满不了依赖,不先检测就会整张图卡死;而依赖了一个不在图里的节点,构建时根本找不到它的 Future。两者都属于“送进来就知道必错”的非法输入,最合适的做法是当场用 failedFuture 返回一个异常完成的 Future,把错误尽早、干净地报错给调用方,而不是拖到中途某个莫名其妙的地方卡住或抛 NPE。

(这一节记住一点:调度器实现 AutoCloseable,close() 用 shutdownNow+awaitTermination 让池体面退场、配合 try-with-resources 防泄漏;cancelAll() 靠 ConcurrentHashMap 登记活跃 Future、完成时在 whenComplete 里自动销账;入口要 hasCycle 与依赖存在性两道快速自检,用 failedFuture 早报错;整图超时只需在聚合 Future 上再挂一层 orTimeout。)


终章 · 回头看一眼我们一起搭了个什么东西

从序章那个半夜被告警叫醒的下单接口出发,我们一行行码推到了现在。回头看一眼,手里这个东西已经能自告奋勇地接住一开始那个难题了。

全景一张图

把全文的东西按从上到下串一遍,大致是这样:

你交给编排器的:一张 TaskGraph(AsyncNode 带依赖 + 容错配置 + 回调)
        │
        ▼
入口自检:hasCycle 快速失败 + 依赖存在性校验(不对就直接 failedFuture)
        │
        ▼
Future 懒构建:computeIfAbsent 一个节点只建一次;节点 = allOf(依赖).thenRunAsync(本体),异常走 exceptionally 哨兵
        │
        ▼
单任务执行链 buildExecutionChain(洋葱,由内到外):
   基础任务 + orTimeout + interrupt ─▶ 重试(delayedExecutor+Promise) ─▶ 熔断记录 ─▶ 降级 handle
        │
        ├──(若是条件节点)──▶ resolve 拍板 → 取 target → executeTask
        ▼
聚合收尾:FailureMode 决定 allOf().thenApply(FAIL_ALL) 还是 allOf().handle(PRESERVE_PARTIAL) → ResultContext
        │
        ▼
全程:AtomicLong 计数 + ThreadPoolExecutor 实时指标 → OrchestratorStats 快照;AutoCloseable/cancelAll 管生命周期

端到端的最终形态

把一开始那个下单场景,配齐所有能力再组装一遍(这次 stock 会先试、不行就重试、连续挂就熔断、彻底不行走降级,然后由一个条件节点根据库存拍板分路):

AsyncNode<String> user = AsyncNode.of("user", c -> { sleep(200); return "user:张三"; });
AsyncNode<String> sku  = AsyncNode.of("sku",  c -> { sleep(200); return "sku:iPhone"; });

AsyncNode<Integer> stock = AsyncNode.of("stock", c -> {
        sleep(150);
        // 模拟偶发失败:重试会在这里发光
        return 42;
    })
    .dependsOn(user, sku)
    .withTimeout(300, TimeUnit.MILLISECONDS)
    .withRetry(new RetryPolicy(2, 100, 2.0))
    .withCircuitBreaker(new CircuitBreaker(5, 3000))
    .withFallback(FallbackPolicy.ofValue(0));   // 实在拿不到库存就降级为 0

// 条件路由:根据 stock 的结果决定走发货还是缺货登记
ConditionalNode<String> dispatch = ConditionalNode.of("dispatch", c ->
        ((Integer) c.get("stock")) > 0 ? "ship" : "shortage")
    .dependsOn(stock)
    .route("ship",     AsyncNode.of("ship",     c -> { sleep(80);  return "已发货"; }))
    .route("shortage", AsyncNode.of("shortage", c -> { sleep(80);  return "已登记缺货"; }));

AsyncNode<String> notify = AsyncNode.of("notify", c -> { sleep(50); return "通知已发"; })
    .dependsOn(dispatch)
    .withFallback(FallbackPolicy.ofValue("通知失败,已转站内信"));   // 末尾环节给个台阶

TaskGraph graph = new TaskGraph().addNode(user).addNode(sku).addNode(stock)
                                 .addNode(dispatch).addNode(notify);

try (AsyncOrchestrator orchestrator = new AsyncOrchestrator(executor)) {
    ResultContext ctx = orchestrator.executeAsync(graph, 2, TimeUnit.SECONDS).get();  // 整图超时 2s
    System.out.println("最终结果:" + ctx.get("notify"));
    System.out.println(orchestrator.getStats().toText());
}

顺利时输出大致长这样(user/sku 并行,总时长远小于串行相加):

最终结果:通知已发
线程[核心2 最大4 当前2 活跃0] 队列[等待0 剩余100 使用率0.0%] 任务[提交5 成功5 失败0]

如果中途 stock 抽风,你会看到 [重试] 日志冒出来;如果它一直挂,会先 [熔断] 开闸、最后靠降级返回一个兼容结果,整单不会崩。这些行为都不需要改业务代码,全在刚才那一层层洋葱里自动发生。

关于性能,说几句实在话

绕了这么一大圈,我们吹了很久的“高性能”,到底高在哪?得诚实地拆清楚:

  • 框架自身开销基本可忽略。它没有额外轮询线程、没有后台调度循环,所有推进都靠 CF 回调在已有线程上顺带完成;唯一的固定开销是每层链创建几个 CF 对象,纳秒到微秒量级。
  • 真正的瓶颈始终在用户线程池大小和单任务时长。编排器不会凭空把 200ms 的调用变快,它只是把“能并行的都并行”、把“等待不占线程”做到极致。池子太小,并行度就上不去;单任务太慢,再怎么编排也白搭。
  • 链式依赖有固有调度成本。每个节点从上游完成到下游启动,有一小段回调调度的固定延迟;节点越多、链越深,这部分累加越明显。极端简单的任务(比如就是读个内存变量)其实不值得包这么重的洋葱。

一句话:这个框架省的是“等待占用的线程”和“人为编排的心力”,不是魔法,不会让下游本身变快。

每个机制一句话记忆

  • 并行:互不依赖的任务同时跑,耗时从“相加”变“取最长”。
  • 线程池:线程很贵,复用 + 有界队列 + 拒绝策略控制成本。
  • CompletableFuture:把“干等”换成“订阅”,跑完自动触发下游。
  • 懒构建:computeIfAbsent 让每个节点 Future 只建一次。
  • 依赖失败传播:thenRunAsync 在上游失败时被整体跳过,三无靠 exceptionally 哨兵补记录。
  • 超时:orTimeout 单线程计时,配 AtomicReference+interrupt 补强(且只对可中断等待有效)。
  • 重试:delayedExecutor 非阻塞退避 + Promise 递归,退避不占业务线程。
  • 熔断:三态机前置拦截、后置记账;注意 N+1 只算 1 的计数口径。
  • 降级:唯一把失败变成功的一层,在最外层 handle。
  • 洋葱:由内到外固定“超时→重试→熔断→降级”,顺序不可反。
  • 条件路由:resolve 拍板 + 分支表,运行时选路。
  • 失败模式:PRESERVE_PARTIAL 用 handle 保留成功,FAIL_ALL 用 thenApply 整体失败。
  • 回调:装饰器叠加、onComplete 防双触发、try-catch 隔离不让旁路炸穿主链。
  • 监控:AtomicLong 计数 + 不可变快照。
  • 生命周期:AutoCloseable + cancelAll + 入口两道自检。

使用注意清单

最后把这些坑集中列一下,方便真上手时对照:

  1. 失败的副作用(记日志、标跳过、触发失败回调)必须挂在 exceptionally/handle 上,别塞进 thenRunAsync/thenCompose 的正向 lambda——上游一失败,那些 lambda 根本不执行。
  2. orTimeout 只判超时、不打断任务体;需要真停下就用 AtomicReference 记线程 + interrupt,且明白 interrupt 只对 sleep/wait/可中断 IO 有效。
  3. 重试退避不占的是你的业务线程池,不是“根本没有线程”;全局就一条 JDK 内部的计时守护线程。
  4. 熔断计数口径是“冒泡到熔断层的最终一次失败”,配了重试时 N+1 次真实尝试只算 1 次,设阈值时要按这个换算。
  5. 用户回调一定要 try-catch 隔离,否则一个写日志的回调能把正常任务拖成失败。
  6. 降级在内层全跑完、真失败后才触发,不是一进来就返回兜底值。
  7. PRESERVE_PARTIAL 保留的是板上已有记录,被整体跳过的下游可能仍是“三无”,读结果时别假设每个未完成节点都被标了失败。

一开始我们只知道“别让互不依赖的事互等”,写着写着,就把线程、线程池、CompletableFuture、超时、重试、熔断、降级、路由、监控、生命周期一个一个请了进来。每一个新东西,都对应一个当时实在绕不开的具体问题。这,就是我们从零搭起一个异步任务编排框架的全部经过。

posted @ 2026-10-14 17:06  佛祖让我来巡山  阅读(127)  评论(0)    收藏  举报

佛祖让我来巡山博客站 - 创建于 2018-08-15

开发工程师个人站,内容主要是网站开发方面的技术文章,大部分来自学习或工作,部分来源于网络,希望对大家有所帮助。

Bootstrap中文网