ElasticsearchNotes

第 22 章:Lucene 与存储原理

zjc 于 2026-01-22 发布

这是《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,还可能包含:

这些结构支撑了 matchmatch_phrasetermboolspan query 和 BM25 评分。

22.3.1 Term Dictionary 与 Posting List

Term Dictionary 保存所有词项及其指向的 posting list。Lucene 通常把词项字典拆成:

不同 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_idtext,写入时会先分词,查询 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 的一部分,用于:

如果不需要返回某个字段,可以设置 stored: false;如果完全不需要 _source,也可以关闭,但必须清楚代价:

PUT telemetry-v1
{
  "mappings": {
    "_source": { "enabled": false },
    "properties": {
      "@timestamp": { "type": "date" },
      "host.name": { "type": "keyword" },
      "message": { "type": "text" }
    }
  }
}

关闭 _source 后:

  1. 搜索结果无法返回原始字段;
  2. 不能 update;
  3. 不能 reindex;
  4. 高亮和部分脚本能力受限;
  5. 后续补字段需要重新采集数据。

日志类数据如果确认永远只查询、不更新、不重建,才考虑关闭 _source。更常见的优化是查询时用 _source filtering,而不是建索引时全局关闭。

22.6 Segment 不可变性

Segment 一旦写入,就不会修改。不可变性带来几个重要优势:

代价是:

可以用 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)

合并过程可以理解为:

  1. 选择若干小 Segment;
  2. 按文档编号归并读取数据;
  3. 跳过 deleted 文档;
  4. 重写倒排索引、Doc Values、Stored Fields;
  5. 构建新 Segment;
  6. 原子切换 Segment 列表;
  7. 删除旧 Segment 文件。

合并策略会综合考虑 Segment 大小、数量、删除比例、磁盘阈值和当前 IO 压力。生产系统不应盲目调小 refresh_interval,否则会产生大量小 Segment,让 CPU 同时承担查询归并和后台合并。

_force_merge 会触发强制合并:

POST /products-v3/_forcemerge?max_num_segments=1

注意:

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_interval1s。写入成功并不代表立刻可搜,而是最多等待一次 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" }
  ]
}

执行时可能用到:

  1. Analyzer 分析查询字符串;
  2. 倒排索引找到候选文档和评分信息;
  3. Filter 利用缓存和位图;
  4. 遍历多个 Segment 并合并结果;
  5. 排序字段读取 Doc Values;
  6. 返回 top N 的 _source
  7. 高亮按需从 _source 或 Term Vectors 取文本。

这也解释了几个性能原则:

22.11 Lucene 与 ES 的边界

能力 Lucene Elasticsearch
单机索引 通过分片管理
分布式副本
REST API
集群选主和分配
查询归并 单索引内 跨分片
安全与权限 基本无 内置安全体系
段合并 调度和限制

Elasticsearch 的性能问题通常分三类:

  1. Lucene 层:索引结构、Segment、Doc Values、页缓存;
  2. 分布式层:分片路由、副本、查询扇出、集群状态;
  3. 应用层:查询写法、分页、聚合、更新频率和写入并发。

排障时先判断问题属于哪一层,再决定看什么指标。

本章小结

Lucene 通过不可变 Segment、倒排索引、Doc Values、Stored Fields 和 translog,提供了一个可高效搜索和恢复的本地索引引擎。Elasticsearch 在其上构建分片、副本和分布式查询能力。理解这些结构后,刷新、合并、更新成本、过滤缓存和分页开销就不再神秘。

思考题

  1. 为什么更新一个文档在 Lucene 中是“删除 + 新写入”?
  2. 倒排索引和 Doc Values 分别解决什么方向的数据访问?
  3. 关闭 _source 能节省磁盘,但会失去哪些能力?
  4. 为什么在持续写入索引上执行 _forcemerge 是危险的?
  5. refresh=truerefresh=wait_for 的区别是什么?