2600 字
约 8 分钟
13
S13-J04 · 写入链路:refresh、translog 与持久化的取舍

S13-J04 · 写入链路:refresh、translog 与持久化的取舍

一条 PUT /orders/_doc/1 返回 200,不代表能被搜到,也不代表已经安全。前者卡在 refresh,后者卡在 translog——两个完全不同的机制被同一句"写入成功"糊在一起,线上大多数"写进去查不到"与"重启后少几百条"都从这里开始。按"等待点 → 可见性 → 持久性 → 闸门 → 放大代价"五层看,参数就不玄了。


1. 一次索引请求走完的路径

步骤 发生在哪 做了什么 客户端在等什么
1 路由 协调节点 按 hash(_id) % 主分片数 定分片,转发到主分片节点 等下游应答
2 写主分片 主分片 校验 mapping、写 buffer + translog,按 durability 决定是否 fsync 等 translog 落盘
3 同步副本 主分片 → 副本 并发转发操作,等副本写 translog 等 wait_for_active_shards 个副本确认
4 应答 协调节点 汇总结果,返回 _shard 明细 收 200

第 2、3 步都是同步等待,所以写入延迟的组成是:

写入延迟 ≈ 主分片 translog fsync + 最慢那个副本的 fsync + 网络往返 + refresh 排队(仅 wait_for 时)

_shard 里出现 "failed": 1 说明副本写失败而主分片成功,整体仍返回 200——被多数客户端忽略的降级信号:

{ "_index": "orders", "_id": "1", "result": "created",
  "_shards": { "total": 2, "successful": 1, "failed": 1 } }

2. refresh:可见性的三个开关

文档写进 buffer 后对搜索不可见,refresh 把它变成可搜的 Lucene 段并重开 searcher。

触发方式 写法 代价 适用
间隔刷新 "refresh_interval": "1s"(默认) 每分片每秒产生小段,合并压力上升 绝大多数业务索引
立即刷新 ?refresh=true 每次写都造一个段 极少数写完即读场景
等待刷新 ?refresh=wait_for&timeout=30s 并入下一个自然周期,不额外造段 写后读的推荐做法
# 推荐:读自己写,不制造额外段
curl -XPOST 'localhost:9200/orders/_doc/1?refresh=wait_for&timeout=30s' \
     -H 'Content-Type: application/json' -d '{"sku":"A-1","qty":2}'

# 不推荐:批量循环里带 refresh=true
for id in $(seq 1 1000); do
  curl -XPUT "localhost:9200/orders/_doc/$id?refresh=true" -H 'Content-Type: application/json' -d '{"sku":"A"}'
done

第二种写法在几千文档量级上就会留下大量微小段,随后长期被 merge 拖住。批量导入的正确姿势是先设 refresh_interval: -1,导完改回:

PUT /orders/_settings
{ "index": { "refresh_interval": "-1" } }

-1 意思是"永不自动刷新",代价明确:导入期间任何 _search 都看不到新数据,直到手动 POST /orders/_refresh 或改回间隔。忘了改回来是最常见的生产事故——写入全部成功、搜索停在旧数据,手动 refresh 后"突然恢复"。


3. translog:未成段数据的唯一来源

refresh 之后文档才可搜,但 refresh 并不落盘——真正的持久化靠 translog(预写日志,顺序追加)。

index.translog.durability fsync 时机 掉电后果 吞吐
request(默认) 每请求结束 fsync 已返回 200 的不丢 受磁盘限制
async 每 sync_interval(5s)批量 fsync 可能丢最后一个窗口 明显更高
PUT /orders/_settings
{ "index": {
    "translog.durability": "async",
    "translog.sync_interval": "5s",
    "translog.flush_threshold_size": "1gb" } }

flush_threshold_size 控制日志涨到多大触发 flush,与 durability 是两件事:一个决定何时清,一个决定每次写是否立刻落盘。

该用哪个不取决于"要不要性能",而取决于"丢最后 N 秒数据由谁承担"。订单、审计用 request;可从上游全量重建的检索副本用 async 完全合理。把高价值数据配 async,等于把可靠性押在"恰好别掉电"上。


4. flush 与 commit point

概念 做什么 谁触发
refresh 生成可搜段、打开 searcher 间隔 / 手动 / ?refresh
flush 段落盘、清 translog、写 commit point 阈值、周期、POST /_flush
merge 小段并成大段 后台合并策略
commit point 标记哪些段属于本次提交 每次 Lucene commit

一条文档生命周期是:buffer → refresh 成段 → translog 持续追加 → flush 时段 fsync 并清 translog → merge 重写。掉电恢复则从最后一个 commit point 加载已提交段,再重放其后的 translog 操作。

curl -XPOST 'localhost:9200/orders/_flush?wait_if_ongoing=true'
curl 'localhost:9200/orders/_stats/translog?pretty'   # translog 大小与操作数
curl 'localhost:9200/_cat/nodes?v&h=name,disk.avail,heap.percent'

5. 段合并:写入放大真正发生的地方

写入放大的来源不是 refresh 而是 merge——每次合并都要读出源段、重写、再落盘一次。小段越多,merge 次数越多。

index.merge.policy 相关项 作用 调大/调小的后果
segments_per_tier(10) 每层最多几段 调小:合并更勤
max_merge_at_once 一次最多合并几段 调大:单次更重、段更少
max_merged_segment(5gb) 此大小以上不再合并 调大:单段更大、恢复慢
expunge_deletes_allowed(10) 删除比例阈值 调低:更早回收删除
PUT /orders/_settings
{ "index.merge.policy": { "segments_per_tier": 10,
    "max_merge_at_once": 10, "max_merged_segment": "5gb" } }

合并策略属于"知道自己在做什么才动"的参数。更有效的是从源头少产生小段:批量写别加 refresh=true、导入期 refresh_interval: -1、用 bulk 而非逐条写。观察 _nodes/stats/indices/merges 的 total_time_in_millis 与 current。只读历史索引写完做一次 POST /index/_forcemerge?max_num_segments=1 划算——仍在写入的索引不要做,期间要吃满 IO 与临时磁盘。


6. 两道写入闸门

6.1 wait_for_active_shards

PUT /orders/_settings
{ "index": { "write.wait_for_active_shards": 1 } }
取值 含义 何时卡住
1 只要主分片活跃 主分片未分配
all(默认语义按版本核对) 主分片 + 全部副本 副本迁移 / 故障中
quorum 多数分片活跃,避免脑裂写 分片数少时阈值低

副本恢复期间 wait_for_active_shards: all 会让写入变慢甚至抛 UnavailableShardsException。滚动重启的常见做法是临时放宽到 1,完成后改回。

6.2 线程池队列满:es_rejected_execution_exception

[es_rejected_execution_exception] rejected execution of ... on
EsThreadPoolExecutor [name=write, queue capacity = 10000]
线程池 默认队列(按版本核对) 应对
write 10000 降并发、加大每批条数而非批数
search 1000 优化慢查询、加副本分担

队列长度是节点级设置,不该为"消掉异常"去加——请求在内存里堆得越久,换来的是更长延迟而非更高吞吐。


7. bulk 的参数与幂等语义

curl -XPOST 'localhost:9200/_bulk?pipeline=order-enrich' \
  -H 'Content-Type: application/x-ndjson' --data-binary @orders.ndjson
参数 经验区间 说明
单批字节数 5–15 MB(典型量级) 按单节点堆与文档大小调整
单批条数 数千条 过大易触发单节点 RequestTimeoutException
客户端并发 1–4 个并发 bulk 并发比批量大小更容易打爆线程池
?op_type=create 仅新增语义 冲突返回 version_conflict_engine_exception

bulk 是部分成功的:整体 200,但单条可能带 "status": 429 或 "error",必须逐条解析 items:

resp = client.bulk(operations=ops)
retry = [i["index"]["_id"] for i in resp["items"]
         if i["index"]["status"] == 429]

处理原则:create 语义下 version_conflict_engine_exception 是正常的并发结果,不该无脑重试;该重试的是 429 与连接类错误,配指数退避。把冲突也重试,会掩盖上游重复投递。


8. 副本数与延迟

副本越多,写入越受"最慢副本"牵制:主分片要等所有应确认副本完成才应答。

PUT /orders/_settings
{ "index": { "number_of_replicas": 1 } }
副本数 读吞吐 写延迟 故障容忍
0 低 最低 无
1 约翻倍 多一次副本往返 丢 1 节点
2 更高 受最慢副本牵制 丢 2 节点

写多读少的索引没必要上 2 副本;跨机房的副本往返会直接放大写入 P99。


9. 排错:写入链路的典型现象

现象 原因 定位 处理
写完立刻搜不到 未到 refresh 周期 _stats/refresh 用 wait_for
永远搜不到新数据 refresh_interval: -1 未改回 _settings 恢复 1s
重启后少一批文档 async 掉了最后窗口 配置 + 上游日志 改回 request
es_rejected_execution write 队列满 _cat/threads?v 降并发
UnavailableShards 活跃分片不足 _cluster/health?level=indices 放宽闸门或等恢复
单节点 bulk 超时 批量过大 / 在 merge _cat/threads 拆批、错峰
磁盘被 translog 吃满 flush_threshold_size 过大 _stats/translog 调小

10. 边界:这些选择本质上是产品决策

需求 该动的 不该动的
写完立刻要查到 refresh=wait_for 全局改成 0 或 1ms
导入要快 导入期 -1 + 大 bulk 关掉 translog
允许丢秒级数据 async + 上游可重建 订单主索引开 async
扛高并发写 客户端限流 + 合适副本 无限加队列
磁盘紧张 清冷索引、适度 max_merged_segment 删 translog

一句话:refresh 决定别人何时看得见,translog 决定掉电后还剩什么,merge 决定你为写入付多少隐藏磁盘 IO。三者都没有"最优值",只有"数据能不能重放"与"用户能不能等"两个答案。


11. 自测

  1. 一次写入返回 200 但 _search 查不到,第一个该看的设置是什么?
  2. refresh=true 与 refresh=wait_for 在段的生成上有什么本质差别?
  3. translog.durability 设成 async 时,掉电最坏会丢什么?
  4. flush 与 refresh 各自的产物是什么?为什么 translog 要在 flush 时清空?
  5. 写入放大来自 refresh 还是 merge?为什么"少产生小段"更有效?
  6. _bulk 返回 200 时,为什么还可能有一半文档没写进去?
S13-J04 · 写入链路:refresh、translog 与持久化的取舍
https://www.clxhxhhr.top/posts/6034/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。