Skip to main content

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

第 3 章 —— 用依赖关系思考

承诺: 读完本章后,你会知道如何把一个 graph 读成依赖结构,如何预测它的执行顺序,以及如何摆脱“按顺序写代码”的思维习惯。


学习目标

  1. 区分 隐式依赖(由 .output 引用推断)与 显式依赖depends_on)。
  2. 看到一个 graph 定义时,能够预测哪些节点并行运行、哪些节点必须等待。
  3. 理解引擎的 completion-driven scheduler 如何把 DAG 变成实际执行计划。
  4. 识别那些会无意中杀死并行度的多余依赖。

前置条件

源示例

文件展示内容
order-process.bloge真实流程中的扇出、汇合和分支
bff-dashboard.bloge五路并行扇出与不同韧性策略
depends-timeout.bloge显式依赖与 timeout

为什么这很重要

第 2 章里你跑过了一个或两个节点的 graph,那足够你理解基本机制。但真实工作流往往有很多节点和更复杂的依赖关系——这时候,执行顺序已经不是“从上到下看文件”那么简单了。

如果你不理解引擎如何解析依赖关系,通常会遇到这些问题:

  • 本来能并行的节点被你错误串行化
  • 引入循环依赖,结果在编译期就被拒绝。
  • 做时序排查时总以为系统会“按你眼睛看到的顺序执行”,而实际上引擎从来没有承诺过这一点。

关键转变是:你声明的是“谁依赖谁”,引擎决定的是“何时运行谁”。


心智模型

把 DAG 当成一份承诺

BLOGE graph 是一个 DAG(有向无环图)。每个 node 是顶点,每条 dependency 是有向边。

Diagram: 03-thinking-in-dependencies figure 1

这样读这张图:

  • AB 没有入边 → 它们是 source nodes → 引擎会立刻并行启动它们。
  • C 同时依赖 A 和 B → 必须等 两边都完成 才能启动。
  • D 只依赖 B → B 一完成它就能启动,即使 A 还在跑。
  • E 同时依赖 C 和 D → 它会等待二者都完成。

这就是 completion-driven 的调度模型。某个节点一旦完成,引擎就会检查它的下游:是不是已经满足所有依赖?如果满足,就把该节点投入执行。

隐式依赖与显式依赖

形成边有两种方式:

方式语法适用场景
隐式依赖input 里引用 someNode.output大多数情况下都用它——编译器会自动推断依赖
显式依赖depends_on = [someNode]你需要执行顺序,但不需要消费上游数据

两者最终形成的是同一种运行时边。大部分时候更常见的是隐式依赖,因为你通常确实需要上游数据。


第一个可运行示例

看看 order-process.bloge 里的依赖结构:

graph orderProcess {

node fetchUser : FetchUserOperator {
input { userId = ctx.userId }
timeout = 3s
}

node fetchProducts : FetchProductsOperator {
input { productIds = ctx.productIds }
timeout = 5s
}

node calcPrice : CalcPriceOperator {
input {
user = fetchUser.output
products = fetchProducts.output
}
}

// ...
}

依赖分析如下:

节点依赖谁原因
fetchUser(无)只读 ctx,是 source node
fetchProducts(无)只读 ctx,是 source node
calcPricefetchUserfetchProducts在输入里引用了两个 .output

执行时间线:

Diagram: 03-thinking-in-dependencies figure 2

fetchUserfetchProducts 同时启动;calcPrice 必须等二者都结束后才能开始。依赖形状已经由 graph 声明,因此业务代码不必再组装线程池或 CompletableFuture.allOf();运行时 executor 的容量仍由应用配置负责。


用四帧观察 ready set 如何移动

“A 和 B 并行”很容易复述,却也很容易误解。调度器不会拿着光标从第一行走 到最后一行;它维护一组依赖已经满足的 node,再由完成事件推动这组集合变化。

图:ready set 四帧轨迹

时间戳到底证明了什么

图中的时间是经过取整的规范化轨迹,不是性能承诺。RC1 探针真正断言的是 下面几组先后关系:

观察必须成立的不等式
两个源 node 同时具备资格start(fetchUser)start(fetchProducts) 都发生在释放任一源 node 之前
只完成一个源 node 还不够另一个源 node 被阻塞时,start(calcPrice) 仍未设置
fan-in 在两者之后开始start(calcPrice) > start(fetchUser)start(calcPrice) > start(fetchProducts)

在 42 ms,fetchUser 已经结束,但 calcPrice 仍有一个未满足依赖。到 67 ms, 第二个完成事件把计数减到零,calcPrice 才进入 ready set。这就是图形化 fan-in 背后的技术机制。

改一条边,先预测一个后果

删掉对 fetchProducts 的依赖,calcPrice 可能在 42 ms 就开始,此时商品数据 还不存在。给 fetchProducts 增加一条来自 fetchUser 的多余依赖,两个源调用 又会变成串行。因此,依赖不是绘图偏好,而是一条可执行的数据与顺序声明。


拆开来看

Fan-out

当多个 source node 只从 ctx 读取输入时,引擎会把它们同时启动。这就是 fan-out

// Five independent fetches — all run in parallel
node fetchProfile : FetchProfileOperator { input { userId = ctx.userId } }
node fetchOrders : FetchOrdersOperator { input { userId = ctx.userId } }
node fetchRecommendations : FetchRecommendationsOperator { input { userId = ctx.userId } }
node fetchNotifications : FetchNotificationsOperator { input { userId = ctx.userId } }
node fetchLoyalty : FetchLoyaltyOperator { input { userId = ctx.userId } }

(摘自 bff-dashboard.bloge。)

Fan-in

当一个节点在输入里同时引用多个上游输出时,它就形成 fan-in —— 必须等待所有上游:

node aggregate : AggregateOperator {
input {
profile = fetchProfile.output
orders = fetchOrders.output
recommendations = fetchRecommendations.output
notifications = fetchNotifications.output
loyalty = fetchLoyalty.output
}
}

不消费数据的显式依赖

有时你只需要顺序,不需要上游输出。比如你希望 B 在 A 完成后再运行,即便 B 不用 A 的数据:

node a : OpA {}
node b : OpB {
depends_on = [a]
}

(摘自 depends-timeout.bloge。)

显式的 depends_on 和通过 .output 推断出来的边,在运行时本质上是同一种东西。


常见陷阱

❌ 无意中把并行写成串行

node fetchUser : FetchUserOperator {
input { userId = ctx.userId }
}

node fetchProducts : FetchProductsOperator {
// WRONG — this creates an unnecessary dependency on fetchUser
depends_on = [fetchUser]
input { productIds = ctx.productIds }
}

由于 fetchProducts 只需要 ctx.productIds,这个 depends_on 根本没有必要。它会强行让 fetchProductsfetchUser 跑完,从而损失并行度。

经验法则: 只有在你真的需要上游先完成——要么为了拿它的数据,要么为了保证副作用顺序——才声明依赖。


常见故障

循环依赖 —— 编译器拒绝

如果不小心让两个 node 相互读取输出:

node a : OpA {
input { x = b.output.value }
}
node b : OpB {
input { y = a.output.value }
}

编译器会抛出 GraphDefinitionException,诊断中包含 cycle。原因不是某个 node 运行失败,而是整张依赖图已经无法做拓扑排序,因此任何业务代码都不应 开始执行。

修复: 让数据只沿一个方向流动。若两个 node 看起来都需要对方输出, 引入一个中间事实,或重新划分能力边界;不要用隐藏的共享状态绕过环。


引导式重写

order-process 为基础,假设现在有一个新需求:在创建订单之前,你必须独立地做库存检查和授信检查。

依赖图大概会变成:

Diagram: 03-thinking-in-dependencies figure 3

思考下面几个问题:

  1. checkInventory 能和 checkCredit 并行吗?(可以,只要它依赖的是 calcPrice 或更早节点,而不是 checkCredit 本身。)
  2. branch 应该从哪些结果汇合?(应该同时依赖 checkCreditcheckInventory 的输出。)
  3. 如果所有节点耗时都恰好 1 秒,最短总耗时是多少?(3 秒:第一层 fetch 并行 1 秒,接着 calcPrice 1 秒,再接着两个检查并行 1 秒。)

脑力检查

  1. 什么是 source node(没有入边、只读 ctx 的节点。)
  2. 如果节点 C 在输入里引用了 A.outputB.output,引擎至少会推断出哪些依赖?(C 依赖 A,C 也依赖 B。)
  3. 为什么引擎要求 graph 必须是 DAG,而不允许有环?(一旦成环,就意味着某个节点在等待自己,graph 不可能完成。编译器会用 Kahn 算法检测并拒绝这种结构。)
  4. 如果你给一个只读 ctx 的节点加上 depends_on = [fetchUser],会发生什么?(它会被迫等待 fetchUser,即使它根本不需要那份数据——这就是无意中的串行化。)

练习

  1. 打开 bff-dashboard.bloge
  2. 在纸上(或用 ASCII)画出它的依赖图。把 source nodes 标成 [S],把 terminal node 标成 [T]
  3. 预测执行时间线。如果每个 fetch 都耗时 200 ms,而 aggregate 耗时 50 ms,总 wall-clock time 会是多少? (约 250 ms —— 所有 fetch 并行,然后 aggregate 执行。)
  4. 现在再加一个 fetchWishlist 节点,让它依赖 fetchProfile.output.userId。重新画图。新的 wall-clock time 会是多少? (约 450 ms —— fetchWishlist 要先等 fetchProfile,而 aggregate 还要等所有六个节点。)

实验验收卡

  • 预期与观察: 两个源节点同时进入 ready set,fan-in 只在两者完成后开始。
  • 失败与恢复: 增加一条源节点间依赖;移除后恢复并行形状。
  • 证明边界: 只证明依赖驱动调度,不证明外部服务延迟或线程安全。
  • 练习合同: 价格图;只改一条 depends_on;交付前后时间线;观察顺序符合预测即停止。

回顾

  • BLOGE graph 是一个 DAG。节点是顶点,依赖是有向边。
  • 依赖既可以是隐式的(由 .output 推断),也可以是显式的(depends_on)。
  • 引擎使用 completion-driven scheduler:当某个节点完成后,检查哪些下游节点已经“就绪”。
  • 当多个 source nodes 互不依赖时,会形成 fan-out
  • 当某个节点等待多个上游时,会形成 fan-in
  • 多余的依赖会杀死并行度;只有真的需要顺序时才声明它。

下一步

第 4 章 —— 流动的数据里,你会学习数据如何在 graph 中流动:输入绑定、输出引用、路径表达式、transform,以及什么时候应该用 operator、什么时候只需要不调度 Operator 的投影。


参考链接

Coding Agent: Open the versioned task guide.