索引建好那一刻,成本结构基本就定了。主分片数改不了,字段类型改不了,副本数一动写入放大立刻变化。所以"建好之后不敢改 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. 自测
- 加字段、改类型、改分析器,哪一个会返回成功但实际没生效?
- 分片数偏多时,哪一项开销最先在监控上出现?
- 为什么"按三年后估分片数"通常是个坏主意?
- reindex 的成本里,最容易被漏算的是哪一项?
refresh=wait_for和refresh=true的差别,落在段数上是什么后果?- 什么情况下
dynamic: strict比dynamic: true更省事?