Chapter 01
名词地图与集群拓扑
起点给了你整张概念地图——这一章把图上的名词逐个定义清楚,重点是 cluster→node→index→shard→segment 谁装谁,以及 text / keyword 这条最高频的认知分水岭。
本章你将建立的 schema
- ES 里"东西"的层级:cluster / node / index / shard / Lucene segment 谁装谁
- 一条文档的解剖:document / field / _source / mapping
- text 与 keyword 的本质区别,以及为什么它是最高频的字段错配来源
这一章只做一件事:把名词钉死。后面两章讲机制(写入为什么近实时、检索为什么打分会偏),靠的全是这一章建立的词汇——如果"shard 和 segment 谁装谁"还含糊,那两章会读得磕磕绊绊。所以这里给每个名词一个 ≤30 字的硬定义,再往下挖一层机制,最后用一个类比收尾(并指出类比在哪里失效)。
图 1.1 是这一章的总纲。下面五节,就是把这张图里的每个框单独拎出来定义。读的顺序是从最小的单位(一条文档)往外扩,再下钻到最底层(segment),最后落到那条最高频的字段分水岭。
1.1文档与字段 · document / field / _source
document 是一条 JSON 对象,写入与检索的基本单位;field 是它的键值对;_source 是原始 JSON 的完整副本。
ES 不是按行存表,它按"文档"存。一条文档就是一个完整的 JSON 对象——一个商品、一篇日志、一个订单。把"基本单位"定成 JSON 而不是表行,是因为真实数据天然嵌套(一个商品有多个标签、一段地址有省市区),用一个自描述的 JSON 装进去,比拆成多张表再 JOIN 更贴合搜索场景。
field 就是文档里的每一个键值对:"title": "无线降噪耳机" 是一个 field,"price": 899 是另一个。每个 field 有自己的类型(由 mapping 决定,见 1.2),类型决定它怎么被存、怎么被搜。
底层机制(比文档深一层):_source 和"用于搜索的结构"是两份存储
这是全章第一个、也是最容易被忽略的机制点。把一条文档写进 ES,它在底层至少被拆成两份不同的存储:
- 用于搜索的倒排结构:把字段值切成一个个 term,记录"哪个 term 出现在哪些文档里"。这份结构不保留原文——它只知道
"无线"这个 term 出现在文档 1、文档 7,但拼不回原始那句"无线降噪耳机"。 _source:写入时原始 JSON 的一份完整、未经处理的副本,单独存在磁盘上。
检索时,倒排结构负责"找到哪些文档命中",但返回给你看的那段 JSON 是从 _source 里取出来的——因为倒排结构根本还原不了原文。这就解释了一个常被当成理所当然、其实很关键的事实:索引结构 ≠ 原文,两者是分开存的。关掉 _source("_source": {"enabled": false})能省磁盘,但代价是命中后拿不回原始文档、也没法 reindex——这正是因为搜索结构里没有原文可还原。
"索引结构 ≠ 原文"是后面所有内容的起点。01 章只需记住"原文单独存在 _source 里";到 03 章会再加一份:keyword 字段还会建第三份存储 doc_values(列式,专供聚合排序)。同一个字段值,按用途被存成好几份——这不是浪费,是 ES 用空间换"既能搜又能聚合"的核心手段。
类比(带边界)
document 像数据库里的一行记录,field 像一个列。但边界在于:行记录的 schema 是建表时定死的、所有行共享一套列;ES 文档则是 schema-on-write 的 JSON,每条文档可以带不同的字段,mapping 在第一次见到新字段时才动态长出来(见 1.2)。把它当成"严格的表行"会在这里翻车。
小例子
PUT /products/_doc/1
{
"title": "无线降噪耳机",
"brand": "Acme",
"price": 899,
"tags": ["audio", "wireless"]
}
products是 index(这类文档的集合,见 1.2),_doc是固定端点,1是这条文档的_id。- 请求体整段就是这条 document;
title/brand/price/tags是它的 field。 - 这段 JSON 会被原样存进
_source;同时每个字段按各自类型被拆进搜索结构。两份存储,一次写入。
与下一个概念的关系:一条文档要被存、被搜,ES 得先知道每个字段是什么类型、走哪条索引路径——这套"字段规则"就是 mapping,下一节。
1.2索引与 mapping · index / mapping
index 是一类文档的集合(逻辑分组);mapping 是定义每个字段如何被存储和索引的 schema。
同一类文档(所有商品)放进同一个 index,才能一次性搜整批、共享一套字段规则。而 mapping 回答的是"这个字段到底怎么处理"——是切词做全文检索,还是原样存好做精确匹配?是不是要额外建一份列式结构好做聚合?没有 mapping,ES 不知道把 "2026-06-01" 当日期还是当字符串,更不知道该不该给它分词。
底层机制(比文档深一层):mapping 决定字段走哪条索引路径
mapping 不是"声明一下类型"那么轻。它实际决定了每个字段在底层被处理成什么:
- 要不要分词:
text类型的字段会过 analyzer 被切成多个 term;keyword不分词、整串存一个 term(详见 1.5)。 - 要不要建 doc_values:大多数非
text字段默认建 doc_values——一份列式磁盘结构,专门支撑排序和聚合(细节在 03 章)。text默认不建,所以直接对text聚合会失败或退化。
同一个字符串值,因为 mapping 不同,可以走完全不同的两条路。这就是为什么 1.5 那条分水岭如此重要——而它的根,就在 mapping。
不预先定义 mapping 时,ES 用 dynamic mapping:第一次见到某个字段,就按值推断一个类型并固定下来。方便,但有两个失败模式:① 第一条文档里 "id": "00123" 被推断成 text,后面想当数字用就晚了——已存在字段的类型不能原地改;② 日志里塞进上千个动态 key,mapping 爆炸(mapping explosion),拖垮集群。生产环境对结构稳定的数据,显式写 mapping 比依赖推断稳得多。
类比(带边界)
mapping 像关系数据库的表结构(CREATE TABLE 里的列类型)。但边界有两条:① 它默认是动态的,能在写入时自动长出新列,而建表语句是静态的;② 一个字符串字段默认会被映射成两个可用形态——字段本身是 text,外加一个 .keyword 子字段(这叫 multi-field),相当于"一列同时存了分词版和原样版"。关系数据库没有这种"一列两形态"的概念。
小例子
PUT /products
{
"mappings": {
"properties": {
"title": { "type": "text" },
"brand": { "type": "keyword" },
"price": { "type": "integer" }
}
}
}
title声明为text:会被分词,用于全文检索(搜"耳机"能命中"无线降噪耳机")。brand声明为keyword:原样存,用于精确过滤和聚合(按品牌分组统计)。price声明为integer:默认建 doc_values,可做范围过滤、排序、求和。- 这里显式区分了
text和keyword;若不写 mapping,title和brand都会被 dynamic mapping 推断成text+.keyword子字段的组合形态。
text 只进倒排(朱红,分词后可搜但不可聚合),keyword / 数值额外进 doc_values(可聚合排序)。这张分叉图就是 1.5 那条分水岭的全貌预览。与下一个概念的关系:到这里"一条文档怎么被定义、被拆分"清楚了。但这些文档物理上存在哪、怎么扛住单机放不下的数据量?答案是被切片分到多台机器上——这就是集群拓扑。
1.3集群拓扑 · cluster / node / shard 主副本
cluster 是一组协同的 node;node 是一个 ES 进程;shard 是 index 被切成的片,每片是一个独立的 Lucene index。
一个 index 的数据量可以远超单台机器的内存和磁盘。把它水平切成若干 shard、分布到多个 node,单机放不下的数据就放下了,单机扛不住的查询也能并行分摊。primary / replica 的主副本设计则同时解决两件事:容灾(一台 node 挂了,副本顶上)和查询吞吐(读请求可以打到副本上分担)。
底层机制(比文档深一层):写读如何在分片上并行
index 在创建时被切成 N 个 primary shard,由 ES 分布到不同 node 上。每个 primary 可以配置若干 replica(副本)。三条关键规则:
- 写入只走 primary:一条文档先被路由到某个 primary shard 写入,再由它同步给自己的 replica。primary 是这条文档的"写入权威"。
- 读可走 primary 或 replica:查询时,每个 shard(primary 或它的某个 replica,由协调节点挑)并行执行,结果再汇总。replica 越多,并发读吞吐越高。
- primary 与它的 replica 不同 node:否则那台 node 一挂,主副本一起没,容灾就失效了。
primary shard 的数量在 index 创建时定死,之后不能改。原因在 03 章会还原成一个路由公式:一条文档落到哪个 shard,由 hash(_routing) % primary_数量 决定;除数一变,所有老文档的归属全乱。这条"分片数固定"是与"段不可变"并列的第二条公理——记住结论,机制留到 03 章。(replica 数量则可以随时调整,它不参与路由计算。)
类比(带边界)
shard 像分库分表里的一个"分片"——数据按某种规则散到多个库。但边界在于:分库分表的分片是逻辑划分,底层还是普通的库表;ES 的每个 shard 是一个完整、独立、自包含的 Lucene index——它自己有完整的倒排索引、自己的 segment、能独立完成一次搜索。换句话说,shard 不是"半个索引",而是"一个小而全的索引"。这一点直接通向下一节。
小例子
PUT /products
{
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1
}
}
number_of_shards: 2:这个 index 被切成 2 个 primary shard(图 1.1 里的 P0、P1)。这个数之后不能改。number_of_replicas: 1:每个 primary 配 1 个副本,于是共 2 primary + 2 replica = 4 个 shard,分布到不同 node。这个数可以随时改。- 查"耳机"时,2 个 primary(或它们的 replica)并行各搜一半数据,结果汇总返回。
与下一个概念的关系:既然一个 shard 就是一个完整的 Lucene index,那 Lucene index 内部又长什么样?下钻最后一层——segment。
1.4Lucene segment
segment 是 Lucene 写在磁盘上的不可变数据文件;一个 shard 由多个 segment 累加组成。
倒排索引一旦建好,原地插入一个新文档代价极高——要在无数个 term 的 postings 列表中间见缝插针。Lucene 的解法是:不改旧的,只写新的。新文档攒一批,打包成一个全新的 segment 写到磁盘,旧 segment 原封不动。一个 shard 就是一摞这样的 segment 叠加而成。这条"只增不改"是整个写入模型能高效运转的根。
底层机制(关键,比文档深一层):shard 是一摞只增不改的 segment
把一个 shard 的内部摊开,它不是一块整数据,而是一摞 segment——每个 segment 自己是一个迷你倒排索引,包含一批文档的全部索引结构。核心性质只有一条,但它撑起后面整整一章:
每个 segment 一旦写出,就再也不被修改——immutable。
"不可变"带来三个直接推论,这里只点名、细节全在 02 章:
- 近实时(refresh):新文档要先被打包成一个新 segment、且这个 segment 对搜索可见,才能被搜到。这个动作默认每 1 秒做一次——这就是"写完默认 1 秒内搜不到"的来源。细节在 02 章。
- 删除不真删:既然 segment 不可改,删一条文档就不能从 segment 里抠掉,只能在一个单独的
.del标记里记一笔"这条已删",搜索时跳过。磁盘空间当时并不释放。细节在 02 章。 - 段合并(merge):segment 越攒越多会拖慢搜索,ES 在后台把多个小 segment 合并重写成一个大的——这时被标删的文档才真正被丢弃、磁盘才回收。细节在 02 章。
segment 像一本只追加的账本:写错了不涂改旧页,而是翻到新页记一笔更正,旧页永远保留。这个类比抓住了"不可变 + 只追加"的精髓。但边界在这里:普通账本不会回头重抄,ES 却会在后台把旧页合并、重抄成新页(merge),并在重抄时把作废的记录彻底丢掉。所以更准确的说法是"一本会定期被誊清的只追加账本"。
把一个 index 的数据想象成"一摞只增不改的 segment"。那么"更新一条已存在的文档",在这摞 segment 上到底意味着什么操作?(提示:旧 segment 不能改。)
展开答案(先停 10 秒再点)
不是"原地改",而是新写 + 标删:把更新后的整条文档作为一条新文档写进一个新 segment,同时在旧文档所在位置打上"已删除"标记。于是同一个 _id 在物理上短暂存在新旧两份,靠"标删 + 取最新"对外表现为一次更新。
这也解释了为什么 ES 里没有"部分字段原地更新磁盘"这回事——所谓 partial update,底层也是"读出整条 → 改内存 → 整条重写新 segment"。旧版本要等 merge 时才被真正清理。完整的写入链路、版本号怎么保证取到最新,都在 02 章。
与下一个概念的关系:到这里"东西的层级"从 cluster 一路下钻到了 segment。但有一个贯穿始终、决定字段能不能搜、能不能聚合的分叉还没正面讲——它发生在字段类型这一层,是日常最高频的错配来源。
1.5text vs keyword · 认知分水岭
text 过 analyzer 切成多个 term,可全文检索、不可直接聚合排序;keyword 原样存一个 term,可精确匹配/排序/聚合、不做全文相关性匹配。
"搜文章正文"和"按状态精确分组统计"是两种根本不同的需求。全文检索要把句子切成词、忽略大小写、容忍词序——这要分词。精确匹配和聚合要的恰恰相反:原值一字不差、能当 key 分桶。一个字段类型满足不了两种需求,所以 ES 把字符串劈成两类:text 管搜,keyword 管精确与聚合。判断一个字段该用哪个,是日常最高频的设计决策,错配会直接触发"搜不到"或"聚合报错"。
底层机制(比文档深一层):分词路径 vs 列式路径
两种类型在底层是两条完全不同的处理流水线:
text→ 过 analyzer → 进倒排:analyzer 是一条三段流水线——char filter(预处理字符)→ tokenizer(切词)→ token filters(如小写化、去停用词)。"Quick Fox"经标准 analyzer 产出 term[quick, fox](注意:小写、拆成两个),这些 term 进倒排索引。text默认不建 doc_values,所以直接对它聚合/排序会退化到一种叫 fielddata 的堆上结构——把字段值临时加载进 JVM 堆,数据量一大极易 OOM。fielddata / doc_values 的细节在 03 章。keyword→ 不分词 → 进倒排 + doc_values:"Quick Fox"原封不动作为单个 term 存入(大小写、空格全保留),同时默认建 doc_values——一份列式磁盘结构,专供聚合和排序高效读取。所以keyword能分组统计、能排序,但你搜"fox"匹配不到它(它存的是整串"Quick Fox",不是fox)。
"Quick Fox",按字段类型走两条互补的路:text 切成小写 term 进倒排(左,朱红),keyword 整串进 doc_values(右)。注意:两条路的能力恰好互补——一边能搜不能聚合,另一边能聚合不能搜。这正是 dynamic mapping 默认给字符串同时建 text 和 .keyword 子字段(multi-field)的原因:让你既能用字段本身全文搜,又能用 .keyword 聚合排序。这是全章最重要的一节:term 不分词,match 才分词
错配几乎都源于一个混淆:term 查询不分词,match 查询会分词。对一个 text 字段用 term 查原始整句,往往一条都命中不了——因为倒排里存的是小写切词后的 token,不是你给的原句。
一个字段 title 走默认 dynamic mapping(即被映射成 text + .keyword),写入一条文档 {"title": "Hello World"}。然后执行 {"term": {"title": "Hello World"}}。命中吗?
展开答案(先停 10 秒再点)
不命中。title 是 analyzed text 字段,写入时 "Hello World" 被 analyzer 切成并小写化为 [hello, world] 两个 term 存进倒排。而 term 查询不分词,它拿着原始字符串 "Hello World"(带空格、带大写)去倒排里找一个一模一样的 term——倒排里只有 hello 和 world,没有 "Hello World",所以零命中。
三种修法,各对应一个正确心智模型:① 想全文搜,用 {"match": {"title": "Hello World"}}——match 会把查询词同样分词成 [hello, world] 再匹配,命中。② 想精确匹配整串,查子字段 {"term": {"title.keyword": "Hello World"}}——.keyword 存的就是原样整串。③ 这也直接说明聚合为什么要用 title.keyword 而不是 title:聚合需要原值当 key,分词后的 text 给不了。
小例子
# 写入(title 走 dynamic mapping,得到 text + title.keyword)
PUT /docs/_doc/1
{ "title": "Hello World" }
# ① term 查 analyzed text 字段 —— 0 命中(倒排里是 [hello, world])
GET /docs/_search
{ "query": { "term": { "title": "Hello World" } } }
# ② match 会分词 —— 命中
GET /docs/_search
{ "query": { "match": { "title": "Hello World" } } }
# ③ 精确匹配 / 聚合,走 .keyword 子字段
GET /docs/_search
{
"query": { "term": { "title.keyword": "Hello World" } },
"aggs": { "by_title": { "terms": { "field": "title.keyword" } } }
}
| 维度 | text | keyword |
|---|---|---|
| 写入时处理 | 过 analyzer,切成多个小写 term | 不分词,原样整串存一个 term |
| 底层存储 | 倒排索引(默认无 doc_values) | 倒排 + doc_values(列式) |
| 全文检索(match) | 支持,这是它的本职 | 不做相关性匹配 |
| 精确匹配(term) | 对原句几乎总落空 | 支持,匹配整串 |
| 聚合 / 排序 | 默认禁用;强开退化到 fielddata(易 OOM,详见 03 章) | 支持,走 doc_values 高效完成 |
| 典型字段 | 文章正文、商品标题、日志消息 | 状态、标签、品牌、枚举、ID |
与全章的关系:1.1–1.4 建立了"东西的层级",1.5 这条分水岭则贯穿其中——它发生在 1.2 的 mapping 这一层,决定字段值在 1.4 的 segment 倒排里以什么形态存在。下一章(02 写入路径)会把 1.4 的"段不可变"展开成完整的写入与近实时机制;再下一章(03 检索路径)会把 1.5 里点到的 doc_values / fielddata、以及 1.3 里点到的分片路由公式补全。
§本章 self-check
先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。
- 用一句话说清 cluster / node / index / shard / segment 的包含关系:谁装谁?其中哪一层才是"一个完整的 Lucene index"的边界?
- 检索命中后返回给你看的那段 JSON,来自哪份存储?为什么倒排结构本身还原不了它?
- (设计层)一个字段写入的全是
"已发货" / "已签收"这类固定状态值,业务只需要"按状态精确过滤 + 分组统计数量",从不做模糊搜索。该把它设成text还是keyword?如果误设成默认的text,分别会在"过滤"和"聚合"上撞到什么失败?
答案(先做完再展开)
- cluster ⊃ node ⊃ shard ⊃ segment;index 是横跨多个 node 的逻辑分组(不在单台机器上),它被切成的 shard 才落在具体 node 上。"完整 Lucene index 的边界"是 shard——每个 shard 是一个独立自包含的 Lucene index,内部由多个 segment 累加而成。
- 来自
_source(写入时原始 JSON 的完整副本,单独存储)。倒排结构只记录"哪个 term 出现在哪些文档",把原文切碎成 term 后并不保留原始字符串,所以还原不了文档原文——这就是"索引结构 ≠ 原文"。 - 应设成
keyword:状态值要的是原样精确匹配和分组,正好是 keyword 的本职。若误设成text:① 过滤上,term查"已发货"可能落空(被 analyzer 处理过,且text不为精确匹配而生);② 聚合上,对text直接做 terms 聚合默认被禁止,强行打开会退化到 fielddata 把字段值加载进 JVM 堆,状态基数虽小风险尚可,但在高基数字段上就是典型的 OOM 来源(详见 03 章)。正确做法:直接声明keyword,或退而用 dynamic mapping 自动生成的.keyword子字段。
一个字段,既要精确聚合、又要全文搜
有个 company 字段,存公司名如 "Acme Industrial Co."。业务有两个看似矛盾的需求:① 报表要按公司名精确分组统计每家公司的订单数(整串当 key,不能被切词);② 搜索框要支持对公司名做全文搜(输入"industrial"能召回这家)。一个字段类型满足不了两者——你的 mapping 该怎么设计,让这一个字段同时具备两种能力?写出 mapping 片段,并说清查询时各自该引用哪个名字。
提示(卡住再展开)
回看 1.2 的类比边界和图 1.3 的 figcaption:一个字符串值可以同时存成两种形态。把 company 设成 text(供 ① 全文搜),并在它下面挂一个 keyword 类型的子字段(供 ② 精确聚合)——这就是 multi-field。全文搜引用 company,精确聚合引用 company.keyword。这正是 dynamic mapping 默认替你做的事,这里只是把它显式写出来并理解为什么。