Java 后端 · 每日精进

爱上网的小徐

把技术讲清楚,每天一篇 Java 后端

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

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)层面的处理,可以粗略拆成三段:

  1. 写入内存 buffer:文档先进入 Lucene 的 IndexWriter,暂存在内存中的 DocumentsWriterPerThread(DWPT)里。此时文档不可被搜索。
  2. 追加 translog:同时把这次操作以追加方式写入 translog 文件(默认每个分片一个),用于宕机恢复。
  3. 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 影响写入放大,副本越多,主分片要同步的节点越多,写入延迟越高。

五、什么时候别用 / 别踩的坑

  1. 别在写入路径里逐条 refresh。这是把 NRT 退化成"同步写"的最快方式,段数量会爆炸,merge 会吃掉大部分 CPU。
  2. 别把 refresh_interval: -1 当成万能药。关闭 refresh 后必须自己负责在合适时机触发,否则数据长时间不可搜索,业务侧会以为"写丢了"。
  3. 别混淆 refresh 与 flush。refresh 不保证落盘,flush 才保证;宕机恢复依赖的是 translog + 最后一次 commit,不是 refresh。
  4. 别在 async 模式下假设"零丢失"。崩溃时最近一个 sync_interval 窗口内的数据可能丢失,这在金融、订单等场景往往不可接受。
  5. 别忽视 translog 的磁盘占用。写入量大而 flush 触发不及时,translog 会持续增长;配合监控 index.translog.size_in_bytes 观察。
  6. 别用 _refresh 作为对账手段。对账应基于可重放的数据源(如数据库 binlog),而不是依赖 ES 的可见性时序。

小结与预告

refresh 与 translog 分别承担了"可见性"和"持久性"两个正交目标,ES 把它们拆开,才换来了高写入吞吐。调优的本质不是找一组"最优参数",而是明确业务对可见延迟和可容忍丢失量的边界,再倒推配置。

后续这个系列会继续往写入链路的更深处走:Lucene 的 segment merge 策略、IndexWriter 的 flush 触发条件、以及分片路由对写入热点的影响。如果你正在被"写入慢但不知道慢在哪"困扰,欢迎关注,我们把这条链路一段段拆开。

posted @ 2026-10-16 10:00  爱上网的小徐  阅读(8)  评论(0)    收藏  举报
爱上网的小徐
把技术讲清楚 · 每天一篇 Java 后端
© 2026 爱上网的小徐 · 用心记录每一行代码