一次 mapping 变更(把一个字段从 text 改成 keyword)直接改了索引,结果旧数据里该字段的历史值与新写入的格式不一致,查询在这个字段上同时出现"能搜到"和"搜不到"两种结果,持续了三个月才通过全量重建修好。
关系库改表结构有 DDL,搜索索引没有"就地改"—mapping 中的字段类型一旦有数据写入就不可变。所以变更的唯一正确路径是:建新索引、写两处、切别名、删旧索引。
1. 别名是这套流程的枢纽
| 对象 | 作用 |
|---|---|
物理索引(orders_v1) |
真正存数据、带 mapping |
别名(orders) |
指向物理索引,应用只认别名 |
| 写入别名 | 只能指向一个索引 |
| 读别名 | 可指向多个索引 |
别名还能承担"按时间切分"的职责:logs-2026.11 这样的物理索引按天/月创建,logs 别名指向当前可写的那个,查询别名指向最近 N 个。这类"滚动索引"把"重建"变成了"新建一个",成本从数小时降到分钟级。
关键规则:应用永远只使用别名。一旦应用里写死了物理索引名,"零停机重建"就做不到了——因为切换必须改代码。
2. 零停机重建的五步
| 步骤 | 操作 | 风险 |
|---|---|---|
| 1 建新索引 | 用新 mapping 建 orders_v2 |
无 |
| 2 双写 | 同时写 orders_v1 与 orders_v2 |
一致性处理 |
| 3 回填 | 把旧数据从源库或 v1 重新写入 v2 | 耗时长、影响集群 |
| 4 切读别名 | 把 orders 指向 v2 |
需验证 |
| 5 清理 | 停写 v1,确认无引用后删除 | 需观察期 |
回填期间还有一个隐患:双写与回填会竞争同一批文档。同一文档先被回填写入旧版本、再被双写写入新版本,最终值取决于顺序。可靠做法是让回填只写"双写开始之前的历史数据",时间边界写进任务参数。
第三步是最耗时的:回填速度受集群写入能力限制,全量重灌一个亿级索引通常要数小时到一天。这期间双写仍在进行,所以回填必须能处理"同一文档被写两次"(按 ID upsert,天然幂等)。
// 用别名切换:原子操作,无需停写
curl -XPOST "localhost:9200/_aliases" -H 'Content-Type: application/json' -d '{
"actions": [
{ "remove": { "index": "orders_v1", "alias": "orders" } },
{ "add": { "index": "orders_v2", "alias": "orders" } }
]
}'
// 两个动作在同一个请求里,是原子的:不会出现别名短暂不存在的窗口
3. 回填的三种方式
| 方式 | 速度 | 影响 |
|---|---|---|
| 从源库读再写 | 中 | 需要源库支持批量读 |
从旧索引读再写(_reindex) |
快 | 占用集群 IO 与 CPU |
| 快照恢复后重建 | 最快 | 需要快照与新版 mapping 兼容 |
限速不只是"给个速率上限",还要能观察副作用:
// 回填期间持续观察集群压力,而不是等它变红
watch -n 30 'curl -s "localhost:9200/_cluster/health?pretty" | grep -E "status|unassigned"'
curl -s "localhost:9200/_nodes/stats/thread_pool?pretty" | grep -E "write|search" -A3
// write 队列持续非零 → 回填压过了写入能力,应降速
// unassigned 分片出现 → 集群不稳,应暂停回填
第二种最省事,代价是它同时读写的都是同一个集群,回填期间集群负载会明显上升。所以要在低峰做,并限速。
// 限速 reindex:把对集群的影响压在可控范围
curl -XPOST "localhost:9200/_reindex?wait_for_completion=false" -d '{
"source": { "index": "orders_v1", "size": 1000 },
"dest": { "index": "orders_v2", "op_type": "index" },
"conflicts": "proceed"
}'
// 用 _tasks 观察进度;必要时 POST /_tasks/<id>/_cancel 中止
4. 双写的一致性怎么处理
| 策略 | 一致性 | 复杂度 |
|---|---|---|
| 应用层双写(同步) | 强 | 写入延迟上升 |
| 应用层双写(异步) | 最终 | 需处理顺序 |
| 变更数据捕获(CDC) | 最终 | 需管道 |
| 只回填不双写 | 有窗口 | 窗口内新数据只在一侧 |
第四种(只回填不双写)在低频更新的数据上可行,前提是能明确"回填时刻"这个边界:边界之后的新数据只在一侧,需要在切换前补一轮增量。这类补写常常被遗漏,症状是"切换后最近一小时的数据搜不到"。
第三种是大型系统的主流做法:业务只写数据库,由 CDC 同步到索引。这样"索引重建"变成"换一个消费者",而不是改业务代码。
// 三种策略的取舍
同步双写:实现简单,写延迟翻倍,失败要处理"一边成功一边失败"
异步双写:写延迟不变,需保证顺序(按文档 ID 分区)
CDC :业务无感,需维护同步管道与位点管理
5. 切换前的验证清单
| 检查 | 目的 |
|---|---|
| 文档数量对齐 | 回填完整性 |
| 抽样比对若干文档 | 字段是否正确映射 |
| 关键查询结果对比 | 排序与过滤是否等价 |
| 别名指向确认 | 避免切错 |
| 回滚方案可用 | 切回 v1 |
验证还可以做得更具体:挑十条已知"应该排在前面"的查询,看新索引的结果是否仍然合理。这是业务视角的验证,比随机抽样更接近真实。
第三项最容易被跳过:mapping 改了,查询的相关性排序可能整体改变。同一条查询在 v2 上返回的顺序不同是正常的,但必须确认"这个变化是可接受的",而不是上线后才发现。
// 切前对比:同一查询在新旧索引上的结果差异
diff <(curl -s "localhost:9200/orders_v1/_search?q=keyword:foo&size=20" | jq -r '.hits.hits[]._id') \
<(curl -s "localhost:9200/orders_v2/_search?q=keyword:foo&size=20" | jq -r '.hits.hits[]._id')
// 差异应当能被解释(如新增了排序字段),无法解释的差异意味着 mapping 或分析器有问题
6. 排错:五种"重建后不对"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 部分文档搜不到 | 回填未完成就切了 | 对齐文档数后再切 |
| 中文搜不到 | 新索引未配置分析器 | 检查 analyzer 映射 |
| 排序全变了 | 字段类型从 text 改 keyword | 预期内,需业务确认 |
| 写入报错 | 写别名指向了两个索引 | 写入别名只能一个 |
| 磁盘暴涨 | 新旧索引并存 | 按计划删旧索引 |
第四项是别名使用的硬规则:读别名可以指多个索引,写别名只能指一个。把两者弄混,写入会直接失败。
7. 边界:什么情况可以简单改
| 场景 | 为什么 | 做法 |
|---|---|---|
| 索引为空(无数据) | 无不可变约束 | 直接删了重建 |
| 只加字段(新增属性) | mapping 允许新增字段 | 直接改,不用重建 |
| 开发环境的临时索引 | 无数据价值 | 重建即可 |
| 已有快照恢复流程 | 重建成本低 | 用快照 + 新 mapping |
还有一条与团队协作有关的边界:别名切换应当是一次变更,而不是一次操作。它有明确的前置检查、执行人、观察窗口与回滚动作;把它当成"执行一条命令",最容易在切错别名时无人发现。
判断口径一句话:这次变更改了已有字段的类型吗。没改(只新增字段),通常可以就地更新;改了,就必须走重建流程。
8. 自测
- 为什么 mapping 里的字段类型不能就地改?这与关系库的 DDL 差在哪?
- 别名在这套流程里承担什么职责?为什么应用里不能写死物理索引名?
- 零停机重建的五步中,哪一步最耗时?它为什么必须幂等?
- 三种回填方式各自的代价是什么?
_reindex为什么要限速? - 切读别名为什么必须与移除旧别名放在同一个请求里?
- "只新增字段"与"改字段类型"分别对应什么做法?判断依据是什么?