重建索引、Alias、双写与 CDC:Schema 怎样无损演进
把商品价格从字符串改成数值后,已有倒排与字段值结构需要重新建立。与此同时,订单、库存和商品编辑仍在产生变化。迁移必须既搬完旧数据,又接住搬运期间的新状态。
旧索引继续提供查询,新索引按新结构构建。确认数据和查询都可用后,入口切到新索引;回滚窗口内,旧索引还要持续更新,或能通过重放追平。
索引结构怎样演进
原地添加与重新建立
Mapping 中已有字段的主要类型通常不能直接修改。新增字段可以更新 Mapping,但历史文档不会因此自动拥有该字段值;给现有 text 新增一个多字段表示,也需要重新索引旧文档才能补齐新表示。支持的变更见 Put Mapping API。
常见处理方式如下:
| 变更 | 主要处理 |
|---|---|
| 新增可选字段 | 更新 Mapping,修改写入器;需要查询历史值时回填 |
| 字符串金额改为数值、object 改为 nested | 新建目标索引并转换文档 |
| 调整索引时 analyzer | 按新 analyzer 重新建立索引结构 |
| 修改查询时分析或排序逻辑 | 验证与已有词项兼容性,再调整查询 |
| 改分片组织、routing 或索引排序 | 选择支持的专用索引操作,或重建到新索引 |
| 仅调整动态设置 | 按该设置允许的更新方式修改,观察生效情况 |
索引名可以用稳定的业务名称加代际,例如 products-g1、products-g2。代际描述索引结构和投影逻辑版本;文档中的 sourceVersion 描述某一商品的业务更新次序,两者不能互相替代。
新索引需要显式配置 Mapping、analyzer、分片、副本、refresh 和容量。_reindex 复制文档,不会替你完整复制源索引设置和模板。迁移期间两个代际同时占用磁盘,回填、merge 和线上查询还会竞争 CPU、内存及 I/O。
先确认是否具备重建所需的原始信息
Reindex 读取源文档的 _source。源索引禁用了 source 时,不能直接靠这条 API 重建;若 source 曾经过裁剪,缺失字段也不会凭空恢复。要求和脚本变换能力见 Reindex Documents API。
重建来源可以是:
- 完整、可用的旧索引 source,适合只改变索引结构或可由旧字段推导的新字段。
- 业务数据库的一致快照,适合补充旧搜索文档没有保存的信息。
- 可完整重放的事件记录,前提是保留范围足够、事件能重建最终状态,且历史 schema 仍能解释。
只保存“库存减一”而没有初始库存、只保留最近一段事件,或者删掉历史商品后没有墓碑,都可能使重建缺少必要输入。选择来源前,先列出目标每个字段从哪里得到。
数据库是多表模型时,还要定义商品、分类、品牌等记录组合的时间关系。分别全表扫描后直接拼接,可能组合出数据库中从未同时存在过的一份商品视图。
读 alias 与写 alias 各司其职
应用查询 → products-read → products-g1
投影写入 → products-write → products-g1
回填任务 → products-g2alias 可以关联一个或多个索引,也可以带查询过滤和 routing。写 alias 应明确唯一的 is_write_index;同时把两个代际挂在读 alias 上,会让同一业务对象以两个不同 _index 的命中返回,不能假定搜索引擎自动按业务 ID 去重。基本行为见 Index aliases。
过滤 alias 方便集中应用搜索条件,但不代替访问控制,也不能假定它过滤所有按 ID 读取的接口。应用仍需校验身份、索引权限及具体 API 的过滤语义。
全量与增量怎样衔接
回填快照不包含之后的持续变化
Reindex 从源索引建立用于扫描的数据视图,再批量写入目标。在回填开始后更新的商品、后来删除的商品,不会自动作为一条持续变更流同步过去。
需要为全量与增量建立明确衔接点。例如数据库快照对应日志位置 L0,之后按日志顺序持续消费变化;等回填完成,再确认目标已经应用所有要求的增量位置。
仅保存“最近一条事件的时间戳”不足以判断追平。多个分区可能分别落后,中间失败的事件也可能形成空洞;应记录每个有序来源已确认处理的位置,以及尚未解决的失败。CDC 快照与日志衔接可查 Debezium MySQL connector。
直接双写、Outbox 与 CDC
| 方式 | 变化如何到达搜索 | 必须处理的问题 |
|---|---|---|
| 应用直接写数据库和搜索 | 同一个请求发两次外部写入 | 一处成功、一处失败;响应丢失;补偿和重试 |
| 事务 Outbox | 业务数据与事件在同一数据库事务落盘,随后投递 | 投递重复、积压、事件顺序、消息清理 |
| 日志 CDC | 读取数据库提交日志,转换为搜索投影 | 快照衔接、表结构变化、删除、日志保留和消费位置 |
普通数据库事务无法顺带保证一次 OpenSearch HTTP 写入同时提交。先写哪一边都存在中途失败窗口。Outbox 将“记录需要同步的变更”纳入业务事务,再通过可重放的投递完成后续写入;其事件结构和路由示例见 Outbox Event Router。
CDC 提供行级变更,但业务搜索文档可能依赖多个表。更新品牌名称时,需要找到所有受影响商品并刷新投影;只监听商品主表仍会留下旧品牌字段。DDL、主键变化、删除记录和历史快照事件也需要分别处理。
无论采用哪条路径,投影消费者都应能重复处理事件,保留明确的失败修复入口。Bulk 的逐项确认和位点推进见客户端、Bulk 与重试。
新增量不能被旧回填盖回去
一种常见交错是:
旧快照:p2 / 版本 1 / 价格 200
新增量:p2 / 版本 2 / 价格 999 → 先到达新索引
旧回填:p2 / 版本 1 / 价格 200 → 后到达新索引如果都是无条件 index,最后的旧回填会覆盖新价格。可采用统一业务版本,令目标写入只接受更新的版本。对 OpenSearch 源索引执行 Reindex 时,dest.version_type=external 使用源文档的版本;因此源侧 _version 必须已经代表那套业务次序,不能临时把任意内部版本解释为数据库版本。
若版本只存在于 source 字段中,需要明确的投影脚本或从业务来源回填的写入器,将它正确带到外部版本参数。规则见 Index Document API。
conflicts: proceed 允许回填跳过版本冲突并继续,但不会把 Mapping 错误、脚本异常等其他失败自动变成成功。最终检查失败数组、冲突数量及对应业务记录;大量冲突可能来自预期的新版本,也可能暴露版本方案错误。
删除必须挡住旧数据复活
当 p3 在旧快照之后被删除,只在新索引物理删除一次仍有风险:旧回填稍后可能重新创建它。使用带版本的删除或软删除状态,将“已删除”作为可排序的状态参与仲裁。
硬删除的版本保留期有限,不能长期对抗任意历史重放。保留软删除文档的寿命应覆盖回填和所有可能的旧事件来源,再统一安排清理。删除保留规则见 Delete Document API。
在真实索引间执行回填与切换
准备两个独立代际
下载并解压OpenSearch 实验工程,进入 opensearch-lab。使用 Linux Bash、Docker/Compose、curl、jq,有 Docker 执行权限的普通账号。单节点 OpenSearch 3.8.0 限制 2 GiB,回环端口 19222,禁用安全插件;专供隔离实验。正式集群的安全和部署配置见 Docker 安装说明。
set -euo pipefail
command -v docker curl jq
docker compose -p search22 up -d --wait --wait-timeout 180
SEARCH_URL=http://127.0.0.1:19222
V1="lab22-migrate-$$-g1"
V2="lab22-migrate-$$-g2"
READ_ALIAS="lab22-migrate-$$-read"
WRITE_ALIAS="lab22-migrate-$$-write"
for index in "$V1" "$V2"; do
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X PUT "$SEARCH_URL/$index" -H 'Content-Type: application/json' \
--data-binary @src/test/resources/bulk/index.json | jq -e '.acknowledged'
done
for id in 1 2 3 4; do
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X PUT "$SEARCH_URL/$V1/_doc/p$id?version=1&version_type=external" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --argjson price "$((id * 100))" \
'{version:1,price:$price,deleted:false}')" | jq -e '.result == "created"'
done
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/$V1/_refresh" | jq -e '._shards.failed == 0'创建失败时停止,确认冲突来源后换用新的专用名称。上面两个索引使用相同基础字段,以便观察版本和切换;实际结构迁移将 V2 的建索引请求替换为目标 Mapping,并单独验证转换结果。
建立旧代际入口:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/_aliases" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg index "$V1" --arg read "$READ_ALIAS" --arg write "$WRITE_ALIAS" \
'{actions:[
{add:{index:$index,alias:$read}},
{add:{index:$index,alias:$write,is_write_index:true}}
]}')" | jq -e '.acknowledged'提交异步任务并读完整结果
TASK=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/_reindex?wait_for_completion=false&requests_per_second=2" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg src "$V1" --arg dst "$V2" \
'{source:{index:$src,size:1},dest:{index:$dst,version_type:"external"},
conflicts:"proceed"}')" | jq -er '.task')
DONE=false
for attempt in $(seq 1 30); do
STATE=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$SEARCH_URL/_tasks/$TASK")
if printf '%s\n' "$STATE" | jq -e '.completed == true' >/dev/null; then
DONE=true
break
fi
sleep 1
done
test "$DONE" = true
printf '%s\n' "$STATE" | jq -e \
'has("error") == false and (.response.failures | length) == 0'
printf '%s\n' "$STATE" | jq \
'.response | {total,created,updated,version_conflicts,failures}'没有并发修改的这条手动路径应复制四份文档,目标 created 为 4。任务 ID 只表示异步任务已经提交;超出等待时间时保留 ID 继续检查,不要立即重复创建任务。任务管理接口见 Tasks APIs。
requests_per_second 用于控制回填速率,但请求分批执行,低速率并不意味着每个瞬间都完全均匀。回填速率还应给线上查询、merge 和副本同步留出资源。取消任务时,先请求取消,再确认实际任务结束;已经写入目标的文档会保留,取消不会回滚整次 Reindex。
对账后原子切换 alias
对账至少包含业务键集合、每条业务版本、关键字段、删除状态和转换后的查询结果。只比较 count 会漏掉“少了一条,同时多了一条”,也会漏掉相同 ID 上的错误价格。
工程里另有一项真实负例:两索引都只有 p1,sourceVersion 都是 1,但价格分别为 100 和 999。数量和版本比较都通过,规范化内容比较才能找出问题。修复到共同的版本 2 后才一致。
固定数据的手动实验先 refresh,再按 ID 排序比较完整来源内容。下面只适用于四份固定文档;大数据集需要按稳定键遍历,不能把 size=10 当全量对账:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/$V2/_refresh" | jq -e '._shards.failed == 0'
SOURCE=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$SEARCH_URL/$V1/_search?size=10&track_total_hits=true")
TARGET=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$SEARCH_URL/$V2/_search?size=10&track_total_hits=true")
for RESULT in "$SOURCE" "$TARGET"; do
printf '%s\n' "$RESULT" | jq -e \
'.timed_out == false and ._shards.failed == 0 and .hits.total.relation == "eq" and .hits.total.value == 4'
done
SOURCE_CONTENT=$(printf '%s\n' "$SOURCE" | jq -cS '.hits.hits | sort_by(._id) | map({_id,_source})')
TARGET_CONTENT=$(printf '%s\n' "$TARGET" | jq -cS '.hits.hits | sort_by(._id) | map({_id,_source})')
test "$SOURCE_CONTENT" = "$TARGET_CONTENT"
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X POST "$SEARCH_URL/_aliases" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg old "$V1" --arg new "$V2" \
--arg read "$READ_ALIAS" --arg write "$WRITE_ALIAS" \
'{actions:[
{remove:{index:$old,alias:$read}},
{remove:{index:$old,alias:$write}},
{add:{index:$new,alias:$read}},
{add:{index:$new,alias:$write,is_write_index:true}}
]}')" | jq -e '.acknowledged'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$SEARCH_URL/_alias/$READ_ALIAS" | jq .这次请求在同一个 alias 元数据更新中移除旧关联并建立新关联,具体语义见 Manage Aliases API。
这种原子性针对别名配置。已解析到旧索引的在途查询可能仍然从旧索引返回;已经发往旧索引的写入也不会自动搬到新索引。读写都经 alias 的系统,应在切换窗口暂停或围栏写入、排空旧请求,或者继续对两个代际可靠投递并在切换后再追平。不能仅凭 alias acknowledged 就解除迁移保护。
此处使用 remove 移除关联,没有使用 remove_index。后者会删除旧索引,破坏直接回滚和进一步对账所需的数据。
验证并发更新、删除和回切
ReindexTest 使用真实异步 Reindex,按以下交错执行:
- g1 写入 p1 到 p4,版本均为 1;g2 提前写入 p2 版本 2、价格 999。
- 开始限速回填,确认任务仍在运行,再更新 g1 的 p2,并删除 p3。
- g2 保留 p3 的版本 2 软删除状态;旧回填遇到更新版本产生冲突。
- 等待任务完成,逐份比较两边有效文档,确认 p2 是 999、p3 不再参与搜索。
- 切换读写 alias,新增 p5,检查实际写入和查询返回的
_index均为 g2。 - 发现 g1 缺 p5,拒绝直接回切;补齐后对账,再切回 g1 并读取 p5。
测试固定调度这些 HTTP 更新,以观察 Reindex 与投影更新交错时的版本处理。数据库日志采集器不在这条执行链中;接入生产 CDC 后,还需验证快照、日志位点、重启恢复和上游保留期。
运行两项集成测试:
mkdir -p .m2
docker run --rm --memory 2g --entrypoint mvn \
--user "$(id -u):$(id -g)" --network search22_default \
-e MAVEN_CONFIG=/tmp/maven -e SEARCH_URL=http://opensearch:9200 \
-v "$PWD":/work -v "$PWD/.m2":/m2 -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Duser.home=/tmp -Dmaven.repo.local=/m2 -Dtest=ReindexTest clean verify预期 Tests run: 2, Failures: 0, Errors: 0。在该调度中回填出现版本冲突,最终有效记录一致;冲突数由实际交错决定,测试要求至少发现提前写入的新版本冲突,而不把固定数量当迁移成功条件。Java 25 可替换为同系列 eclipse-temurin-25 镜像复跑。
切换以后怎样保留恢复能力
回滚需要追平旧代际
切换以后只向 g2 写入,g1 会逐渐变旧。保留 g1 目录只保住旧数据,回切会遗漏切换后的修改。
可选择继续向 g1 和 g2 投影一段时间,或保留从切换位置开始的可重放变更。回滚前检查 g1 的 schema 是否仍能表示新数据;如果新功能写入了旧 schema 无法表示的状态,即使事件都在,也可能只能向前修复。
读 alias 与写 alias 可以按迁移设计分阶段切换,但要分别说明当前查询和更新落到哪里。回滚只切读入口、写入却继续落 g2,会使旧索引重新落后,不能作为长期运行状态。
失败位置决定保留什么
| 失败时点 | 应保留的信息与处理 |
|---|---|
| 目标 Mapping 或转换失败 | 原输入、转换版本、失败业务键;修复目标后重新回填受影响范围 |
| 回填进程或任务中断 | 任务 ID、目标已有内容、回填范围;检查是否仍运行再安排恢复 |
| 增量出现空洞 | 分区位置、失败事件、版本;补齐后重新对账 |
| alias 响应丢失 | 读取实际 alias 配置;不要凭客户端超时猜测已经切或未切 |
| 切换后查询质量下降 | 保留请求样本、目标代际、查询差异;确认旧代际追平后决定回切 |
| 新 schema 已产生旧版无法承载的数据 | 保持变更记录,采用兼容转换或向前修复 |
观察新索引时,要比较常见关键词、筛选条件、排序、聚合和尾延迟,也要检查租户隔离、删除隐藏与 freshness。抽样命中一致可以发现问题,但对全量完整性仍需业务键、版本和失败位置核对。
清理应晚于回滚窗口结束
迁移稳定后,先停止旧代际增量投递,再确认没有客户端直连旧索引、没有存活 PIT 或导出依赖旧数据,并保存恢复所需的配置和记录。需要备份时执行并验证恢复,而不是只看快照任务返回成功。快照机制见 Snapshot and restore。
手动实验的 V1、V2 均由前面的命令创建。确认异步任务已结束后,删除这两个专用索引;连带的实验 alias 会随索引删除。不要把以下变量换成通配符或生产索引:
for index in "$V1" "$V2"; do
curl -q --noproxy '*' --fail-with-body --silent --show-error \
-X DELETE "$SEARCH_URL/$index" | jq -e '.acknowledged'
done
docker compose -p search22 down命名卷继续保留。清理过程中失败时保留原任务和资源名称,逐个核对剩余资源,避免错误处理覆盖真正的迁移失败原因。
权威资料与规范地址
索引结构与重建任务
- Mapping 更新:https://docs.opensearch.org/latest/api-reference/index-apis/put-mapping/
- Reindex:https://docs.opensearch.org/latest/api-reference/document-apis/reindex/
- Docker 安装:https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
- 任务管理:https://docs.opensearch.org/latest/api-reference/tasks/tasks/
增量追赶与文档版本
- Debezium MySQL 快照与日志:https://debezium.io/documentation/reference/stable/connectors/mysql.html
- Outbox Event Router:https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
- 文档版本:https://docs.opensearch.org/latest/api-reference/document-apis/index-document/
- 删除版本保留:https://docs.opensearch.org/latest/api-reference/document-apis/delete-document/
