BLOGE
0.9.8-RC1在线版 · 事实校验 2026-09-15 · English
第 3 章 —— 用依赖关系思考
承诺: 读完本章后,你会知 道如何把一个 graph 读成依赖结构,如何预测它的执行顺序,以及如何摆脱“按顺序写代码”的思维习惯。
学习目标
- 区分 隐式依赖(由
.output引用推断)与 显式依赖(depends_on)。 - 看到一个 graph 定义时,能够预测哪些节点并行运行、哪些节点必须等待。
- 理解引擎的 completion-driven scheduler 如何把 DAG 变成实际执行计划。
- 识别那些会无意中杀死并行度的多余依赖。
前置条件
源示例
| 文件 | 展示内容 |
|---|---|
order-process.bloge | 真实流程中的扇出、汇合和分支 |
bff-dashboard.bloge | 五路并行扇出与不同韧性策略 |
depends-timeout.bloge | 显式依赖与 timeout |
为什么这很重要
第 2 章里你跑过了一个或两个节点的 graph,那足够你理解基本机制。但真实工作流往往有很多节点和更复杂的依赖关系——这时候,执行顺序已经不是“从上到下看文件”那么简单了。
如果你不理解引擎如何解析依赖关系,通常会遇到这些问题:
- 本来能并行的节点被你错误串行化。
- 引入循环依赖,结果在编译期就被拒绝。
- 做时序排查时总以为系统会“按你眼睛看到的顺序执行”,而实际上引擎从来没有承诺过这一点。
关键转变是:你声明的是“谁依赖谁”,引擎决定的是“何时运行谁”。
心智模型
把 DAG 当成一份承诺
BLOGE graph 是一个 DAG(有向无环图)。每个 node 是顶点,每条 dependency 是有向边。
这样读这张图:
- A 和 B 没有入边 → 它们是 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 |
calcPrice | fetchUser、fetchProducts | 在输入里引用了两个 .output |
执行时间线:
fetchUser 和 fetchProducts 同时启动;calcPrice 必须等二者都结束后才能开始。依赖形状已经由 graph 声明,因此业务代码不必再组装线程池或 CompletableFuture.allOf();运行时 executor 的容量仍由应用配置负责。
用四帧观察 ready set 如何移动
“A 和 B 并行”很容易复述,却也很容易误解。调度器不会拿着光标从第一行走 到最后一行;它维护一组依赖已经满足的 node,再由完成事件推动这组集合变化。
时间戳到底证明了什么
图中的时间是经过取整的规范化轨迹,不是性能承诺。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 根本没有必要。 它会强行让 fetchProducts 等 fetchUser 跑完,从而损失并行度。
经验法则: 只有在你真的需要上游先完成——要么为了拿它的数据,要么为了保证副作用顺序——才声明依赖。
常见故障
循环依赖 —— 编译器拒绝
如果不小心让两个 node 相互读取输出:
node a : OpA {
input { x = b.output.value }
}
node b : OpB {
input { y = a.output.value }
}
编译器会抛出 GraphDefinitionException,诊断中包含 cycle。原因不是某个
node 运行失败,而是整张依赖图已经无法做拓扑排序,因此任何业务代码都不应
开始执行。
修复: 让数据只沿一个方向流动。若两个 node 看起来都需要对方输出, 引入一个中间事实,或重新划分能力边界;不要用隐藏的共享状态绕过环。
引导式重写
以 order-process 为基础,假设现在有一个新需求:在创建订单之前,你必须独立地做库存检查和授信检查。
依赖图大概会变成:
思考下面几个问题:
checkInventory能和checkCredit并行吗?(可以,只要它依赖的是calcPrice或更早节点,而不是checkCredit本身。)- branch 应该从哪些结果汇合?(应该同时依赖
checkCredit和checkInventory的输出。) - 如果所有节点耗时都恰好 1 秒,最短总耗时是多少?(3 秒:第一层 fetch 并行 1 秒,接着
calcPrice1 秒,再接着两个检查并行 1 秒。)
脑力检查
- 什么是 source node?(没有入边、只读
ctx的节点。) - 如果节点 C 在输入里引用了
A.output和B.output,引擎至少会推断出哪些依赖?(C 依赖 A,C 也依赖 B。) - 为什么引擎要求 graph 必须是 DAG,而不允许有环?(一旦成环,就意味着某个节点在等待自己,graph 不可能完成。编译器会用 Kahn 算法检测并拒绝这种结构。)
- 如果你给一个只读
ctx的节点加上depends_on = [fetchUser],会发生什么?(它会被迫等待fetchUser,即使它根本不需要那份数据——这就是无意中的串行化。)
练习
- 打开
bff-dashboard.bloge。 - 在纸上(或用 ASCII)画出它的依赖图。把 source nodes 标成
[S],把 terminal node 标成[T]。 - 预测执行时间线。如果每个 fetch 都耗时 200 ms,而 aggregate 耗时 50 ms,总 wall-clock time 会是多少? (约 250 ms —— 所有 fetch 并行,然后 aggregate 执行。)
- 现在再加一个
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 的投影。
参考链接
- 核心架构 —— 引擎调度设计
- DSL 规 范 ——
depends_on与输入绑定语法 order-process.bloge—— 源码bff-dashboard.bloge—— 源码depends-timeout.bloge—— conformance fixture
Coding Agent: Open the versioned task guide.