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. 自测
- 一次写入返回 200 但
_search查不到,第一个该看的设置是什么? refresh=true与refresh=wait_for在段的生成上有什么本质差别?translog.durability设成async时,掉电最坏会丢什么?- flush 与 refresh 各自的产物是什么?为什么 translog 要在 flush 时清空?
- 写入放大来自 refresh 还是 merge?为什么"少产生小段"更有效?
_bulk返回 200 时,为什么还可能有一半文档没写进去?