这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 Elasticsearch 的搜索能力来自 Apache Lucene。理解 Lucene 后,很多“看起来很魔法”的现象都会变得自然:为什么写入后要等刷新才能搜到,为什么更新等于删除加写入,为什么过滤不评分更快,为什么磁盘上会同时存在很多小文件,为什么段合并会占用大量 CPU 和 IO。
本章聚焦 Lucene 的核心数据结构与存储模型。
22.1 Lucene 是什么
Lucene 是一个高性能、全功能的倒排索引引擎库。它提供:
- 文档索引与搜索;
- 分词和分析器;
- 倒排索引、列式存储和存储字段;
- 段合并、事务日志和近实时搜索;
- 相关性评分和查询执行框架。
Lucene 不是分布式数据库。它本身只管理本地索引,不知道节点、分片、副本、集群状态和请求路由,这些由 Elasticsearch 补齐。
Elasticsearch
|-- 集群、分片、副本、分配
|-- REST API、安全、SQL、ES|QL
|-- 请求解析、分布式查询归并
+-- Lucene Segment
|-- 倒排索引
|-- Doc Values
|-- Stored Fields
+-- Term Vectors / Points / Vectors 等可选结构
一个 Elasticsearch 分片在逻辑上是一个 Lucene 索引。一个分片内部可以有多个 Segment;每个 Segment 是一个不可变的、可独立搜索的小索引。
22.2 文档的内部编号
Lucene 会给每个 Segment 内的文档分配一个从 0 开始的内部 doc id。这个 id 不是业务文档 _id,而是 Lucene 内部用于定位 posting、Doc Values 和 Stored Fields 的编号。
Segment A:
doc 0 -> sku_10001
doc 1 -> sku_10002
doc 2 -> sku_10003
Segment B:
doc 0 -> sku_10004
doc 1 -> sku_10005
删除文档时,Lucene 不会立即从 posting list 中移除它,而是记录一个 deleted bitset。查询会先命中 doc id,再过滤已删除文档。
这就是“更新文档”的真实流程:
update doc 100
-> 标记旧版本 deleted
-> 写入一个新版本到新 segment
-> 查询时旧版本被过滤
-> merge 时删除文档真正物理清除
因此高频更新同一个文档会带来额外写入放大:业务以为更新了 1 次,Lucene 可能写入 1 个新文档、标记 1 次删除,并在后台合并时再次搬运数据。
22.3 倒排索引
倒排索引解决“一个词出现在哪些文档里”。
以三个标题为例:
| doc | 标题分词 |
|---|---|
| 0 | elasticsearch, search |
| 1 | lucene, search |
| 2 | elasticsearch, engine |
倒排索引大致为:
| Term | Posting List |
|---|---|
| elasticsearch | 0, 2 |
| search | 0, 1 |
| lucene | 1 |
| engine | 2 |
Posting List 不只是一串 doc id,还可能包含:
- 词频 TF;
- 文档长度或 norm;
- 词项位置 position;
- 起始/结束 offset;
- payload;
- 跳表结构,用于快速交集和定位。
这些结构支撑了 match、match_phrase、term、bool、span query 和 BM25 评分。
22.3.1 Term Dictionary 与 Posting List
Term Dictionary 保存所有词项及其指向的 posting list。Lucene 通常把词项字典拆成:
.tim:term dictionary;.tip:term index,FST 结构,常驻内存;.doc:doc id 与词频;.pos:词项位置; -.pay`:offset 和 payload。
不同 Lucene 版本文件名和布局有差异,不必死记文件名。更重要的是理解访问路径:
term -> 内存 FST 定位词典块 -> 磁盘/页缓存读取 term dictionary
-> 找到 posting list -> 按位图或跳表遍历 doc id
22.3.2 为什么 term 查询快
term 查询不需要分词、不需要扫描所有文档。它只需要在词典中定位一个词项,然后读取其 posting list。
GET products-v3/_search
{
"query": {
"term": { "brand_id": "brand_1001" }
}
}
如果 brand_id 是 text,写入时会先分词,查询 brand_1001 也可能被分词,导致精确匹配不可靠。因此 ID、枚举、标签、状态通常应使用 keyword。
22.4 Doc Values
倒排索引适合“词找文档”,但排序和聚合需要“文档找值”。
例如按价格排序:
GET products-v3/_search
{
"sort": [{ "price": "desc" }]
}
如果只靠倒排索引,就必须遍历所有价格词项,再收集文档,成本非常高。Doc Values 把字段值按文档编号列式存储,适合排序、聚合和脚本访问。
| 存储 | 访问方向 | 主要用途 |
|---|---|---|
| Inverted Index | term -> docs | 文本搜索、过滤 |
| Doc Values | doc -> value | 排序、聚合、脚本 |
| Stored Fields | doc -> source | 返回 _source |
| Page Cache | 磁盘块缓存 | 近似全内存读性能 |
默认情况下,多数字段类型会启用 doc_values。如果字段只需要搜索,不需要排序聚合,可以关闭以省磁盘:
PUT demo-v1
{
"mappings": {
"properties": {
"search_only_keyword": {
"type": "keyword",
"doc_values": false
},
"aggregate_only_keyword": {
"type": "keyword",
"index": false
}
}
}
}
反过来,只要排序或聚合字段,可以 index: false,减少倒排索引开销。
22.5 Stored Fields 与 _source
Elasticsearch 默认把原始 JSON 保存在 _source 中。_source 是 Stored Fields 的一部分,用于:
- 返回搜索结果;
- update / update by query;
- reindex; -高亮重建上下文;
- 数据修复和重建索引。
如果不需要返回某个字段,可以设置 stored: false;如果完全不需要 _source,也可以关闭,但必须清楚代价:
PUT telemetry-v1
{
"mappings": {
"_source": { "enabled": false },
"properties": {
"@timestamp": { "type": "date" },
"host.name": { "type": "keyword" },
"message": { "type": "text" }
}
}
}
关闭 _source 后:
- 搜索结果无法返回原始字段;
- 不能 update;
- 不能 reindex;
- 高亮和部分脚本能力受限;
- 后续补字段需要重新采集数据。
日志类数据如果确认永远只查询、不更新、不重建,才考虑关闭 _source。更常见的优化是查询时用 _source filtering,而不是建索引时全局关闭。
22.6 Segment 不可变性
Segment 一旦写入,就不会修改。不可变性带来几个重要优势:
- 文件可以被操作系统页缓存长期复用;
- 数据结构不需要支持原地更新;
- 并发读不需要加写锁;
- 压缩率高;
- 缓存和预读策略简单。
代价是:
- 文档删除只能打标记;
- 更新等于删除加新写;
- 查询需要遍历多个 Segment;
- 小 Segment 会带来句柄、内存和查询开销;
- 后台合并会消耗 CPU、磁盘 IO 和网络。
可以用 API 观察段信息:
GET /products-v3/_segments
输出中常见字段:
| 字段 | 含义 |
|---|---|
num_docs |
未删除文档数 |
deleted_docs |
已标记删除文档数 |
size |
Segment 磁盘大小 |
memory_in_bytes |
Segment 内部结构占用内存 |
version |
Lucene 版本 |
如果大量 Segment 只有几十 KB 到几 MB,通常是刷新间隔太短;如果删除文档比例很高,通常是频繁更新或删除后未合并。
22.7 Segment Merge
Segment Merge 把多个小 Segment 合并成大 Segment,同时物理清除已删除文档。
Segment 0 (100 docs, 20 deleted)
Segment 1 (200 docs, 0 deleted)
Segment 2 (150 docs, 80 deleted)
|
v merge
Segment 3 (350 docs, 0 deleted)
合并过程可以理解为:
- 选择若干小 Segment;
- 按文档编号归并读取数据;
- 跳过 deleted 文档;
- 重写倒排索引、Doc Values、Stored Fields;
- 构建新 Segment;
- 原子切换 Segment 列表;
- 删除旧 Segment 文件。
合并策略会综合考虑 Segment 大小、数量、删除比例、磁盘阈值和当前 IO 压力。生产系统不应盲目调小 refresh_interval,否则会产生大量小 Segment,让 CPU 同时承担查询归并和后台合并。
_force_merge 会触发强制合并:
POST /products-v3/_forcemerge?max_num_segments=1
注意:
- 只适合只读索引、日志保留结束前的低峰操作或重建后的索引;
- 在持续写入的索引上强制合并,会带来巨大 IO 浪费;
- 合并期间磁盘占用可能临时上升;
- 分片数很多时,一次 force merge 可能拖慢整个集群。
22.8 Translog
Lucene 的写入是近实时,搜索依赖 Segment,但 Segment 刷新前数据还不可见。如果每次写入都生成 Segment,成本太高。事务日志 translog 用于保证未 flush 到 Lucene 的变更在进程崩溃后可恢复。
Index API
-> 写入 IndexingBuffer / Lucene 内存
-> 追加 translog
-> 返回客户端
refresh:
-> IndexingBuffer 生成新 Segment
-> 打开搜索
flush:
-> 将 Lucene 变更提交到磁盘
-> 保留或清空 translog
translog 的持久化策略:
index.translog.durability |
行为 |
|---|---|
request |
每个请求完成后 fsync,可靠性最高,成本最高 |
async |
每隔 sync_interval 刷盘,吞吐更高,可能丢最近请求 |
相关参数:
PUT orders-v3/_settings
{
"index.translog.durability": "request",
"index.translog.sync_interval": "5s",
"index.translog.retention.size": "512mb"
}
注意不同版本对 translog retention 的支持和默认值有变化。不要只为了写入吞吐把可靠性参数调低,除非业务能接受对应丢失窗口。
22.9 近实时搜索
Elasticsearch 通常说自己是近实时(NRT),不是强实时。
默认 refresh_interval 为 1s。写入成功并不代表立刻可搜,而是最多等待一次 refresh 后可搜。
PUT orders-v3/_doc/order_10001?refresh=wait_for
{
"order_id": "order_10001",
"amount": 99.00
}
刷新选项:
| 参数 | 含义 | 适用 |
|---|---|---|
| 空 | 不主动刷新,等待周期 refresh | 大多数写入 |
refresh=false |
显式不刷新 | 默认行为等价 |
refresh=true |
请求后立即 refresh | 测试、极低频写入 |
refresh=wait_for |
等到下次 refresh 后返回 | 写完必须可查,又不想主动触发 |
不要在高吞吐写入中使用 refresh=true。它会把批量写入拆成频繁 refresh,产生大量小 Segment。
22.10 查询如何使用这些结构
以一个查询为例:
GET products-v3/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "轻薄笔记本" } }
],
"filter": [
{ "term": { "status": "on_sale" } },
{ "range": { "price": { "lte": 8000 } } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "sales_30d": "desc" }
]
}
执行时可能用到:
- Analyzer 分析查询字符串;
- 倒排索引找到候选文档和评分信息;
- Filter 利用缓存和位图;
- 遍历多个 Segment 并合并结果;
- 排序字段读取 Doc Values;
- 返回 top N 的
_source; - 高亮按需从
_source或 Term Vectors 取文本。
这也解释了几个性能原则:
- 先用 filter 缩小候选集,再做评分;
- 排序聚合字段保留 Doc Values;
- 不需要返回的字段做 source filtering;
- 避免频繁小批量 refresh;
- 控制分片和 Segment 数量;
- 深分页会让每个分片维护大量候选文档。
22.11 Lucene 与 ES 的边界
| 能力 | Lucene | Elasticsearch |
|---|---|---|
| 单机索引 | 是 | 通过分片管理 |
| 分布式副本 | 否 | 是 |
| REST API | 否 | 是 |
| 集群选主和分配 | 否 | 是 |
| 查询归并 | 单索引内 | 跨分片 |
| 安全与权限 | 基本无 | 内置安全体系 |
| 段合并 | 是 | 调度和限制 |
Elasticsearch 的性能问题通常分三类:
- Lucene 层:索引结构、Segment、Doc Values、页缓存;
- 分布式层:分片路由、副本、查询扇出、集群状态;
- 应用层:查询写法、分页、聚合、更新频率和写入并发。
排障时先判断问题属于哪一层,再决定看什么指标。
本章小结
Lucene 通过不可变 Segment、倒排索引、Doc Values、Stored Fields 和 translog,提供了一个可高效搜索和恢复的本地索引引擎。Elasticsearch 在其上构建分片、副本和分布式查询能力。理解这些结构后,刷新、合并、更新成本、过滤缓存和分页开销就不再神秘。
思考题
- 为什么更新一个文档在 Lucene 中是“删除 + 新写入”?
- 倒排索引和 Doc Values 分别解决什么方向的数据访问?
- 关闭
_source能节省磁盘,但会失去哪些能力? - 为什么在持续写入索引上执行
_forcemerge是危险的? refresh=true和refresh=wait_for的区别是什么?