Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

第 6 章 —— 韧性设计

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


学习目标

  1. 解释韧性求值顺序,以及 为什么 retry 包裹每次尝试的 timeout,而 fallback 是最终降级手段。
  2. 在节点上配置 retrytimeoutfallback,并预测 operator 抛异常时的运行时行为。
  3. 根据场景选择合适的 backoff 策略(fixedexponentialjitter)。
  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 章中,你在一些节点上见过 retrytimeoutfallback。它们看起来只是简单的注解。但实际上,这些策略的 执行顺序 决定了你的 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.approvedchargePayment.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延迟如何增长:fixedexponentialjitter

三种策略:

策略行为适用场景
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 会运行? RefundPaymentOperatorReleaseInventoryOperator 都会运行,按此顺序 —— 反向拓扑序。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 = 2sretry = { attempts: 1, backoff: 50ms, strategy: fixed }
    • reserveInventory 添加 fallback,返回 { reservationId: "NONE" }
    • 追踪当 reserveInventory 的 operator 总是抛异常时会发生什么:ReleaseInventoryOperator 会运行吗?(不会 —— fallback 保住了 reserveInventory,所以 graph 继续执行。Compensation 只有在 graph 最终失败时才会触发。)
  3. 编写一个新 graph,包含三个独立的服务调用,各自使用不同的退避策略(fixedexponentialjitter)。添加一个扇入节点,要求三者全部完成。给其中两个配置 fallback。预测:在什么条件下 graph 会失败?在什么条件下会优雅降级?


实验验收卡

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

回顾

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

下一步

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


参考链接

Coding Agent: Open the versioned task guide.