4. 文档嵌套结构:嵌套文档模型、父子文档关系、嵌套查询与聚合

在倒排索引与文档存储的融合架构中,文档嵌套结构是处理复杂业务实体的核心手段。领域驱动设计中的聚合根、值对象、实体关系,在索引层需要精确映射为嵌套文档模型、父子文档关系或扁平化字段。本章从领域建模视角出发,系统阐述三种嵌套模式的适用场景、索引结构、查询能力与聚合约束。

4.1 嵌套文档模型(Nested Document Model)

嵌套文档模型将子对象作为独立的内嵌文档存储在父文档的数组字段中。每个嵌套文档在 Lucene 层面被索引为独立的隐藏文档(hidden document),从而保持子对象字段间的查询独立性。

4.1.1 核心特征

特征 说明 领域映射示例
存储方式 子文档嵌入父文档字段数组 订单包含多个商品行项
索引独立性 每个嵌套对象被索引为独立 Lucene 文档 商品行项的名称、价格、数量可独立匹配
查询隔离 跨嵌套对象的字段不会交叉匹配 不会将商品A的名称与商品B的价格组合命中
更新代价 修改任一嵌套对象需重建整个父文档 订单状态变更需重索引全部商品行项

4.1.2 领域建模示例:订单与商品行项

// 订单聚合根(嵌套文档映射)
{
  "orderId": "ORD-2024-001",
  "customer": {
    "name": "张三",
    "level": "VIP"
  },
  "items": [
    {
      "productId": "P001",
      "productName": "机械键盘",
      "quantity": 2,
      "unitPrice": 399.00,
      "category": "外设"
    },
    {
      "productId": "P002",
      "productName": "无线鼠标",
      "quantity": 1,
      "unitPrice": 199.00,
      "category": "外设"
    }
  ],
  "totalAmount": 997.00
}

4.1.3 嵌套查询与聚合

嵌套查询使用 nested 查询类型,确保查询条件作用于同一个嵌套对象内部。

// 查询包含"机械键盘"且单价大于300元的订单
// 正确:使用 nested 查询,条件作用于同一嵌套对象
{
  "query": {
    "nested": {
      "path": "items",
      "query": {
        "bool": {
          "must": [
            { "match": { "items.productName": "机械键盘" } },
            { "range": { "items.unitPrice": { "gt": 300 } } }
          ]
        }
      }
    }
  }
}

// 错误:不使用 nested 查询会导致跨对象交叉匹配
// 可能匹配到"机械键盘"和"鼠标单价>300"的订单

嵌套聚合使用 nested 聚合路径,再配合子聚合分析嵌套对象内的统计信息。

// 统计每个商品类别的平均单价和总销量
{
  "size": 0,
  "aggs": {
    "items_nested": {
      "nested": { "path": "items" },
      "aggs": {
        "by_category": {
          "terms": { "field": "items.category" },
          "aggs": {
            "avg_price": { "avg": { "field": "items.unitPrice" } },
            "total_qty": { "sum": { "field": "items.quantity" } }
          }
        }
      }
    }
  }
}
领域驱动设计提示: 嵌套文档模型适合值对象集合(如订单行项、地址列表、标签集合)。这些子对象没有独立生命周期,随聚合根一起创建和销毁。嵌套文档的更新代价较高,不适合频繁修改子对象的场景。

4.2 父子文档关系(Parent-Child Relationship)

父子文档关系通过 join 字段在同一个索引中建立文档间的层级关联。父文档和子文档是独立的 Lucene 文档,拥有各自的 _id 和版本号,支持独立更新。

4.2.1 核心特征对比

维度 嵌套文档 父子文档
文档独立性 子文档依附于父文档 父子文档完全独立
更新粒度 必须重建整个父文档 可单独更新子文档
查询性能 较快(同文档块) 较慢(需跨文档关联)
子文档数量 建议 < 100 可支持数万级
领域映射 值对象集合 实体聚合(独立生命周期)

4.2.2 领域建模示例:博客文章与评论

// 索引映射定义 join 字段
{
  "mappings": {
    "properties": {
      "join_field": {
        "type": "join",
        "relations": {
          "article": "comment"   // 父类型: 子类型
        }
      },
      "title": { "type": "text" },
      "content": { "type": "text" },
      "author": { "type": "keyword" }
    }
  }
}

// 父文档(文章)
{
  "_id": "article-001",
  "title": "领域驱动设计实践",
  "content": "本文探讨DDD在微服务中的应用...",
  "join_field": { "name": "article" }
}

// 子文档(评论)
{
  "_id": "comment-001",
  "join_field": {
    "name": "comment",
    "parent": "article-001"   // 指定父文档ID
  },
  "commenter": "李四",
  "text": "非常实用的文章!",
  "rating": 5
}

4.2.3 父子查询与聚合

父子查询支持从父查子和从子查父两种方向。

// 查询:查找被评分大于4的评论的文章
{
  "query": {
    "has_child": {
      "type": "comment",
      "query": {
        "range": { "rating": { "gte": 4 } }
      },
      "score_mode": "max"   // 取子文档最高分
    }
  }
}

// 查询:查找某篇文章下所有包含"精彩"的评论
{
  "query": {
    "has_parent": {
      "parent_type": "article",
      "query": {
        "term": { "_id": "article-001" }
      }
    },
    "bool": {
      "must": { "match": { "text": "精彩" } }
    }
  }
}

父子聚合需要先通过 children 聚合进入子文档空间。

// 聚合:统计每篇文章的平均评分
{
  "size": 0,
  "aggs": {
    "articles": {
      "terms": { "field": "_id", "size": 10 },
      "aggs": {
        "comments": {
          "children": { "type": "comment" },
          "aggs": {
            "avg_rating": { "avg": { "field": "rating" } }
          }
        }
      }
    }
  }
}
性能警告: 父子查询需要维护全局的父子关系映射表(global ordinals),当父文档数量超过百万级时,内存占用和查询延迟会显著上升。建议在领域建模阶段评估父子关系的规模,必要时考虑数据分片或反范式化。

4.3 嵌套结构选型决策矩阵

在领域建模阶段,根据以下决策矩阵选择最合适的嵌套结构:

领域特征 推荐模式 理由
子对象无独立生命周期,随父对象一起创建/删除 嵌套文档 更新一致性高,查询性能好
子对象有独立生命周期,需要单独更新 父子文档 避免重建整个父文档
子对象数量极少(< 10)且查询频繁 嵌套文档 减少关联开销
子对象数量巨大(> 1000)且持续增长 父子文档 避免父文档膨胀
需要跨子对象做聚合统计 嵌套文档 嵌套聚合语法简洁,性能更优
子对象需要独立权限或版本控制 父子文档 每个子文档拥有独立 _id 和版本号

4.4 嵌套结构在倒排索引中的物理存储

理解嵌套结构在 Lucene 层面的物理存储方式,有助于做出正确的领域建模决策。

嵌套文档 vs 父子文档 物理存储对比 嵌套文档存储 父文档(订单) _id: ORD-001, customer: 张三 嵌套子文档 1(商品行项) productName: 机械键盘, price: 399 嵌套子文档 2(商品行项) productName: 无线鼠标, price: 199 Lucene 隐藏文档:独立索引,共享段文件 更新需重建整个父文档 父子文档存储 父文档(文章) _id: article-001, title: DDD实践 join 字段关联 子文档 1(评论) _id: comment-001, parent: article-001 子文档 2(评论) _id: comment-002, parent: article-001 独立 Lucene 文档:各自维护版本号 可单独更新任一子文档

4.5 嵌套查询与聚合的领域驱动设计模式

在领域驱动设计中,嵌套查询和聚合应遵循以下模式:

  1. 聚合内查询: 使用嵌套文档模型,查询条件限定在同一个聚合根内部。例如“查找包含过期商品行项的订单”。
  2. 跨聚合查询: 使用父子文档模型或应用层关联。例如“查找被高评分用户评论的文章”。
  3. 聚合统计: 嵌套聚合用于值对象集合的统计分析;父子聚合用于实体集合的统计分析。
  4. 版本一致性: 嵌套文档保证聚合根内部的一致性;父子文档允许子实体独立版本演进。
最佳实践: 在领域建模阶段,先识别聚合边界。聚合内的值对象集合使用嵌套文档;聚合间的实体关联使用父子文档或应用层服务组合。避免在同一个索引中混合超过两层嵌套,否则查询复杂度会指数级上升。

4.6 嵌套结构的性能优化策略

优化策略 适用场景 具体做法
限制嵌套深度 嵌套文档 嵌套层数不超过 2 层,避免递归嵌套
控制嵌套数量 嵌套文档 单个父文档的嵌套子文档建议 < 100
使用 eager_global_ordinals 父子文档 在索引刷新时预加载父子关系映射
反范式化 高频查询场景 将常用子字段冗余到父文档中
分片路由优化 父子文档 父子文档路由到同一分片,减少跨分片查询
领域驱动设计提示: 当嵌套文档的更新频率超过查询频率时,考虑将值对象提升为独立实体,使用父子文档关系。反之,当查询频率远高于更新频率时,嵌套文档是更优选择。这个决策应基于领域事件风暴中识别出的业务频率。