文档设计 # 这一页回答两个问题:应该把什么放进一个文档?字段怎么设计才适合搜索? 这里聚焦单个文档层面的建模,跨文档关系(Nested、Parent-Child、反范式)放在后续章节。
什么是文档 # 在 Easysearch 中,一个 文档(Document) 是被序列化为 JSON 的最顶层对象,指定了唯一 ID 并存储到 Easysearch 中。例如:
{ "name": "John Smith", "age": 42, "confirmed": true, "join_date": "2014-06-01", "home": { "lat": 51.5, "lon": 0.1 }, "accounts": [ { "type": "facebook", "id": "johnsmith" }, { "type": "twitter", "id": "johnsmith" } ] } 文档可以包含字符串、数字、布尔、日期、嵌套对象、数组等多种类型。
文档元数据 # 每个文档都有三个核心元数据:
元数据 说明 _index 文档存放的索引,是逻辑命名空间 _id 文档的唯一标识符,可自定义或自动生成 _source 文档的原始 JSON 内容 此外,每个文档还有 _version 字段——每次对文档修改(包括删除)时版本号递增,用于并发控制。
...
数据建模 # 如何为搜索设计数据结构?这是每个 Easysearch 用户的关键问题。
和传统关系型数据库不同,Easysearch 更倾向于通过冗余与预计算来换取读性能与查询表达力。好的建模能让查询简洁高效,坏的建模则会导致查询复杂、性能低下。
本章内容 # 主题 说明 文档设计 文档结构、字段设计、多字段、标识符与路由、更新模式与并发控制 反范式与权衡 规范化 vs 冗余、何时反范式、更新成本与一致性 Nested 建模 一对多/多对多的嵌套对象、笛卡尔积假匹配、nested 查询与聚合 Parent-Child 建模 父子关联、与 Nested 对比、路由策略、has_child/has_parent 向量字段建模 向量/语义搜索的字段设计、维度选择、存储优化与性能权衡 时间序列建模 按时间组织索引、别名管理、索引模板、数据保留策略 多租户建模 一用户一索引 vs 共享索引、路由优化、别名、容量规划 推荐阅读顺序 # 文档设计 → 先搞定单个文档"放什么字段、怎么设计" 反范式与权衡 → 理解搜索系统中冗余的价值与代价 Nested → 一对多 / 多对多下的数组字段与嵌套对象 Parent-Child → 需要真正引用关系、且更新/生命周期不同步时 向量字段建模 → 语义搜索场景中的向量存储与优化 时间序列 → 日志/指标/事件流的索引规划 多租户 → 多用户/多组织场景下的索引划分与隔离 与其他章节的关系 # 索引与分片设计:从基础设施角度讨论分片与模板,与本章的数据视角互补 映射基础:具体的 Mapping 配置语法与字段类型 核心概念:索引、文档、分片等底层概念
反范式与权衡 # 在关系型数据库中,规范化(范式)是黄金法则。但在 Easysearch 这样的搜索系统中,反范式(denormalization)往往是更好的选择。本页解释为什么,以及如何在一致性和性能之间做权衡。
搜索系统中的规范化与冗余 # 与传统数据库"强规范化"不同,面向搜索的文档往往会有适度冗余:
预先把常用的派生信息存进文档(如标准化后的地区名、拼音、缩写) 把查询高频的外键信息"带过来",减少查询时的 join 需求 但冗余也要有边界:
冗余会放大存储与更新成本 冗余字段过多,会让 mapping 变得臃肿、难以维护 经验做法:
只冗余"确实会被高频查询/排序/聚合"的字段 对变动频率极高的冗余信息,要慎重评估更新成本 为什么需要反范式? # 使用 Easysearch 得到最好搜索性能的方法是有目的地在索引时进行反范式。对每个文档保持一定数量的冗余副本可以在需要访问时避免进行关联操作。
示例:博客文章与用户 # 如果我们希望通过用户姓名找到他写的博客文章,可以在博客文档中包含这个用户的姓名:
PUT /my_index/_doc/1 { "name": "John Smith", "email": "john@smith.com", "dob": "1970/10/24" } PUT /my_index/_doc/2 { "title": "Relationships", "body": "It's complicated...", "user": { "id": 1, "name": "John Smith" } } 通过单次查询就能找到用户 John 的博客文章:
GET /my_index/_search { "query": { "bool": { "must": [ { "match": { "title": "relationships" }}, { "match": { "user.
...
Nested 建模 # Nested 解决的是这样一种典型需求:数组里是一组对象,而不是一堆无关字段的拼接,查询时既要对数组元素内部做精确匹配,又不想被"笛卡尔积假匹配"坑到。
什么时候需要 nested? # 先看一个常见例子:订单里有多条明细 items:
{ "order_id": "O-1", "items": [ { "sku": "A", "price": 100, "qty": 1 }, { "sku": "B", "price": 200, "qty": 2 } ] } 如果你把 items.sku、items.price、items.qty 都当成普通多值字段:
items.sku = [“A”, “B”] items.price = [100, 200] items.qty = [1, 2] 此时一个查询:
items.sku = "A" 且 items.price = 200 在"扁平多值字段"模型下是会命中的(因为它只在每个字段内部看是否包含该值),但现实里并不存在 sku=A 且 price=200 这一条明细。这就是经典的"笛卡尔积假匹配"。
要避免这种问题,就需要 nested。
如何定义 nested 字段 # 在 Mapping 中,把数组元素声明为 nested 类型(示意):
...
Parent-Child 建模 # Parent-Child 用来表达"两个文档属于不同类型/生命周期,但又需要建立关联"的场景。相比 nested,它更适合父文档频繁变化 / 子文档数量较多 / 生命周期不同步的情况。
什么时候考虑 Parent-Child? # 典型场景:
主资源 + 活动记录:例如用户(父)+ 多条行为日志/评论(子) 订单 + 物流/状态变更记录:订单比较稳定,状态记录会持续追加 文档 + 标签/评分:标签或评分变化频率远高于主体文档 这类关系有几个共同特点:
父/子文档生命周期不同步(子可以频繁新增/删除,父相对稳定) 子文档数量可能很多,如果全部嵌入父文档会让父文档变得非常庞大 查询时既可能只查子文档,也可能需要"从父找子"或"从子找父" Parent-Child 与 Nested 的对比 # 可以用下面的方式做一个快速选择:
更适合 Nested 的情况:
子元素数量有限,整体更新成本可接受 查询几乎总是"连带父文档一起看" 不需要单独对"子"做大规模搜索或独立生命周期管理 更适合 Parent-Child 的情况:
子元素数量较多,且经常新增/删除 子文档需要独立参与搜索与统计 父/子有不同的更新/存储策略 Nested 更像"文档内部的结构", Parent-Child 更像"两个文档集合之间的引用关系"。
建模要点(概念级) # 底层上,父文档与子文档都存储在同一个索引中,通过一个"连接字段"来描述父/子的关系:
...
时间序列建模 # 时间序列数据(如日志、指标、事件流)是 Easysearch 的常见用例。这类数据有几个特点:文档数量快速增长、基本不更新、主要查询最近的数据。本页介绍如何为时间序列数据设计索引结构。
时间序列数据的特点 # 与传统的搜索场景不同,时间序列数据有以下特点:
文档数量快速增长:日志、指标等数据持续写入,不会停顿 文档基本不更新:写入后很少修改,主要是追加 查询集中在最近数据:大多数查询关注最近几小时、几天或几周的数据 旧数据逐渐失去价值:随着时间推移,旧数据的查询频率降低 按时间范围索引 # 如果我们为此种类型的文档建立一个超大索引,我们可能会很快耗尽存储空间。日志事件会不断的进来,不会停顿也不会中断。
我们可以使用 scroll 查询和批量删除来删除旧的事件。但这种方法非常低效。当你删除一个文档,它只会被标记为被删除。在包含它的段被合并之前不会被物理删除。
替代方案是,我们使用一个时间范围索引。你可以着手于一个按年的索引 (logs_2014) 或按月的索引 (logs_2014-10)。也许当你的数据变得十分繁忙时,你需要切换到一个按天的索引 (logs_2014-10-24)。删除旧数据十分简单:只需要删除旧的索引。
这种方法有这样的优点,允许你在需要的时候进行扩容。你不需要预先做任何艰难的决定。每天都是一个新的机会来调整你的索引时间范围来适应当前需求。
应用相同的逻辑到决定每个索引的大小上。起初也许你需要的仅仅是每周一个主分片。过一阵子,也许你需要每天五个主分片。这都不重要——任何时间你都可以调整到新的环境。
使用别名管理时间序列索引 # 别名可以帮助我们更加透明地在索引间切换。当创建索引时,你可以将 logs_current 指向当前索引来接收新的日志事件,当检索时,更新 last_3_months 来指向所有最近三个月的索引:
POST /_aliases { "actions": [ { "add": { "alias": "logs_current", "index": "logs_2014-10" }}, { "remove": { "alias": "logs_current", "index": "logs_2014-09" }}, { "add": { "alias": "last_3_months", "index": "logs_2014-10" }}, { "remove": { "alias": "last_3_months", "index": "logs_2014-07" }} ] } 这样,写入操作始终使用 logs_current 别名,查询操作可以使用 last_3_months 别名来查询最近三个月的数据。
...
多租户建模 # 多租户(Multi-tenancy)是指多个用户或组织共享同一个 Easysearch 集群,但各自的数据需要隔离。本页介绍多租户场景下的索引设计、路由策略和容量规划思路。
多租户的两种主要模式 # 模式一:一个用户一个索引 # 通常来说,用户使用 Easysearch 的原因是他们需要添加全文检索或者需要分析一个已经存在的应用。他们创建一个索引来存储所有文档。公司里的其他人也逐渐发现了 Easysearch 带来的好处,也想把他们的数据添加到 Easysearch 中去。
幸运的是,Easysearch 支持多租户,所以每个用户可以在相同的集群中拥有自己的索引。有人偶尔会想要搜索所有用户的文档,这种情况可以通过搜索所有索引实现,但大多数情况下用户只关心它们自己的文档。
一些用户有着比其他人更多的文档,一些用户可能有比其他人更多的搜索次数,所以这种对指定每个索引主分片和副本分片数量能力的需要应该很适合使用"一个用户一个索引"的模式。类似地,较为繁忙的索引可以通过分片分配过滤指定到高配的节点。
提示:不要为每个索引都使用默认的主分片数。想想看它需要存储多少数据。有可能你仅需要一个分片——再多的都只是浪费资源。
大多数 Easysearch 的用户读到这里就已经够了。简单的"一个用户一个索引"对大多数场景都可以满足了。
模式二:共享索引 + 过滤 # 对于例外的场景,你可能会发现需要支持很大数量的用户,都是相似的需求。一个例子可能是为一个拥有几千个邮箱账户的论坛提供搜索服务。一些论坛可能有巨大的流量,但大多数都很小。将一个有着单个分片的索引用于一个小规模论坛已经是足够的了——一个分片可以承载很多个论坛的数据。
我们需要的是一种可以在用户间共享资源的方法,给每个用户他们拥有自己的索引这种印象,而不在小用户上浪费资源。
共享索引的实现 # 我们可以为许多的小用户使用一个大的共享的索引,将用户标识索引进一个字段并且将它用作一个过滤器:
PUT /forums { "settings": { "number_of_shards": 10 }, "mappings": { "properties": { "forum_id": { "type": "keyword" }, "title": { "type": "text" } } } } PUT /forums/_doc/1 { "forum_id": "baking", "title": "Easy recipe for ginger nuts", .
...
最佳实践 # 从实战经验出发,提供可直接落地的使用与运维最佳实践。每篇指南聚焦一个主题,给出可操作的方案与配置建议。
实践指南 # 主题 说明 数据建模 文档设计、Nested、Parent-Child、反范式、时间序列、多租户 索引与分片设计 分片数量规划、Mapping 设计、模板管理 查询调优与慢查询排查 Profile API、慢查询日志、常见性能陷阱 数据生命周期与保留策略 ILM 策略设计、冷热分层、自动清理 AI 搜索与向量检索架构 多路召回、Hybrid Search、与 LLM 集成 安全与多租户最佳实践 角色体系设计、多租户隔离、审计日志