本文是 ES 存储引擎系列的第二篇,承接上一篇关于段、提交点、Translog 的介绍,详细拆解”写入确认→Refresh→Flush→段合并”的完整生命周期,并解释为什么 ES 需要同时支持”近实时搜索”和”实时 CRUD”两套机制。
写在前面
ES 的写入流程不是”写完就能立刻搜到”这么简单,而是经历了多个阶段,每个阶段都在性能和数据安全/可见性之间做权衡:
graph LR
A["1. 写入确认<br>Translog 落地"] --> B["2. Refresh<br>近实时可搜索"]
B --> C["3. Flush<br>持久化到磁盘"]
C --> D["4. 段合并<br>优化存储结构"]| 阶段 | 解决的问题 | 默认触发频率 |
|---|---|---|
| 写入与确认 | 数据不丢失(持久化保证) | 每次写请求 |
| Refresh | 数据能被搜索到(近实时可见性) | 默认 1 秒 |
| Flush | 数据真正落到磁盘(持久化) | Translog 达 512MB 或 30 分钟 |
| 段合并 | 控制段数量、回收已删除文档空间 | 段数量/删除比例超过阈值 |
一、写入与确认——实时的持久化
1.1 写入流程
- 客户端请求:客户端发送写入请求,即向 ES 的协调节点发送一个索引文档的请求。
- 路由:协调节点根据文档 ID 确定所属主分片,将请求转发给主分片所在的节点。
- 写入缓冲区:主分片将文档写入内存缓冲区(此时文档不可搜索)。
- 记录 Translog:记录 Translog,防止数据丢失。
- 确认写入:根据配置的
durability级别,等待 Translog 同步到磁盘后(async级别无需等待,直接返回),客户端收到”写入成功”的确认(但此时搜索仍不可见)。
sequenceDiagram
participant C as 客户端
participant Co as 协调节点
participant P as 主分片
participant T as Translog
C->>Co: 发送写入请求
Co->>P: 路由转发到主分片
P->>P: 写入内存缓冲区,此时不可搜索
P->>T: 记录 Translog
T-->>P: 按 durability 策略确认落盘
P-->>Co: 写入成功
Co-->>C: 返回写入成功,此时搜索不可见重要认知:在这一步,客户端拿到”写入成功”的响应,并不代表文档已经可以被 _search 接口搜到。这正是”近实时(Near Real-Time)”这个名字的由来——写入是被持久化保证的(数据不丢),但可见性有延迟。这是 ES 区别于传统数据库(写完立刻能查到)的一个常被忽视、却很重要的语义差异,很多线上 Bug(例如”刚插入的数据搜不到”)的根因都在这里。
二、Refresh——实现”近实时搜索”
2.1 为什么需要 Refresh
Lucene 允许新段被写入和打开——使其包含的文档在未进行一次完整提交时便对搜索可见。这种方式比进行一次提交代价要小得多,并且在不影响性能的前提下可以被频繁地执行。
在 Elasticsearch 中,写入和打开一个新段的轻量过程叫做 Refresh。默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch 是近实时搜索:文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。Refresh 是将内存中的文档数据转化为可搜索形式的过程。
- 在 Refresh 之前,文档只存在于写入层面(内存缓冲区),完全不可被搜索。
- 在 Refresh 之后,文档被构建成搜索数据结构,立即可被
_searchAPI 查询到。
2.2 Refresh 流程
- 触发 Refresh:默认每秒一次,自动触发 Refresh 操作。
- 创建新的段:Refresh 操作会清空内存缓冲区,为缓冲区中的所有文档构建倒排索引,生成一个全新的、不可变的 Lucene 段(这个段本身就是一个完整的倒排索引)。
- 段被写入文件系统缓存:这个新创建的段被写入到操作系统的文件系统缓存中,这是一个内存操作,速度极快。
- 段被打开,文档可被搜索:一旦段在文件系统缓存中就绪,它就会被”打开”,此时这个新段内的所有文档立即变得可被
_searchAPI 搜索到。
关键点:第 3 步只是写入操作系统的文件系统缓存(page cache),而不是直接 fsync 到物理磁盘——这正是 Refresh 操作能做到”轻量、可被频繁执行”的原因。真正把数据强制写入物理磁盘的,是下一节的 Flush 操作。也正因如此,Refresh 之后的数据仍然存在丢失风险(如果机器突然断电,文件系统缓存中尚未 fsync 的数据可能丢失)——但 Translog 已经记录了这些操作,所以即使段丢失,也能通过重放 Translog 恢复。
2.3 Refresh 触发条件
① 时间间隔触发(设置为 -1 可禁止自动刷新)
1 | PUT /my_index/_settings |
② 内存缓冲区触发
内存缓冲区接近满状态时触发,通常在持续高写入压力下发生。
1 | { |
③ 显式手动触发
1 | # 刷新特定索引 |
④ 特殊情况
Flush 前、索引恢复或重分配时、索引设置变更等场景也会触发 Refresh。
生产环境调优提示:refresh_interval 是 ES 性能调优中最常见的一个参数。如果业务场景对”秒级可见性”不敏感(比如离线批量导入数据),可以把 refresh_interval 临时调大甚至设为 -1(禁用自动刷新),导入完成后再手动 _refresh 一次或恢复默认值——这样能显著提升批量写入吞吐量,因为减少了频繁构建小段、再合并小段的开销。这是 ES 官方推荐的”批量导入加速”标准做法之一。
三、Flush——持久化变更
3.1 为什么需要 Flush
Flush 是将内存中和文件系统缓存中的数据永久写入磁盘,并创建一个新的持久化状态点的过程。
- 在 Flush 之前,数据可能只存在于内存或文件系统缓存中,有丢失风险。
- 在 Flush 之后,数据已被安全写入物理磁盘,即使断电也不会丢失。
3.2 段持久化和提交流程
- 触发 Flush:当 Translog 大小达到阈值(默认 512MB)或定时触发时,启动 Flush。
- 确保数据在段中:首先执行一次 Refresh,确保所有数据都已转化成段,保证 Flush 能捕获到所有未持久化的数据。
- 段持久化到磁盘:调用
fsync,强制操作系统将文件系统缓存中的所有脏页(dirty pages)真正写入物理磁盘。 - 创建新提交点:生成一个新的提交点(
segments_N)文件,记录当前所有已持久化的、有效的段的完整列表;提交点本身也会通过fsync确保写入磁盘。 - 清空 Translog:由于所有操作都已被安全持久化到段中,对应的 Translog 记录不再需要——当前 Translog 文件被删除(截断),并创建一个新的、空的 Translog 文件。
flowchart LR
A["触发 Flush"] --> B["执行 Refresh<br>确保数据已转化成段"]
B --> C["调用 fsync<br>段持久化到磁盘"]
C --> D["创建新提交点 segments_N"]
D --> E["清空旧 Translog<br>创建新的空 Translog"]3.3 Flush 触发条件
① Translog 大小触发(主要方式)
这是最常见、最重要的触发条件。Translog 文件持续增长,当大小达到 flush_threshold_size(默认 512MB),自动触发 Flush 操作,Translog 被清空,重新开始记录。
1 | PUT /my_index/_settings |
② 时间间隔触发
即使 Translog 大小未达标,也会按时间间隔强制 Flush,用于:
- 数据安全:防止少量写入的数据长时间留在内存中。
- 系统维护:定期清理和优化索引结构。
1 | PUT /my_index/_settings |
③ 显式手动触发
1 | # Flush 特定索引 |
wait_if_ongoing:如果已有 Flush 在进行,是否等待。force:强制 Flush,即使没有需要 Flush 的数据。
④ 系统事件触发
在特定系统事件发生时自动触发:索引关闭时、分片重分配时、节点关闭前、备份/快照前。
Refresh vs Flush 的本质区别:很多初学者容易混淆这两个操作,可以这样区分——Refresh 解决的是”能不能搜到“的问题(数据从内存进入可搜索的段,但段可能还只在文件系统缓存里);Flush 解决的是”会不会丢“的问题(把文件系统缓存中的数据真正 fsync 到物理磁盘,并清空 Translog)。即使从不手动 Flush,数据也不会丢失(因为有 Translog 兜底),但 Translog 会无限增长,故障重启时需要重放的操作也会越来越多,恢复时间变长——这就是为什么需要定期 Flush 把”未完成的事务”清空。
四、段合并
4.1 为什么需要段合并
由于段的不可变性和频繁的 Refresh,会导致一些问题:段数量爆炸、逻辑删除累积、存储效率低。
段合并是 Lucene 后台将多个小的、已提交的段合并成更大的、更优化的新段的过程。在合并过程中,会物理清除那些在 .del 文件中被标记为”已删除”的文档,真正释放磁盘空间。
- 合并过程中,搜索继续在旧段上进行(不影响线上搜索可用性)。
- 新合并段准备好后,原子性切换到新段。
4.2 段合并过程
- 选择合并候选段:合并策略(默认
TieredMergePolicy)分析所有段,选择一组大小相近、包含较多删除文档的段作为合并候选,策略目标是平衡合并开销和收益。 - 创建新合并段:系统创建一个全新的、更大的段,只从原始段中拷贝未被标记为删除的文档,真正物理删除那些在
.del文件中标记的文档。 - 替换旧段:新合并段创建完成后,系统生成一个新的提交点,新提交点引用新合并段,不再引用被合并的旧段,新段被打开,立即可被搜索。
- 清理旧段:被替换的旧段被标记为”待删除”,在安全的时候,这些旧段文件被物理删除,此时被删除文档的磁盘空间真正被释放。
graph LR
subgraph 合并前
S1["段 1 小"]
S2["段 2 小"]
S3["段 3 含较多删除"]
end
S1 -->|拷贝未删除文档| M["新合并段 大"]
S2 -->|拷贝未删除文档| M
S3 -->|拷贝未删除文档| M
M --> CP["新提交点引用新段"]
CP -->|旧段标记待删除并物理清理| Done["磁盘空间释放"]4.3 段合并策略
Elasticsearch 使用合并策略(Merge Policy)来决定哪些段应该被合并以及如何合并。默认且最常用的策略是 TieredMergePolicy,采用”分层”的思想来管理段——按段大小分成若干层(小段、中段、大段),优先在同一层内进行合并,避免大段被反复参与合并造成不必要的 I/O 开销。
1 | PUT /my_index/_settings |
4.4 段合并的触发条件
① 自动触发(TieredMergePolicy)
- 段数量和大小:当某一”层”的段数量超过阈值时——由
segments_per_tier控制。 - 删除文档比例:当某个段中包含大量被删除的文档时——由
deletes_pct_allowed控制。
② 手动触发
1 | # 强制合并索引(谨慎使用!) |
_forcemerge 使用警告:手动强制合并存在以下风险,生产环境务必谨慎使用:
- 长时间阻塞:大索引可能耗时数小时。
- 资源耗尽:消耗大量 CPU、内存、I/O。
- 磁盘空间翻倍:合并期间新旧段共存,磁盘占用会短暂翻倍。
- 无法中断:强制停止可能导致索引损坏。
_forcemerge 通常只建议在只读索引(如日志类场景中,前一天已经不再写入的旧索引)上使用,将其合并为单个段以节省存储和提升查询性能;对于仍在持续写入的活跃索引,应该让 TieredMergePolicy 自动管理,不要手动干预。
五、从写入到搜索完整流程图
graph TD
A["客户端发送写入请求"] --> B["协调节点路由至主分片"]
B --> C["数据写入内存缓冲区"]
C --> D["操作追加至 Translog"]
D --> E{"Translog 持久化策略"}
E -->|request 默认| F["等待 Translog fsync"]
E -->|async| G["按间隔异步刷盘"]
F --> H["返回客户端:写入成功"]
G --> H
H --> I{"等待 Refresh 触发?"}
I -->|是,默认 1s| J["清空内存缓冲区<br>构建倒排索引,创建新 Lucene 段"]
J --> K["段写入文件系统缓存"]
K --> L["段被打开,文档可被搜索"]
L --> M["数据可被搜索<br>存在于文件系统缓存的段中"]
M --> N{"等待 Flush 触发?"}
N -->|是| O["执行 Refresh"]
O --> P["调用 fsync 将所有段持久化至磁盘"]
P --> Q["创建新提交点,记录所有有效段"]
Q --> R["清空旧 Translog"]
R --> S["数据被持久化<br>存在于磁盘的段中"]
M --> T["段合并:优化存储与查询"]
S --> T
T --> U["选择多个小段"]
U --> V["合并为新的大段<br>物理删除已标记文档"]
V --> W["新提交点排除旧段"]
W --> X["删除旧段,释放空间"]六、实时 CRUD 操作
6.1 为什么有了近实时搜索后,还要有实时的 CRUD?
近实时搜索是通过 Refresh 操作实现的,默认每秒一次,将内存缓冲区的数据变成可搜索的段。而实时 CRUD 是通过 Translog 实现的——当通过文档 ID 进行获取、更新、删除时,会先检查内存缓冲区和 Translog,从而获取最新版本的数据,**不依赖 Refresh 的”秒级延迟”**。
这两种机制服务于完全不同的使用场景和用户需求,核心问题是:如何在同一系统中协调”高性能搜索”和”强一致性操作”这两种看似矛盾的需求?
① 从用户心理角度看,人们对不同操作有着截然不同的期望
- 搜索场景中,用户能够接受短暂延迟:当用户搜索”最新手机”时,如果结果中不包含 1 秒前刚上架的商品,他们通常能够理解。
- 直接操作场景中,用户要求立即反馈:更新个人信息后立即查看,期望看到变化立即生效;库存信息需要及时更新不能超卖——这种”所见即所得”的心理预期是用户体验的底线。
② 应该让低成本的操作实时,高成本的操作近实时
- 近实时搜索的成本较高:需要构建完整的倒排索引结构,涉及分词、排序、压缩等复杂计算,需要管理多个段文件的合并和优化。
- 实时 CRUD 的成本较低:基于文档 ID 的操作只需查询 Translog 和内存缓冲区,类似于数据库的主键查询,通过简单索引快速定位,资源消耗可控,不会对系统造成过大压力。
③ 需要提供灵活的工具而非僵硬的规则
Elasticsearch 通过清晰的 API 设计明确了这种权衡:
| API | 语义 |
|---|---|
/_search | 接受近实时特性,获得极致性能 |
/_doc/{id} | 获得实时一致性,满足强一致性需求(点查) |
?refresh=true 参数 | 在需要时强制立即可见,灵活控制 |
这种设计让开发者能够根据具体场景选择合适工具,而不是被迫接受”一刀切”的解决方案。
6.2 实时 CRUD 具体流程
对于基于文档 ID 的 CRUD 操作,**Translog 是查找最新数据的”真相源”**。
- 检查内存缓冲区:在接收请求的数据节点上,系统会检查当前内存缓冲区中是否有该文档 ID 的任何未提交的更改。如果找到,说明这是绝对最新的版本,可以直接返回。
- 查询 Translog:如果在内存缓冲区中没有找到,系统会查询该分片的 Translog——它按时间顺序记录了所有操作,因此能告诉我们关于这个文档 ID 的最新操作是什么(索引/创建、更新、删除)。
- 根据 Translog 记录采取行动:
- 情况 A:Translog 显示文档存在(创建或更新)——Translog 不仅记录操作类型,还记录了该文档在哪个 Lucene 段中。系统直接定位到对应的段文件,读取文档内容并返回。即使这个段是几个小时前创建的,只要 Translog 指出这是最新版本的位置,就会从这里读取。
- 情况 B:Translog 显示文档已被删除——系统会立即返回
404 Not Found,而不会再去任何段中查找。即使某些旧段中还存在这个文档的数据,但由于 Translog 记录了删除操作,它被认为已不存在。
- 作为最后手段的段查找:如果 Translog 中也没有该文档 ID 的记录,说明文档要么不存在,要么最后一次操作已经被持久化。此时,系统会回退到标准的搜索方式,在所有已提交的段中查找该文档。
flowchart TD
A["GET/UPDATE/DELETE 请求,带文档 ID"] --> B{"内存缓冲区中<br>有该 ID 的未提交更改?"}
B -->|是| C["直接返回最新版本"]
B -->|否| D{"查询该分片 Translog"}
D -->|记录为创建/更新| E["定位到 Translog 记录的<br>Lucene 段,读取并返回"]
D -->|记录为删除| F["立即返回 404 Not Found"]
D -->|无该 ID 记录| G["回退到标准段查找方式"]总结
ES 的写入到可搜索/可持久化的完整生命周期,本质上是围绕**”四层缓冲”**展开的:
- 内存缓冲区(写入但不可搜索)
- Translog(保证不丢,支撑实时 CRUD)
- 文件系统缓存中的段(Refresh 后可搜索,但未 fsync)
- 磁盘上持久化的段(Flush 后真正落盘)
而段合并则是在这套机制运行过程中,持续清理”逻辑删除”留下的存储碎片,维持系统长期运行的健康度。
理解了这套机制,再回头看”为什么 ES 是近实时搜索”和”为什么 GET 操作却是实时的”,答案就很清晰了:两者依赖的是完全不同的数据通路——前者依赖 Refresh 后的段,后者依赖 Translog 本身。这也是 ES 设计中”用合适的工具做合适的事”这一工程哲学的典型体现。



