如何比较两个 Elasticsearch 索引并找出缺失的文档

作者:来自 Elastic David Pilato

刚接触 Elasticsearch?加入我们的 Elasticsearch 入门网路会议。你也可以开始免费云试用,或现在就在你的本地机器上试用 Elastic。


在管理 Elasticsearch 索引时,你可能需要验证一个索引中的所有文档是否也存在于另一个索引中,例如在 reindex 操作、迁移或数据管道之后。Elasticsearch 并未提供内置的"diff"命令来完成这一点,但正确的方法取决于一个关键问题:两个索引之间的文档 ID 是否稳定

更多阅读:

问题

假设你有两个索引,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... 获取。

原文:www.elastic.co/search-labs...

相关推荐
海兰2 小时前
使用 Elastic Workflows 监控 Kibana 仪表板访问数据
android·人工智能·elasticsearch·rxjava
希望永不加班3 小时前
SpringBoot 整合 Elasticsearch 实现全文检索
java·spring boot·后端·elasticsearch·全文检索
risc1234563 小时前
【Elasticsearch】副本分片(Replica Shard)的 globalCheckpoint 更新与推进机制
elasticsearch
Makoto_Kimur13 小时前
Elasticsearch面试八股整理
elasticsearch
青稞社区.1 天前
Claude Code 源码深度解析:运行机制与 Memory 模块详解
大数据·人工智能·elasticsearch·搜索引擎·agi
Aktx20FNz1 天前
iFlow CLI 完整工作流指南
大数据·elasticsearch·搜索引擎
学习3人组1 天前
TortoiseGit冲突解决实战上机练习
大数据·elasticsearch·搜索引擎
A__tao1 天前
Elasticsearch Mapping 一键生成 Go Struct,支持嵌套解析
elasticsearch·es
zs宝来了1 天前
Elasticsearch 索引原理:倒排索引与 Segment 管理
elasticsearch·索引·倒排索引·源码解析·segment