返回博客

派生属性依赖 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

  1. DAG 是派生属性级联计算的核心数据结构——它精确表达了属性之间的依赖关系,保证了计算顺序的正确性,是从"单个计算属性"到"计算属性网络"的关键升级。
  2. 拓扑排序 + 并行化 = 正确又高效——Kahn 算法给出正确的计算顺序,同层级节点的并行执行提供了线性加速,两者结合解决了"正确性"和"性能"的双重需求。
  3. 循环依赖必须在注册时拦截——运行时发现循环依赖意味着系统崩溃,DFS 三色标记法在 O(V+E) 时间内完成全局环检测,将问题拦截在设计期。
  4. 精准影响分析避免无效计算——通过 Schema 维度(DAG 路径)和实例维度(关系链)的交叉,精确定位需要重算的属性和对象,避免全量重算。
  5. 故障处理决定一致性保障级别——全量回滚(强一致性)、标记脏数据(最终一致性)、部分提交+补偿(混合),不同业务场景选择不同策略。

#Next Article

下一篇 S4-09 Schema 变更管理 将讨论 Ontology Schema 的版本控制——当 ObjectType 需要新增属性、修改类型、删除字段时,如何在不停机的情况下完成安全迁移,如何处理向后兼容性,如何自动生成数据迁移脚本。

#ontology #derived-property #dag #topological-sort #cycle-detection #cascade-recomputation #impact-analysis #graph-algorithm