当前位置: 首页 > news >正文

深度解析 Elasticsearch 更新与删除文档原理:段不可变性与 .del 文件的秘密

深度解析 Elasticsearch 更新与删除文档原理:段不可变性与 .del 文件的秘密

    • 前言
    • 一、核心前提:Lucene 段不可变性
      • 1.1 什么是段(Segment)?
      • 1.2 段不可变意味着什么?
    • 二、删除文档:标记而非物理删除
      • 2.1 .del 文件机制
      • 2.2 .del 文件生命周期
      • 2.3 .del 文件的存储格式
    • 三、更新文档:删除 + 新增
      • 3.1 更新操作的内部流程
      • 3.2 版本控制机制
      • 3.3 更新操作的内部数据结构变化
      • 3.4 部分更新 vs 全文替换
    • 四、段合并(Segment Merge):最终的物理清理
      • 4.1 为什么需要段合并?
      • 4.2 合并流程详解
      • 4.3 手动触发合并
      • 4.4 自动合并策略
    • 五、删除与更新的性能影响
      • 5.1 性能开销对比
      • 5.2 如何减少更新/删除的负面影响?
    • 六、常见面试题
      • Q1:删除文档后,磁盘空间为什么没有立即释放?
      • Q2:更新文档的内部原理是什么?
      • Q3:频繁更新对集群有什么影响?
      • Q4:删除操作在什么情况下会丢失数据?
    • 七、总结
    • 八、面试加分回答

🌺The Begin🌺点点关注,收藏不迷路🌺

前言

很多开发者以为 Elasticsearch 的更新和删除操作与关系型数据库类似——找到数据,原地修改或删除。然而,由于 Lucene 底层的段(Segment)不可变性,Elasticsearch 的更新和删除采用了完全不同的策略。

理解这一机制,不仅有助于回答面试问题,更能帮助你在实际生产中避免常见的性能陷阱。

本文将深入剖析:

  • 为什么文档不可变?
  • 删除操作的 .del 文件机制
  • 更新操作如何转化为“删除+新增”
  • 段合并如何物理清理数据
  • 版本控制与并发安全

一、核心前提:Lucene 段不可变性

1.1 什么是段(Segment)?

Lucene 的索引由多个组成,每个段都是一个独立的倒排索引结构。

索引 (Index) ├── Segment A (不可变) │ ├── 倒排索引 (Term → Posting List) │ ├── DocValues (列式存储) │ └── 存储字段 (_source) ├── Segment B (不可变) ├── Segment C (不可变) └── ...

1.2 段不可变意味着什么?

操作关系型数据库Lucene 段
修改文档原地更新不可能
删除文档物理删除不可能
新增文档追加到数据页创建新段

为什么不设计为可变?

优势说明
无锁并发读操作无需加锁,多个线程可同时读取
缓存友好段文件可被操作系统页缓存,永不失效
压缩高效无需预留更新空间,压缩比更高
故障恢复段只读,系统崩溃不会损坏已有数据

代价:更新和删除不能原地操作,必须采用标记 + 延迟清理策略。


二、删除文档:标记而非物理删除

2.1 .del 文件机制

当删除请求到达时,Elasticsearch 并不会立即从磁盘上移除文档,而是在.del文件中做一个标记

┌─────────────────────────────────────────────────────────────────────┐ │ 删除操作内部流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 删除请求: DELETE /my_index/_doc/123 │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 1: 查找文档所在的位置 │ │ │ │ • 定位文档 123 属于哪个 Segment │ │ │ │ • 获取文档在该 Segment 中的 DocID(内部文档编号) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 2: 在 .del 文件中标记删除 │ │ │ │ • Segment 目录下有一个同名 .del 文件 │ │ │ │ • 使用位图(Bitmap)或数组标记 DocID 状态 │ │ │ │ • 示例: .del 文件记录 [DocID: 123 → DELETED] │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 3: 查询时的过滤 │ │ │ │ • 搜索时,倒排索引仍会返回 DocID 123 │ │ │ │ • 但查询执行器会检查 .del 文件 │ │ │ │ • 如果被标记为删除,则从结果中过滤掉 │ │ │ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘

2.2 .del 文件生命周期

时间线 ──────────────────────────────────────────────────────────────▶ T0: 文档写入 ┌─────────────────────────────────────────────────────────────────┐ │ Segment A (10 篇文档,无删除标记) │ │ .del 文件: [空] │ └─────────────────────────────────────────────────────────────────┘ T1: 删除文档 3 和 7 ┌─────────────────────────────────────────────────────────────────┐ │ Segment A (10 篇文档,但有 2 篇被标记删除) │ │ .del 文件: [Doc3: ✅, Doc7: ✅] ← 位图标记 │ └─────────────────────────────────────────────────────────────────┘ T2: 其他文档写入(创建新段) ┌─────────────────────────────────────────────────────────────────┐ │ Segment A (10 docs, 2 deleted) │ │ Segment B (5 docs, 0 deleted) │ │ Segment C (8 docs, 0 deleted) │ └─────────────────────────────────────────────────────────────────┘ T3: 段合并(物理清理) ┌─────────────────────────────────────────────────────────────────┐ │ 【合并过程】 │ │ 读取 Segment A → 跳过被标记删除的文档 → 只保留 8 篇有效文档 │ │ 读取 Segment B → 保留全部 5 篇 │ │ 读取 Segment C → 保留全部 8 篇 │ │ ↓ │ │ 写入新 Segment D (21 篇文档,无删除标记) │ │ ↓ │ │ 删除旧 Segment A、B、C 及对应的 .del 文件 │ └─────────────────────────────────────────────────────────────────┘

2.3 .del 文件的存储格式

// .del 文件的简化实现原理publicclassDelFile{// 方式1:位图(BitSet)—— 高效存储大量删除标记privateBitSetdeletedBitSet;// 方式2:数组(较老版本)privateint[]deletedDocIds;publicbooleanisDeleted(intdocId){returndeletedBitSet.get(docId);}publicvoidmarkDeleted(intdocId){deletedBitSet.set(docId);}}

三、更新文档:删除 + 新增

3.1 更新操作的内部流程

由于文档不可变,更新 = 标记删除 + 重新索引

┌─────────────────────────────────────────────────────────────────────┐ │ 更新操作内部流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 更新请求: POST /my_index/_update/123 │ │ { "doc": { "title": "new title" } } │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 1: 获取原文档 │ │ │ │ • 从 _source 中读取文档 123 的完整内容 │ │ │ │ • 获取当前版本号 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 2: 应用更新 │ │ │ │ • 将请求中的字段合并到原文档中 │ │ │ │ • 类似 JavaScript 的 Object.assign(oldDoc, updateDoc) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 3: 标记旧文档为删除 │ │ │ │ • 在 .del 文件中标记文档 123 的旧版本被删除 │ │ │ │ • 旧文档不再被返回(但尚未物理清理) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 4: 索引新文档 │ │ │ │ • 将更新后的文档作为全新文档写入 │ │ │ │ • 分配新的内部 DocID │ │ │ │ • 可能写入不同的 Segment(取决于当前内存缓冲区) │ │ │ │ • 版本号 +1 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Step 5: 返回成功 │ │ │ │ Result: "updated" │ │ │ │ Version: 2 │ │ │ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘

3.2 版本控制机制

每次文档变更,版本号递增,用于乐观锁并发控制

// 更新前{"_id":"123","_version":1,"_source":{"title":"old title","content":"..."}}// 更新后{"_id":"123","_version":2,"_source":{"title":"new title","content":"..."}}

乐观锁使用示例

// 要求当前版本必须为 1,否则更新失败POST/my_index/_update/123?if_seq_no=1&if_primary_term=1{"doc":{"title":"new title"}}

3.3 更新操作的内部数据结构变化

更新前(索引状态): ┌─────────────────────────────────────────────────────────────────────┐ │ Segment A (.del: 空) │ │ ┌─────────┬──────────┬─────────────────────────────────────────┐ │ │ │ DocID │ 文档ID │ 内容 │ │ │ ├─────────┼──────────┼─────────────────────────────────────────┤ │ │ │ 0 │ 100 │ {title: "A", content: "..."} │ │ │ │ 1 │ 123 │ {title: "old title", content: "..."} │ │ │ │ 2 │ 456 │ {title: "C", content: "..."} │ │ │ └─────────┴──────────┴─────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ 更新文档 123 后: ┌─────────────────────────────────────────────────────────────────────┐ │ Segment A (.del: 标记 DocID 1 为删除) │ │ ┌─────────┬──────────┬─────────────────────────────────────────┐ │ │ │ DocID │ 文档ID │ 内容 │ │ │ ├─────────┼──────────┼─────────────────────────────────────────┤ │ │ │ 0 │ 100 │ {title: "A", content: "..."} │ │ │ │ 1 │ 123 │ {title: "old title", ...} ← 被标记删除 │ │ │ │ 2 │ 456 │ {title: "C", content: "..."} │ │ │ └─────────┴──────────┴─────────────────────────────────────────┘ │ │ │ │ Segment B (新段,刚刚被创建) │ │ ┌─────────┬──────────┬─────────────────────────────────────────┐ │ │ │ DocID │ 文档ID │ 内容 │ │ │ ├─────────┼──────────┼─────────────────────────────────────────┤ │ │ │ 0 │ 123 │ {title: "new title", content: "..."} │ │ │ └─────────┴──────────┴─────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ 查询文档 123 时:过滤掉 Segment A 中被删除的版本,返回 Segment B 中的新文档

3.4 部分更新 vs 全文替换

操作本质注意
POST /_update部分字段更新内部需要读取原文档,合并后重新索引
PUT /_doc/123全文替换直接删除旧版本,索引新文档
POST /_update?detect_noop=true仅当内容变更时才更新避免无意义的版本递增
// 全文替换(直接 PUT)PUT/my_index/_doc/123{"title":"brand new title","content":"completely new content"}// 部分更新(_update API)POST/my_index/_update/123{"doc":{"title":"only update this field"}}

四、段合并(Segment Merge):最终的物理清理

4.1 为什么需要段合并?

随着时间推移,会出现两个问题:

问题说明
段文件过多每次 Refresh 都会创建新段,短期内大量小段影响查询性能
删除标记累积被删除的文档仍占据磁盘空间,浪费资源

段合并解决方案:后台线程定期将多个小段合并成一个大段,同时物理清理被标记删除的文档。

4.2 合并流程详解

合并前: ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Segment │ │ Segment │ │ Segment │ │ Segment │ │ A │ │ B │ │ C │ │ D │ │ 100 docs │ │ 120 docs │ │ 90 docs │ │ 110 docs │ │ 5 删除 │ │ 8 删除 │ │ 3 删除 │ │ 10 删除 │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ └────────────┴────────────┴────────────┘ │ ▼ ┌─────────────────┐ │ 合并过程 │ │ 读取所有段 │ │ 跳过已删除文档 │ │ 重新构建倒排索引 │ └────────┬────────┘ │ ▼ 合并后: ┌─────────────────────────────────────────────────────────────────────┐ │ Segment E (合并后的大段) │ │ • 总文档: (100+120+90+110) = 420 篇 │ │ • 删除文档: (5+8+3+10) = 26 篇 ← 物理清理,不写入新段 │ │ • 有效文档: 394 篇 │ │ • .del 文件: 空(新段无删除标记) │ └─────────────────────────────────────────────────────────────────────┘ 旧的 Segment A、B、C、D 及它们的 .del 文件被删除 ✅ 磁盘空间释放

4.3 手动触发合并

// 手动触发段合并(生产环境谨慎使用)POST/my_index/_forcemerge?max_num_segments=1

使用场景

  • 索引不再写入新数据(如历史日志索引)
  • 需要释放磁盘空间
  • 提升只读索引的查询性能

注意事项

  • 合并过程消耗大量 IO 和 CPU
  • 应在业务低峰期执行
  • 对正在写入的索引影响较大

4.4 自动合并策略

ES 默认使用TieredMergePolicy

// 合并策略核心参数-segments_per_tier:每层级的段数(默认10-max_merge_at_once:单次最大合并段数(默认10-max_merged_segment:单段最大大小(默认5GB-reclaim_deletes_weight:删除标记清理权重

五、删除与更新的性能影响

5.1 性能开销对比

操作标记删除写入新段后续合并清理
Delete占用空间,最终清理
Update占用空间,最终清理
Index(新建)无额外开销

关键点:频繁更新/删除会导致:

  1. .del文件膨胀,占用磁盘
  2. 查询时需要过滤被删除文档,增加 CPU 开销
  3. 段合并需要 rewrite 大量数据,消耗 IO

5.2 如何减少更新/删除的负面影响?

策略说明
减少更新频率批量合并更新,而非逐条更新
避免高删除率删除率超过 50% 的索引考虑重建
定期 force_merge对不活跃索引执行强制合并
使用 ILM 管理自动管理索引生命周期
考虑用 Data Stream时间序列数据的最佳实践

六、常见面试题

Q1:删除文档后,磁盘空间为什么没有立即释放?

回答

因为 Lucene 段的不可变性,删除操作只是在.del文件中做标记,文档并未被物理移除。只有等段合并发生时,被标记删除的文档才会被跳过,旧段被删除,磁盘空间才会真正释放。这种设计牺牲了即时空间回收,换来了更高的查询性能和并发能力。

Q2:更新文档的内部原理是什么?

回答

更新操作本质上是“标记删除 + 新增”的组合:

  1. 读取原文档的完整内容
  2. 应用字段更新,生成新文档
  3. 在原文档所在段的.del文件中标记删除旧版本
  4. 将新文档作为全新文档写入新段
  5. 查询时过滤掉被标记的旧版本,返回新版本

版本号会递增,支持乐观锁并发控制。最终段合并时,旧版本被物理清理。

Q3:频繁更新对集群有什么影响?

回答

频繁更新会导致三方面影响:

  • 磁盘膨胀:旧版本文档直到段合并才会清理,短期磁盘占用翻倍
  • 查询变慢:每个被删除的文档在查询时都需要检查.del文件
  • IO 压力:段合并需要重写大量数据,消耗 IO 和 CPU

建议对于高频更新的场景,考虑将此索引放在高性能 SSD 节点上,或调整合并策略参数。

Q4:删除操作在什么情况下会丢失数据?

回答

删除操作本身不会丢失数据(不是物理删除),但以下情况需注意:

  • delete_by_query没有事务保障,执行中如果集群故障,部分文档可能未被删除
  • 删除后立即 force_merge 可能物理删除数据,无法恢复
  • 没有备份时删除的文档无法找回

建议:重要数据定期快照备份;批量删除使用异步任务并监控进度。


七、总结

维度删除更新
核心操作在 .del 文件中标记标记删除 + 写入新文档
物理清理段合并时段合并时
对查询影响需要过滤被删除文档需要过滤被删除文档
版本控制不适用版本号 + 乐观锁
磁盘空间立即可见标记,实际不释放新旧文档同时存在,短期占用翻倍
触发合并_forcemerge或自动合并_forcemerge或自动合并

核心要点

  1. Lucene 段不可变,删除和更新无法原地操作
  2. 删除 →.del文件标记,查询时过滤
  3. 更新 →标记删除旧版+索引新版
  4. 段合并 →物理清理被标记删除的文档
  5. 版本控制 → 支持乐观锁并发

八、面试加分回答

面试官:请详细描述 Elasticsearch 更新和删除文档的过程。

候选人
“由于 Lucene 底层采用不可变段(Segment)设计,ES 的删除和更新不能原地操作。

删除操作:文档并未被物理移除,而是在对应段的.del文件中进行标记。查询时,倒排索引仍会返回该文档的 DocID,但执行器会检查.del文件,将已标记的文档过滤掉。

更新操作:本质是‘标记删除 + 新增’的组合:首先读取原文档,应用字段更改生成新文档,然后在.del文件中标记旧版本为删除,最后将新文档作为新文档索引(可能写入不同段)。版本号会递增,支持乐观锁并发。

物理清理:两种操作都不会立即释放磁盘空间。ES 后台会定期执行段合并,读取多个小段,跳过被标记删除的文档,写入新的大段,然后删除旧段及其.del文件,磁盘空间才真正释放。也可以通过_forcemergeAPI 手动触发。

性能影响:频繁的更新和删除会导致.del文件膨胀、查询时需要额外过滤、段合并消耗 IO。建议对频繁更新的索引使用 SSD,并考虑调整合并策略参数。”



🌺The End🌺点点关注,收藏不迷路🌺
http://www.jsqmd.com/news/704463/

相关文章:

  • HPH的构造是怎样的 3分钟看懂
  • INAV飞控系统完整配置指南:从零开始打造智能无人机
  • 让Python三维数据可视化变得简单有趣:PyVista入门指南
  • 面试官总问分布式锁?从Redisson源码角度聊聊它的‘看门狗’机制到底怎么防死锁
  • Pyodide包管理终极指南:在浏览器中轻松运行Python的完整方案
  • 外贸获客新解法!昊客网络助力家具企业抢占海外流量红利 - 深圳昊客网络
  • hph的构造一看就懂
  • Kubernetes Pod 网络通信优化方案
  • 更改localhost解析地址为ipv4
  • 2026年3月地垫打印机生产厂家口碑推荐,地垫打印机生产厂家,地垫打印机智能控制,操作更便捷 - 品牌推荐师
  • Java 面试:深入探讨微服务与云原生技术
  • 新手必看:用海思ISP工具给摄像头做黑电平校正(BLC)的完整流程
  • 5步精通FanControl:从零配置到专业级风扇控制
  • UE5实战:用UGameInstanceSubsystem管理全局游戏状态(附完整代码示例)
  • JOLT变换的条件逻辑
  • 互联网大厂 Java 求职面试:音视频场景下的技术考察
  • 如何用深度学习象棋AI工具VinXiangQi快速提升你的棋艺水平
  • 开源低代码平台 Moltis 全栈架构解析与实战指南
  • 硬件工程师避坑指南:TVS管结电容是如何“偷偷”影响你的高速信号完整性的?
  • 从慢查询到秒级响应:SQL调优实战全解析
  • 如何用Moonlight TV在电视上畅玩PC游戏:超低延迟串流全攻略
  • Spring Boot微服务中的分布式追踪实践
  • 大麦网自动抢票脚本:5分钟上手,告别手动抢票失败
  • 别再傻傻分不清!用一张图搞懂NMOS和PMOS的电流方向与开关逻辑
  • Armv8-M安全扩展架构解析与嵌入式系统安全实践
  • Luong注意力机制:原理、实现与工程优化
  • 轻量级邮件发送库chekusu/mails:SMTP协议封装与实战应用
  • 解密Scrapy-Pinduoduo:构建电商数据智能采集系统的技术实践
  • 智能体网络协议ANP:构建AI原生协作网络的核心架构与实践
  • 掌握Cura切片引擎:从模型到完美打印的实战进阶指南