深度解析 Elasticsearch 更新与删除文档原理:段不可变性与 .del 文件的秘密
深度解析 Elasticsearch 更新与删除文档原理段不可变性与 .del 文件的秘密前言一、核心前提Lucene 段不可变性1.1 什么是段Segment1.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 什么是段SegmentLucene 的索引由多个段组成每个段都是一个独立的倒排索引结构。索引 (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_no1if_primary_term1{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_nooptrue仅当内容变更时才更新避免无意义的版本递增// 全文替换直接 PUTPUT/my_index/_doc/123{title:brand new title,content:completely new content}// 部分更新_update APIPOST/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 (合并后的大段) │ │ • 总文档: (10012090110) 420 篇 │ │ • 删除文档: (58310) 26 篇 ← 物理清理不写入新段 │ │ • 有效文档: 394 篇 │ │ • .del 文件: 空新段无删除标记 │ └─────────────────────────────────────────────────────────────────────┘ 旧的 Segment A、B、C、D 及它们的 .del 文件被删除 ✅ 磁盘空间释放4.3 手动触发合并// 手动触发段合并生产环境谨慎使用POST/my_index/_forcemerge?max_num_segments1使用场景索引不再写入新数据如历史日志索引需要释放磁盘空间提升只读索引的查询性能注意事项合并过程消耗大量 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新建❌✅无额外开销关键点频繁更新/删除会导致.del文件膨胀占用磁盘查询时需要过滤被删除文档增加 CPU 开销段合并需要 rewrite 大量数据消耗 IO5.2 如何减少更新/删除的负面影响策略说明减少更新频率批量合并更新而非逐条更新避免高删除率删除率超过 50% 的索引考虑重建定期 force_merge对不活跃索引执行强制合并使用 ILM 管理自动管理索引生命周期考虑用 Data Stream时间序列数据的最佳实践六、常见面试题Q1删除文档后磁盘空间为什么没有立即释放回答因为 Lucene 段的不可变性删除操作只是在.del文件中做标记文档并未被物理移除。只有等段合并发生时被标记删除的文档才会被跳过旧段被删除磁盘空间才会真正释放。这种设计牺牲了即时空间回收换来了更高的查询性能和并发能力。Q2更新文档的内部原理是什么回答更新操作本质上是“标记删除 新增”的组合读取原文档的完整内容应用字段更新生成新文档在原文档所在段的.del文件中标记删除旧版本将新文档作为全新文档写入新段查询时过滤掉被标记的旧版本返回新版本版本号会递增支持乐观锁并发控制。最终段合并时旧版本被物理清理。Q3频繁更新对集群有什么影响回答频繁更新会导致三方面影响磁盘膨胀旧版本文档直到段合并才会清理短期磁盘占用翻倍查询变慢每个被删除的文档在查询时都需要检查.del文件IO 压力段合并需要重写大量数据消耗 IO 和 CPU建议对于高频更新的场景考虑将此索引放在高性能 SSD 节点上或调整合并策略参数。Q4删除操作在什么情况下会丢失数据回答删除操作本身不会丢失数据不是物理删除但以下情况需注意delete_by_query没有事务保障执行中如果集群故障部分文档可能未被删除删除后立即 force_merge 可能物理删除数据无法恢复没有备份时删除的文档无法找回建议重要数据定期快照备份批量删除使用异步任务并监控进度。七、总结维度删除更新核心操作在 .del 文件中标记标记删除 写入新文档物理清理段合并时段合并时对查询影响需要过滤被删除文档需要过滤被删除文档版本控制不适用版本号 乐观锁磁盘空间立即可见标记实际不释放新旧文档同时存在短期占用翻倍触发合并_forcemerge或自动合并_forcemerge或自动合并核心要点Lucene 段不可变删除和更新无法原地操作删除 →.del文件标记查询时过滤更新 →标记删除旧版索引新版段合并 →物理清理被标记删除的文档版本控制 → 支持乐观锁并发八、面试加分回答面试官请详细描述 Elasticsearch 更新和删除文档的过程。候选人“由于 Lucene 底层采用不可变段Segment设计ES 的删除和更新不能原地操作。删除操作文档并未被物理移除而是在对应段的.del文件中进行标记。查询时倒排索引仍会返回该文档的 DocID但执行器会检查.del文件将已标记的文档过滤掉。更新操作本质是‘标记删除 新增’的组合首先读取原文档应用字段更改生成新文档然后在.del文件中标记旧版本为删除最后将新文档作为新文档索引可能写入不同段。版本号会递增支持乐观锁并发。物理清理两种操作都不会立即释放磁盘空间。ES 后台会定期执行段合并读取多个小段跳过被标记删除的文档写入新的大段然后删除旧段及其.del文件磁盘空间才真正释放。也可以通过_forcemergeAPI 手动触发。性能影响频繁的更新和删除会导致.del文件膨胀、查询时需要额外过滤、段合并消耗 IO。建议对频繁更新的索引使用 SSD并考虑调整合并策略参数。”The End点点关注收藏不迷路