跳至主要内容

Elasticsearch 读写原理指南


### 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,保证了横向扩展和高可用。

此博客中的热门博文

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。