上一篇解决了文本怎样变成词项——analysis pipeline 把原始文本标准化为可索引的 term。这一篇进入文档写入后怎样变成可搜索的。

一条文档经过 analysis 生成词项后,并不会立即对搜索可见。ES 的写入路径涉及内存缓冲、translog 持久化、refresh 生成新 segment、flush 提交到磁盘四个步骤。这套机制让 ES 在写入吞吐和搜索延迟之间取得平衡,也是"近实时搜索"(near-real-time, NRT)这个名字的由来。

本文只抓一个问题:ES 的近实时搜索是怎么实现的,refresh 和 flush 的区别在哪里。

写入路径的完整流程

一条文档从客户端发出到最终持久化到磁盘,经过以下步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Document
→ Coordinating Node(路由到目标 shard 的 primary)
→ Primary Shard:
1. 写入 in-memory buffer(indexing buffer)
2. 追加到 translog(顺序写磁盘,提供持久化保障)
3. 返回写入成功(同时转发给 replica)
→ [等待 refresh_interval,默认 1 秒]
→ Refresh:
4. 把 in-memory buffer 中的数据生成一个新的 Lucene segment
5. 新 segment 对搜索可见(但未 fsync 到磁盘)
6. 清空 in-memory buffer
→ [等待 flush 条件触发]
→ Flush:
7. 把所有未提交的 segment fsync 到磁盘(Lucene commit)
8. 清空 translog

上面的文本描述涉及 Client、Coordinating Node、Primary Shard、Replica、in-memory buffer、translog、segment、磁盘等多个实体和两条时间线(refresh / flush)。下图把这些实体和时间点放到一张图里,纵向是数据流,横向标注两个关键时间点。

flowchart TD
    Client["Client"] -->|"路由"| Coord["Coordinating Node"]
    Coord --> Primary["Primary Shard"]
    Primary --> Buf["In-memory Buffer"]
    Primary --> TL["Translog<br/>(fsync 到磁盘)"]
    Primary -->|"转发"| Replica["Replica Shard"]

    Buf -->|"refresh<br/>(默认 1s)"| Seg["新 Segment<br/>(内存中,可搜索)"]
    Seg -->|"flush<br/>(translog 达阈值)"| Disk["Segment fsync 到磁盘<br/>+ 清空 Translog"]

    style Buf fill:#fff4e0,stroke:#cc9944
    style TL fill:#f0fff0,stroke:#66aa66
    style Seg fill:#f0f4ff,stroke:#6688cc
    style Disk fill:#e8e8e8,stroke:#888888

这个流程中有两个关键的时间点:refresh 之后文档可搜索,flush 之后文档持久化到磁盘。两者之间的间隔就是"近实时"中"近"的含义——不是真正的实时,而是有一个 refresh 间隔(默认 1 秒)。

Refresh:从不可见到可搜索

Refresh 的作用是把 in-memory buffer 中的数据转化为一个新的 Lucene segment。这个新 segment 被打开(open),立即对搜索可见——但尚未被 fsync 到磁盘。

1
2
3
4
5
6
7
refresh 前:
in-memory buffer: [doc1, doc2, doc3] ← 不可搜索
existing segments: [seg0, seg1] ← 可搜索

refresh 后:
in-memory buffer: [] ← 已清空
existing segments: [seg0, seg1, seg2] ← seg2 是新生成的,可搜索

Refresh 前后的状态变化涉及 buffer、segment 集合、搜索可见性三个维度的同时变更,下面这张图把 refresh 前后两个快照并排放置,箭头标注数据的去向。

flowchart LR
    subgraph BEFORE["refresh 前"]
        direction TB
        buf1["In-memory Buffer<br/>doc1, doc2, doc3<br/>(不可搜索)"]
        segs1["已有 Segments<br/>seg0, seg1<br/>(可搜索)"]
    end

    subgraph AFTER["refresh 后"]
        direction TB
        buf2["In-memory Buffer<br/>(空)"]
        segs2["Segments<br/>seg0, seg1, seg2<br/>(全部可搜索)"]
    end

    buf1 -->|"生成 seg2"| segs2

Refresh 的代价不高(相比 fsync),但也不是零成本。每次 refresh 都会产生一个新的 segment,segment 过多会增加搜索开销并触发后台 merge。

refresh_interval 控制自动 refresh 的间隔,默认 1 秒。可以根据场景调整:

场景 refresh_interval 理由
默认 1s 近实时搜索,写入后约 1 秒可见
批量导入 "-1"(禁用) 写入完成后手动 refresh,避免频繁生成小 segment
准实时要求 200ms 更快可见,但 merge 压力更大
日志场景 30s 日志不需要秒级可见,减少 segment 数量

手动触发 refresh:

1
POST /my-index/_refresh

Translog:写入的持久化保障

Refresh 让文档可搜索,但新 segment 还没有 fsync 到磁盘。如果这时候节点崩溃,in-memory buffer 和未提交的 segment 都会丢失。Translog(事务日志)就是为了解决这个问题。

每次写入操作在进入 in-memory buffer 的同时,会追加到 translog。Translog 是一个顺序写的日志文件,默认在每次写入操作后 fsync 到磁盘(index.translog.durability: request)。

1
2
3
4
5
6
7
8
写入流程:
1. 写入 in-memory buffer ← 内存中,不持久
2. 追加到 translog ← fsync 到磁盘,持久
3. 返回写入成功

节点恢复:
1. 重放 translog 中的操作
2. 重建 in-memory buffer 和未提交的 segment

Translog 的角色等同于关系型数据库中的 WAL(Write-Ahead Log)。核心思想相同:在数据结构本身持久化之前,先把操作记录到一个顺序写的日志中,用日志保证持久性。

Translog 的 durability 可以配置:

配置 行为 持久性 性能
request(默认) 每次写入后 fsync translog 最高 每次写入一次 fsync
async sync_interval(默认 5s)fsync 一次 可能丢失最近 5s 的数据 更高吞吐

Flush:Lucene Commit

Flush 是把 Lucene 的状态持久化到磁盘的操作。它做三件事:

  1. 把所有 in-memory buffer 中的数据生成 segment(等同于一次 refresh)
  2. 对所有未提交的 segment 执行 fsync(Lucene commit point)
  3. 清空 translog(因为 segment 已经持久化,不再需要 translog 来恢复)
1
2
3
4
5
6
7
8
9
flush 前:
segments on disk (committed): [seg0, seg1]
segments in memory (uncommitted): [seg2, seg3]
translog: [op1, op2, op3, op4, ...]

flush 后:
segments on disk (committed): [seg0, seg1, seg2, seg3]
segments in memory: []
translog: [] (cleared)

Flush 的触发条件:

  • Translog 大小超过阈值(index.translog.flush_threshold_size,默认 512MB)
  • 距离上次 flush 时间超过阈值
  • 手动调用 _flush API

手动触发 flush:

1
POST /my-index/_flush

Refresh vs Flush 对比

维度 Refresh Flush
核心动作 buffer → new segment(内存中) segment fsync to disk + clear translog
效果 文档变为可搜索 文档持久化到磁盘
默认间隔 1 秒 translog 达到 512MB 或手动触发
成本 低(不涉及磁盘 fsync) 高(fsync 所有未提交 segment)
数据安全 无(新 segment 未持久化) 有(segment 已在磁盘上)
类比 打开新文件供读取 数据库 checkpoint

实验:观察近实时行为

创建一个 index 并设置较长的 refresh_interval:

1
2
3
4
5
6
7
8
PUT /nrt-demo
{
"settings": {
"refresh_interval": "-1",
"number_of_shards": 1,
"number_of_replicas": 0
}
}

写入一条文档:

1
2
3
4
POST /nrt-demo/_doc/1
{
"message": "this document is not yet searchable"
}

立即搜索——不会返回任何结果:

1
2
3
GET /nrt-demo/_search
{ "query": { "match_all": {} } }
# hits.total.value = 0

手动 refresh:

1
POST /nrt-demo/_refresh

再次搜索——文档出现:

1
2
3
GET /nrt-demo/_search
{ "query": { "match_all": {} } }
# hits.total.value = 1

恢复默认 refresh_interval:

1
2
PUT /nrt-demo/_settings
{ "index": { "refresh_interval": "1s" } }

模式提炼:WAL + 延迟可见性

1
write → WAL (durable) → buffer (fast) → background materialize → visible

ES 的写入路径本质是"先写日志保持久、再异步物化提供查询"的模式。这个模式的核心 trade-off 是:牺牲即时可见性(延迟 1 秒),换取更高的写入吞吐(不需要每次写入都 fsync 索引文件)。

系统 WAL 物化操作 可见性延迟
Elasticsearch Translog Refresh(生成 segment) 默认 1 秒
MySQL (InnoDB) Redo log Checkpoint(写脏页) 事务提交即可见
PostgreSQL WAL Checkpoint 事务提交即可见
Apache Kafka Log segment 本身就是日志存储 取决于 ISR + acks
SQLite WAL mode journal Checkpoint 写入即可见

ES 和传统数据库的关键区别在于:数据库的 WAL 用于崩溃恢复,可见性由事务控制,事务提交后立即可见。ES 的 translog 也用于崩溃恢复,但可见性由 refresh 控制,refresh 之前文档不可搜索。

工程迁移表

概念 Elasticsearch MySQL (InnoDB) PostgreSQL Kafka
预写日志 Translog Redo log WAL Log segment
内存缓冲 Indexing buffer Buffer pool Shared buffers Page cache
可搜索时机 Refresh 之后 Commit 之后 Commit 之后 Consumer lag
持久化时机 Flush 之后 Checkpoint 之后 Checkpoint 之后 fsync / flush
默认持久化策略 每次写入 fsync translog 每次 commit fsync redo log 每次 commit fsync WAL 可配 acks

常见误解

误解一:写入返回成功就表示文档可以被搜索到。 写入返回成功只表示文档已经进入 in-memory buffer 和 translog。文档在下一次 refresh 之后才能被搜索到。如果需要写入后立即搜索,可以在写入请求中加 ?refresh=true(但会影响性能)或 ?refresh=wait_for(等待下一次自然 refresh)。

误解二:refresh 就是 flush。 refresh 只是生成新 segment 并打开供搜索,不涉及磁盘 fsync。flush 才是把 segment fsync 到磁盘。refresh 频繁执行(默认每秒),flush 较少执行(translog 达到阈值时)。

误解三:禁用 translog 可以提高写入性能。 ES 不支持完全禁用 translog。可以把 index.translog.durability 设为 async 减少 fsync 频率,但代价是节点崩溃时可能丢失最近几秒的数据。在数据可重放的场景(如从 Kafka 消费写入)中可以接受。

练习

  1. 创建一个 refresh_interval: -1 的 index,写入 5 条文档,验证搜索不到。然后手动 _refresh,验证搜索可以返回结果。

  2. 写入文档后,用 _segments API 观察 segment 变化。对比 refresh 前后和 flush 前后 segment 的 committed 状态。

  3. index.translog.durability 设为 async,用批量写入测试吞吐量差异。注意:这个实验要在测试环境进行,不要在生产数据上操作。

系列导航

上一篇 下一篇
Analysis 管道:从原始文本到可搜索词项 搜索执行模型:Query-Then-Fetch 的两阶段流程

参考资料