2825 字
约 9 分钟
20
S13-A08 · ES 索引建模:分片、字段与写入成本

索引建好那一刻,成本结构基本就定了。主分片数改不了,字段类型改不了,副本数一动写入放大立刻变化。所以"建好之后不敢改 mapping"不是心理问题,是这三个约束叠加出来的真实后果。建模时真正需要的不是一份最佳实践清单,而是判据:这一步的代价落在谁身上,失效时从哪里开始崩。


1. mapping 变更只有三种可能,代价差一个量级

变更 能否在线 代价结构
加字段 可以 旧文档该字段为空;新字段的 doc_values 要等新段才生效
改字段类型 不行 新建索引 → reindex → 别名切换 → 留回滚窗口
改分析器 只对新数据生效 新旧文档分词不同,同一查询命中率分裂,要统一只能重建

第一条的"可以"有前提:字段可空,且下游代码不假设"每条文档都有它"。改分析器最容易误判——PUT _mapping 会成功返回,你以为改完了,其实只对之后写入的文档生效。

PUT /orders_v1/_mapping
{
  "properties": {
    "channel": { "type": "keyword", "doc_values": true }
  }
}

2. 分片数:三个方向都要付钱,只有一个方向省钱

影响面 分片偏多的后果 分片偏少的后果
单分片固定开销 每分片一套段元数据、词典、文件句柄,堆占用线性上升 单分片过大,段合并与故障恢复时间拉长
查询扇出 一次搜索打全部主副分片,协调节点合并成本上升 扇出小,但分片内竞争变重
再平衡代价 节点增减时迁移单元多,恢复带宽被摊薄 单元少,单次迁移却更久

单分片多大算合适:按 8.x 官方口径,10–50GB 是常见区间;日志类可以更大,因为查询几乎总带时间范围。这不是硬阈值,取决于你能忍多长的段合并与恢复时间。

按数据量估还是按增长估:按增长估,但不按"三年后"估。分片数是为未来 12–24 个月设的;更远的量级变化交给 rollover + 别名,而不是一次开 60 个分片然后长期空着。

主分片数 = ceil(预计18个月索引总大小GB ÷ 单分片目标GB)
curl -XPUT 'http://HOST:9200/orders_v1' -H 'Content-Type: application/json' -d '
{ "settings": { "number_of_shards": 6, "number_of_replicas": 1,
                "refresh_interval": "5s" } }'

3. 主分片数改不了,所以 reindex 是必修课

机制层面:分片数写在索引元数据里,文档归属由 hash(_routing) % 主分片数 决定。改数字等于改路由,所有文档位置都得重算,所以只能新建索引再灌数据。

reindex 的真实成本不是"跑一次任务",是四件事叠加:

POST _reindex?wait_for_completion=false&slices=auto&requests_per_second=2000
{
  "source": { "index": "orders_v1" },
  "dest":   { "index": "orders_v2" }
}
  • 双份磁盘:新旧索引同时在线,峰值占用接近两倍;
  • 双份 IO:读旧写新,集群 indexing 压力翻倍;
  • 副本重建:新索引的副本要重新同步一遍;
  • 切换窗口:reindex 期间的增量写入靠双写或追平脚本补,补不齐就是"切完少了半小时数据"。

判断:这张索引如果有持续写入,reindex 前先解决增量同步。重建本身不难,难的是切换那一刻的数据缺口。

4. 副本数就是写入放大的乘数

副本数 一次写入落到几个分片 可用性 写入开销
0 1(仅主分片) 节点挂即丢数据 最低,不能用于生产
1 2 扛单节点故障 默认值,写入放大约两倍
2 3 扛双节点故障 CPU 与磁盘约三倍,refresh 和 merge 同步放大

副本的代价不只是"多存一份字节":每一份都要走分析、建段、refresh、写 translog。所以"副本调 2 更安全"在小集群上常常等于把写入瓶颈提前引爆。

5. text 与 keyword 是两种索引,不是两种类型

结构 用途 陷阱
text 全文检索,分词后建倒排 不能直接排序聚合;fielddata 开在堆上会炸
keyword 精确匹配、排序、聚合、doc_values 不分词,长文本当 keyword 会撑爆词典
双字段 同一原文供两种用途 索引体积增加,典型量级两成以上,只给真要检索的字段开
"title": {
  "type": "text",
  "analyzer": "ik_max_word",
  "fields": { "raw": { "type": "keyword", "ignore_above": 256 } }
}

doc_values 是列式存储,给排序聚合用,默认对非 text 字段开启。纯展示、从不排序聚合的字段可以关掉换空间——但关了之后开不回来,只能重建。

nested 解决的是"数组内字段相关性":不加它,{"a":1,"b":2} 和 {"a":2,"b":1} 会被当成同一个匹配。代价是每个 nested 文档独立存储、查询要做 join、更新父文档等于重写全部子文档。

join 让父子文档落在同一分片,限制是父子必须用相同 routing,写入时不能图省事随机路由。用它替代应用层 join 通常不划算,除非父子天然同生命周期。

6. 写入路径:写完立刻要查,是要付钱的

阶段 做什么 可调项
indexing buffer 文档进内存 buffer,同时写 translog indices.memory.index_buffer_size
refresh buffer 转成新 segment,变得可搜索 refresh_interval,默认 1s
flush 落盘并清空 translog translog 大小与时长阈值
PUT /orders_v1/_settings
{ "index": { "refresh_interval": "30s", "translog.durability": "async",
             "translog.sync_interval": "5s" } }

取舍:refresh_interval 从 1s 调到 30s,写入吞吐上升、可见性延迟 30s;批量导入期设 -1 再手动 _refresh 是常规做法。单条写后要立刻读,用 refresh=wait_for,别用 refresh=true——后者让所有分片强制生成新段,小批高并发时会把段数打爆。

translog.durability: async 拿吞吐换的是"进程崩溃时最近几秒的写入"。这不是一致性问题,是耐久性边界,得写进 SLA 讨论里。

7. 字段爆炸:动态 mapping 是最贵的默认值

日志类数据最容易踩:每个请求带一个不同的 JSON key,一年后 mapping 里几万个字段,集群状态膨胀,每次 mapping 更新都要广播到所有节点。

PUT /logs_v1
{ "settings": { "index.mapping.total_fields.limit": 2000 },
  "mappings": { "dynamic": "strict" } }

dynamic: strict 让未声明字段直接写入失败——在日志场景这是好事,逼你把可变部分塞进 flattened 或一个 keyword 的 map 字段。total_fields.limit 默认 1000,调高只是把爆炸时间往后推。

8. 容量估算口径:四个乘数,别只算原始数据

容量估错通常不是算错,是口径漏项。四个乘数缺一不可:

磁盘 ≈ 原始数据 × (1 + 索引开销倍率) × (1 + 副本数) × 增长系数
口径项 典型量级 漏掉之后的症状
原始数据 按业务日志/表体积实测 起点就错
索引开销倍率 常见 1.1–2.0,全文索引偏上限 磁盘提前告警
副本数 默认 1,即总占用翻倍 以为是估算误差,实际是结构性翻倍
增长系数 按 12 个月增长率,不按线性外推 半年后触发紧急扩容

冷热分层解决的是"老数据不该占热节点资源",不是"省磁盘"。热节点给 SSD 与高堆,温节点给大容量盘,超过保留期的索引直接删除或进 snapshot。ILM 的价值在把删除变成策略:min_age + delete 阶段写进策略后,没人需要记得清日志。

PUT _ilm/policy/logs_30d
{ "policy": { "phases": {
  "hot":  { "actions": { "rollover": { "max_primary_shard_size": "30gb" } } },
  "warm": { "min_age": "7d", "actions": { "forcemerge": { "max_num_segments": 1 } } },
  "delete": { "min_age": "30d", "actions": { "delete": {} } } } } }

判断:如果一张索引的写入与查询都集中在最近 7 天,ILM 是必需品;如果数据量小且保留期无限,ILM 只是多一层要维护的状态机。

9. 查询侧反推建模,别名负责切换

建模顺序应该反过来:先列查询形态(过滤字段、排序字段、聚合字段),再定字段类型。按查询设计字段,比按表结构翻译字段更接近真实成本。

POST /_aliases
{ "actions": [
  { "remove": { "index": "orders_v1", "alias": "orders" } },
  { "add":    { "index": "orders_v2", "alias": "orders" } } ] }

别名切换是原子的,但"零停机"只在读侧成立:写侧要靠双写或追平。别名不加版本号,版本号只出现在物理索引名上。

10. 排错

现象 根因 处理方向
集群长期 yellow 副本无处分配 节点数是否 ≥ 副本数+1;查 allocation 约束
写入变慢、refresh 耗时上升 段数过多、refresh 过频 调 refresh_interval,看 _segments 的段数量级
circuit_breaking_exception 聚合或排序吃堆 缩小 size、加过滤、避开深分页
查询结果新旧混杂 改过分析器但没 reindex 新旧文档分词不同,统一只能重建
mapping 更新被拒 同名字段类型不同 不能改,只能新索引
reindex 卡住 源索引热、requests_per_second 太高 降速;slices 并行要留 IO 余量

11. 边界

场景 判断
数据量在单分片以内(几 GB) 1 分片 1 副本,别做容量推演
查询形态单一(只按 ID 取) 不需要建模,KV 更合适
写入量远大于查询 优先保写入:refresh 拉长、副本 1、字段精简
每天全量重建的分析型索引 rollover + 别名,不纠结分片数
需要事务性更新 不要把 ES 当主存储

ES 的强项是"检索 + 聚合",不是"随机更新 + 强一致"。把主数据放进去,再用一致性去补,代价会比一开始就分开高得多。

12. 自测

  1. 加字段、改类型、改分析器,哪一个会返回成功但实际没生效?
  2. 分片数偏多时,哪一项开销最先在监控上出现?
  3. 为什么"按三年后估分片数"通常是个坏主意?
  4. reindex 的成本里,最容易被漏算的是哪一项?
  5. refresh=wait_for 和 refresh=true 的差别,落在段数上是什么后果?
  6. 什么情况下 dynamic: strict 比 dynamic: true 更省事?
S13-A08 · ES 索引建模:分片、字段与写入成本
https://www.clxhxhhr.top/posts/6156/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。