5月27日 23:53

Elasticsearch 更新和删除操作的底层原理是什么?

Elasticsearch 底层基于 Lucene,而 Lucene 的段(segment)是不可变的。这意味着已写入段的文档无法原地修改或删除。Elasticsearch 的更新和删除操作都建立在这一约束之上,通过标记删除 + 重新索引的方式实现,再由段合并完成物理清理。

更新操作:标记删除 + 重新索引

Elasticsearch 的更新并不是原地修改文档。当你更新一个文档时,实际发生的是两步操作:

  1. 旧文档在 .del 文件中被标记为 deleted
  2. 新文档被索引到一个新的段中

也就是说,更新 = 删除旧版本 + 插入新版本。这是由倒排索引的不可变性决定的——段一旦写入就无法修改,只能追加。

json
PUT /products/_doc/1 { "name": "MacBook Pro", "price": 14999, "updated_at": "2025-01-15" }

上述请求如果文档 ID=1 已存在,旧文档会被标记删除,新文档写入新段。如果不指定 ID,则直接作为新文档插入。

部分更新(Partial Update)

全量替换需要发送完整文档,网络开销大。部分更新通过 _update API 只修改指定字段,但底层仍然是标记删除 + 重新索引——只是服务端帮你完成了合并旧文档和新字段的步骤:

json
POST /products/_update/1 { "doc": { "price": 12999 } }

脚本更新

对于需要动态计算的场景,可以用脚本更新:

json
POST /products/_update/1 { "script": { "source": "ctx._source.price += params.delta", "params": { "delta": 500 } } }

upsert 操作

当不确定文档是否存在时,upsert 可以在文档不存在时插入、存在时更新:

json
POST /products/_update/1 { "doc": { "price": 12999 }, "upsert": { "name": "MacBook Pro", "price": 12999 } }

删除操作:逻辑删除与段合并清理

删除文档时,Elasticsearch 不会立即从磁盘移除数据。而是在 .del 文件中标记该文档为 deleted 状态。被标记的文档仍然存在于段中,但查询时会被过滤掉。

json
DELETE /products/_doc/1

物理删除何时发生?

物理删除发生在段合并(segment merge)过程中。Lucene 后台会定期将多个小段合并为大段,此时被标记为 deleted 的文档不会被写入新段,从而实现真正的磁盘空间回收。

你也可以手动触发合并清理:

json
POST /products/_forcemerge?only_expunge_deletes=true

only_expunge_deletes=true 表示只合并含有删除文档的段,不影响无删除标记的段。

按条件批量删除

对于需要按查询条件删除的场景,使用 delete_by_query

json
POST /products/_delete_by_query { "query": { "range": { "price": { "lte": 100 } } } }

注意:delete_by_query 是先扫描再逐个标记删除,大数量下耗时长,建议在低峰期执行并设置 wait_for_completion=false 异步执行。

版本控制与乐观并发

_version 字段

每个文档都有一个 _version 字段,每次写操作(index、update、delete)都会使版本号递增。这用于防止旧版本覆盖新版本——如果一个更新请求基于的版本号已过期,操作会被拒绝。

乐观并发控制

Elasticsearch 使用 if_seq_noif_primary_term 实现乐观并发控制(OCC)。在读取文档时获取当前的 seq_no 和 primary_term,更新时带上这两个值,如果文档已被其他操作修改(seq_no 已变),则返回 409 冲突:

json
PUT /products/_doc/1?if_seq_no=5&if_primary_term=1 { "name": "MacBook Pro", "price": 13999 }

如果不做并发控制,两个请求同时更新同一文档,后到的请求会覆盖先到的结果——这在电商库存扣减等场景下是严重问题。

近实时搜索与 refresh 机制

文档写入后并不是立即可搜索。Elasticsearch 的写入流程是:

  1. 文档先写入内存缓冲区(index buffer)
  2. 同时写入 translog(事务日志,保证持久性)
  3. 每隔 refresh_interval(默认 1s)执行一次 refresh,将内存缓冲区的数据写入新段,文档变为可搜索

这意味着更新和删除操作也有近一秒的延迟才对搜索可见。生产环境中,可以适当调大 refresh_interval(如 30s)来提升写入吞吐量,代价是搜索可见延迟增加。

性能优化要点

更新场景:

  • 优先使用部分更新而非全量替换,减少网络传输和 _source 重写开销
  • 高频更新使用 Bulk API 批量提交
  • 避免在热索引上频繁单条更新,考虑异步队列聚合后批量写入

删除场景:

  • 大批量删除用 delete_by_query 而非逐条 DELETE
  • 删除后若段膨胀明显,执行 force_merge 回收空间(只对只读索引执行,否则可能产生超大段)
  • 删除大量数据后关注磁盘水位,段合并需要额外磁盘空间

通用建议:

  • 监控 GET /_nodes/stats/indexing 中的索引吞吐和删除计数
  • 调整 index.merge.policy 控制段合并策略和频率
  • 更新和删除都会产生 translog 和段碎片,定期评估索引是否需要 reindex

面试中回答这个问题,核心要讲清楚三点:段不可变导致更新是删除+插入、删除是逻辑标记物理清理靠段合并、并发控制靠 seq_no/primary_term 实现乐观锁。理解这三层,就能应对追问。

标签:ElasticSearch