Skip to main content

BLOGE 0.9.9-RC1 在线版 · 编制 2026-11-02 · English

第 6 章 —— 韧性设计

承诺: 读完本章后,你会知道 BLOGE 内建的韧性链 —— retry → timeout → fallback —— 如何保护每个节点,以及 compensation 如何在 graph 下游失败时撤销已完成的工作。


学习目标​

  1. 解释韧性求值顺序,以及 为什么 retry 包裹每次尝试的 timeout,而 fallback 是最终降级手段。
  2. 在节点上配置 retry、timeout 和 fallback,并预测 operator 抛异常时的运行时行为。
  3. 根据场景选择合适的 backoff 策略(fixed、exponential、jitter)。
  4. 声明 compensate 块来表达尽力而为的、节点级别的 saga 清理 —— 并理解为什么这 不是 分布式事务回滚。

前置条件​

源示例​

文件展示内容
error-handling-showcase.bloge支付节点上的 retry + timeout + fallback,附带汇总步骤
retry-with-backoff.bloge基于循环的 retry,自定义指数退避并携带状态
order-saga.bloge三节点 saga,每个节点带有 compensation
retry-fallback-output.blogeConformance fixture:最小节点上的 retry + fallback
node-compensation.blogeConformance fixture:单节点 compensation
compensation.blogeConformance fixture:两节点 compensation 链
ADR-007Retry → Timeout → Fallback 顺序的架构决策记录

为什么这很重要​

在第 1–4 章中,你在一些节点上见过 retry、timeout 和 fallback。它们看起来只是简单的注解。但实际上,这些策略的 执行顺序 决定了你的 graph 是干净地恢复还是掩盖了真正的故障。

考虑一个在高负载下不稳定的支付节点:

  • 如果 fallback 在重试耗尽 之前 就运行了,你会在第一次小波动时就无声降级 —— 没有向任何人收费,却生成了虚假的"人工审核"记录。
  • 如果一个 timeout 包裹了 所有 重试尝试,一次慢请求就可能吃掉整个时间预算,让后续的重试根本没有时间执行。
  • 如果 compensation 试图充当分布式事务,一次 compensation 失败就会阻断清理流程,让系统处于半完成状态。

BLOGE 用两条清晰的规则消除了这些陷阱:

  1. Retry 包裹每次尝试的 timeout;fallback 是最终兜底。
  2. Compensation 是尽力而为的节点级清理,不是原子回滚。

一旦内化了这两条规则,你就可以把韧性声明 写在它所保护的节点旁边,并信任引擎正确地执行它。


心智模型​

韧性链​

Diagram: 06-resilience-by-design figure 1

从里向外读这张图:

  1. Operator —— 你的业务代码先执行。
  2. Timeout —— 每次 单独的尝试 都有自己的计时器。如果超时,该次尝试以 OperatorTimeoutException 失败。
  3. Retry —— 失败后,引擎等待退避时长然后重试,最多 attempts 次。每次新尝试都会启动一个全新的 timeout。
  4. Fallback —— 只有当 所有 尝试都失败(包括超时)之后,引擎才会替换为 fallback 值。这是最后的手段,不是提前退出。

关键洞察: 总体挂钟时间可能超过单个 timeout 值,因为每次重试尝试都有自己的时间预算。 例如,timeout = 1s 配合 attempts: 3 最长可能耗时 3 × 1 s + 退避延迟。

Compensation —— 撤销路径​

Diagram: 06-resilience-by-design figure 2

同样的链条,如果用 Mermaid 渲染,会长这样:

Diagram: 06-resilience-by-design figure 3

当下游节点失败且 graph 无法继续时,引擎按 反向拓扑序 遍历已 完成 的节点,并调用每个节点的 compensate operator(如果声明了的话)。Compensation 是 尽力而为的:如果 ReleaseInventory 本身抛出异常,错误会被记录到 CompensationResult 中,RefundPayment 仍然会运行。这是 saga 风格的清理,不是分布式事务回滚。


第一个可运行示例​

以下是完整的 error-handling-showcase.bloge:

graph errorHandlingShowcase {
node chargePayment : ChargePaymentOperator {
input {
orderId = ctx.orderId
amount = ctx.amount
simulateFailure = ctx.simulateFailure
}
retry = { attempts: 1, backoff: 25ms, strategy: exponential }
timeout = 1s
fallback = { approved: false, note: "Gateway unavailable; queued for manual review" }
}

node summarizeOutcome : SummarizeOutcomeOperator {
depends_on = [chargePayment]
input {
approved = chargePayment.output.approved
note = chargePayment.output.note
}
timeout = 1s
}
}

走一遍 ChargePaymentOperator 一直抛异常的场景:

  1. 第 1 次尝试 开始。operator 有 1 s 的时间来完成。它抛异常了。
  2. 引擎等待 25 ms(基于基准值的指数退避),然后启动 第 2 次尝试,带有全新的 1 s timeout。它又抛异常了。
  3. 配置的单次重试已耗尽。引擎激活 fallback:{ approved: false, note: "Gateway unavailable; queued for manual review" }。
  4. summarizeOutcome 通过 chargePayment.output.approved 和 chargePayment.output.note 接收 fallback 输出 —— graph 以降级结果成功完成。

注意: summarizeOutcome 没有 retry 或 fallback。如果它失败了,graph 就会失败。韧性只在需要的地方声明。


看似安全的失败:超时之后,钱已经扣了​

设想 ChargeCardOperator 带着 idempotencyKey=order-42 发出请求。支付服务 在 430 ms 完成扣款,但响应被延迟。500 ms 时 graph 超时,并把 node 标为 失败。“node 失败”和“扣款没有发生”是两个不同事实。

图:支付先落账、响应后超时

把执行控制与 effect 事实分开​

观察它能说明什么它不能说明什么
node 状态是带 timeout 的 FAILED引擎按策略停止等待远端系统已经回滚
支付账本中存在 order-42扣款 effect 已存在graph 收到了响应
重试返回相同 charge idprovider 遵守了幂等键provider 的所有动作都可安全重试

RC1 基础探针把这个问题缩到最小:伪账本先递增,Operator 响应再延迟;随后 node 进入 FAILED,账本计数仍为 1。这不是支付网络仿真,它只证明一个 更窄的结论:引擎 timeout 不能作为 effect 未发生的证据。

先设计恢复对话,再增加 retry​

对有 effect 的 Operator,增加 retry 前先定义三件事:

  1. 从业务操作推导稳定幂等键,不要使用 attempt number。
  2. 提供一次 reconciliation 查询,在响应不确定时回答“这个 effect 是否发生”。
  3. 当业务确实需要撤销时,提供具有独立失败处理的 compensation 动作。

retry 只有在这个合同内才安全。compensation 是一个新的 effect,不是时间 机器,而且它可以独立失败。引擎控制 attempt 和时间;integration 负责外部 账本的事实。


拆开来看​

Retry 与退避策略​

retry = { attempts: 3, backoff: 200ms, strategy: exponential }
字段含义
attempts初始执行之 后 的最大重试次数(因此 attempts: 3 意味着最多 4 次总执行)
backoff重试之间的基准延迟
strategy延迟如何增长:fixed、exponential 或 jitter

三种策略:

策略行为适用场景
fixed每次延迟相同简单场景,延迟可预测
exponential延迟每次翻倍(25 ms → 50 ms → 100 ms …)对过载服务施加背压
jitter指数退避加随机扰动避免并行节点的惊群重试

Fallback​

fallback = { approved: false, reason: "credit service unavailable" }

Fallback 是一个 静态值,当所有重试尝试和 timeout 都耗尽后替换节点的输出。它是最终降级手段 —— graph 继续运行,但使用的是替代数据。

Conformance fixture retry-fallback-output.bloge 展示了最小形式:

graph g {
node a : Op {
retry = { attempts: 3, backoff: 200ms, strategy: exponential }
fallback = { score: 0 }
output {
result: String
}
}
}

Compensation​

Compensation 声明在产生副作用的节点 内部:

node reserveInventory : ReserveInventoryOperator {
output {
reservationId: String
}

compensate : ReleaseInventoryOperator {
input {
reservationId = reserveInventory.output.reservationId
}
}
}

(摘自 order-saga.bloge。)

关键规则:

  • 只有已完成的节点才会被 compensate。 如果 chargePayment 从未完成,就没有什么需要撤销的。
  • 反向拓扑序。 引擎先 compensate 最后完成的节点,然后向前回溯。
  • 尽力而为。 一次 compensation 失败会被记录到 CompensationResult 中,但不会阻止后续的 compensation。
  • 不是分布式事务。 没有两阶段提交。每个 compensate 块独立运行。设计你的 compensation operator 时应确保幂等性。

Conformance fixture compensation.bloge 展示了两个节点的 compensation 链:

graph g {
node pay : PaymentOp {
input { amount = ctx.amount }
compensate : ReversePayment {
input { txId = pay.output.transactionId }
}
}
node ship : ShipOp {
depends_on = [pay]
compensate : CancelShipment {
input { trackingId = ship.output.trackingId }
}
}
}

如果 ship 失败,引擎会 compensate pay(唯一 已完成 的节点),运行 ReversePayment 并传入原始交易 ID。


常见陷阱​

❌ 把 compensation 当成分布式事务回滚​

// WRONG mental model — expecting atomic all-or-nothing
node reserveInventory : ReserveInventoryOperator {
compensate : ReleaseInventoryOperator {
input { reservationId = reserveInventory.output.reservationId }
}
}

node chargePayment : ChargePaymentOperator {
depends_on = [reserveInventory]
compensate : RefundPaymentOperator {
input { chargeId = chargePayment.output.chargeId }
}
}

如果 RefundPaymentOperator 抛异常,库存预留 仍然 会被释放 —— 引擎不会停止 compensate。如果 ReleaseInventoryOperator 也抛异常,你最终会有 两个 compensation 失败,而不是一次干净的回滚。

设计 compensation operator 时应确保幂等性,并能容忍部分失败。 积极记录日志。使用 GraphResult 中的 CompensationResult 列表来检测和告警 compensation 失败。


常见故障​

所有重试都耗尽,而且没有 fallback​

node chargePayment : ChargePaymentOperator {
retry = { attempts: 3, backoff: 200ms, strategy: exponential }
timeout = 1s
// No fallback declared
}

如果每一次尝试都失败,graph 会直接停下来:

GraphResult {
status: FAILED,
failedNode: "chargePayment",
error: "chargePayment failed after the initial attempt and all configured retries"
}

所有下游节点都会被标记为 NOT_REACHED。

修复: 如果业务上允许降级,就声明 fallback;如果确实属于不可恢复故障,就让失败继续向上传播。


引导式重写​

从 order-saga graph 出发,添加韧性策略:

graph orderSaga {
node reserveInventory : ReserveInventoryOperator {
timeout = 3s
retry = { attempts: 2, backoff: 100ms, strategy: jitter }
output {
reservationId: String
}

compensate : ReleaseInventoryOperator {
input {
reservationId = reserveInventory.output.reservationId
}
}
}

node chargePayment : ChargePaymentOperator {
depends_on = [reserveInventory]
timeout = 5s
retry = { attempts: 3, backoff: 200ms, strategy: exponential }
fallback = { chargeId: null, status: "payment_deferred" }
output {
chargeId: String
}

compensate : RefundPaymentOperator {
input {
chargeId = chargePayment.output.chargeId
}
}
}

node shipOrder : ShipOrderOperator {
depends_on = [chargePayment]
timeout = 10s
input {
failShipping = ctx.failShipping
}
}
}

思考以下问题:

  1. 如果 chargePayment 在所有 3 次重试后仍然失败,会怎样? (使用 fallback 值 { chargeId: null, status: "payment_deferred" }。shipOrder 接收它。没有 compensation 运行,因为没有节点导致 graph 失败 —— fallback 保住了它。)
  2. 如果 shipOrder 随后失败(没有 fallback),哪些 compensation operator 会运行? (RefundPaymentOperator 和 ReleaseInventoryOperator 都会运行,按此顺序 —— 反向拓扑序。chargePayment 以 fallback 值完成,reserveInventory 正常完成。)
  3. 单独 chargePayment 的最坏情况挂钟时间是多少? (初始尝试:5 s + 3 次重试 × (退避 + 5 s)。以 200 ms 起始的指数退避:≈ 5 + 0.2 + 5 + 0.4 + 5 + 0.8 + 5 = ~21.4 s。)

脑力检查​

  1. BLOGE 中的韧性求值顺序是什么? (Retry → Timeout(每次尝试)→ Fallback。)
  2. 为什么每次重试尝试都有自己的 timeout,而不是共享一个全局 timeout? (这样一次慢请求就不会消耗掉整个时间预算,导致后续重试没有时间执行。参见 ADR-007。)
  3. Fallback 何时激活? (只有在所有重试尝试 —— 包括它们各自的 timeout —— 都耗尽之后。它是最终降级手段。)
  4. 一次 compensation 失败是否会阻止引擎 compensate 更早的节点? (不会。Compensation 是尽力而为的。每次失败都被记录到 CompensationResult 中,引擎继续处理下一个节点。)
  5. 引擎按什么顺序 compensate 节点? (反向拓扑序,且仅针对主执行已成功完成的节点。)
  6. 设计题: 你的 graph 会并行调用三个外部支付网关,每个节点都配置了 retry + fallback。如果三个节点最后都走到了 fallback,下游聚合节点会同时收到三份“降级结果”。你会如何设计这个聚合节点,让它能区分真实数据和 fallback 数据?(聚合 operator 应该检查每个输入里的标记或哨兵值——例如 fallback 带上的 "status": "degraded" 字段——然后决定是带着部分数据继续完成,还是升级为 graph 级故障。真正要设计的是:当“全部都降级”时,产品应该算成功完成,还是应该显式失败。)

练习​

  1. 打开 error-handling-showcase.bloge。

    • 将 retry 策略从 exponential 改为 jitter,并将 attempts 增加到 3。
    • 预测:如果每次尝试在失败前恰好耗时 800 ms,1 s 的 timeout 会触发吗?(不会 —— 800 ms < 1 s,所以每次尝试是因为 operator 异常而失败,而不是因为 timeout。)
  2. 打开 order-saga.bloge。

    • 为 chargePayment 添加 timeout = 2s 和 retry = { attempts: 1, backoff: 50ms, strategy: fixed }。
    • 为 reserveInventory 添加 fallback,返回 { reservationId: "NONE" }。
    • 追踪当 reserveInventory 的 operator 总是抛异常时会发生什么:ReleaseInventoryOperator 会运行吗?(不会 —— fallback 保住了 reserveInventory,所以 graph 继续执行。Compensation 只有在 graph 最终失败时才会触发。)
  3. 编写一个新 graph,包含三个独立的服务调用,各自使用不同的退避策略(fixed、exponential、jitter)。添加一个扇入节点,要求三者全部完成。给其中两个配置 fallback。预测:在什么条件下 graph 会失败?在什么条件下会优雅降级?


实验验收卡​

  • 预期与观察: graph 可在超时后 fallback,但外部扣款可能已发生。
  • 失败与恢复: 把 effect 响应延迟到 timeout 后;用幂等键和 reconciliation 恢复。
  • 证明边界: 证明 node completion 与外部 effect 是两份事实,不证明 exactly-once。
  • 练习合同: 扣款探针;只改响应延迟;交付 graph 状态和账本计数;两份事实分列即停止。

编码实践​

BP-09 — 为重试指定主要所有者​

graph、Operator、SDK 重试需要共同预算。同一业务请求的键跨尝试稳定;幂等声明不会替提供方实现去重。

单变量练习: 计算两层各允许 3 次尝试的调用量,再运行工作坊 effect-key 实验;把 durable 恢复失败次数与调用重试分开。

原理、反例与边界

BP-10 — 保留未知与降级事实​

超时停止等待,不一定停止远端动作。fallback 语义应显式;补偿只处置已确认发生且可逆的动作,并引用原 receipt。

单变量练习: 扣款成功但响应丢失时先说明对账步骤,再与允许降级为人工复核的信用查询比较。

原理、反例与边界

回顾​

  • BLOGE 按严格顺序应用韧性策略:Retry → Timeout → Fallback。每次重试尝试都有自己的 timeout 预算;fallback 是所有尝试耗尽后的最终降级手段。
  • 三种 退避策略 控制重试之间的延迟:fixed、exponential 和 jitter。
  • Fallback 是一个静态替代值 —— graph 以降级数据继续运行而不是失败。
  • Compensation(compensate 块)提供尽力而为的、节点级别的 saga 清理。引擎按反向拓扑序运行 compensation,仅针对已完成的节点,并捕获失败而不中断后续处理。
  • Compensation 不是 分布式事务回滚。设计 compensation operator 时应确保幂等性。
  • 韧性声明在 节点上,而不是埋在业务代码里 —— 引擎拥有 retry 循环、timeout 计时器和 compensation 遍历的控制权。

下一步​

在第 7 章——设计良好的 Operator里,你会学习如何编写可测试、可组合、并且与你目前所见的韧性和调度特性良好协作的 operator。


参考链接​

Coding Agent: Open the versioned task guide.