派生属性依赖 DAG:级联计算的底层机制
场景:单层派生属性(S4-07 已覆盖)
Coomia发布于 2025年8月12日24 分钟阅读
分享本文Twitter / X
派生属性依赖 DAG:级联计算的底层机制
“系列:S4 本体建模 · 第 8 篇 | 难度:中级 | 阅读时间:18 分钟
#TL;DR
- 派生属性之间可以形成依赖链——属性 A 依赖属性 B,属性 B 依赖属性 C,当 C 变更时需要按 C → B → A 的顺序级联重算,这个依赖关系用 有向无环图(DAG) 表达。
- 拓扑排序保证计算顺序正确——无论依赖链多复杂,DAG 的拓扑排序都能给出唯一正确的重算顺序,避免"用过期值计算"的数据不一致问题。
- 循环依赖检测在注册时完成——coomia-dip 在派生属性定义阶段就进行 DAG 环检测,拒绝任何会导致无限递归的定义,将问题拦截在设计期。
#1. 为什么需要依赖 DAG
#1.1 简单派生属性的局限
Code
场景:单层派生属性(S4-07 已覆盖)
ObjectType: Order
totalAmount = SUM(LineItem.lineTotal)
这很直观:lineTotal 变了 → totalAmount 重算。
只有一层依赖,不需要考虑顺序。
#1.2 多层依赖的出现
Code
场景:电商平台的多层计算
层级 0(原始属性):
LineItem.unitPrice = 29.99
LineItem.quantity = 3
层级 1(第一层派生):
LineItem.lineTotal = unitPrice × quantity
→ 29.99 × 3 = 89.97
层级 2(第二层派生):
Order.subtotal = SUM(LineItem.lineTotal)
→ 89.97 + 149.99 + ... = 539.94
层级 3(第三层派生):
Order.taxAmount = subtotal × taxRate
→ 539.94 × 0.13 = 70.19
Order.totalAmount = subtotal + taxAmount
→ 539.94 + 70.19 = 610.13
层级 4(第四层派生):
Customer.lifetimeValue = SUM(Order.totalAmount)
→ 610.13 + 1230.50 + ... = 15892.40
问题:unitPrice 变了,4 层都要重算
如果顺序错了(先算 totalAmount 再算 subtotal),结果就是错的!
#1.3 图论的天然映射
Code
依赖关系的图表示:
unitPrice ─────┐
├──→ lineTotal ──→ subtotal ──┬──→ taxAmount ──┐
quantity ──────┘ │ │
└──→ totalAmount ←┘
│
↓
lifetimeValue
关键观察:
├── 每个节点是一个属性(原始或派生)
├── 每条边表示"A 被 B 依赖"(A 变了 B 要重算)
├── 图是有向的(依赖方向明确)
├── 图必须无环(否则无限递归)
└── 这就是 DAG(Directed Acyclic Graph)
#2. DAG 的数据结构
#2.1 节点定义
Code
DAG 节点(DagNode):
┌─────────────────────────────────────────────────┐
│ DagNode │
├─────────────────────────────────────────────────┤
│ nodeId: STRING # 全局唯一标识 │
│ objectTypeId: STRING # 所属 ObjectType │
│ propertyId: STRING # 属性标识 │
│ nodeType: ENUM # SOURCE / DERIVED │
│ level: INT # 拓扑层级(0 = 源) │
│ expression: STRING # 计算表达式(派生节点) │
│ reducer: ENUM # 聚合函数 │
│ evaluationMode: ENUM # REALTIME / DEFERRED │
│ dependencies: LIST # 依赖的节点 ID 列表 │
│ dependents: LIST # 被依赖的节点 ID 列表 │
│ lastComputedAt: TIMESTAMP │
│ computeTimeMs: LONG # 上次计算耗时 │
└─────────────────────────────────────────────────┘
示例:
nodeId: "Order::totalAmount"
objectTypeId: "Order"
propertyId: "totalAmount"
nodeType: DERIVED
level: 3
expression: "subtotal + taxAmount"
reducer: null(表达式派生,非聚合派生)
evaluationMode: DEFERRED
dependencies: ["Order::subtotal", "Order::taxAmount"]
dependents: ["Customer::lifetimeValue"]
#2.2 边定义
Code
DAG 边(DagEdge):
┌─────────────────────────────────────────────────┐
│ DagEdge │
├─────────────────────────────────────────────────┤
│ sourceNodeId: STRING # 被依赖方 │
│ targetNodeId: STRING # 依赖方 │
│ edgeType: ENUM # SAME_OBJECT / │
│ # CROSS_OBJECT / │
│ # CROSS_RELATION │
│ relationPath: STRING # 跨对象时的关系路径 │
│ weight: DOUBLE # 计算权重(用于调度) │
└─────────────────────────────────────────────────┘
边类型说明:
SAME_OBJECT —— 同一对象内的属性依赖
例:Order.totalAmount 依赖 Order.subtotal
无需跨关系查找,计算最快
CROSS_OBJECT —— 跨对象类型的属性依赖
例:Order.subtotal 依赖 LineItem.lineTotal
需要通过关系路径定位关联对象
CROSS_RELATION —— 经过多级关系的依赖
例:Customer.lifetimeValue 依赖 Order.totalAmount
需要遍历 Customer → Order 关系
#2.3 完整 DAG 示例
Code
电商场景的完整 DAG:
Level 0(源属性):
[LineItem::unitPrice] [LineItem::quantity] [LineItem::discount]
[Order::taxRate] [Product::cost]
Level 1:
[LineItem::lineTotal]
= unitPrice × quantity × (1 - discount)
依赖:unitPrice, quantity, discount
Level 2:
[Order::subtotal]
= SUM(LineItem.lineTotal)
依赖:LineItem::lineTotal
[Order::itemCount]
= COUNT(LineItem)
依赖:LineItem(存在性)
[Product::totalSold]
= SUM(LineItem.quantity)
依赖:LineItem::quantity
Level 3:
[Order::taxAmount]
= subtotal × taxRate
依赖:Order::subtotal, Order::taxRate
[Order::averageItemPrice]
= subtotal / itemCount
依赖:Order::subtotal, Order::itemCount
Level 4:
[Order::totalAmount]
= subtotal + taxAmount
依赖:Order::subtotal, Order::taxAmount
Level 5:
[Customer::lifetimeValue]
= SUM(Order.totalAmount)
依赖:Order::totalAmount
[Customer::orderCount]
= COUNT(Order)
依赖:Order(存在性)
Level 6:
[Customer::averageOrderValue]
= lifetimeValue / orderCount
依赖:Customer::lifetimeValue, Customer::orderCount
#3. 拓扑排序与计算顺序
#3.1 为什么顺序至关重要
Code
错误的计算顺序:
假设 LineItem.unitPrice 从 29.99 改为 39.99
错误顺序(先算高层级):
1. 计算 Order.totalAmount
→ 使用旧的 subtotal = 539.94
→ totalAmount = 539.94 + 70.19 = 610.13 ← 错误!
2. 计算 Order.subtotal
→ 使用新的 lineTotal
→ subtotal = 569.94
3. 此时 totalAmount 已经算过了,不会再算
→ 最终 totalAmount = 610.13(应该是 643.64)
正确顺序(按拓扑层级从低到高):
1. 计算 LineItem.lineTotal = 39.99 × 3 = 119.97
2. 计算 Order.subtotal = 119.97 + 149.99 + ... = 569.94
3. 计算 Order.taxAmount = 569.94 × 0.13 = 74.09
4. 计算 Order.totalAmount = 569.94 + 74.09 = 644.03 ✓
5. 计算 Customer.lifetimeValue = 644.03 + ... ✓
#3.2 Kahn 算法(BFS 拓扑排序)
Code
coomia-dip 使用 Kahn 算法实现拓扑排序:
输入:变更的源属性集合 changedSources
算法步骤:
1. 找到所有受影响的节点
affected = 从 changedSources 开始,沿 dependents 边 BFS 遍历
2. 计算子图的入度
对 affected 子图中每个节点,统计来自 affected 内部的入度
3. BFS 拓扑排序
queue = 入度为 0 的节点(即 changedSources 本身)
result = []
while queue 非空:
node = queue.dequeue()
result.append(node)
for dependent in node.dependents:
if dependent in affected:
dependent.inDegree -= 1
if dependent.inDegree == 0:
queue.enqueue(dependent)
4. 验证
if len(result) < len(affected):
→ 存在循环依赖!(不应该发生,注册时已检测)
else:
→ result 就是正确的计算顺序
示例执行:
changedSources = {LineItem::unitPrice}
Step 1 - 受影响节点:
LineItem::lineTotal, Order::subtotal, Order::taxAmount,
Order::totalAmount, Customer::lifetimeValue,
Order::averageItemPrice, Customer::averageOrderValue
Step 2 - 入度计算:
lineTotal: 0(源在 changedSources 中)
subtotal: 1(依赖 lineTotal)
taxAmount: 1(依赖 subtotal)
totalAmount: 2(依赖 subtotal 和 taxAmount)
averageItemPrice: 1(依赖 subtotal)
lifetimeValue: 1(依赖 totalAmount)
averageOrderValue: 1(依赖 lifetimeValue)
Step 3 - BFS:
Round 1: [lineTotal] → subtotal.inDegree = 0
Round 2: [subtotal] → taxAmount = 0, totalAmount = 1, avgItem = 0
Round 3: [taxAmount, avgItemPrice] → totalAmount = 0
Round 4: [totalAmount] → lifetimeValue = 0
Round 5: [lifetimeValue] → averageOrderValue = 0
Round 6: [averageOrderValue]
最终顺序:
lineTotal → subtotal → taxAmount → avgItemPrice
→ totalAmount → lifetimeValue → averageOrderValue
#3.3 并行化机会
Code
同一层级的节点可以并行计算:
并行执行计划:
Batch 1(并行):[lineTotal]
↓
Batch 2(并行):[subtotal]
↓
Batch 3(并行):[taxAmount, averageItemPrice] ← 这两个可以并行!
↓
Batch 4(并行):[totalAmount]
↓
Batch 5(并行):[lifetimeValue]
↓
Batch 6(并行):[averageOrderValue]
并行度分析:
顺序执行:7 个计算步骤
并行执行:6 个批次(Batch 3 并行了 2 个)
理论加速比:7/6 = 1.17x
更复杂的场景(宽 DAG)可以获得更高的并行度:
10 个独立的 Level-2 属性 → 1 个批次并行执行
顺序需要 10 步,并行只需 1 步 → 10x 加速
#4. 循环依赖检测
#4.1 为什么循环依赖是致命的
Code
假设允许循环依赖:
A.x = B.y + 1
B.y = A.x + 1
当 A.x = 10 时:
B.y = 10 + 1 = 11
A.x = 11 + 1 = 12 ← A.x 变了!
B.y = 12 + 1 = 13 ← B.y 又变了!
A.x = 13 + 1 = 14 ← 无限循环!
结果:
├── 系统进入无限计算循环
├── CPU 100%,内存持续增长
├── 最终 OOM 崩溃
└── 数据处于不确定状态(最后算到哪一步?)
#4.2 注册时检测(DFS 染色法)
Code
coomia-dip 在派生属性注册时进行环检测:
算法:DFS 三色标记法
颜色含义:
WHITE(未访问)—— 初始状态
GRAY(正在访问)—— DFS 栈中,祖先节点
BLACK(已完成)—— 所有后代都已访问
检测逻辑:
function hasCycle(node):
node.color = GRAY
for dep in node.dependencies:
if dep.color == GRAY:
→ 发现环!dep 是当前节点的祖先
→ 记录环路径:dep → ... → node → dep
return true
if dep.color == WHITE:
if hasCycle(dep):
return true
node.color = BLACK
return false
示例 1 —— 无环(正常注册):
新增派生属性:Order.totalAmount = subtotal + taxAmount
检测过程:
DFS(totalAmount) → GRAY
DFS(subtotal) → GRAY
DFS(lineTotal) → GRAY → BLACK ✓
subtotal → BLACK ✓
DFS(taxAmount) → GRAY
DFS(subtotal) → 已经是 BLACK,跳过 ✓
DFS(taxRate) → GRAY → BLACK ✓
taxAmount → BLACK ✓
totalAmount → BLACK ✓
结论:无环,注册成功
示例 2 —— 有环(拒绝注册):
新增派生属性:Order.subtotal = totalAmount - taxAmount
(但 totalAmount 已经依赖 subtotal!)
检测过程:
DFS(subtotal) → GRAY
DFS(totalAmount) → GRAY
DFS(subtotal) → 发现 GRAY!→ 环路径!
环路径:subtotal → totalAmount → subtotal
结论:拒绝注册,返回错误信息
错误信息示例:
┌─────────────────────────────────────────────────────┐
│ CIRCULAR_DEPENDENCY_DETECTED │
│ │
│ Cannot register derived property: │
│ Order.subtotal = totalAmount - taxAmount │
│ │
│ Circular dependency path: │
│ Order.subtotal │
│ → Order.totalAmount (depends on subtotal) │
│ → Order.subtotal (CYCLE!) │
│ │
│ Suggestion: │
│ Break the cycle by using source properties │
│ instead of derived properties in the expression. │
└─────────────────────────────────────────────────────┘
#4.3 跨对象类型的环检测
Code
跨对象类型的循环依赖更隐蔽:
Customer.riskScore = AVG(Order.riskLevel)
Order.riskLevel = CASE WHEN customer.riskScore > 80 THEN ...
环路径跨越了两个 ObjectType:
Customer::riskScore → Order::riskLevel → Customer::riskScore
coomia-dip 的 DAG 是全局的,不限于单个 ObjectType:
全局 DAG = 所有 ObjectType 的派生属性合并
环检测在全局 DAG 上运行:
├── 注册 Customer.riskScore 时,依赖 Order.riskLevel → 无环
├── 注册 Order.riskLevel 时,依赖 Customer.riskScore
│ → DFS 发现 Customer.riskScore → Order.riskLevel → Customer.riskScore
│ → 拒绝注册
└── 无论跨多少个 ObjectType,只要有环就能检测到
#5. 变更传播策略
#5.1 精准影响分析
Code
当源属性变更时,不是所有派生属性都需要重算:
场景:修改了 LineItem#1001 的 unitPrice
完整 DAG 有 50 个派生属性节点
但实际受影响的只有:
LineItem#1001.lineTotal ← 直接依赖
Order#2001.subtotal ← #1001 属于 Order#2001
Order#2001.taxAmount ← 依赖 subtotal
Order#2001.totalAmount ← 依赖 subtotal 和 taxAmount
Order#2001.averageItemPrice ← 依赖 subtotal
Customer#3001.lifetimeValue ← #2001 属于 Customer#3001
Customer#3001.averageOrderValue ← 依赖 lifetimeValue
影响分析的两个维度:
1. Schema 维度:哪些属性定义受影响(DAG 图上的路径)
2. 实例维度:哪些具体对象实例受影响(通过关系链定位)
两个维度的交叉 = 精准的重算任务列表
避免不必要的计算(其他 Order 的 subtotal 不需要重算)
#5.2 批量变更合并
Code
当多个源属性同时变更时,合并重算请求:
场景:批量导入更新了 1000 个 LineItem 的 unitPrice
低效方式(逐个处理):
变更 #1 → 重算 lineTotal → 重算 subtotal → ...
变更 #2 → 重算 lineTotal → 重算 subtotal → ...
...
变更 #1000 → 重算 lineTotal → 重算 subtotal → ...
如果 1000 个 LineItem 属于 100 个 Order:
subtotal 被重算了 1000 次(但只需要 100 次!)
高效方式(合并处理):
收集所有变更 → 按 DAG 层级分组 → 批量重算
Level 1: 批量重算 1000 个 lineTotal
Level 2: 批量重算 100 个 subtotal(去重后)
Level 3: 批量重算 100 个 taxAmount
Level 4: 批量重算 100 个 totalAmount
Level 5: 批量重算 50 个 lifetimeValue(去重后)
减少了 90% 的重复计算!
合并窗口策略:
├── 时间窗口:收集 100ms 内的所有变更
├── 数量窗口:收集 1000 个变更
├── 混合策略:先到先触发
└── 可配置:每个派生属性可以独立设置
#5.3 优先级调度
Code
不同派生属性的重算优先级不同:
优先级矩阵:
┌──────────┬────────────────┬──────────────────────────┐
│ 优先级 │ 特征 │ 示例 │
├──────────┼────────────────┼──────────────────────────┤
│ P0(立即)│ 影响用户交互 │ 购物车总价、库存余量 │
│ P1(快速)│ 影响业务决策 │ 风险评分、信用额度 │
│ P2(正常)│ 影响报表展示 │ 月度销售额、客户LTV │
│ P3(延迟)│ 仅影响分析 │ 历史趋势、统计指标 │
└──────────┴────────────────┴──────────────────────────┘
调度逻辑:
1. P0 属性:同步计算,在源属性写入的同一事务中完成
2. P1 属性:异步计算,100ms 内完成
3. P2 属性:异步计算,进入计算队列,秒级完成
4. P3 属性:异步计算,进入低优先级队列,分钟级完成
优先级传播规则:
子节点的优先级 ≤ 父节点中最高优先级的那个
例:subtotal 被 P0 的 totalAmount 和 P2 的 monthlyRevenue 依赖
→ subtotal 的优先级自动提升为 P0
#6. 大规模 DAG 的性能优化
#6.1 DAG 分区
Code
超大 DAG 的分区策略:
场景:全局 DAG 有 10,000 个节点
问题:
├── 每次变更都要遍历整个 DAG?
├── 拓扑排序要处理 10,000 个节点?
└── 太慢了!
解决方案:DAG 分区
按 ObjectType 边界分区:
Partition 1: LineItem 相关的派生属性
Partition 2: Order 相关的派生属性
Partition 3: Customer 相关的派生属性
...
跨分区边(Cross-Partition Edge):
Order::subtotal → SUM(LineItem::lineTotal)
这条边连接了 Partition 1 和 Partition 2
分区计算策略:
1. 变更发生在 Partition 1
2. 先在 Partition 1 内部做拓扑排序和计算
3. 计算完成后,通过跨分区边通知 Partition 2
4. Partition 2 再做内部拓扑排序和计算
5. 分区之间通过消息队列异步通知
优势:
├── 每个分区的节点数 << 全局节点数
├── 分区内计算可以高度优化
├── 分区之间并行处理(无依赖的分区)
└── 故障隔离(一个分区出错不影响其他分区)
#6.2 增量拓扑排序
Code
当 DAG 结构变更时(新增/删除派生属性),不需要重新全局排序:
增量更新算法:
新增节点:
1. 确定新节点的依赖关系
2. 新节点的 level = max(依赖节点的 level) + 1
3. 更新依赖节点的 dependents 列表
4. 环检测只需要检查新增的边
时间复杂度:O(E_new),E_new = 新增的边数
删除节点:
1. 检查是否有其他节点依赖该节点
2. 如果有 → 拒绝删除(或级联删除)
3. 如果没有 → 直接移除节点和相关边
4. 无需重新排序(其他节点的 level 不变)
时间复杂度:O(D),D = 被依赖的节点数
修改依赖:
1. 删除旧边,添加新边
2. 重新计算受影响节点的 level
3. 环检测只需要检查新增的边
时间复杂度:O(E_old + E_new + V_affected)
#6.3 计算缓存与失效
Code
缓存策略:
┌─────────────────────────────────────────────────────┐
│ DAG 计算缓存架构 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ L1 Cache │ │ L2 Cache │ │ 持久存储 │ │
│ │ (进程内) │ ←→ │ (Redis) │ ←→ │ (DB) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 命中率目标:L1 > 90%, L2 > 99% │
│ 失效策略:精准失效(只失效受影响的节点) │
└─────────────────────────────────────────────────────┘
精准缓存失效:
源属性变更 → 沿 DAG 的 dependents 边遍历
→ 只失效路径上的节点缓存
→ 其他节点的缓存保持有效
示例:
LineItem#1001.unitPrice 变更
失效:
✗ LineItem#1001.lineTotal(缓存失效)
✗ Order#2001.subtotal(缓存失效)
✗ Order#2001.totalAmount(缓存失效)
保持:
✓ LineItem#1002.lineTotal(不受影响)
✓ Order#2002.subtotal(不受影响)
✓ Product#4001.totalSold(不受影响)
#7. DAG 可视化与调试
#7.1 DAG 可视化 API
Code
API: GET /api/v1/ontology/dag/visualize
请求参数:
objectTypeId: "Order" # 可选,限定 ObjectType
depth: 3 # 可选,展示深度
format: "mermaid" # 可选,输出格式
响应(Mermaid 格式):
graph TD
LP[LineItem.unitPrice] --> LT[LineItem.lineTotal]
LQ[LineItem.quantity] --> LT
LD[LineItem.discount] --> LT
LT --> OS[Order.subtotal]
OS --> OT[Order.taxAmount]
OS --> OA[Order.totalAmount]
OT --> OA
OA --> CL[Customer.lifetimeValue]
style LP fill:#e8f5e9
style LQ fill:#e8f5e9
style LD fill:#e8f5e9
style LT fill:#fff3e0
style OS fill:#fff3e0
style OT fill:#fff3e0
style OA fill:#fff3e0
style CL fill:#fce4ec
绿色 = 源属性
橙色 = 派生属性
粉色 = 跨 ObjectType 的派生属性
#7.2 影响分析 API
Code
API: POST /api/v1/ontology/dag/impact-analysis
请求:
{
"sourceProperty": "LineItem.unitPrice",
"changeType": "VALUE_CHANGE",
"affectedInstances": ["LineItem#1001", "LineItem#1002"]
}
响应:
{
"affectedNodes": [
{
"property": "LineItem.lineTotal",
"level": 1,
"affectedInstances": 2,
"estimatedComputeTimeMs": 5,
"evaluationMode": "REALTIME"
},
{
"property": "Order.subtotal",
"level": 2,
"affectedInstances": 1,
"estimatedComputeTimeMs": 10,
"evaluationMode": "DEFERRED"
},
{
"property": "Order.totalAmount",
"level": 4,
"affectedInstances": 1,
"estimatedComputeTimeMs": 5,
"evaluationMode": "DEFERRED"
},
{
"property": "Customer.lifetimeValue",
"level": 5,
"affectedInstances": 1,
"estimatedComputeTimeMs": 50,
"evaluationMode": "DEFERRED"
}
],
"totalAffectedInstances": 5,
"totalEstimatedComputeTimeMs": 70,
"maxDepth": 5,
"parallelBatches": 5
}
#7.3 重算追踪
Code
每次重算都有完整的追踪链:
RecomputeTrace:
├── traceId: "tr-20250115-001"
├── trigger: "VALUE_CHANGE"
├── sourceProperty: "LineItem.unitPrice"
├── sourceInstance: "LineItem#1001"
├── timestamp: "2025-01-15T10:30:00Z"
├── steps:
│ ├── Step 1:
│ │ ├── property: "LineItem.lineTotal"
│ │ ├── instance: "LineItem#1001"
│ │ ├── oldValue: 89.97
│ │ ├── newValue: 119.97
│ │ ├── computeTimeMs: 2
│ │ └── status: SUCCESS
│ ├── Step 2:
│ │ ├── property: "Order.subtotal"
│ │ ├── instance: "Order#2001"
│ │ ├── oldValue: 539.94
│ │ ├── newValue: 569.94
│ │ ├── computeTimeMs: 8
│ │ └── status: SUCCESS
│ └── ...
├── totalSteps: 6
├── totalComputeTimeMs: 45
└── status: COMPLETED
用途:
├── 调试:为什么 Customer.lifetimeValue 突然变了?
├── 审计:谁的什么操作触发了级联重算?
├── 性能分析:哪个步骤最慢?
└── 回滚:如果重算出错,可以恢复到旧值
#8. 故障处理与一致性
#8.1 重算失败处理
Code
场景:级联重算到第 3 步时失败了
Step 1: lineTotal 重算成功 ← 已持久化
Step 2: subtotal 重算成功 ← 已持久化
Step 3: taxAmount 重算失败 ← 除零错误!
Step 4: totalAmount 未执行
Step 5: lifetimeValue 未执行
问题:数据处于部分更新状态
lineTotal 和 subtotal 是新值
taxAmount 还是旧值
totalAmount 还是旧值
→ 不一致!
处理策略:
策略 A —— 全量回滚(强一致性):
所有已完成的步骤全部回滚到旧值
├── lineTotal 恢复为 89.97
├── subtotal 恢复为 539.94
└── 数据保持一致但为旧状态
适用:金融场景,不允许部分更新
策略 B —— 标记脏数据(最终一致性):
已完成的步骤保留新值
失败及未执行的步骤标记为 STALE
├── taxAmount 标记为 STALE
├── totalAmount 标记为 STALE
├── lifetimeValue 标记为 STALE
└── 后台重试任务会继续尝试重算
适用:大多数场景,允许短暂不一致
策略 C —— 部分提交 + 补偿(混合):
已完成的步骤提交
失败的步骤进入重试队列
重试成功后继续后续步骤
├── 最多重试 3 次
├── 重试间隔指数退避:1s, 5s, 30s
└── 3 次都失败 → 告警 + 人工介入
适用:电商等场景
#8.2 并发变更冲突
Code
场景:两个变更同时触发同一个派生属性的重算
Transaction 1: LineItem#1001.unitPrice 变为 39.99
Transaction 2: LineItem#1002.unitPrice 变为 19.99
两者都要重算 Order#2001.subtotal
冲突解决策略:
策略 1 —— 乐观锁:
每个派生属性值带版本号
重算时:
读取 subtotal(version=5, value=539.94)
计算新值
CAS 更新:UPDATE ... SET value=569.94, version=6 WHERE version=5
如果失败(version 已被改)→ 重新读取 + 重新计算
优点:无锁,高并发
缺点:冲突多时重试多
策略 2 —— 合并窗口:
100ms 内的多个变更合并为一次重算
Transaction 1 和 2 的变更被收集到同一个窗口
一次性重算 subtotal(使用所有最新值)
优点:避免重复计算
缺点:增加了延迟(最多 100ms)
coomia-dip 默认使用策略 2(合并窗口),
P0 优先级的属性使用策略 1(乐观锁)。
#9. 实战:构建一个完整的 DAG
#9.1 需求分析
Code
场景:SaaS 订阅平台
业务需求:
1. 订阅的 MRR(月度经常性收入)
2. 客户的总 MRR
3. 客户的健康分数(综合多个指标)
4. 产品的总 MRR 和使用率
5. 公司级的总 MRR 和客户健康度分布
#9.2 属性定义与 DAG 构建
Code
ObjectType 定义:
Subscription:
源属性:
unitPrice, quantity, discount, status, startDate, endDate
派生属性(Level 1):
mrr = unitPrice × quantity × (1 - discount) × IF(status == "ACTIVE", 1, 0)
isActive = status == "ACTIVE"
daysToExpiry = endDate - today()
Customer:
源属性:
name, segment, lastLoginAt
派生属性(Level 2):
totalMrr = SUM(Subscription.mrr)
activeSubscriptions = COUNT(Subscription WHERE isActive == true)
daysSinceLogin = today() - lastLoginAt
派生属性(Level 3):
healthScore = CUSTOM(
score = 0
IF totalMrr > 1000: score += 30
IF activeSubscriptions >= 2: score += 20
IF daysSinceLogin < 7: score += 30
IF daysSinceLogin < 30: score += 20
RETURN score
)
Product:
源属性:
name, category
派生属性(Level 2):
totalMrr = SUM(Subscription.mrr)
subscriberCount = COUNT(DISTINCT Subscription.customerId)
派生属性(Level 3):
averageMrrPerSubscriber = totalMrr / subscriberCount
Company(单例对象):
派生属性(Level 3):
totalMrr = SUM(Customer.totalMrr)
totalCustomers = COUNT(Customer)
healthyCustomers = COUNT(Customer WHERE healthScore >= 80)
派生属性(Level 4):
healthyCustomerRatio = healthyCustomers / totalCustomers
averageMrrPerCustomer = totalMrr / totalCustomers
生成的 DAG 结构:
Level 0: unitPrice, quantity, discount, status, startDate, endDate,
lastLoginAt, name, segment
Level 1: Subscription.mrr, Subscription.isActive,
Subscription.daysToExpiry
Level 2: Customer.totalMrr, Customer.activeSubscriptions,
Customer.daysSinceLogin,
Product.totalMrr, Product.subscriberCount
Level 3: Customer.healthScore,
Product.averageMrrPerSubscriber,
Company.totalMrr, Company.totalCustomers,
Company.healthyCustomers
Level 4: Company.healthyCustomerRatio,
Company.averageMrrPerCustomer
总计:24 个节点,28 条边,最大深度 4
#9.3 变更传播验证
Code
测试用例:修改 Subscription#S001 的 unitPrice
Before:
Subscription#S001.unitPrice = 99
Subscription#S001.mrr = 99 × 2 × (1 - 0.1) = 178.20
Customer#C001.totalMrr = 178.20 + 299.00 = 477.20
Customer#C001.healthScore = 60(totalMrr < 1000)
Company.totalMrr = 477.20 + 1230.50 + ... = 8500.00
After (unitPrice → 199):
Step 1: Subscription#S001.mrr = 199 × 2 × 0.9 = 358.20
Step 2: Customer#C001.totalMrr = 358.20 + 299.00 = 657.20
Product#P001.totalMrr += (358.20 - 178.20) = +180.00
Step 3: Customer#C001.healthScore = 60(totalMrr 仍 < 1000)
Company.totalMrr = 8500 + 180 = 8680.00
Step 4: Company.averageMrrPerCustomer = 8680 / 50 = 173.60
验证:所有值一致,无过期数据被使用 ✓
#10. 与其他系统的对比
Code
派生属性 DAG 的设计对比:
┌──────────────┬──────────────────┬──────────────────┐
│ 特性 │ coomia-dip │ 传统 ETL │
├──────────────┼──────────────────┼──────────────────┤
│ 计算触发 │ 数据变更自动触发 │ 定时调度 │
│ 计算粒度 │ 单个对象实例 │ 整表/整批 │
│ 依赖管理 │ 显式 DAG │ 隐式(靠文档) │
│ 环检测 │ 注册时自动检测 │ 无(运行时爆炸) │
│ 影响分析 │ 精准到实例 │ 整表重算 │
│ 延迟 │ 毫秒 ~ 秒 │ 分钟 ~ 小时 │
│ 可视化 │ 内置 DAG 可视化 │ 需要外部工具 │
│ 追踪 │ 内置重算追踪 │ 需要外部日志 │
└──────────────┴──────────────────┴──────────────────┘
┌──────────────┬──────────────────┬──────────────────┐
│ 特性 │ coomia-dip │ 电子表格 │
├──────────────┼──────────────────┼──────────────────┤
│ 规模 │ 百万级对象 │ 万级单元格 │
│ 并发 │ 支持 │ 不支持 │
│ 持久化 │ 自动 │ 手动保存 │
│ 权限控制 │ 属性级别 │ 文件级别 │
│ 版本管理 │ Schema 版本化 │ 无 │
│ API 访问 │ 原生支持 │ 需要导出 │
└──────────────┴──────────────────┴──────────────────┘
coomia-dip 的派生属性 DAG 本质上是:
"企业级的电子表格公式引擎"
+ 百万级规模
+ 多用户并发
+ 完整的审计和追踪
#Key Takeaways
- DAG 是派生属性级联计算的核心数据结构——它精确表达了属性之间的依赖关系,保证了计算顺序的正确性,是从"单个计算属性"到"计算属性网络"的关键升级。
- 拓扑排序 + 并行化 = 正确又高效——Kahn 算法给出正确的计算顺序,同层级节点的并行执行提供了线性加速,两者结合解决了"正确性"和"性能"的双重需求。
- 循环依赖必须在注册时拦截——运行时发现循环依赖意味着系统崩溃,DFS 三色标记法在 O(V+E) 时间内完成全局环检测,将问题拦截在设计期。
- 精准影响分析避免无效计算——通过 Schema 维度(DAG 路径)和实例维度(关系链)的交叉,精确定位需要重算的属性和对象,避免全量重算。
- 故障处理决定一致性保障级别——全量回滚(强一致性)、标记脏数据(最终一致性)、部分提交+补偿(混合),不同业务场景选择不同策略。
#Next Article
下一篇 S4-09 Schema 变更管理 将讨论 Ontology Schema 的版本控制——当 ObjectType 需要新增属性、修改类型、删除字段时,如何在不停机的情况下完成安全迁移,如何处理向后兼容性,如何自动生成数据迁移脚本。
#ontology #derived-property #dag #topological-sort #cycle-detection #cascade-recomputation #impact-analysis #graph-algorithm