### 1. 什么是 segment,里面装了什么?
在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含:
* **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。
* **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。
* **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。
* **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。
* **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。
所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。
---
### 2. 原始文档和 FST 为什么都在 segment 里?
* **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。
* **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。
---
### 3. 查询时到底是怎么遍历 segment 的?
1. **请求入口**
客户端的搜索请求先到达 **协调节点**,协调节点把请求 **广播** 给所有目标 **shard(主分片或副本分片)**。每个 shard 在所在机器上独立执行搜索。
2. **在单个 shard 内部**
*Elasticsearch* 为该 shard 创建一个 `IndexSearcher`,内部通过 `DirectoryReader` 把 **该 shard 当前所有活跃的 segment** 包装成一个 `MultiReader`。
接下来,搜索线程会遍历 `MultiReader` 的每个 `LeafReaderContext`(即每个 segment),对每个 segment 执行相同的查询逻辑。
3. **在每个 segment 上的具体步骤**
* 对目标字段的 FST(`TermsEnum`)执行 `seekExact(term)`,这一步只读取极少的 FST 节点,成本在微秒级。
* 如果 FST 表明该 term 不在此 segment,直接返回空,不会读取倒排列表。
* 如果 term 存在,则通过 `postingsEnum` 读取该 term 在本 segment 中的所有 doc‑id、频次、位置信息,计算得分并产生局部 Top‑K。
* 最后,`IndexSearcher` 用 `TopDocsMerger` 把所有 segment 的局部结果合并成全局的 Top‑K/聚合结果,返回给协调节点。
4. **结果合并**
协调节点把各 shard(包括副本)返回的局部结果进行全局排序、去重或二次聚合,最终将结果返回给客户端。
> **要点**:查询并不是一次性遍历所有文档,而是 **对每个 segment 的只读 FST 进行一次快速定位**,只有真正匹配的 segment 才会进一步读取倒排列表。
---
### 4. 为什么说 segment 是不可变的?
* **写入阶段(flush/refresh)**:当 RAM 缓冲区满或达到 `refresh_interval` 时,Elasticsearch 把内存中的倒排结构一次性写入磁盘,生成新 segment。此时所有文件(包括已经压缩好的 FST)被 **一次性写完并关闭**,之后不再修改。
* **删除**:只在对应 segment 的 `*.del` 位图里标记,不会改动 FST、postings 等文件。
* **更新**:实际上是 **delete + index**,旧 segment 仍保持不变,新文档写入一个 **全新的 segment**(带新的 FST)。
* **合并(merge)**:后台读取若干小 segment,**重新构造一个更大的 segment**(包括重新生成的 FST),写入新文件后再删除旧文件。整个过程始终是 “写新、删旧”,从不在已有文件上就地修改。
因此,**segment 的不可变性指的是磁盘文件一旦生成后就只读**,所有的“变动”都是通过生成新的文件来实现的。
---
### 5. Term 查询的实际表现——是否真的要遍历所有 segment?
是的,**每个活跃的 segment 都会被检查一次**,但检查的成本极低:
* **FST 查找**:只读取少量字节,几乎是常数时间。如果 term 不在该 segment,立刻返回空。
* **倒排列表读取**:仅在 term 实际出现的 segment 中进行,读取量与该 term 在该 segment 中的文档数量成正比。
* **合并阶段**:只把每个 segment 的局部 Top‑K 合并,复杂度是 `O(k log S)`(k 为返回条数,S 为 segment 数),这在实际中几乎可以忽略。
所以,即便需要“遍历所有 segment”,对查询性能的影响主要取决于 **segment 的数量** 而不是每个 segment 的大小。
---
### 6. 写入速率低于刷新间隔会产生什么?
* **大量小 segment**:每次刷新只会把极少的文档写入一个新 segment,导致 **segment 数量激增**。这些小 segment 的 FST 也很小(几 KB),查询时仍然快速定位,但搜索器需要遍历更多的 leaf,CPU 开销会略有上升。
* **合并的必要性**:后台合并会把这些小 segment 合并成更大的 segment,重新生成一个更稠密、更高效的 FST,同时回收已经删除的文档占用的磁盘空间。
* **调优思路**:
- 延长 `index.refresh_interval`(让更多文档累计后一次性 flush),或者关闭自动刷新,仅在需要时手动刷新。
- 调整 `index.merge.policy.*` 参数,让合并更积极地把小 segment 合并成大 segment。
- 在低写入场景下,适当增大 `index.translog.flush_threshold_size`,让 translog 和刷新同步进行,避免频繁产生极小的 segment。
---
### 7. FST 的压缩到底发生在哪一步?
1. **首次写入(flush/refresh)**:当新 segment 被写入磁盘时,Lucene 的 `FSTBuilder` 把内存中的 term 集合一次性压缩成 FST 并写入 `*.tim`(或 `*.tis/*.tii`)文件。即使 segment 很小,这一步也已经完成了压缩,只是因为词条数量少,压缩率有限。
2. **合并阶段**:后台合并读取多个小 segment 的 FST,**重新构造一个统一的 FST**,在写入新 segment 时再次进行压缩。合并后的 FST 往往能获得更好的前缀共享,从而得到更高的压缩率。
> **因此**,FST 的压缩既在 **segment 创建时** 完成,也会在 **合并生成新 segment 时** 再次执行。两次压缩都是一次性写入,只是合并时得到的是更稠密、更高效的词典。
---
### 8. 副本与主分片的 segment 关系
每个 **副本 shard** 都拥有自己完整的 segment 集合,这些 segment 与主分片的内容相同,但是 **独立的文件拷贝**。查询时副本会自行遍历自己的 segment,提供横向扩展和高可用。合并、刷新、压缩等操作在每个副本上独立执行,只要副本与主分片的 segment 列表保持一致,搜索结果就一致。
---
### 9. 实际调优步骤(实战指南)
1. **观察当前 segment 状况**
- 使用 `GET /_cat/segments/<index>?v&h=index,shard,segment,num_docs,size` 查看每个 shard 上的 segment 数量和大小。
- 通过 `GET /<index>/_search?profile=true` 查看搜索时每个 segment 的耗时与命中数。
2. **如果发现大量小 segment**(多数 segment 只有几百 KB 或更小),可以从以下两方面入手:
- **延长刷新间隔**:`PUT <index>/_settings { "index.refresh_interval": "30s" }`(或 `-1` 关闭自动刷新,手动刷新)。
- **加速合并**:调大 `index.merge.policy.max_merge_at_once`、`segments_per_tier`,或在低峰期手动执行 `POST <index>/_forcemerge?max_num_segments=1`。
3. **控制写入与刷新节奏**
- 增大 `index.translog.flush_threshold_size`,让写入更多数据后才触发一次刷新。
- 对于批量写入(bulk),可以适当调高 `index.bulk.max_size`,一次性发送更多文档,减少刷新次数。
4. **监控合并进度**
- `GET /<index>/_stats?human&filter_path=indices.*.total.merge` 能看到当前正在进行的合并数量、已完成的大小等。
- 若合并长期滞后,检查 `index.merge.scheduler.max_thread_count` 是否被限制太低,适当提升。
5. **验证效果**
- 再次执行 `_cat/segments`,确认小 segment 已被合并为更大的 segment。
- 用 `profile` 再次跑一次查询,观察每个 segment 的搜索耗时是否明显下降。
---
### 10. 小结
* **Segment** 是只读、不可变的倒排索引块;每个 segment 包含自己的 **FST(倒排词典)**、倒排列表、存储字段等。
* **查询** 时,Elasticsearch 对每个 shard 的所有 segment 逐一执行 **FST 查找**,只有匹配的 segment 才继续读取倒排列表,最后把各 segment 的局部结果合并。
* **写入慢、刷新频繁** 会产生大量小 segment,导致搜索时遍历的 leaf 增多。通过 **延长刷新间隔、加快合并** 可以把这些小 segment 合并成更大的、压缩率更高的 segment。
* **FST 的压缩** 在 **segment 创建时** 已经完成,合并阶段会再次重新压缩,始终遵循“一次写入、只读使用”的不可变模型。
* **副本** 拥有独立的 segment 集合,查询时也会独立遍历其 segment,保证了横向扩展和高可用。