BLOGE
0.9.8-RC1在线版 · 事实校验 2026-09-15 · English
第 6 章 —— 韧性设计
承诺: 读完本章后,你会知道 BLOGE 内建的韧性链 —— retry → timeout → fallback —— 如何保护每个节点,以及 compensation 如何在 graph 下游失败时撤销已完成的工作。
学习目标
- 解释韧性求值顺序,以及 为什么 retry 包裹每次尝试的 timeout,而 fallback 是最终降级手段。
- 在节点上配置
retry、timeout和fallback,并预测 operator 抛异常时的运行时行为。 - 根据场景选择合适的
backoff策略(fixed、exponential、jitter)。 - 声明
compensate块来表达尽力而为的、节点级别的 saga 清理 —— 并理解为什么这 不是 分布式事务回滚。
前置条件
源示例
| 文件 | 展示内容 |
|---|---|
error-handling-showcase.bloge | 支付节点上的 retry + timeout + fallback,附带汇总步骤 |
retry-with-backoff.bloge | 基于循环的 retry,自定义指数退避并携带状态 |
order-saga.bloge | 三节点 saga,每个节点带有 compensation |
retry-fallback-output.bloge | Conformance fixture:最小节点上的 retry + fallback |
node-compensation.bloge | Conformance fixture:单节点 compensation |
compensation.bloge | Conformance fixture:两节点 compensation 链 |
| ADR-007 | Retry → Timeout → Fallback 顺序的架构决策记录 |
为什么这很重要
在第 1–4 章中,你在一些节点上见过 retry、timeout 和 fallback。它们看起来只是简单的注解。但实际上,这些策略的 执行顺序 决定了你的 graph 是干净地恢复还是掩盖了真正的故障。
考虑一个在高负载下不稳定的支付节点:
- 如果 fallback 在重试耗尽 之前 就运行了,你会在第一次小波动时就无声降级 —— 没有向任何人收费,却生成了虚假的"人工审核"记录。
- 如果一个 timeout 包裹了 所有 重试尝试,一次慢请求就可能吃掉整个时间预算,让后续的重试根本没有时间执行。
- 如果 compensation 试图充当分布式事务,一次 compensation 失败就会阻断清理流程,让系统处于半完成状态。
BLOGE 用两条清晰的规则消除了这些陷阱:
- Retry 包裹每次尝试的 timeout;fallback 是最终兜底。
- Compensation 是尽力而为的节点级清理,不是原子回滚。
一旦内化了这两条规则,你就可以把韧性声明 写在它所保护的节点旁边,并信任引擎正确地执行它。
心智模型
韧性链
从里向外读这张图:
- Operator —— 你的业务代码先执行。
- Timeout —— 每次 单独的尝试 都有自己的计时器。如果超时,该次尝试以
OperatorTimeoutException失败。 - Retry —— 失败后,引擎等待退避时长然后重试,最多
attempts次。每次新尝试都会启动一个全新的 timeout。 - Fallback —— 只有当 所有 尝试都失败(包括超时)之后,引擎才会替换为 fallback 值。这是最后的手段,不是提前退出。
关键洞察: 总体挂钟时间可能超过单个
timeout值,因为每次重试尝试都有自己的时间预算。 例如,timeout = 1s配合attempts: 3最长可能耗时 3 × 1 s + 退避延迟。
Compensation —— 撤销路径
同样的链条,如果用 Mermaid 渲染,会长这样:
当下游节点失败且 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 次尝试 开始。operator 有 1 s 的时间来完成。它抛异常了。
- 引擎等待 25 ms(基于基准值的指数退避),然后启动 第 2 次尝试,带有全新的 1 s timeout。它又抛异常了。
- 配置的单次重试已耗尽。引擎激活 fallback:
{ approved: false, note: "Gateway unavailable; queued for manual review" }。 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 id | provider 遵守了幂等键 | provider 的所有动作都可安全重试 |
RC1 基础探针把这个问题缩到最小:伪账本先递增,Operator 响应再延迟;随后
node 进入 FAILED,账本计数仍为 1。这不是支付网络仿真,它只证明一个
更窄的结论:引擎 timeout 不能作为 effect 未发生的证据。
先设计恢复对话,再增加 retry
对有 effect 的 Operator,增加 retry 前先定义三件事:
- 从业务操作推导稳定幂等键,不要使用 attempt number。
- 提供一次 reconciliation 查询,在响应不确定时回答“ 这个 effect 是否发生”。
- 当业务确实需要撤销时,提供具有独立失败处理的 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
}
}
}
思考以下问题:
- 如果
chargePayment在所有 3 次重试后仍然失败,会怎样? (使用 fallback 值{ chargeId: null, status: "payment_deferred" }。shipOrder接收它。没有 compensation 运行,因为没有节点导致 graph 失败 —— fallback 保住了它。) - 如果
shipOrder随后失败(没有 fallback),哪些 compensation operator 会运行? (RefundPaymentOperator和ReleaseInventoryOperator都会运行,按此顺序 —— 反向拓扑序。chargePayment以 fallback 值完成,reserveInventory正常完成。) - 单独
chargePayment的最坏情况挂钟时间是多少 ? (初始尝试:5 s + 3 次重试 × (退避 + 5 s)。以 200 ms 起始的指数退避:≈ 5 + 0.2 + 5 + 0.4 + 5 + 0.8 + 5 = ~21.4 s。)
脑力检查
- BLOGE 中的韧性求值顺序是什么? (Retry → Timeout(每次尝试)→ Fallback。)
- 为什么每次重试尝试都有自己的 timeout,而不是共享一个全局 timeout? (这样一次慢请求就不会消耗掉整个时间预算,导致后续重试没有时间执行。参见 ADR-007。)
- Fallback 何时激活? (只有在所有重试尝试 —— 包括它们各自的 timeout —— 都耗尽之后。它是最终降级手段。)
- 一次 compensation 失败是否会阻止引擎 compensate 更早的节点?
(不会。Compensation 是尽力而为的。每次失败都被记录到
CompensationResult中,引擎继续处理下一个节点。) - 引擎按什么顺序 compensate 节点? (反向拓扑序,且仅针对主执行已成功完成的节点。)
- 设计题: 你的 graph 会并行调用三个外部支付网关,每个节点都配置了 retry + fallback。如果三个节点最后都走到了 fallback,下游聚合节点会同时收到三份“降级结果”。你会如何设计这个聚合节点,让它能区分真实数据和 fallback 数据?(聚合 operator 应该检查每个输入里的标记或哨兵值——例如 fallback 带上的
"status": "degraded"字段——然后决定是带着部分数据继续完成,还是升级为 graph 级故障。真正要设计的是:当“全部都降级”时,产品应该算成功完成,还是应该显式失败。)
练习
-
打开
error-handling-showcase.bloge。- 将 retry 策略从
exponential改为jitter,并将attempts增加到3。 - 预测:如果每次尝试在失败前恰好耗时 800 ms,1 s 的 timeout 会触发吗?(不会 —— 800 ms < 1 s,所以每次尝试是因为 operator 异常而失败,而不是因为 timeout。)
- 将 retry 策略从
-
打开
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 最终失败时才会触发。)
- 为
-
编写一个新 graph,包含三个独立的服务调用,各自使用不同的退避策略(
fixed、exponential、jitter)。添加一个扇入节点,要求三者全部完成。给其中两个配置 fallback。预测:在什么条件下 graph 会失败?在什么条件下会优雅降级?
实验验收卡
- 预期与观察: graph 可在超时后 fallback,但外部扣款可能已发生。
- 失败与恢复: 把 effect 响应延迟到 timeout 后;用幂等键和 reconciliation 恢复。
- 证明边界: 证明 node completion 与外部 effect 是两份事实,不证明 exactly-once。
- 练习合同: 扣款探针;只改响应延迟;交付 graph 状态和账本计数;两份事实分列即停止。
回顾
- BLOGE 按严格顺序应用韧性策略:Retry → Timeout → Fallback。每次重试尝试都有自己的 timeout 预算;fallback 是所有尝试耗尽后的最终降级手段。
- 三种 退避策略 控制重试之间的延迟:
fixed、exponential和jitter。 - Fallback 是一个静态替代值 —— graph 以降级数据继续运行而不是失败。
- Compensation(
compensate块)提供尽力而为的、节点级别的 saga 清理。引擎按反向拓扑序运行 compensation,仅针对已完成的节点,并捕获失败而不中断后续处理。 - Compensation 不是 分布式事务回滚。设计 compensation operator 时应确保幂等性。
- 韧性声明在 节点上,而不是埋在业务代码里 —— 引擎拥有 retry 循环、timeout 计时器和 compensation 遍历的控制权。
下一步
在第 7 章——设计良好的 Operator里,你会学习如何编写可测试、可组合、并且与你目前所见的韧性和调度特性良好协作的 operator。
参考链接
- ADR-007 — 韧性顺序 —— Retry → Timeout → Fallback 的架构决策
- 核心架构 —— 引擎内部机制与调度设计
- DSL 规范 —— 韧性与 compensation 语法
error-handling-showcase.bloge—— 完整示例源码retry-with-backoff.bloge—— 基于循环的 retry 示例order-saga.bloge—— saga compensation 示例retry-fallback-output.bloge—— conformance fixturenode-compensation.bloge—— conformance fixturecompensation.bloge—— conformance fixture
Coding Agent: Open the versioned task guide.