2826 字
约 9 分钟
15
S18-X05 · 管道流水线架构:阶段化处理的可替换性

一篇稿件进来:敏感词过滤、摘要抽取、封面裁剪、标签打点、索引写入。写成一个大方法,第一个需求变更就打穿它;拆成"阶段"又遇到新问题——阶段之间传什么、失败在哪停、耗时怎么算清。

管道的价值不在"分阶段"——任何方法拆成私有方法都算分阶段。价值在阶段契约足够窄,窄到可以单独替换、单独计时、单独重跑。下面按"契约 → 数据形态 → 失败 → 并行 → 观测 → 边界"拆。


1. 管道与责任链的差别

两条最实在的差别:责任链问"谁来处理",管道问"数据到哪一步";责任链环节接口统一(handle(ctx)),管道阶段可异构 Stage<I,O>。

维度 责任链 管道
语义 依次尝试,命中即停 依次变换,逐步推进
接口 统一 handle(Context) 可异构 Stage<I,O>
中断 常态(拒绝就停) 异常态(失败才停)
数据 共享同一个上下文 每阶段产生新值
失败处理 通常立即返回拒绝原因 需要显式的重试/降级/死信策略
顺序 可以靠 @Order 猜出意图 顺序即数据依赖,必须写死或声明

如果你发现自己写的"管道"每个阶段都返回同一个 Result 并且一到失败就返回,那它其实是责任链,别硬套 Stage<I,O>——多出来的类型参数只会增加噪音。


2. 阶段契约:Stage<I,O> 写成什么样

public interface Stage<I, O> {
    O apply(I input) throws StageException;
    default String name() { return getClass().getSimpleName(); }
}

// 顺序组合:任一段抛异常即整体失败
public final class Pipeline<T> {
    private final List<Stage<T, T>> stages;   // 同构串联时才好这么写
    public T run(T in) throws StageException { /* 逐段 apply */ }
}

三个契约细节,缺一个半年后必咬人:

契约点 要求 不要求的后果
输入是否可为 null 明确写 @Nullable 或 Objects.requireNonNull 半路 NPE,栈里看不出是哪段的锅
是否允许原地修改输入 声明"纯函数,不改入参" 上游复用同一对象时数据被下游篡改
异常类型 只抛 StageException(受检或运行时统一包装) 上层 catch 写不准,只能 catch (Exception)

代价:Java 没有高阶类型,串 5 个阶段要写一堆类型参数,编译器能查错但读起来累。阶段超过 4 个时改上下文对象更现实。


3. 上下文对象 vs 逐步变换

// 形态 A:逐步变换,类型安全
byte[]   raw    = fetch(url);
String   text   = extractor.apply(raw);
Document doc    = parser.apply(text);
int      score  = scorer.apply(doc);

// 形态 B:上下文对象,阶段各自读写
public class ContentContext {
    byte[] raw; String text; Document doc; int score;
    Map<String, Object> attrs = new HashMap<>();
    List<String> errors = new ArrayList<>();
}

public interface ContentStage {
    void run(ContentContext ctx) throws StageException;
}
维度 逐步变换 上下文对象
类型安全 编译期强约束 弱,attrs 里全是 Object
阶段可插拔 加减阶段要改类型签名 加减阶段不动签名
调试成本 低,断点看局部变量 高,得知道"这个字段是谁写的"
上下文膨胀 不会 极易变成"上帝对象"

实操取舍:阶段 ≤ 4 且数据形态线性演进 → 逐步变换;阶段 ≥ 5、有分支跳过、有可选阶段 → 上下文对象,但要加约束:阶段产物用 record 承载而不是 Map<String,Object>;每个可变字段标"唯一写入者"(// written by: ParserStage only)。没有这条,半年后没人知道 text 被谁改过。


4. 失败与重试放在哪一层

三层可选,选错就是重复数据的源头:

// 1) 阶段内重试(只适合幂等的远程调用)
public Doc apply(String text) throws StageException {
    return Retryer.of(3, Duration.ofMillis(200)).run(() -> llm.parse(text));
}

// 2) 阶段间重试(整段重跑,要求阶段纯函数)
for (int i = 0; i < 2; i++) {
    try { doc = parser.apply(text); break; }
    catch (TransientException e) { if (i == 1) throw e; }
}

// 3) 整条管道重跑(要求全链路幂等 + 有去重键)
pipeline.run(ctx);   // 入口用 traceId 做幂等锁
重试层级 适用 前提 坑
阶段内 单次远程调用超时、限流 该调用幂等 重试放大下游压力,必须带抖动
阶段间 阶段本身不稳定但纯 阶段无副作用 重跑会把下游已写入的数据写两遍
整条管道 消息消费失败重投 全链路幂等键 没有去重键就是数据重复制造机

一条硬规则:有副作用的阶段(写库、发消息、扣额度)不放在可重试段落里。如果业务要求"先写索引再回写状态",把写索引拆成管道外的 outbox,管道只负责产出待写数据。


5. 并行阶段:fork-join 形态

摘要、标签、封面裁剪互不依赖,可以并行:

public Doc enrich(Doc doc) throws StageException {
    CompletableFuture<String> summary = CompletableFuture.supplyAsync(() -> summarizer.apply(doc), pool);
    CompletableFuture<List<String>> tags = CompletableFuture.supplyAsync(() -> tagger.apply(doc), pool);

    try {
        return doc.withSummary(summary.get(3, TimeUnit.SECONDS))
                  .withTags(tags.get(3, TimeUnit.SECONDS));
    } catch (Exception e) {
        summary.cancel(true); tags.cancel(true);
        throw new StageException("enrich failed", e);
    }
}
并行形态 适用 风险
CompletableFuture.allOf 所有分支都必须成功 一个失败要主动 cancel 其余,否则泄漏任务
allOf + 超时 有 SLA 的在线链路 超时后子任务仍在跑,占线程
invokeAll + Future 分支数量固定、需要逐个取结果 中断语义要自己处理

并行只在分支耗时接近且都超过毫秒级时划算,微秒级的纯计算并行化得不偿失。


6. 可观测性:每阶段耗时打点

不打点的话,线上慢了你只看到"接口 800ms",无法归因。最小实现:

public <I, O> Stage<I, O> timed(Stage<I, O> stage, StageMetrics metrics) {
    return input -> {
        long start = System.nanoTime();
        try {
            O out = stage.apply(input);
            metrics.record(stage.name(), "ok", System.nanoTime() - start);
            return out;
        } catch (RuntimeException e) {
            metrics.record(stage.name(), "fail", System.nanoTime() - start);
            throw e;
        }
    };
}
指标 维度 用来回答
阶段耗时 P50/P99 stage 哪一段是瓶颈
阶段失败率 stage + error_type 失败集中在哪
阶段输入/输出体积 stage 是不是某段把数据放大了

打点别用 System.currentTimeMillis()——同一毫秒内的两个阶段会得到 0ms,看起来"免费"。用 nanoTime。


7. 与事件驱动、工作流的边界

维度 管道 事件驱动 工作流引擎
方向 单向 扇出,多消费者 有向图,可分支可回退
状态 无状态,一次调用内完成 无中心状态,靠事件溯源 有持久化状态与补偿
失败 就地失败,调用方感知 靠重投 + 死信 补偿事务、人工干预
跨天/跨服务 不合适 合适 合适
调试 单机断点即可 需要链路追踪 需要流程实例视图

判断口径:一次请求内跑完、失败可整体重来 → 管道;要等回调、要人审批、要跨天 → 工作流。 用管道模拟"等审批",最后一定写出一个轮询数据库的 while 循环。


8. 两个真实场景

8.1 内容处理管线

阶段 输入 → 输出 失败策略 可跳过
Fetch url → byte[] 重试 2 次 否
Extract byte[] → text 直接失败 否
Clean text → text 降级为原文 是
Summarize text → summary 超时降级为空 是
Index doc → docId 失败进 outbox 重投 否

注意 Clean 和 Summarize 的差别:Clean 失败降级(原文照样能发),Summarize 超时降级(摘要为空也能发)。降级策略要写在阶段契约里,不是写在调用方,否则同一阶段在不同调用方行为不一致。

8.2 支付前置校验链

风控、限额、券、余额四项检查。它们表面像管道,实际更像责任链:任一失败即拒绝,且要返回具体原因。硬套管道会逼出"每段都返回 CheckResult 并传递"的写法,等于把责任链包了一层壳。识别方法:如果每个阶段的输出类型都一样,并且失败即终止,那就是责任链。


9. 常见坑

现象 原因 处理
上下文越来越大,字段几十个 阶段把中间态都塞进 ctx 阶段产物用 record,ctx 只放跨阶段必需项
调换两个阶段顺序就出错 隐含数据依赖没声明 阶段声明 requires() / produces(),启动时校验
一个事务包住整条管道 远程调用在事务里,连接被长时间占用 事务只在单个阶段内,跨阶段用 outbox
某阶段偶发失败但无法重跑 该阶段有副作用 拆出副作用,管道只产数据
并行化后线程池打满 共享业务线程池 管道用独立、有界的池;分支带超时

第三条值得展开:管道里塞 @Transactional 是自然冲动("要么全成功"),但管道常含 HTTP 与文件 IO,事务开着等网络,连接池几分钟就枯。做法是阶段内本地事务 + 阶段间最终一致。


10. 边界:什么时候别用管道

场景 更合适 理由
阶段间要反复回退重算 递归/迭代求解 管道是单向的
需要在任意阶段插入人工干预 工作流 管道无持久化状态
每个阶段输出同构、失败即停 责任链 管道契约是多余的抽象
只有 2 个阶段 两个方法调用 引入 Stage 接口纯属自嗨
阶段间需要事务强一致 单个服务方法 + 本地事务 跨阶段事务在分布式下不成立

11. 自测

  1. 什么信号说明你写的"管道"其实是责任链?
  2. Stage<I,O> 契约里必须显式声明哪三件事?
  3. 上下文对象在什么条件下开始变成上帝对象,怎么约束?
  4. 有副作用的阶段为什么不能放进可重试段落?
  5. 并行阶段里一个分支失败,剩下分支该怎么处理?
  6. 阶段耗时打点为什么不能用 currentTimeMillis?
S18-X05 · 管道流水线架构:阶段化处理的可替换性
https://www.clxhxhhr.top/posts/5720/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。