Elasticsearch 系列 · 延续倒排索引一篇
Elasticsearch 为什么是"近实时"
写一条数据进 Elasticsearch,为什么不是立刻就能被搜到,而是要等大概 1 秒?这篇拆开这背后的完整流程:内存缓冲区、事务日志(translog)、段(segment)、以及 refresh / flush / merge 这三个经常被搞混的动作,配一个可以逐步播放、每一步都验证过内部状态一致性的动画演示。
01 · 一个违反直觉的现象
刚写完,搜不到,是 bug 吗
很多人第一次用 Elasticsearch 都会踩到这个"坑":调用接口插入一条数据,接口返回成功,紧接着立刻搜索,却发现搜不到——过一小会儿(通常不到 1 秒)再搜,就有了。这不是 bug,是设计如此,官方文档管这个特性叫 near real-time(近实时),"近"这个字就是特意标注给你看的。要理解为什么,得先知道写入的数据经过了哪几层。
02 · 三个核心概念
段 / translog / 三种不同节奏
Elasticsearch 底层的 Lucene 索引不是一整块,而是很多个小的"段"拼起来的。每个段一旦写出来就不可变——不能往里加文档,也不能真的删掉一个文档,只能整体拿去和别的段合并。这个"不可变"的约束,是接下来一切设计的起点。
每次写入,除了进内存缓冲区,还会追加写一行到磁盘上的日志文件里。它不是拿来搜索用的,唯一的作用是"万一进程这时候崩了,内存里的数据丢了,重启后能靠重放这份日志把数据找回来"——一份专门为故障恢复准备的安全网。
refresh(默认约 1 秒一次)决定"什么时候能被搜到";flush决定"什么时候真正安全落盘、translog 可以清空";merge决定"什么时候把小段拼成大段、腾出被删文档占的空间"。三件事各管各的,互不等待。
03 · 现场直播
一条数据从写入到"可以被搜到"的完整旅程
下面的演示依次做了:写入两条数据、refresh、再写一条、删除最早那条、refresh、再写一条、refresh、flush、最后 merge。每一步都记录了当时"如果现在发起一次搜索,能搜到哪些文档"——这份状态还用一份完全独立的第二种算法重新推导过一遍,两边逐条比对一致,不是随手画的动画。
04 · refresh 到底做了什么
把内存缓冲区"冻"成一个新段
演示第 3 步(第一次 refresh)发生的事:内存缓冲区里的 A、B 两条数据,被写成一个新的、不可变的段,这个段立刻对搜索可见——缓冲区随之清空。这就是"能被搜到"这件事发生的唯一时刻。Elasticsearch 默认每 index.refresh_interval(默认 1 秒)自动做一次这个动作,这也是"近实时"里那个大约 1 秒延迟的来源。
注意:refresh 生成的新段,此刻很可能只存在于操作系统的文件系统缓存里,并没有真正 fsync 到磁盘——"能搜到"不等于"已经安全持久化",这是两件独立的事,分别由 refresh 和接下来要讲的 flush 负责。
05 · 删除是"打个标记",不是真的删
Tombstone(墓碑)
演示第 5 步删除文档 A 时,A 并没有从它所在的段里消失——那个段已经不可变了,做不到。真正发生的是:这个段内部多了一条"A 已删除"的标记(墓碑,tombstone)。搜索的时候,凡是命中了带墓碑标记的文档,会被直接跳过、当作不存在;但它占用的磁盘空间要一直等到这个段被 merge,才会被真正回收。这和我们在 Go map 那篇讲的 tombstone 是同一个思路——不同的系统,同一个"不能就地删除时该怎么办"的解法。
06 · flush:什么时候才真正"安全"
落盘,并且清空 translog
演示第 9 步(flush)做了两件事:把所有还没 fsync 的段真正写到磁盘上;把 translog 清空(准确说是新开一个空的)。第二件事的逻辑是:既然这些操作现在已经安全地体现在磁盘上的段里了,就不再需要靠重放 translog 来恢复它们,继续留着只会让 translog 文件越滚越大。
观察这一步的 可搜索文档集合完全没有变化——flush 不影响"能搜到什么",它只影响"崩溃后能不能恢复、还需不需要重放多少数据"。这正是本文最想讲清楚的一点:refresh 管可见性,flush 管持久性,是两条互不干扰的时间线,现实里 flush 的频率通常比 refresh 低得多。
07 · merge:把碎片拼起来,顺便扫墓
为什么段不能无限增多
Elasticsearch 默认每秒就可能产生一个新段(如果这段时间内有写入的话)。段一多,一次搜索就要挨个查完所有段再合并结果,还会占用大量文件句柄和内存——merge 就是后台安静地把几个小段拼成一个大段。演示最后一步:三个段(其中一个带着 A 的墓碑)被合并成一个新段,合并过程中,带墓碑的文档被彻底清除,磁盘空间才真正被回收。
三者的关系,一句话总结:refresh 决定你多久能看到新数据;flush 决定数据多久变得"扛得住宕机";merge 决定存储多久被真正清理干净。三件事默认节奏都不一样,互相独立触发,不要把它们当成同一件事的三个名字。
08 · 一个实际的取舍
能不能让它更"实时"
把 refresh_interval 调小(甚至设成写入时立即触发)确实能让数据更快可搜,但代价是:每次 refresh 都会生成一个新段,频率越高,段积累得越快,后台 merge 的压力越大,反而可能拖慢整体的写入和查询性能。所以真实项目里,如果业务能接受"几秒延迟",通常会保持默认或者调大这个间隔(比如日志类场景常见调到 30 秒甚至更久),用短暂的延迟换取更好的吞吐量——这是一个典型的"实时性 vs 系统开销"的权衡,没有免费的午餐。
09 · 参考与说明
这篇文章做了哪些简化
- 这篇讲的是 Elasticsearch/Lucene 公开文档里描述的机制和设计动机,不是逐行对照源码——面向的是想理解"为什么"而不是想读实现代码的读者,和之前 Go 运行时系列的写法刻意不同。想再深入可以看 Elasticsearch 官方文档里 near real-time 这一节,以及 Elasticsearch from the Bottom Up 这篇经典博客。
- 真实的 translog 还涉及"写入确认策略"(
index.translog.durability,默认每次请求都fsynctranslog,也可以改成定期fsync换取更高吞吐但增加丢数据窗口)——这篇为了让主线索清楚,统一按"每次写入都追加 translog"处理,没有展开这层可配置的权衡。 - 真实的 merge 由一套可配置的"合并策略"(merge policy)决定什么时候合并哪些段,会综合考虑段大小、段数量等因素;演示里为了让效果一眼可见,直接把 flush 后现存的全部段合并成了一个。
- 演示里的"删除"简化成了只处理已经进入某个段的文档;真实实现里,如果删除的文档还在内存缓冲区、尚未 refresh,做法会不同(直接从待写入的操作里抹掉即可)。
- 本文演示的 10 步完整流程,用 Python 脚本真实模拟并做了断言校验——尤其是"任意时刻能搜到什么"这件事,专门写了第二种独立算法重新推导一遍,和主流程逐步产出的结果逐条核对完全一致。