作者:来自 Elastic David Pilato

刚接触 Elasticsearch?加入我们的 Elasticsearch 入门网路会议。你也可以开始免费云试用,或现在就在你的本地机器上试用 Elastic。
在管理 Elasticsearch 索引时,你可能需要验证一个索引中的所有文档是否也存在于另一个索引中,例如在 reindex 操作、迁移或数据管道之后。Elasticsearch 并未提供内置的"diff"命令来完成这一点,但正确的方法取决于一个关键问题:两个索引之间的文档 ID 是否稳定?
更多阅读:
- Elasticsearch:消除 Elasticsearch 中的重复数据
- Logstash:如何在 Elasticsearch 中查找和删除重复文档
- 使用 Elasticsearch 进行日志重复数据删除
问题
假设你有两个索引,index-a(源)和 index-b(目标),你想找出所有存在于 index-a 但缺失于 index-b 的文档。
一种简单的方法是同时查询两个索引并在内存中比较结果,但这种方法无法扩展。Elasticsearch 旨在处理数百万文档,一次性加载所有数据并不现实。
有两种场景:
- ID 稳定:两个索引对同一文档使用相同的 _id(例如,将 emp_no 作为文档 ID)。这是最简单的情况。
- ID 生成:文档通过不同的数据管道写入,并分配了随机或顺序 ID。你无法通过 _id 比较;需要基于内容进行匹配。
接下来我们分别讲解这两种情况。
步骤 0 ------ 一个更轻量的 Elasticsearch CLI
本文中的所有示例都使用 escli,这是一个用 Rust 编写的小型命令行接口(CLI),对 Elasticsearch REST API 进行了封装。它从环境变量中读取你的集群 URL 和凭据,因此你无需在每个命令中重复认证 headers。
为了说明这一点的重要性,下面是一个使用原始 curl 的典型 _search 调用:
json
`
1. curl -X GET \
2. -H "Authorization: ApiKey $ELASTIC_API_KEY" \
3. -H "Content-Type: application/json" \
4. -d '{"query":{"term":{"user.id":"kimchy"}}}' \
5. "$ELASTICSEARCH_URL/my-index-000001/_search"
`AI写代码
使用 escli,相同的请求变为:
css
`./escli search --index my-index-000001 <<< '{"query":{"term":{"user.id":"kimchy"}}}'`AI写代码
凭据存储在 .env 文件中,escli 会自动加载 ------ 无需在每次调用时使用 -H "Authorization: ...",也降低了在 shell 历史中泄露敏感信息的风险。请求体通过 stdin(<<<)传入,这使得可以轻松地通过 jq 动态构建并传递多行 JSON。
步骤 1 ------ 统计两个索引中的文档数量
在进行完整扫描之前,先快速统计每个索引的文档数量。如果数量相同,两个索引很可能已经同步,就不需要再进行扫描。
css
`
1. ./escli count --index index-a
2. ./escli count --index index-b
`AI写代码
_count API 返回:
json
`{ "count": 1000000 }`AI写代码
如果计数不同,则继续进行完整比较。
步骤 2 ------ 当 ID 有意义时:使用 op_type=create
如果两个索引对同一文档使用相同的 _id,例如因为你使用像 emp_no 这样的业务主键而不是生成的 UUID 来索引文档,你可以通过一次 _reindex 调用来查找并修复缺失的文档。
为什么使用有意义的 ID
当数据具有自然主键时,使用有意义的字段作为 _id(而不是随机 UUID)是一种最佳实践。这意味着:
- 同一文档始终具有相同的 _id,无论由哪个数据管道写入。
- 你可以通过 ID 轻松更新或删除文档。
- 你可以使用 op_type=create 来跳过目标中已存在的文档。
- 无需在客户端进行扫描或比较。
op_type=create 技巧
使用 _reindex 并设置 op_type=create,会尝试将源索引中的每个文档创建到目标索引中。如果目标中已存在相同 _id 的文档,Elasticsearch 会将其报告为 version_conflict 并继续处理,而不会覆盖已有文档。设置 conflicts=proceed 可以让 API 在遇到冲突时继续执行,而不是在第一个冲突时中止。
vbnet
`
1. ./escli reindex <<< '{
2. "source": { "index": "index-a" },
3. "dest": { "index": "index-b", "op_type": "create" },
4. "conflicts": "proceed"
5. }'
`AI写代码
响应会准确地告诉你发生了什么:
json
`
1. {
2. "total": 1000000,
3. "created": 49594,
4. "version_conflicts": 950406,
5. "failures": []
6. }
`AI写代码
- created:从 index-b 中缺失并已被添加的文档。
- version_conflicts:在 index-b 中已存在且未被修改的文档。
无需扫描,无需客户端比较,无需中间文件。所有操作都在服务端完成,在一个包含 100 万文档的数据集中大约只需 6 秒。
步骤 3 ------ 当 ID 不稳定时:基于业务键的比较
有时你无法依赖 _id。一个在写入时生成 ID 的数据管道,每次处理同一条记录都会分配不同的 _id。如果 index-a 和 index-b 由这样的两个管道生成,同一个员工记录在一个索引中可能是 _id: "abc123",而在另一个索引中是 _id: "xyz789",即使底层数据完全相同。
在这种情况下,你需要基于内容而不是 ID 来匹配文档。关键是识别一组字段,它们组合在一起构成一个唯一的业务键。
对于员工数据集,一个合理的业务键是(first_name、last_name、birth_date)。如果在 index-b 中不存在具有这三个字段相同组合的文档,则 index-a 中的该文档就被视为 "缺失"。
3a ------ 使用 PIT + search_after 扫描源索引
在源索引上打开一个时间点 (PIT)以获取一致的快照,然后分页遍历,仅获取业务键字段:
css
`
1. ./escli open_point_in_time index-a 5m
2. # → { "id": "46ToAwMDaWR..." }
`AI写代码
vbnet
`
1. ./escli search <<< '{
2. "size": 10000,
3. "_source": ["first_name", "last_name", "birth_date"],
4. "pit": { "id": "46ToAwMDaWR...", "keep_alive": "5m" },
5. "sort": [{ "_shard_doc": "asc" }]
6. }'
`AI写代码
排序键 _shard_doc 是全索引分页最有效的排序方式:它使用内部 Lucene 文档顺序,无额外开销。使用 search_after 重复,直到响应中没有命中。完成后务必关闭 PIT:
json
`./escli close_point_in_time <<< '{"id": "46ToAwMDaWR..."}'`AI写代码
对于源文档的每一页,通过 _msearch 检查目标索引。为每页源文档构建一个 _msearch 请求,每个文档一个子查询。每个子查询在三个业务键字段上使用 bool/must 并设置 size: 0;我们只需要知道是否存在匹配,不需要检索文档本身。
bash
`
1. ./escli msearch << 'EOF'
2. {"index": "index-b"}
3. {"size":0,"query":{"bool":{"must":[{"term":{"first_name.keyword":"Alice1"}},{"term":{"last_name.keyword":"Smith"}},{"term":{"birth_date":"1985-03-12"}}]}}}
4. {"index": "index-b"}
5. {"size":0,"query":{"bool":{"must":[{"term":{"first_name.keyword":"Bob2"}},{"term":{"last_name.keyword":"Jones"}},{"term":{"birth_date":"1990-07-24"}}]}}}
6. EOF
`AI写代码
响应包含每个子查询对应的一条记录,顺序与子查询相同:
json
`
1. {
2. "responses": [
3. { "hits": { "total": { "value": 1 } } },
4. { "hits": { "total": { "value": 0 } } }
5. ]
6. }
`AI写代码
total.value == 0 表示 index-b 中没有文档匹配该业务键;该文档缺失。从源页收集对应的 _id。
关于 .keyword 子字段:term 查询要求精确(keyword)匹配。first_name 和 last_name 字段在索引映射中必须有 .keyword 子字段。演示的 mapping.json 包含了这一点。
3c --- 使用按日期分片加速
如果业务键包含日期字段,你可以将源数据按日期分片,并将每个分片作为独立任务运行。每个分片打开自己的 PIT,并在 birth_date 上使用范围过滤器,运行自己的 msearch 循环,并将结果写入单独文件。父脚本并行启动所有分片,并在所有任务完成后汇总结果。
但根据你的用例,你也可能希望按其他字段分片;例如,如果有 team 字段,可以为每个团队运行一个分片。关键是找到一个字段,使数据可以被分割成合理均匀的块,从而可以并行处理。
yaml
`
1. [compare] Launching 5 slices in parallel...
3. → Slice 1: 1960-01-01 → 1969-12-31 ✅ --- 244408 checked, 12207 missing
4. → Slice 2: 1970-01-01 → 1979-12-31 ✅ --- 243624 checked, 12212 missing
5. → Slice 3: 1980-01-01 → 1989-12-31 ✅ --- 243551 checked, 11921 missing
6. → Slice 4: 1990-01-01 → 1999-12-31 ✅ --- 243895 checked, 11991 missing
7. → Slice 5: 2000-01-01 → 2009-12-31 ✅ --- 24522 checked, 1263 missing
`AI写代码
1M 数据集上的性能
为了验证这些方法,演示在 index-a 中生成 1,000,000 条文档,并故意在 index-b 中跳过约 5%(49,594 条缺失文档),然后运行完整的 compare → reindex 循环。
MacBook M3 Pro 上的结果:
Comparison (compare-indices.sh):
| 策略 | 比较 | 重建索引 | 总计 | 工作原理 |
|---|---|---|---|---|
| op_type | 6s | 6s | 全量 _reindex 服务端执行,跳过已存在文档 | |
| business-key | 1m 38s | 4s | 1m 42s | PIT 扫描 + 按业务键 _msearch |
| split-by-date | 32s | 4s | 36s | 同 business-key,相同逻辑,5 个切片并行执行 |
op_type=create 方法最快,因为所有操作都在服务端执行,无需客户端扫描。split-by-date 策略通过并行处理将 business-key 耗时从 1m 38s 缩短到 36s:对于两个 1M 文档索引之间的比较,效果不错。
决策树
sql
`
1. Are _id values stable between both indices?
2. ├── Yes → _reindex with op_type=create (6s, server-side)
3. └── No → Do you have a reliable business key?
4. ├── Yes, simple scan is fast enough → business-key (1m 42s)
5. └── Yes, and you need more speed → split-by-date (36s, parallel)
`AI写代码
结论
Elasticsearch 不提供原生的索引差异命令,但正确的策略取决于你的数据模型:
-
尽可能使用功能性 _id(例如 emp_no 这样的自然业务键)。它能解锁最简单、最快的方式:使用 _reindex 并设置 op_type=create,通过一次服务端调用即可查找并填补缺失文档。
-
当 ID 不稳定时,使用 PIT + _msearch 按业务键匹配。按某个字段分片并并行处理以恢复大部分性能。如果你经常需要这样操作,可以考虑在摄取时计算业务键字段的哈希并用作 _id。这样你既有稳定的 ID,又有高效的查询。
完整示例,包括数据集生成、比较脚本和重建索引脚本,可在 github.com/dadoonet/bl... 获取。