Elasticsearch 倒排索引与写入性能:refresh 与 translog 的取舍

Elasticsearch 倒排索引与写入性能:refresh 与 translog 的取舍
关键词标签:Elasticsearch、倒排索引、translog、refresh、近实时搜索、Java 后端
一个反直觉的事实
很多人第一次接触 Elasticsearch 时会默认一个假设:写入成功后立刻就能搜到。但真实情况是——index 请求返回 201 Created 时,这条文档大概率还搜不到。这不是 bug,而是 Lucene 段(segment)模型下的必然结果:写入走的是内存 buffer + translog,只有经过 refresh 生成新的 segment 并打开 searcher 之后,文档才变得"可见"。
这个设计不是妥协,而是一次取舍:牺牲"写后立即可见",换来写入吞吐与持久化的解耦。理解 refresh 与 translog 的分工,是理解 ES 写入性能的第一道门槛,也是很多"写入慢、数据丢失、磁盘爆掉"问题的根源。
本文假设有一个 order-service(仅为讲解示例,非真实系统),通过 Java 客户端持续写入订单文档。我们从这个场景出发,把 refresh 和 translog 拆开讲清楚。
一、写入链路:从 index 请求到可被搜索
1.1 三段式结构:buffer、translog、segment
一次 index 请求在分片(shard)层面的处理,可以粗略拆成三段:
- 写入内存 buffer:文档先进入 Lucene 的
IndexWriter,暂存在内存中的DocumentsWriterPerThread(DWPT)里。此时文档不可被搜索。 - 追加 translog:同时把这次操作以追加方式写入 translog 文件(默认每个分片一个),用于宕机恢复。
- refresh 生成 segment:满足触发条件后,buffer 中的文档被 flush 成一个新的 Lucene segment,并打开新的
DirectoryReader,文档才变得可搜索。
这里有个容易混淆的点:refresh ≠ flush。refresh 只是让数据"可见",不保证落盘到 segment 文件;flush 才会真正 fsync segment 文件并清空 translog。ES 官方文档对此有明确区分,见 Near real-time search 与 Translog。
1.2 为什么不能每条都 refresh
如果每写一条就 refresh 一次,意味着每个文档生成一个 segment。Lucene 的搜索是按 segment 串行合并结果的,segment 越多:
- 搜索时要遍历的段越多,查询延迟上升;
- 后台 merge 压力增大,CPU 和 IO 被 merge 抢占;
- 文件句柄消耗上升。
所以 ES 默认 refresh_interval: 1s,即"最多 1 秒后可见",这就是"NRT(近实时)"这个词的来源。它不是一个精确的延迟承诺,而是上限。
二、refresh 的真实源码路径
2.1 触发方式与调度
refresh 的触发有三条路径:
- 定时触发:由
IndexService内部的RefreshTask(一个Cancellable任务)按refresh_interval周期调用maybeRefresh。 - 显式触发:客户端调用
POST /index/_refresh,对应RefreshRequest。 - 间接触发:写入压力大时,Lucene 的
IndexWriter会因内存 buffer 达到阈值(indices.memory.index_buffer_size,默认是 JVM 堆的 10%)而自动 flush 一个 segment,这也会让数据可见。
在 Java 客户端里显式 refresh 的写法大致是:
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.elasticsearch.indices.RefreshRequest;
public class OrderIndexer {
private final ElasticsearchClient client;
public OrderIndexer(ElasticsearchClient client) {
this.client = client;
}
public void indexOrder(Order order) throws Exception {
IndexResponse resp = client.index(i -> i
.index("orders")
.id(order.id())
.document(order)
// 不在这里 refresh,交给定时策略
);
// resp.result() 为 Created / Updated,此时文档通常还不可搜索
}
/**
* 仅在测试或对账等强可见场景下使用,生产写入路径慎用。
*/
public void forceRefresh(String index) throws Exception {
client.indices().refresh(RefreshRequest.of(r -> r.index(index)));
}
}
注意 client.indices().refresh(...) 是对索引级别发起的,属于比较重的操作,不应放在每条写入之后。
2.2 refresh 到底做了什么
refresh 的核心动作在 Lucene 里是 DirectoryReader.openIfChanged:如果 IndexWriter 产生了新的 segment,就打开一个新的 reader,把新段纳入搜索视图。ES 侧则通过 SearcherManager 管理这个 reader 的切换,并保证旧 reader 在正在进行的查询结束后再释放。
这也解释了一个现象:refresh 不会让旧数据消失,只是让新 reader 看到了更多段。搜索请求在切换瞬间可能命中旧 reader,这是正常的、被设计允许的。
三、translog:持久化的最后一道防线
3.1 translog 的两种 fsync 策略
translog 是 ES 保证"写入不丢"的关键。它的持久化策略由 index.translog.durability 控制:
| 取值 | 行为 | 适用场景 |
|---|---|---|
request(默认) | 每个 index/bulk/delete 请求后都 fsync translog | 对数据可靠性要求高的写入 |
async | 后台按 index.translog.sync_interval(默认 5s)周期 fsync | 允许少量丢失、追求吞吐 |
这是写入性能与持久化之间最直接的旋钮。request 模式下每次写都要等一次 fsync,单条写入的延迟会被磁盘 fsync 时间主导;async 模式把 fsync 批量化,吞吐会明显改善,代价是崩溃时可能丢失最近若干秒的数据。
3.2 translog 的清理时机
translog 不是无限增长的。它的清理发生在 flush 时:当 Lucene 把内存中的 segment 真正 fsync 到磁盘并提交后,translog 中对应的那部分就可以被截断。触发 flush 的典型条件包括:
- translog 大小超过
index.translog.flush_threshold_size(默认 512MB); - 定时 flush(
index.translog.flush_threshold_period,默认 30 分钟); - 显式调用
POST /index/_flush。
如果写入量很大但 flush 一直不触发,translog 会持续膨胀,最终可能撑爆磁盘——这是生产中比较常见的"磁盘莫名被吃满"的原因之一。
3.3 恢复流程中的 translog 重放
节点重启时,ES 会从最后一个 Lucene commit 点开始,重放 translog 中未提交的操作,把数据恢复到崩溃前的状态。这也是为什么 async 模式会丢数据:最后一段没 fsync 的 translog 在崩溃后无法重放。
四、把旋钮调对:一份可落地的配置思路
下面是一份示意性的索引配置(数值仅作说明,非实测结论):
import co.elastic.clients.elasticsearch.indices.CreateIndexRequest;
import co.elastic.clients.elasticsearch.indices.IndexSettings;
import co.elastic.clients.elasticsearch.indices.IndexSettingsTranslog;
public class IndexTemplate {
public CreateIndexRequest buildOrdersIndex() {
return CreateIndexRequest.of(c -> c
.index("orders")
.settings(IndexSettings.of(s -> s
// 近实时可见延迟:写入密集时可放宽到 30s
.refreshInterval(t -> t.time("30s"))
// 可靠性优先:默认 request;若允许少量丢失可改 async
.translog(tl -> tl
.durability(IndexSettingsTranslog.Durability.Request)
.syncInterval(t -> t.time("5s"))
.flushThresholdSize(t -> t.time("512mb")))
// 控制单次 refresh 的段数量,避免小段过多
.numberOfShards("3")
.numberOfReplicas("1")))
.mappings(m -> m
.properties("orderId", p -> p.keyword(k -> k))
.properties("amount", p -> p.double_(d -> d))
.properties("createdAt", p -> p.date(d -> d))));
}
}
几点经验性判断(非绝对规则):
- 批量导入场景:把
refresh_interval设为-1(关闭定时 refresh),导入完成后手动 refresh 一次,可以减少中间态的段数量。 - 对账 / 单测:用显式 refresh 保证可见性,不要靠 sleep 等待 1 秒。
- 高可靠写入:保持
durability: request;如果吞吐成为瓶颈,再评估async能接受多少数据丢失窗口。 - 副本数:
numberOfReplicas影响写入放大,副本越多,主分片要同步的节点越多,写入延迟越高。
五、什么时候别用 / 别踩的坑
- 别在写入路径里逐条 refresh。这是把 NRT 退化成"同步写"的最快方式,段数量会爆炸,merge 会吃掉大部分 CPU。
- 别把
refresh_interval: -1当成万能药。关闭 refresh 后必须自己负责在合适时机触发,否则数据长时间不可搜索,业务侧会以为"写丢了"。 - 别混淆 refresh 与 flush。refresh 不保证落盘,flush 才保证;宕机恢复依赖的是 translog + 最后一次 commit,不是 refresh。
- 别在
async模式下假设"零丢失"。崩溃时最近一个sync_interval窗口内的数据可能丢失,这在金融、订单等场景往往不可接受。 - 别忽视 translog 的磁盘占用。写入量大而 flush 触发不及时,translog 会持续增长;配合监控
index.translog.size_in_bytes观察。 - 别用
_refresh作为对账手段。对账应基于可重放的数据源(如数据库 binlog),而不是依赖 ES 的可见性时序。
小结与预告
refresh 与 translog 分别承担了"可见性"和"持久性"两个正交目标,ES 把它们拆开,才换来了高写入吞吐。调优的本质不是找一组"最优参数",而是明确业务对可见延迟和可容忍丢失量的边界,再倒推配置。
后续这个系列会继续往写入链路的更深处走:Lucene 的 segment merge 策略、IndexWriter 的 flush 触发条件、以及分片路由对写入热点的影响。如果你正在被"写入慢但不知道慢在哪"困扰,欢迎关注,我们把这条链路一段段拆开。
浙公网安备 33010602011771号