一、为什么需要依赖图
想象你正在维护一个大型 monorepo,里面有几十个包互相依赖。某天你改了底层工具库,发布时随手写了个脚本按目录顺序逐个构建。结果上层包先构建了,引用的还是旧版底层代码,线上直接报错。
问题出在哪?你缺少一张「谁依赖谁」的地图。没有这张地图,构建顺序只能靠人脑记忆和文档维护,而人脑和文档都会过时。更糟糕的是,如果有人不小心引入了循环依赖——A 依赖 B,B 又依赖 A——构建系统会陷入死循环,而你可能要到部署时才发现。
依赖图要解决的就是这类问题:把任务之间的先后约束显式地建模为有向无环图(DAG),用拓扑排序算出合法的执行顺序,并在构建阶段就检测出循环依赖,而不是等到运行时才崩溃。
二、现实类比
大学课程目录里的先修课表。你不能没上「基础物理」就去选「高级物理」。教务系统把每门课当作节点,先修关系当作有向边,从前置课程跟到高级课程,确保你不跳过任何必修步骤。如果某门课同时是另一门课的先修和后修,那就是循环——教务系统会直接拒绝这种排课。
三、核心思想
依赖图将每个任务表示为节点,排序约束表示为有向边。addEdge(A, B) 表示「A 必须在 B 之前完成」——A 是 B 的前置条件。Kahn 算法反复移除入度为 0 的节点,产生一个每个前置条件都在其依赖者之前的合法顺序。
上图中,wash 入度为 0 最先执行,dry 次之,fold 最后。注意 wash 到 fold 的直接边虽然冗余(wash → dry → fold 已隐含),但显式声明不会影响排序结果。
| 属性 | 值 |
|---|---|
| 添加节点/边 | O(1) |
| 拓扑排序 | O(V + E) |
| 循环检测 | 内置于拓扑排序(剩余节点 = 循环) |
| 空间 | O(V + E) 邻接表 |
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| 脏标记(Dirty Flag) | 脏传播沿依赖边标记下游节点 | 依赖图管执行顺序,脏标记管重计算时机 |
| 注册表(Registry) | 注册表追踪组件元数据 | 注册表管发现,依赖图管关系 |
| 访问者(Visitor) | 依赖图上的遍历分发 | 访问者是遍历策略,依赖图是数据结构 |
| 迭代器(Iterator) | 拓扑迭代生成惰性节点序列 | 迭代器是访问接口,依赖图是存储结构 |
五、多语言实现
5.1 Go 实现
package depgraph
import ( "fmt" "sort")
// DependencyGraph 用邻接表表示的有向无环图type DependencyGraph struct { adj map[string][]string}
func New() *DependencyGraph { return &DependencyGraph{adj: make(map[string][]string)}}
// AddEdge 添加一条依赖边:from 必须在 to 之前完成func (g *DependencyGraph) AddEdge(from, to string) { g.adj[from] = append(g.adj[from], to) // 确保目标节点也在图中 if _, ok := g.adj[to]; !ok { g.adj[to] = nil }}
// TopologicalSort 基于 Kahn 算法的拓扑排序,检测循环依赖func (g *DependencyGraph) TopologicalSort() ([]string, error) { inDegree := make(map[string]int) for node := range g.adj { inDegree[node] = 0 } for _, deps := range g.adj { for _, dep := range deps { inDegree[dep]++ } } // 收集入度为 0 的节点作为起始 var queue []string for node, deg := range inDegree { if deg == 0 { queue = append(queue, node) } } sort.Strings(queue) // 确定性输出
var result []string for len(queue) > 0 { node := queue[0] queue = queue[1:] result = append(result, node) for _, dep := range g.adj[node] { inDegree[dep]-- if inDegree[dep] == 0 { queue = append(queue, dep) } } } if len(result) != len(g.adj) { return nil, fmt.Errorf("检测到循环依赖") } return result, nil}5.2 TypeScript 实现
// 依赖图:用邻接表管理任务间的依赖关系class DependencyGraph<T> { private adjacency = new Map<T, Set<T>>();
addNode(node: T): void { if (!this.adjacency.has(node)) { this.adjacency.set(node, new Set()); } }
// 添加依赖边:from 必须在 to 之前 addEdge(from: T, to: T): void { this.addNode(from); this.addNode(to); this.adjacency.get(from)!.add(to); }
// Kahn 算法拓扑排序,内置循环检测 topologicalSort(): T[] { const inDegree = new Map<T, number>(); for (const node of this.adjacency.keys()) inDegree.set(node, 0); for (const [, deps] of this.adjacency) { for (const dep of deps) { inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1); } } const queue: T[] = []; for (const [node, degree] of inDegree) { if (degree === 0) queue.push(node); } const result: T[] = []; while (queue.length > 0) { const node = queue.shift()!; result.push(node); for (const dep of this.adjacency.get(node) ?? []) { const newDegree = inDegree.get(dep)! - 1; inDegree.set(dep, newDegree); if (newDegree === 0) queue.push(dep); } } if (result.length !== this.adjacency.size) { throw new Error("检测到循环依赖"); } return result; }}Kahn 算法的核心思路:每次取出入度为 0 的节点,将其后继节点的入度减 1,重复直到所有节点处理完毕。如果最终结果长度不等于节点总数,说明存在循环——循环中的节点入度永远降不到 0。
六、生产验证
- Cargo(Rust 包管理器) — dep_cache.rs#L143-L175 中的
RegistryQueryer管理 Rust 包的依赖解析图,依赖形成通过回溯解析的 DAG,build_deps产生每个候选包激活的依赖集合。 - pnpm — graph-sequencer#L22-L125 中的
graphSequencer按工作区包的相互依赖关系进行拓扑排序并检测循环,被pnpm -r递归命令使用。 - Terraform — 资源依赖图实现并行 apply,依赖关系从显式引用和隐式依赖中自动推导。
七、小结
何时使用:
- 构建系统——依赖先于被依赖者编译(Make、Bazel、Cargo)
- 包管理器——工作区包的安装/构建顺序(pnpm、npm、yarn)
- 任务调度——带前置条件的作业编排(Airflow、CI/CD 流水线)
- 数据库迁移——按依赖顺序应用 schema 变更
何时不用:
- 设计上存在循环依赖——使用事件驱动或惰性求值等不同模型
- 任务完全独立——简单列表即可,无需图结构
- 依赖在运行时频繁变化——增量方法更合适
- 超大规模图——Kahn 算法天然串行,需要并行改造
八、参考资料
- Kahn 算法 - Wikipedia - 拓扑排序的经典 BFS 实现
- Cargo 依赖解析 - Rust 包管理器的 DAG 实现
- pnpm graph-sequencer - monorepo 拓扑排序与循环检测
- Bazel 构建系统 - 基于 DAG 的增量构建与并行执行
- Terraform 资源依赖图 - 基础设施即代码的依赖管理
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






