Elasticsearch 近实时搜索与实时 CRUD 操作机制
Published in:2026-03-05 | category: 中间件

本文是 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 写入流程

  1. 客户端请求:客户端发送写入请求,即向 ES 的协调节点发送一个索引文档的请求。
  2. 路由:协调节点根据文档 ID 确定所属主分片,将请求转发给主分片所在的节点。
  3. 写入缓冲区:主分片将文档写入内存缓冲区(此时文档不可搜索)。
  4. 记录 Translog:记录 Translog,防止数据丢失。
  5. 确认写入:根据配置的 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 之后,文档被构建成搜索数据结构,立即可被 _search API 查询到。

2.2 Refresh 流程

  1. 触发 Refresh:默认每秒一次,自动触发 Refresh 操作。
  2. 创建新的段:Refresh 操作会清空内存缓冲区,为缓冲区中的所有文档构建倒排索引,生成一个全新的、不可变的 Lucene 段(这个段本身就是一个完整的倒排索引)。
  3. 段被写入文件系统缓存:这个新创建的段被写入到操作系统的文件系统缓存中,这是一个内存操作,速度极快。
  4. 段被打开,文档可被搜索:一旦段在文件系统缓存中就绪,它就会被”打开”,此时这个新段内的所有文档立即变得可被 _search API 搜索到。

关键点:第 3 步只是写入操作系统的文件系统缓存(page cache),而不是直接 fsync 到物理磁盘——这正是 Refresh 操作能做到”轻量、可被频繁执行”的原因。真正把数据强制写入物理磁盘的,是下一节的 Flush 操作。也正因如此,Refresh 之后的数据仍然存在丢失风险(如果机器突然断电,文件系统缓存中尚未 fsync 的数据可能丢失)——但 Translog 已经记录了这些操作,所以即使段丢失,也能通过重放 Translog 恢复。

2.3 Refresh 触发条件

① 时间间隔触发(设置为 -1 可禁止自动刷新)

1
2
3
4
PUT /my_index/_settings
{
"index.refresh_interval": "1s" // 默认值
}

② 内存缓冲区触发

内存缓冲区接近满状态时触发,通常在持续高写入压力下发生。

1
2
3
4
{
"index.translog.flush_threshold_size": "512mb", # 间接影响
"index.memory.index_buffer_size": "10%" # 索引缓冲区大小
}

③ 显式手动触发

1
2
3
4
5
6
7
8
# 刷新特定索引
POST /my_index/_refresh

# 刷新多个索引
POST /index1,index2/_refresh

# 刷新所有索引
POST /_refresh

④ 特殊情况

Flush 前、索引恢复或重分配时、索引设置变更等场景也会触发 Refresh。

生产环境调优提示refresh_interval 是 ES 性能调优中最常见的一个参数。如果业务场景对”秒级可见性”不敏感(比如离线批量导入数据),可以把 refresh_interval 临时调大甚至设为 -1(禁用自动刷新),导入完成后再手动 _refresh 一次或恢复默认值——这样能显著提升批量写入吞吐量,因为减少了频繁构建小段、再合并小段的开销。这是 ES 官方推荐的”批量导入加速”标准做法之一。

三、Flush——持久化变更

3.1 为什么需要 Flush

Flush 是将内存中和文件系统缓存中的数据永久写入磁盘,并创建一个新的持久化状态点的过程。

  • 在 Flush 之前,数据可能只存在于内存或文件系统缓存中,有丢失风险。
  • 在 Flush 之后,数据已被安全写入物理磁盘,即使断电也不会丢失。

3.2 段持久化和提交流程

  1. 触发 Flush:当 Translog 大小达到阈值(默认 512MB)或定时触发时,启动 Flush。
  2. 确保数据在段中:首先执行一次 Refresh,确保所有数据都已转化成段,保证 Flush 能捕获到所有未持久化的数据。
  3. 段持久化到磁盘:调用 fsync,强制操作系统将文件系统缓存中的所有脏页(dirty pages)真正写入物理磁盘。
  4. 创建新提交点:生成一个新的提交点(segments_N)文件,记录当前所有已持久化的、有效的段的完整列表;提交点本身也会通过 fsync 确保写入磁盘。
  5. 清空 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
2
3
4
PUT /my_index/_settings
{
"index.translog.flush_threshold_size": "512mb" // 默认值
}

② 时间间隔触发

即使 Translog 大小未达标,也会按时间间隔强制 Flush,用于:

  • 数据安全:防止少量写入的数据长时间留在内存中。
  • 系统维护:定期清理和优化索引结构。
1
2
3
4
PUT /my_index/_settings
{
"index.translog.flush_threshold_period": "30m" // 默认30分钟
}

③ 显式手动触发

1
2
3
4
5
6
7
8
9
10
11
# Flush 特定索引
POST /my_index/_flush

# Flush 多个索引
POST /index1,index2/_flush

# Flush 所有索引
POST /_flush

# 带参数的Flush
POST /my_index/_flush?wait_if_ongoing=true
  • 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 段合并过程

  1. 选择合并候选段:合并策略(默认 TieredMergePolicy)分析所有段,选择一组大小相近、包含较多删除文档的段作为合并候选,策略目标是平衡合并开销和收益。
  2. 创建新合并段:系统创建一个全新的、更大的段,只从原始段中拷贝未被标记为删除的文档,真正物理删除那些在 .del 文件中标记的文档。
  3. 替换旧段:新合并段创建完成后,系统生成一个新的提交点,新提交点引用新合并段,不再引用被合并的旧段,新段被打开,立即可被搜索。
  4. 清理旧段:被替换的旧段被标记为”待删除”,在安全的时候,这些旧段文件被物理删除,此时被删除文档的磁盘空间真正被释放。
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT /my_index/_settings
{
"index.merge.policy": {
// 每层允许的段数量(越小合并越频繁)
"segments_per_tier": 10,
// 单次合并最多涉及多少个段
"max_merge_at_once": 10,
// 合并后段的最大大小
"max_merged_segment": "5gb",
// 触发合并的删除文档比例阈值
"deletes_pct_allowed": 33,
// 小于此值的段被视为同一大小(避免对小段过度合并)
"floor_segment": "2mb"
}
}

4.4 段合并的触发条件

① 自动触发(TieredMergePolicy)

  • 段数量和大小:当某一”层”的段数量超过阈值时——由 segments_per_tier 控制。
  • 删除文档比例:当某个段中包含大量被删除的文档时——由 deletes_pct_allowed 控制。

② 手动触发

1
2
3
4
5
6
7
8
# 强制合并索引(谨慎使用!)
POST /my_index/_forcemerge

# 只合并包含删除的段
POST /my_index/_forcemerge?only_expunge_deletes=true

# 合并到指定段数量
POST /my_index/_forcemerge?max_num_segments=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 是查找最新数据的”真相源”**。

  1. 检查内存缓冲区:在接收请求的数据节点上,系统会检查当前内存缓冲区中是否有该文档 ID 的任何未提交的更改。如果找到,说明这是绝对最新的版本,可以直接返回。
  2. 查询 Translog:如果在内存缓冲区中没有找到,系统会查询该分片的 Translog——它按时间顺序记录了所有操作,因此能告诉我们关于这个文档 ID 的最新操作是什么(索引/创建、更新、删除)。
  3. 根据 Translog 记录采取行动
    • 情况 A:Translog 显示文档存在(创建或更新)——Translog 不仅记录操作类型,还记录了该文档在哪个 Lucene 段中。系统直接定位到对应的段文件,读取文档内容并返回。即使这个段是几个小时前创建的,只要 Translog 指出这是最新版本的位置,就会从这里读取。
    • 情况 B:Translog 显示文档已被删除——系统会立即返回 404 Not Found,而不会再去任何段中查找。即使某些旧段中还存在这个文档的数据,但由于 Translog 记录了删除操作,它被认为已不存在。
  4. 作为最后手段的段查找:如果 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 的写入到可搜索/可持久化的完整生命周期,本质上是围绕**”四层缓冲”**展开的:

  1. 内存缓冲区(写入但不可搜索)
  2. Translog(保证不丢,支撑实时 CRUD)
  3. 文件系统缓存中的段(Refresh 后可搜索,但未 fsync)
  4. 磁盘上持久化的段(Flush 后真正落盘)

段合并则是在这套机制运行过程中,持续清理”逻辑删除”留下的存储碎片,维持系统长期运行的健康度。

理解了这套机制,再回头看”为什么 ES 是近实时搜索”和”为什么 GET 操作却是实时的”,答案就很清晰了:两者依赖的是完全不同的数据通路——前者依赖 Refresh 后的段,后者依赖 Translog 本身。这也是 ES 设计中”用合适的工具做合适的事”这一工程哲学的典型体现。

Prev:
《格鲁夫给经理人的第一课》读书笔记(四):激励与目标管理
Next:
华为绩效管理与激励体系学习总结