Elasticsearch 写入链路的 refresh、flush 与 translog 边界:为什么 bulk 吞吐会突然塌陷

Elasticsearch 写入链路的 refresh、flush 与 translog 边界:为什么 bulk 吞吐会突然塌陷

1. 先看一个真实场景:bulk 为什么会突然变慢

假设现在有一个订单搜索服务,每天凌晨两点做全量重建,白天接收增量订单。重建时用 bulk 每批 5000 条写入,前十分钟吞吐稳定在每秒 3 万条,突然某个时刻开始变慢,客户端开始收到 es_rejected_execution_exception,重试队列越堆越长,最终整条链路像被掐住一样塌陷。

很多人第一反应是"集群资源不够",于是加节点、加内存,但问题依旧。真正的原因往往不是 CPU 或磁盘被打满这么简单,而是写入链路上几个有明确边界的阶段互相挤压:refresh 把内存里的段刷成可搜索段,flush 把 translog 落盘并触发 Lucene 提交,translog 本身的持久化策略又决定了每次 bulk 到底要付出多少磁盘同步代价。

这篇文章的目标不是让你背参数,而是让你先建立一条可以复述的写入链路,再理解每个阶段什么时候成为瓶颈,最后能根据现象判断该调哪里、不该调哪里。

2. 一句话模型与整体框架

先记住一个最小模型:一条文档从进入 Elasticsearch 到真正安全,需要经过"内存缓冲区 → 可搜索段 → 段提交 → translog 落盘"四道关口,refresh 管的是可搜索性,flush 管的是段提交,translog 管的是崩溃恢复。

把整体拆成三部分:协调节点、分片写入路径、持久化路径。一次 bulk 请求到达协调节点后,会被拆到目标分片所在的数据节点;在数据节点上,文档先写入内存缓冲区和 translog,再由 refresh 变成可搜索段,最后由 flush 触发真正的 Lucene 提交。

text 复制代码
客户端 bulk 请求
      |
      v
协调节点 Coordinating Node
      |  按分片路由
      v
数据节点 Data Node
      |
      +--> 内存缓冲区 Index Buffer --(refresh)--> 可搜索段 Segment
      |
      +--> translog --(flush)--> Lucene commit + 清空 translog
      |
      v
副本分片同步

这张图里,refresh 和 flush 是两条不同的时间线:refresh 高频、轻量,只让数据可被搜到;flush 低频、重,涉及 fsync 和 Lucene commit。bulk 吞吐塌陷,通常发生在这两条时间线在高负载下发生资源竞争时。

3. refresh_interval:让数据多久可见

3.1 它解决什么问题

新写入的文档默认不会立刻被搜索到,因为 Elasticsearch 不是每写一条就更新索引,而是先攒在内存缓冲区里。refresh 的作用是把缓冲区内容生成一个新的可搜索段,让搜索请求能看到这批数据。refresh_interval 就是控制这个动作的频率。

默认值是 1 秒。这意味着写入后最多等 1 秒才能搜到。对搜索业务来说这通常可接受,但对高吞吐写入场景,每秒一次 refresh 会不断产生新段,段越多,后续合并压力越大。

3.2 一个具体例子

假设现在每秒写入 2 万条订单,默认 refresh_interval: 1s,那么每秒会生成一个包含 2 万条的段。一小时后就是 3600 个段,虽然后台 merge 会持续合并,但 CPU 和 IO 会明显被吃掉。如果把这个参数改成 30s,段数量下降到每小时 120 个,写入吞吐通常能明显提升。

json 复制代码
PUT /orders/_settings
{
  "index": {
    "refresh_interval": "30s"
  }
}

这个配置适合批量重建、日志采集这类"写入优先、实时性要求低"的场景。不适合用户下单后立刻要搜到订单的强实时场景。

3.3 边界与常见错误

这里最容易误解的是把 refresh 当成持久化。refresh 只影响可见性,不影响数据是否安全。数据在 refresh 之前已经在 translog 里了,所以即使节点崩溃,也能通过 translog 恢复。另一个常见错误是在批量导入时把 refresh_interval 设为 -1,导入结束后忘记改回来,导致线上数据长时间不可见。

4. translog 持久化:数据到底安不安全

translog 是每次写入都会追加的预写日志,它的作用是:如果进程崩溃但磁盘没坏,重启后可以用 translog 把还没提交到 Lucene 的数据补回来。它解决的是"写入成功但还没形成段就宕机"的安全问题。

translog 有两种持久化策略,由 index.translog.durability 控制。request 是默认值,每个请求都 fsync,安全但慢;async 是定时 fsync,由 index.translog.sync_interval 控制间隔,默认 5 秒,快但可能丢这几秒数据。

策略 每次写入代价 崩溃丢失窗口 适用场景
request 每次 fsync,代价高 几乎为 0 金融、订单等强一致场景
async 定时 fsync,代价低 默认最多 5 秒 日志、埋点、可重放数据
json 复制代码
PUT /logs/_settings
{
  "index": {
    "translog.durability": "async",
    "translog.sync_interval": "5s"
  }
}

这套配置适合日志采集,因为日志本身可重放,丢几秒可以接受。但如果你的业务是支付流水,就不要为了吞吐把它改成 async,否则故障时无法向业务交代。

5. flush 阈值:什么时候真正落盘

flush 和 refresh 不同,它做的是更重的事:把内存中的段提交到 Lucene,生成 commit point,并清空 translog。只有 flush 之后,translog 才不会无限增长。

触发 flush 的条件主要有三类:translog 大小超过 index.translog.flush_threshold_size,默认 512MB;定时 flush;以及显式调用 flush API。当 translog 达到阈值,Elasticsearch 会先执行 refresh,再执行 Lucene commit,然后清空 translog。

text 复制代码
写入持续追加 translog
        |
        v
translog 大小 >= flush_threshold_size
        |
        v
触发 refresh -> Lucene commit -> 清空 translog

关键点是:flush 期间会产生明显的磁盘同步和段提交开销。如果 bulk 写入速率很高,translog 快速逼近阈值,flush 就会频繁触发,和 refresh、merge 抢 IO,最终表现为吞吐突然下降。这就是"bulk 吞吐塌陷"最常见的底层原因之一。

6. bulk 背压:请求是怎么被拒绝的

6.1 背压从哪来

Elasticsearch 不会无限接收写入。每个数据节点上有一个写入线程池 write,队列容量有限。当队列满时,新请求会被拒绝,客户端收到 es_rejected_execution_exception。这不是 bug,而是背压机制在保护节点不被压垮。

一个 bulk 请求包含很多文档,一旦被拒绝,整批都会失败。如果客户端不区分可重试异常,直接快速重试,就会形成重试风暴,让队列更满,吞吐进一步塌陷。

6.2 可复现的 Java 示例:带退避的 bulk 写入

下面这个完整示例用 Java 客户端向本地 Elasticsearch 写入订单数据,包含批量提交、异常识别和指数退避。前置环境是 JDK 17、Maven 依赖 co.elastic.clients:elasticsearch-java,以及本地 9200 端口的 Elasticsearch。

java 复制代码
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class BulkOrderWriter {
    public static void main(String[] args) throws Exception {
        RestClient restClient = RestClient.builder(new HttpHost("localhost", 9200)).build();
        ElasticsearchClient client = new ElasticsearchClient(
                new RestClientTransport(restClient, new JacksonJsonpMapper()));

        List<Map<String, Object>> orders = new ArrayList<>();
        for (int i = 0; i < 5000; i++) {
            orders.add(Map.of("orderId", i, "amount", i * 1.5, "status", "PAID"));
        }

        int attempt = 0;
        while (attempt < 5) {
            try {
                BulkRequest.Builder br = new BulkRequest.Builder();
                for (Map<String, Object> order : orders) {
                    br.operations(op -> op.index(idx -> idx.index("orders").document(order)));
                }
                BulkResponse response = client.bulk(br.build());
                if (response.errors()) {
                    for (BulkResponseItem item : response.items()) {
                        if (item.error() != null) {
                            System.out.println("部分失败: " + item.error().reason());
                        }
                    }
                    break;
                }
                System.out.println("写入成功,条数: " + orders.size());
                break;
            } catch (Exception e) {
                attempt++;
                long backoff = (long) Math.pow(2, attempt) * 200;
                System.out.println("第 " + attempt + " 次重试,等待 " + backoff + "ms,原因: " + e.getMessage());
                Thread.sleep(backoff);
            }
        }
        restClient.close();
    }
}

关键步骤是:构造 bulk 请求、判断 response.errors()、对整批异常做指数退避。预期结果是前几次可能看到重试日志,最终写入成功或明确失败。容易改错的地方是把 response.errors() 当成异常抛出,其实它是部分失败的标志,需要逐条检查。

7. 写入拒绝排查:从现象到根因

当你看到 es_rejected_execution_exception,不要立刻加节点。先按下面的顺序判断:是线程池队列满、还是磁盘水位高、还是 flush 被拖慢。

现象 可能原因 排查动作
write 队列拒绝 写入速率超过节点处理能力 查看 thread_pool.write.queue 与 rejected 指标
磁盘水位告警 节点磁盘超过 low/high watermark 查看 _cat/allocation
flush 频繁 translog 增长过快 查看 _stats 中 translog 与 flush 指标
搜索变慢 refresh 太频繁或段太多 查看 segment 数量与 merge 状态
bash 复制代码
# 查看各节点线程池写入拒绝次数
GET _cat/thread_pool/write?v&h=node_name,active,queue,rejected

# 查看索引的 flush 与 translog 统计
GET /orders/_stats?filter_path=**.translog,**.flush

这两个命令的作用是:先定位是哪个节点在拒绝,再定位是该节点的 translog 还是 flush 出问题。如果 rejected 集中在少数节点,说明分片分布不均;如果所有节点都拒绝,说明整体写入速率超过集群容量。

8. 完整走一遍:一次 bulk 的生命周期

现在让一次 bulk 请求完整走一遍。客户端发出 5000 条文档,协调节点按 _id 路由到不同分片,每个分片所在数据节点把文档写入内存缓冲区和 translog。此时如果 translog.durability=request,每个请求都要 fsync,磁盘同步成为第一道瓶颈。

接着 refresh 定时触发,把缓冲区变成可搜索段。如果 refresh_interval 太短,段生成太快,merge 线程被唤醒,IO 被抢占。然后 translog 增长到 512MB,触发 flush,执行 Lucene commit 并清空 translog,这一步会产生一次较大的 IO 尖峰。

text 复制代码
客户端 --> 协调节点 --> 分片主副本
                    |
                    +--> 内存缓冲区 + translog
                    |         |
                    |         +--(refresh)--> 可搜索段
                    |         |
                    |         +--(flush)--> Lucene commit + 清空 translog
                    |
                    +--> 副本同步 --> 返回响应

如果这三个动作在时间上重叠,节点 IO 和 CPU 会被同时消耗,write 队列开始堆积,最终触发拒绝。这就是 bulk 吞吐"突然塌陷"而不是"线性下降"的原因:它往往在多个边界同时被触碰时发生。

9. 设计取舍:快与安全不能同时拉满

写入链路的核心取舍是:可见性、持久性、吞吐量三者不能同时最优。 降低 refresh 频率提升吞吐,但延迟可见性;把 translog 改成 async 提升吞吐,但增加丢失窗口;调大 flush 阈值减少 flush 频率,但增加恢复时间。

当你是日志采集时,选择 refresh_interval: 30s、translog.durability: async,优先吞吐。当你是订单支付时,选择默认 1s、request,优先安全。当你是批量重建时,选择 refresh_interval: -1、translog.durability: async,并在结束后恢复设置。

这里看起来相同的两个参数,区别在于:refresh 影响搜索可见性,flush 影响持久化提交。很多人以为把 refresh 关掉数据就不安全了,其实不是,数据仍在 translog 里,只是搜不到。

10. 三个完整示例:从最小验证到生产排障

10.1 最小示例:验证 refresh 对可见性的影响

目标是证明默认 1 秒可见,而关闭 refresh 后不可见。前置环境是本地 Elasticsearch,输入是下面的 REST 请求。

json 复制代码
PUT /demo_refresh
{
  "settings": { "refresh_interval": "-1" }
}

POST /demo_refresh/_doc/1
{ "msg": "hello" }

GET /demo_refresh/_search
{ "query": { "match_all": {} } }

预期结果是搜索返回 0 条,因为 refresh 被关闭。执行 POST /demo_refresh/_refresh 后再次搜索才会返回 1 条。容易改错的地方是忘记关闭后恢复 refresh_interval,导致线上数据不可见。

10.2 业务示例:批量导入时的参数组合

目标是批量导入 100 万条订单并观察吞吐。步骤是先关闭 refresh、设置 async translog,再 bulk 导入,最后恢复设置并强制 refresh。

json 复制代码
PUT /orders_bulk/_settings
{
  "index": {
    "refresh_interval": "-1",
    "translog.durability": "async",
    "translog.sync_interval": "30s"
  }
}

POST /_bulk
{ "index": { "_index": "orders_bulk", "_id": "1" } }
{ "orderId": 1, "amount": 100.0 }
{ "index": { "_index": "orders_bulk", "_id": "2" } }
{ "orderId": 2, "amount": 200.0 }

PUT /orders_bulk/_settings
{ "index": { "refresh_interval": "1s" } }
POST /orders_bulk/_refresh

预期结果是导入期间吞吐明显高于默认配置,恢复后数据可搜索。边界是导入期间数据不可见,且 async 可能丢少量数据。

10.3 排障示例:定位写入拒绝

目标是当出现 es_rejected_execution_exception 时快速定位。输入是下面两条命令,步骤是先看线程池拒绝,再看 translog 和 flush 统计。

bash 复制代码
GET _cat/thread_pool/write?v&h=node_name,active,queue,rejected
GET /orders/_stats?filter_path=**.translog,**.flush,**.refresh

预期结果是能看到哪些节点 rejected 高、translog 是否持续增长、flush 次数是否异常频繁。容易改错的地方是只看总量不看节点分布,导致误判为整体容量不足。

11. 常见误区

第一个误区是把 refresh 当持久化。refresh 只让数据可搜索,不负责安全。第二个误区是把 flush 当唯一落盘手段,其实 translog 本身就是持久化日志。第三个误区是看到拒绝就加节点,但如果是分片分布不均或单个 bulk 过大,加节点解决不了问题。

第四个误区是认为 bulk 越大越好。过大的 bulk 会占用更多内存和线程时间,反而更容易触发拒绝。通常建议单批 5MB 到 15MB,根据文档大小调整。第五个误区是忽略副本写入代价,副本分片同步也会消耗写入线程池和网络。

12. 生产实践建议

写入优先场景:refresh_interval 设为 30s 或 -1,translog 用 async,flush 阈值保持默认或适当调大。安全优先场景:保持默认 1s 和 request,不要为了吞吐牺牲一致性。

客户端侧一定要做指数退避和限流,区分可重试异常和不可重试异常。bulk 批大小控制在 5MB 到 15MB,监控 thread_pool.write.rejected、translog 大小、flush 次数和 segment 数量。分片数不要过多,单分片建议 10GB 到 50GB。

13. 排障清单

  1. 确认 rejected 是集中在少数节点还是全部节点。
  2. 检查磁盘水位是否超过 low/high watermark。
  3. 检查 translog 是否持续增长、flush 是否频繁。
  4. 检查 refresh_interval 是否过短、segment 是否过多。
  5. 检查 bulk 批大小是否过大、客户端是否快速重试。
  6. 检查副本数是否过高导致同步压力大。
  7. 检查 merge 线程是否被大量段拖慢。

14. 面试/复盘问题

refresh 和 flush 的区别是什么?translog 在崩溃恢复中扮演什么角色?为什么 bulk 吞吐会突然塌陷而不是缓慢下降?translog.durability=request 和 async 各适合什么场景?写入拒绝时应该先看哪些指标?这些问题能帮助你把写入链路重新串成一张图。

15. 总结

把写入链路收回到一张图:文档进入内存缓冲区和 translog,refresh 决定可见性,flush 决定段提交和 translog 清理,bulk 背压在线程池队列满时拒绝请求。四者共享磁盘和 CPU,任一边界被触碰都可能引发吞吐塌陷。

工程上记住三句话:可见性用 refresh_interval 控,持久性用 translog 策略控,吞吐用批大小和退避控。遇到拒绝先排查节点分布、磁盘水印和 flush 频率,再决定是否扩容。

16. 参考资料

  • Elasticsearch 官方文档:Index modules,refresh_interval 与 translog 设置
  • Elasticsearch 官方文档:Near real-time search 与 Refresh API
  • Elasticsearch 官方文档:Indexing pressure 与 Thread pools
  • Elasticsearch 官方文档:Bulk API
  • Elasticsearch 官方文档:Flush API 与 Translog
  • Lucene 官方文档:IndexWriter 与 commit 机制
相关推荐
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-10-07
人工智能·深度学习·神经网络·搜索引擎·百度
python与大数据分析1 小时前
大模型 or Not?智慧农业落地,大小模型各有所长
大数据·人工智能
珠海西格电力2 小时前
AI预测与优化:基于机器学习的负荷预测与调度策略
大数据·人工智能·机器学习·信息可视化·架构·能源
HZZD_HZZD2 小时前
商场餐饮铺基本电费怎么算?需量控制与峰值申报
大数据·物联网·腾讯云
Elasticsearch2 小时前
AWS 自动化根因分析:从 CloudWatch 告警到完成故障诊断,仅需 36 秒
elasticsearch
wengad2 小时前
嵌入式金融数据上链:挑战、核心问题与解决方案
大数据·金融·区块链·金融创新
starzy19902 小时前
Flink ResourceManager启动流程源码深度剖析:从ClusterEntrypoint到Slot分配的完整链路
java·大数据·flink
Sayai2 小时前
ClickHouse WITH FILL 实战:监控大屏时间序列补零,附多粒度指标表与物化视图级联设计
大数据·运维·数据仓库·clickhouse·物化视图
2603_954708312 小时前
微能网的核心硬件协调控制装置有哪些功能?
大数据·运维·人工智能·架构·能源