数据库基础体系 · 第 124/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Lucene 与倒排索引内部:Segment、Term、Posting、Merge 和 Cache

Lucene 是 Elasticsearch 的底层搜索库。Elasticsearch 提供分布式索引、分片、副本、请求接口和集群管理;真正保存倒排索引、执行词项查询、合并索引段的核心工作,发生在每个分片内部的 Lucene 索引中。

理解 Lucene,不能只把倒排索引理解成一个简单的:

term -> document_id 列表

真实系统至少还要回答以下问题:

  • 文档如何被分析器转换成 Term?
  • Posting 除了文档 ID,还保存什么?
  • 为什么索引不是一个文件,而是很多 Segment?
  • 新写入的数据何时能够被搜索?
  • 删除和更新为什么不会立刻修改原有文件?
  • Merge 如何重写 Segment,为什么会产生磁盘和 CPU 压力?
  • Cache 缓存的是哪些对象?为什么 Refresh 或 Merge 会影响命中率?
  • Elasticsearch 的分片、副本与 Lucene Segment 分别处于哪一层?

下文以 Elasticsearch 8.x 公开语义和现代 Lucene 的基本模型为背景。Lucene 的具体文件格式、压缩方式和某些缓存实现属于可演进的内部实现,不应把某个版本的文件名或编码细节当作稳定 API。


一、先划清四个边界

1. Elasticsearch、分片、Lucene Index 和 Segment

一个 Elasticsearch 索引可以被划分为多个主分片:

Elasticsearch Index
├── Primary Shard 0
│   └── Lucene Index
│       ├── Segment A
│       ├── Segment B
│       └── Segment C
├── Primary Shard 1
│   └── Lucene Index
└── Replica Shards

这些概念不是同义词:

概念 所在层次 作用
Elasticsearch Index 分布式搜索层 对外暴露的逻辑索引
Shard Elasticsearch 数据分片 一个分片承载一部分文档,并在节点上执行搜索
Lucene Index 单个分片内部 负责索引和搜索的 Lucene 数据结构
Segment Lucene Index 内部 不可变的索引片段

一个主分片通常对应一个 Lucene Index。分片内部会随着写入产生多个 Segment,后台再通过 Merge 合并它们。

因此:

分片解决的是 Elasticsearch 层面的数据分布;Segment 解决的是单个 Lucene Index 内部的增量写入和不可变索引管理。

副本分片不是共享同一组 Segment 文件。每个副本都有自己的 Lucene Index、Segment、Merge 和缓存,虽然逻辑内容应当一致。


2. 文档、字段、Token 和 Term

假设有一条文档:

{
  "title": "Redis and Lucene"
}

字段 title 经过 Analyzer 后,可能产生:

Redis -> redis
and   -> and
Lucene -> lucene

这里有几个不同概念:

  • 文档(Document):提交给 Elasticsearch 的一条 JSON 文档。
  • 字段(Field):文档中的 titlebody 等可索引属性。
  • Token:Analyzer 在分析过程中产生的词元,通常带有文本、位置、偏移量等信息。
  • Term:写入倒排索引后用于检索的字段级词项。

Term 不是全局字符串。title 字段中的 redisbody 字段中的 redis 是两个不同的索引词项:

(title, redis)
(body, redis)

这也是为什么 Term 查询必须指定字段:

{
  "term": {
    "title": "redis"
  }
}

字段类型和 Analyzer 会决定 Term 是否存在。例如 text 字段通常经过分析,keyword 字段通常作为一个整体值索引。

PUT books
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

对于值:

"Redis Cache"

可能得到:

title       -> redis, cache
title.keyword -> "Redis Cache"

因此:

  • title 做全文搜索,查询词会经过分析;
  • title.keyword 做精确查询,通常要求完整值匹配;
  • 同一个原始字段可以通过 multi-field 同时支持全文检索和聚合、排序。

这一步是后续所有倒排结构的前提。如果 Mapping 或 Analyzer 设计错误,后面的 Segment、Posting 和 Cache 都无法弥补语义错误。


二、倒排索引到底反转了什么

传统的正排视角是:

Document 1 -> ["redis", "cache"]
Document 2 -> ["redis", "lucene"]
Document 3 -> ["lucene", "cache"]

倒排索引把它反转成:

Term          -> Documents
redis         -> [1, 2]
cache         -> [1, 3]
lucene        -> [2, 3]

其中:

  • 正排方向:文档到词;
  • 倒排方向:词到文档。

查询 title:redis 时,不需要扫描所有文档,只需要定位 Term redis,再读取其 Posting。

但是,简单的文档 ID 列表还不足以支持:

  • 词频评分;
  • 短语查询;
  • 邻近词查询;
  • 高亮;
  • 位置相关的相关性计算;
  • payload 等高级检索能力。

所以实际结构更接近:

(field, term)
    -> term metadata
    -> postings

三、Term Dictionary 和 Posting 的内部关系

1. Term Dictionary

Term Dictionary 是字段中所有 Term 的索引目录。对一个字段来说,可以抽象为:

cache
lucene
redis

Term Dictionary 通常支持:

  1. 判断某个 Term 是否存在;
  2. 找到某个 Term 对应的 Posting;
  3. 按字典序遍历 Term;
  4. 进行前缀、范围或词典枚举。

现代 Lucene 会使用压缩词典、块式索引以及类似 FST 的结构来减少内存占用并加快词项定位。FST 等是重要的实现方式,但不是 Elasticsearch 用户可依赖的固定文件格式。

Term Dictionary 解决的是:

“这个词项在哪里?”

Posting 解决的是:

“包含这个词项的文档有哪些,以及这些文档中的词项信息是什么?”


2. Posting 的最小内容

对一个 Term,Posting 至少需要表示包含它的文档集合:

redis -> doc 1, doc 2

实际通常还会保存或能够读取以下信息:

  • docID:文档在当前 Segment 中的内部文档编号;
  • freq:该 Term 在文档中出现的次数;
  • positions:出现位置;
  • offsets:原文偏移;
  • payloads:与位置相关的附加字节;
  • 删除状态判断所需的 Segment 信息。

是否保存 positions、offsets、payloads,取决于字段配置和索引选项。并不是每个字段都必须保存全部信息。

例如,文档如下:

doc 1: "Redis cache"
doc 2: "Redis and Lucene"
doc 3: "Lucene cache"

假设分析器只做小写和空格切分,并保留位置:

doc 1:
  redis  position 0
  cache  position 1

doc 2:
  redis  position 0
  and    position 1
  lucene position 2

doc 3:
  lucene position 0
  cache  position 1

则可以抽象出以下 Posting:

Term: redis
  doc 1, freq=1, positions=[0]
  doc 2, freq=1, positions=[0]

Term: cache
  doc 1, freq=1, positions=[1]
  doc 3, freq=1, positions=[1]

Term: lucene
  doc 2, freq=1, positions=[2]
  doc 3, freq=1, positions=[0]

查询:

title:lucene

首先定位 Term lucene,然后读取:

doc 2
doc 3

查询短语:

"lucene cache"

不能只检查两个文档集合是否相交,因为 doc 2 同时包含 lucenecache 的条件在这个例子中不成立,doc 2 根本没有 cache。即使某个文档同时包含两个 Term,也必须比较位置是否连续:

position(cache) = position(lucene) + 1

所以 positions 是短语查询和邻近查询的重要基础。


3. DocID 是 Segment 内部编号

Posting 中的 docID 不是 Elasticsearch _id,也不是用户业务主键。

Lucene 的 docID 通常是当前 Segment 内的连续整数:

Segment A:
  local docID 0
  local docID 1
  local docID 2

Segment B:
  local docID 0
  local docID 1

在一个由多个 Segment 组成的 IndexReader 中,Lucene 会为各 Segment 建立文档基址,把局部编号映射到复合读取器视角下的编号。

这意味着:

  • Segment Merge 后文档编号可能重新分配;
  • 业务 _id 不应当当作 Posting 中的 docID;
  • 不能跨 Segment 直接比较局部 docID;
  • 删除文档时通常不需要重写所有 Posting。

4. 跳跃结构和压缩

Posting 往往按 docID 升序保存:

[3, 10, 11, 25, 100]

为了减少空间,系统通常不直接保存完整数字,而会使用增量编码:

[3, 7, 1, 14, 75]

这类编码称为 docID gap 或 delta encoding。实际实现还会使用块压缩、位打包以及跳跃结构。

跳跃结构的作用是:

假设查询要求同时满足:

termA AND termB

如果:

termA -> [10, 20, 30, 40, 1000]
termB -> [5, 6, 7, 1000]

执行交集时不必逐个比较所有元素。当 termA 当前为 30termB 当前为 7 时,可以跳过 termB 中明显小于 30 的一段 Posting,直接跳到接近目标的位置。

这类结构不会改变查询结果,只是减少不必要的读取和比较。


四、从写入到可搜索:Segment 如何产生

1. 写入不是直接追加到一个倒排文件

Lucene 的核心约束是:

Segment 一旦写出并发布,内容基本不可变。

如果每次写入都直接修改一个巨大的倒排索引,会产生严重的随机写和并发协调问题。因此 Lucene 采用增量方式:

  1. 接收文档;
  2. 对字段执行 Analyzer;
  3. 将 Token 加入内存中的索引缓冲;
  4. 缓冲达到条件后 Flush;
  5. 把这批文档写成一个新的 Segment;
  6. 以后通过 Merge 合并多个 Segment。

可以抽象为:

文档
  -> Mapping / 字段解析
  -> Analyzer
  -> Token
  -> 内存索引缓冲
  -> Flush
  -> Segment

一个 Segment 通常同时包含多种数据:

  • 倒排词典和 Posting;
  • stored fields;
  • norms;
  • points,用于数值和地理空间索引;
  • doc values,用于排序、聚合和脚本访问;
  • 删除文档信息;
  • Segment 元数据。

因此,“倒排索引文件”并不等于 Segment 的全部内容。


2. Refresh、Commit 和 Translog 不是一回事

在 Elasticsearch 中,常见的三个状态容易被混淆。

Refresh:让新 Segment 对搜索可见

Refresh 会打开一个新的近实时读取视图,使已经 Flush 的数据能够被搜索。

写入请求
  -> 内存缓冲
  -> Flush 成 Segment
  -> Refresh 打开新 reader
  -> 搜索可以看到

Refresh 不等同于“数据已经持久化到所有存储介质”。

Commit:建立持久化提交点

Lucene Commit 会生成一个新的提交点,表示一组 Segment 文件及其元数据组成了一个一致的索引状态。提交过程涉及文件持久化和提交点切换。

Translog:Elasticsearch 的写入恢复日志

Elasticsearch 使用 Translog 记录写操作,以便在故障恢复时重放尚未进入安全索引提交状态的操作。具体持久化和刷新策略由 Elasticsearch 的实现和配置控制。

因此:

Refresh != Commit
Refresh != Flush
Commit != Replica 已确认

一个文档可能已经被 Refresh 后搜索到,但这不意味着它经历了你所理解的所有持久化和副本确认步骤。

在写入 API 中:

PUT books/_doc/1?refresh=wait_for
{
  "title": "Redis and Lucene"
}

refresh=wait_for 的语义是等待下一次 Refresh,使请求在返回前通常可以被搜索看到。它不是把每次请求都强制转换成一次独立的底层提交。

而:

PUT books/_doc/1?refresh=true
{
  "title": "Redis and Lucene"
}

会主动触发刷新,通常会增加刷新开销,不应在高吞吐写入路径上无条件使用。


3. Refresh 产生的并发状态

写入和搜索可以并发进行:

旧 reader:
  Segment A, Segment B

写线程:
  继续接收新文档
  生成 Segment C

Refresh:
  新 reader = Segment A + Segment B + Segment C

旧的搜索请求仍可以继续使用旧 reader。新 reader 发布后,新搜索使用新视图。Lucene 通过不可变 Segment 和 reader 快照,降低了搜索线程与写入线程之间的锁竞争。

代价是:

  • Refresh 会增加打开 reader 和管理新 Segment 的成本;
  • Segment 数量会逐渐增加;
  • 新 Segment 可能尚未被操作系统页缓存充分加载;
  • Cache 可能因为新 Segment 身份变化而失效或降低命中率。

五、删除和更新为什么不直接改 Segment

1. 删除通常是逻辑删除

假设某个 Segment 有:

docID: 0, 1, 2, 3

删除 docID=1 后,Lucene 通常不会立即重写包含它的所有 Posting,而是记录一个 live docs 位图:

docID: 0  1  2  3
live:    1  0  1  1

Posting 仍可能包含 docID=1,但查询迭代时会检查该文档是否仍然 live。

所以删除后的短期状态可能是:

倒排 Posting: [0, 1, 2, 3]
live docs:    [1, 0, 1, 1]
最终结果:     [0, 2, 3]

这带来两个重要结论:

  1. 删除不会立即回收所有磁盘空间;
  2. 删除较多时,查询可能仍然需要跳过已删除文档。

Merge 时,旧 Segment 会被重写成新 Segment,已经删除的文档不会复制进去,空间才会真正被回收。


2. 更新通常是删除加新增

在 Elasticsearch 中更新一个文档,逻辑上通常表现为:

旧版本文档 -> 删除
新版本文档 -> 写入

它不是在原有倒排 Posting 中原地修改某个字段。

例如:

PUT books/_doc/1
{
  "title": "Redis"
}

之后再次写入:

PUT books/_doc/1
{
  "title": "Lucene"
}

在索引内部,旧版本和新版本可能在不同 Segment 中存在一段时间。Elasticsearch 通过 _id 和版本语义保证搜索结果不会把旧版本当成有效文档,但物理回收仍依赖删除标记和后续 Merge。

这解释了两个常见现象:

  • 高频更新会产生大量删除文档和 Merge 压力;
  • 文档数量、磁盘占用和有效文档数在短时间内可能不一致。

六、Merge:把许多不可变 Segment 变成更少的 Segment

1. 为什么需要 Merge

如果每批写入都产生一个 Segment:

S1, S2, S3, S4, S5, ...

查询一个 Term 时,可能需要依次访问很多 Segment:

查询 redis
  -> S1 的 redis Posting
  -> S2 的 redis Posting
  -> S3 的 redis Posting
  -> ...

Segment 越多,查询需要维护的读取器和迭代器越多,删除检查、词典查找和结果合并也越复杂。

Merge 的目标是减少 Segment 数量,同时清理逻辑删除:

S1 + S2 + S3
       |
       v
      S4

2. Merge 的逐步过程

假设有三个 Segment:

S1:
  local docs: 0, 1
  terms:
    cache  -> [0]
    redis  -> [0, 1]

S2:
  local docs: 0, 1
  terms:
    cache  -> [1]
    lucene -> [0, 1]

S3:
  local docs: 0
  terms:
    redis  -> [0]

假设 S1 的 docID=1 已经被删除。

Merge 可以执行以下步骤:

第一步:选择待合并 Segment

MergePolicy 根据 Segment 大小、数量、删除比例等因素选择一组 Segment。具体选择算法取决于 Lucene 版本和配置,不能简单理解成“每次选最小的两个”。

第二步:读取 live docs

S1 live: doc 0 保留,doc 1 删除
S2 live: doc 0、1 保留
S3 live: doc 0 保留

第三步:重新分配新 docID

新 Segment 中可能变成:

旧位置       新 docID
S1/doc0  ->  0
S2/doc0  ->  1
S2/doc1  ->  2
S3/doc0  ->  3

被删除的 S1/doc1 不会进入新 Segment。

第四步:合并 Term Dictionary

旧 Term 集合为:

S1: cache, redis
S2: cache, lucene
S3: redis

新 Term 集合为:

cache, lucene, redis

第五步:重写 Posting

根据新的 docID:

cache:
  S1/doc0 -> new doc 0
  S2/doc1 -> new doc 2

=> cache -> [0, 2]

lucene:
  S2/doc0 -> new doc 1
  S2/doc1 -> new doc 2

=> lucene -> [1, 2]

redis:
  S1/doc0 -> new doc 0
  S1/doc1 被删除
  S3/doc0 -> new doc 3

=> redis -> [0, 3]

第六步:原子发布新 Segment

新 Segment 完成并校验后,Lucene 发布新的读取视图。旧 Segment 在仍被旧 reader 使用时不能立刻删除;所有引用释放后,旧文件才可以回收。


3. Merge 的成本和风险

Merge 不是免费操作。它可能消耗:

  • CPU:重新编码词典、Posting、stored fields、doc values;
  • 磁盘读带宽:读取多个旧 Segment;
  • 磁盘写带宽:写入新 Segment;
  • 临时磁盘空间:新旧 Segment 可能同时存在;
  • 文件描述符和后台线程;
  • 页缓存空间。

Merge 过于滞后时:

Segment 数量上升
  -> 查询需要访问更多 Segment
  -> 删除文档积累
  -> 磁盘空间不下降
  -> 查询和写入都受到影响

Merge 过于激进时:

大量写放大
  -> 磁盘 IO 饱和
  -> Refresh 延迟升高
  -> 写入延迟抖动
  -> 搜索延迟也可能受到影响

Lucene 使用 MergePolicy 决定何时、哪些 Segment 需要合并,使用 MergeScheduler 调度实际合并任务。具体策略是实现细节;工程上应观察实际 Segment 数量、Merge 时间、磁盘吞吐和搜索延迟,而不是只调整某一个参数。


4. force_merge 为什么危险

Elasticsearch 提供强制合并接口:

curl -X POST "$ES/books/_forcemerge?max_num_segments=1"

它会请求把分片中的 Segment 合并到指定数量。max_num_segments=1 可能产生非常大的合并任务。

风险包括:

  • 大量磁盘读写;
  • 需要额外临时磁盘空间;
  • 长时间占用 Merge 资源;
  • 写入期间可能很快再次产生新 Segment;
  • 更新频繁的索引很快又失去“一段”的状态。

它更适合已经完成写入、之后主要只读的索引,例如时间序列中的历史索引。对持续写入和频繁更新的热索引,不应把 Force Merge 当作常规“优化按钮”。


七、查询如何利用 Term 和 Posting

1. Term 查询

对于:

GET books/_search
{
  "query": {
    "term": {
      "title.keyword": "Redis"
    }
  }
}

大致过程是:

请求路由到相关分片
  -> 分片内每个 Segment 查找 title.keyword 的 Term
  -> 获取 Posting
  -> 检查 live docs
  -> 构造匹配文档
  -> 读取 stored fields / doc values
  -> 返回分片结果

注意 term 查询不会替你进行全文文本分析。对 text 字段直接使用 term,常见结果是查不到原始大小写形式,因为索引中保存的可能是小写 Term。

例如 Analyzer 生成:

"Redis" -> "redis"

则:

{
  "term": {
    "title": "Redis"
  }
}

不应被理解为自动变成 redis。需要根据字段类型和查询语义选择 matchterm 或其他查询。


2. match 查询和 Analyzer

GET books/_search
{
  "query": {
    "match": {
      "title": "Redis cache"
    }
  }
}

通常会先对查询文本执行相应 Analyzer:

"Redis cache" -> ["redis", "cache"]

然后分别查找这些 Term 的 Posting,再根据布尔逻辑和评分模型组合结果。

如果索引时和查询时使用的 Analyzer 不一致,可能出现:

  • 写入时产生 redis,查询时产生 Redis
  • 索引时保留词干,查询时没有词干化;
  • 索引时使用同义词,查询时未使用同义词;
  • 查询结果数量异常;
  • 相关性排序不符合预期。

可以用 _analyze 检查实际产生的 Token:

curl -X POST "$ES/books/_analyze" \
  -H 'Content-Type: application/json' \
  -d '{
    "field": "title",
    "text": "Redis and Lucene"
  }'

输出会包含 Analyzer 生成的 token。实际结果取决于 Mapping 和分析器配置,诊断时应以该接口返回为准,而不是根据字段名称猜测。


3. BM25 为什么需要 Posting 中的统计信息

Elasticsearch 默认相关性评分通常基于 BM25。它会综合:

  • tf:Term 在当前文档中的出现频率;
  • df:包含该 Term 的文档数量;
  • N:参与评分的文档总数;
  • dl:当前文档字段长度;
  • avgdl:平均字段长度。

一个常见的 BM25 形式为:

score(D,Q)=tQIDF(t)f(t,D)(k1+1)f(t,D)+k1(1b+bDavgdl)\text{score}(D,Q) = \sum_{t \in Q} \operatorname{IDF}(t) \cdot \frac{f(t,D)(k_1+1)} {f(t,D)+k_1\left(1-b+b\frac{|D|}{\operatorname{avgdl}}\right)}

其中:

  • f(t,D)f(t,D):Term tt 在文档 DD 中出现的次数;
  • D|D|:文档字段长度;
  • avgdl\operatorname{avgdl}:该字段的平均长度;
  • k1k_1bb:控制词频饱和和长度归一化的参数;
  • IDF(t)\operatorname{IDF}(t):Term 的逆文档频率。

常见的 IDF 形式为:

IDF(t)=ln(1+Ndf(t)+0.5df(t)+0.5)\operatorname{IDF}(t) = \ln\left( 1+ \frac{N-df(t)+0.5}{df(t)+0.5} \right)

直觉是:

  1. Term 出现在越少的文档中,区分度越高;
  2. Term 在当前文档中出现次数增加会提高分数,但不是无限线性增加;
  3. 文档过长时,单次出现的 Term 重要性会被适度降低。

因此,Posting 不只是“命中的文档 ID”,它还参与评分所需的信息读取。不同字段配置、查询上下文和评分模型可能需要不同程度的 Posting 数据。


八、Segment 与 Elasticsearch 分片、路由和副本

1. 查询先经过分片,再进入 Segment

一次 Elasticsearch 搜索的粗略流程是:

客户端
  -> 协调节点
  -> 相关主分片或副本分片
      -> 每个分片内部的多个 Segment
  -> 各分片返回候选结果
  -> 协调节点合并、排序、截取 Top K
  -> 返回客户端

因此查询开销可以同时受以下因素影响:

  • 请求触达多少个分片;
  • 每个分片包含多少 Segment;
  • Term 的 df 和 Posting 长度;
  • 是否需要 positions、stored fields 或 doc values;
  • 是否需要跨分片排序和聚合;
  • Cache 是否命中。

一个分片内的 Segment 不负责集群路由。路由由 Elasticsearch 根据文档 _id、自定义 routing 和分片数决定。


2. 副本不会共享缓存

主分片和副本分片各自拥有:

  • Lucene Segment;
  • reader;
  • Merge 状态;
  • 文件系统页缓存映射;
  • Lucene 查询缓存;
  • Elasticsearch 分片请求缓存。

某个副本刚刚被分配到新节点时,虽然逻辑数据与主分片一致,但其页缓存和查询缓存可能是冷的。恢复完成不等于缓存已经预热完成。

这也是滚动重启、节点迁移或副本重新分配后,搜索延迟暂时升高的常见原因之一。


九、Cache:缓存的不是同一个东西

“Lucene 有缓存”这句话过于笼统。至少要区分以下层次。


1. 操作系统文件系统页缓存

Lucene 的索引文件通常通过文件系统访问,具体目录实现和访问方式由版本及配置决定。操作系统会把近期访问过的文件页缓存在内存中。

页缓存可能保存:

  • Term Dictionary 的部分页面;
  • Posting 文件的部分页面;
  • doc values 页面;
  • stored fields 页面;
  • points 数据页面。

它不理解 Term、Query 或 Elasticsearch 请求,只缓存文件页。

因此:

第一次查询慢
后续查询变快

可能只是操作系统页缓存变热,并不表示 Elasticsearch 的 Query Cache 命中了。

页缓存会受到以下因素影响:

  • 节点内存;
  • 其他进程的内存压力;
  • Segment 文件大小;
  • Merge 产生的读写流量;
  • 节点重启;
  • 分片迁移。

2. Lucene 层面的查询缓存

Lucene 提供基于查询结果的缓存机制,典型思路是:

Query + Segment
      -> 命中的 docID 集合

由于 Segment 通常不可变,一个查询在某个 Segment 上得到的命中集合可以重复使用。查询结果常表示为高效的文档集合结构,而不是完整的 _source 响应。

关键点是缓存键与 Segment 绑定:

同一个 Query + 同一个 Segment -> 可能命中
同一个 Query + 新生成的 Segment -> 不一定命中

当 Segment 被 Merge 后:

旧 Segment A、B
      -> 新 Segment C

即使逻辑文档内容相同,Segment 身份已经变化,旧的 Segment 级缓存通常不能直接复用。

查询缓存也不是所有查询都值得缓存:

  • 只执行一次的查询没有复用价值;
  • 命中几乎所有文档的查询,缓存结果可能很大;
  • 高频变化的索引会不断产生新 Segment;
  • 复杂查询的缓存成本可能高于重新执行。

具体是否缓存以及缓存容量由 Lucene 和 Elasticsearch 版本实现控制,不能把“执行过一次查询”理解为一定产生缓存条目。


3. Elasticsearch Query Cache

Elasticsearch 在分片级别使用查询缓存来复用部分查询结果,常见场景是过滤上下文中的可缓存查询。

查询上下文和过滤上下文的区别并不是“一个快、一个慢”这么简单:

  • 查询上下文通常需要计算相关性评分;
  • 过滤上下文只关心是否匹配;
  • 过滤结果更容易表示为文档集合;
  • 这类结果更适合在后续请求中复用。

例如:

GET books/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "category": "database" } },
        { "range": { "published_at": { "gte": "2024-01-01" } } }
      ]
    }
  }
}

其中 Term 和 Range 过滤结果可能具备缓存价值,但是否实际进入缓存仍取决于查询类型、使用频率、Segment 状态和缓存策略。

不要把 Query Cache 与以下对象混淆:

  • 完整 JSON 搜索响应;
  • _source 文档内容;
  • 聚合结果;
  • OS 页缓存;
  • fielddata;
  • doc values。

4. Elasticsearch Shard Request Cache

Shard Request Cache 缓存的是更接近“分片级请求结果”的内容,而不是单个 Term 的 Posting。

它可能缓存:

  • 分片上的搜索响应片段;
  • 聚合结果;
  • 命中总数等请求级结果。

默认适合缓存的典型请求是只关心聚合或统计、而不需要返回大量 hits 的请求,例如:

GET books/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category"
      }
    }
  }
}

常见默认语义下,size: 0 的请求更适合进入分片请求缓存;请求中也可以显式控制是否使用请求缓存,但最终仍受请求是否可缓存、索引状态和版本实现影响。

请求缓存通常与分片的搜索视图相关。Refresh 后出现新的搜索状态,相关缓存可能失效,因为同一个请求可能得到不同结果。

这带来一个必要的正确性条件:

旧搜索视图上的响应
  不能被无条件用于新搜索视图

否则新写入、删除或更新可能无法及时反映。


5. Fielddata、Doc Values 不是普通的 Query Cache

keyword、数值、日期等字段进行排序和聚合时,常使用 doc values。doc values 是面向列式访问的数据结构,通常存储在磁盘文件中,并依赖页缓存,不应直接称为“把整个字段放进 Query Cache”。

text 字段启用 fielddata 则可能把分析后的词项数据加载到堆内存中。由于 fielddata 可能占用大量堆内存,现代 Elasticsearch 默认不建议对普通 text 字段直接启用它;通常应使用对应的 keyword multi-field 做排序和聚合。

例如:

{
  "title": {
    "type": "text",
    "fields": {
      "keyword": {
        "type": "keyword"
      }
    }
  }
}

全文搜索使用:

{
  "match": {
    "title": "lucene"
  }
}

聚合使用:

{
  "terms": {
    "field": "title.keyword"
  }
}

这两个操作访问的是不同的数据结构和执行路径。


十、为什么 Refresh 和 Merge 会影响 Cache

把缓存和 Segment 生命周期放在一起看:

Segment A、B
  -> 查询 Q
  -> 生成或命中 Segment 级缓存

写入新文档
  -> Flush 生成 Segment C
  -> Refresh 发布 A、B、C

查询 Q
  -> A、B 的部分结果可能仍可复用
  -> C 没有对应的旧缓存
  -> 请求级缓存可能因搜索视图变化而失效

之后 Merge:

A + B + C -> D

由于 Segment A、B、C 被新 Segment D 替代:

旧缓存键:Q + A
旧缓存键:Q + B
旧缓存键:Q + C

新查询键:Q + D

逻辑上相同的查询,物理缓存键可能已经不同。

因此高写入索引通常比只读索引更难获得稳定的查询缓存收益:

  • Segment 不断刷新;
  • Merge 不断重写;
  • 缓存条目持续淘汰;
  • 页缓存被 Merge IO 争用;
  • 查询结果对应的分片搜索视图不断变化。

这不是缓存实现“失效”,而是缓存必须尊重索引可见性和 Segment 生命周期。


十一、一个可运行的 Elasticsearch 观察示例

下面示例需要一个运行中的 Elasticsearch,并假设通过 localhost:9200 访问。认证和 TLS 环境需要根据部署方式补充。

1. 创建 Mapping 并写入数据

curl -X PUT 'http://localhost:9200/books' \
  -H 'Content-Type: application/json' \
  -d '{
    "settings": {
      "refresh_interval": "1s"
    },
    "mappings": {
      "properties": {
        "title": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword"
            }
          }
        },
        "category": {
          "type": "keyword"
        }
      }
    }
  }'

写入三条文档:

curl -X POST 'http://localhost:9200/books/_bulk?refresh=true' \
  -H 'Content-Type: application/x-ndjson' \
  -d '
{"index":{"_id":"1"}}
{"title":"Redis cache","category":"database"}
{"index":{"_id":"2"}}
{"title":"Redis and Lucene","category":"search"}
{"index":{"_id":"3"}}
{"title":"Lucene cache","category":"search"}
'

_bulk 使用 NDJSON,因此最后必须有换行。refresh=true 便于演示后立即查询,但生产环境不应为了方便测试而对每个批次都强制刷新。


2. 检查 Analyzer 结果

curl -X POST 'http://localhost:9200/books/_analyze' \
  -H 'Content-Type: application/json' \
  -d '{
    "field": "title",
    "text": "Redis and Lucene"
  }'

预期会看到类似:

redis
and
lucene

具体 token 还可能包含 position、offset 等信息,实际输出以集群版本和字段分析器为准。


3. 比较 matchterm

全文查询:

curl -X POST 'http://localhost:9200/books/_search' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": {
      "match": {
        "title": "Redis"
      }
    }
  }'

它通常会对查询文本执行分析,因此能够匹配包含 redis 的文档。

精确查询:

curl -X POST 'http://localhost:9200/books/_search' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": {
      "term": {
        "title.keyword": "Redis cache"
      }
    }
  }'

这个查询针对 keyword 子字段,匹配完整值 Redis cache

下面的查询语义不同:

curl -X POST 'http://localhost:9200/books/_search' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": {
      "term": {
        "title": "Redis"
      }
    }
  }'

它不会自动等价于对全文文本执行 match。如果 title 索引后的 Term 是小写 redis,直接使用大写 Redis 可能没有命中。


4. 查看 Segment

curl 'http://localhost:9200/books/_segments?pretty'

也可以使用:

curl 'http://localhost:9200/_cat/segments/books?v'

结果中通常可以观察到:

  • 分片;
  • Segment 名称;
  • 文档数;
  • 删除文档数;
  • Segment 大小;
  • 是否正在合并等相关信息。

不要根据 Segment 名称推断业务含义。Segment 名称主要是内部标识,版本间格式和展示细节可能变化。

查看分片级统计:

curl 'http://localhost:9200/books/_stats/docs,store,segments,query_cache,request_cache?pretty'

该接口适合观察:

  • 有效文档数和删除文档数;
  • 存储大小;
  • Segment 数量;
  • 查询缓存和请求缓存统计。

统计结果中的计数是累计指标或当前状态指标,不能简单拿一次查询前后的差值就断定某次查询一定命中了某个缓存,还应结合请求模式和 Profile 结果分析。


5. 查看查询执行过程

curl -X POST 'http://localhost:9200/books/_search?pretty' \
  -H 'Content-Type: application/json' \
  -d '{
    "profile": true,
    "query": {
      "bool": {
        "filter": [
          { "term": { "category": "search" } }
        ],
        "must": [
          { "match": { "title": "lucene" } }
        ]
      }
    }
  }'

Profile 可以帮助观察各分片、各查询组件的耗时,但它本身会增加执行开销,不应在高流量生产请求中长期打开。


十二、诊断常见问题

1. “刚写入的文档查不到”

按以下顺序区分原因:

检查 Refresh

如果写入后没有等待刷新,文档可能尚未进入当前搜索视图。测试时可以使用:

POST /index/_refresh

或者写入时使用 refresh=wait_for,但应理解它会等待刷新,不是让每次写入都免费可见。

检查 Analyzer

POST /index/_analyze

确认实际 Term 是否与查询预期一致。

检查字段类型

确认查询的是 title 还是 title.keyword。全文字段和精确字段的词典不同。

检查路由和分片

自定义 routing 错误、只查询部分分片或索引别名过滤,也可能导致“看不到”文档。


2. “删除后磁盘没有下降”

这是正常的物理生命周期现象:

删除请求
  -> live docs 标记为删除
  -> 查询时排除
  -> 等待 Merge
  -> 新 Segment 不再包含该文档
  -> 旧 Segment 文件回收

应观察:

curl 'http://localhost:9200/_cat/segments/books?v'
curl 'http://localhost:9200/books/_stats/docs,store,segments?pretty'

如果删除比例很高但长期没有回收,可能需要检查:

  • Merge 是否被磁盘或线程资源限制;
  • 索引是否持续高频写入;
  • 节点磁盘水位;
  • 是否存在大量小 Segment;
  • 是否人为禁用了或过度限制了后台合并。

不应仅为了马上减少磁盘占用而在线上热索引执行激进 Force Merge。


3. “缓存命中率低”

先确定观察的是哪种缓存:

  • OS 页缓存;
  • Lucene/Elasticsearch Query Cache;
  • Shard Request Cache;
  • fielddata;
  • doc values 访问;
  • 应用层缓存。

然后检查索引是否具有以下特征:

  • Refresh 频繁;
  • Merge 频繁;
  • 查询条件高度随机;
  • 查询结果几乎覆盖全部文档;
  • 每次请求都带不同的时间范围;
  • 分片重定位或节点刚重启;
  • 请求返回大量 hits,无法被简单请求缓存有效复用。

不要通过无限增大堆内存来解决页缓存问题,也不要通过增大 Query Cache 来解决错误的 Mapping 或低选择性查询。


4. “Segment 数量越来越多”

短时间内增加可能是正常的写入结果;长期持续增长则需要结合以下指标:

写入速率
Refresh 频率
Merge 速率
磁盘吞吐
删除比例
分片数量

常见失败路径是:

分片过多
  -> 每个分片都有自己的 Segment
  -> 每次请求触达更多分片
  -> 每个分片的固定管理成本累积
  -> 查询、Merge、缓存管理都变重

Segment 数量问题有时根源不是 Merge 参数,而是分片数量过多、刷新策略过于频繁或索引更新模型不合理。


十三、几个容易混淆的结论

误解一:倒排索引就是 Term 到 _id 的 Map

不准确。倒排索引内部使用的是 Segment 级 docID,并且 Posting 可能包含词频、位置、偏移和 payload。_id 是 Elasticsearch 层面的文档标识,不是倒排 Posting 的基本编号。

误解二:Refresh 就等于持久化

不准确。Refresh 主要解决搜索可见性;提交、Translog、Flush 和副本确认分别处于不同层次。

误解三:更新会原地修改文档

通常不是。更新一般表现为旧版本逻辑删除、新版本新增,最终由 Merge 清理旧数据。

误解四:Segment 越少越好

不准确。Segment 太多会增加查询管理开销,但强行合并也会产生高昂的 IO、CPU 和临时空间成本。持续写入索引即使被合并成一个 Segment,也会继续产生新 Segment。

误解五:查询执行过一次就一定被缓存

不准确。缓存需要考虑查询类型、复用频率、结果大小、Segment 生命周期和缓存准入策略。没有命中缓存时,OS 页缓存变热也可能让查询变快。

误解六:副本共享主分片的 Lucene 文件和缓存

不准确。副本有独立的 Lucene Segment 和本地缓存。副本刚恢复后,逻辑数据可以完整,但缓存和页缓存通常仍是冷的。


十四、把整个生命周期串起来

一条文档从写入到被清理,大致经历:

JSON 文档
  -> Mapping 决定字段如何索引
  -> Analyzer 产生 Token
  -> Token 归一化为字段级 Term
  -> Term、Posting 等结构进入内存缓冲
  -> Flush 写出不可变 Segment
  -> Refresh 发布新的搜索视图
  -> 查询定位 Term 并读取 Posting
  -> live docs 排除逻辑删除
  -> Query Cache / Request Cache / 页缓存可能复用数据
  -> 多个 Segment 被 Merge
  -> 删除文档被物理清理
  -> 新 Segment 取代旧 Segment
  -> 旧 Segment 文件和相关缓存最终释放

在 Elasticsearch 集群中,还要在这个生命周期外层加上:

请求路由
  -> 目标分片
  -> 主分片或副本分片
  -> 分片内部的 Lucene Segment
  -> 分片结果合并

因此,全文检索性能和正确性不是由某个单独组件决定的:

  • Analyzer 决定什么会成为 Term;
  • Term Dictionary 决定如何定位词项;
  • Posting 决定如何枚举和评分文档;
  • Segment 决定写入、读取和删除的物理边界;
  • Merge 决定碎片、删除回收和 IO 放大;
  • Refresh 决定近实时可见性;
  • Cache 决定已经读取过的数据能否被复用;
  • Elasticsearch 分片和副本决定这些操作发生在哪些节点和数据副本上。

理解这些边界后,match 查不到、删除不降盘、刚重启查询变慢、Merge 打满磁盘、缓存命中率波动等现象,就可以从索引生命周期和数据流中逐层定位,而不必把所有问题归因于“倒排索引很快”或“缓存没有生效”。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。