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. 排障清单
- 确认 rejected 是集中在少数节点还是全部节点。
- 检查磁盘水位是否超过 low/high watermark。
- 检查 translog 是否持续增长、flush 是否频繁。
- 检查 refresh_interval 是否过短、segment 是否过多。
- 检查 bulk 批大小是否过大、客户端是否快速重试。
- 检查副本数是否过高导致同步压力大。
- 检查 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 机制