Chapter 02

写入路径:为什么是"近实时"

上一章理清了名词和层级,尤其点出 segment 是不可变的。这一章顺着"不可变"这条线,追一条文档从写入到能被搜到,中间到底发生了什么——近实时、持久化、删除与合并,全是这条公理的推论。

本章你将建立的 schema

  • 倒排索引怎么从一段文本建出来(analyzer → term → postings list),以及它为什么让搜索快
  • "段不可变"如何同时解释 近实时(refresh)、持久化(translog / flush)、删除与更新(merge)
  • refresh / flush / merge 是三件不同的事——混淆它们是大多数写入困惑的根源

写入路径上的每一个反直觉行为,根都扎在 01 章那条公理里:底层是一堆不可变的 Lucene segment。一段已经写出的 segment 永不被原地改写——这一条同时逼出了三个看似无关的现象:写完为什么搜不到(refresh)、崩溃为什么不丢数据(translog / flush)、删除为什么不立刻回收磁盘(merge)。本章四节顺着一条文档的旅程走:先看它被切成什么(倒排索引),再看它何时可见(refresh)、何时落盘(flush),最后看它被删时发生了什么(merge)。

2.1倒排索引:搜索为什么快

写入时把文本切成规范化的 term,每个 term 指向一个有序的文档 id 列表(postings);搜索因此变成几个有序列表的归并,而不是全表扫描。

为什么需要它

没有倒排索引,"找出所有含 fox 的文档"只能逐行取出每篇文章、在正文里做子串匹配,代价是 O(N · 字段长)——文档越多越慢,且每个查询词重来一遍。倒排索引把这份扫描代价一次性预付在写入时:写入时就按词建好索引,于是读取时无需再碰原文。

运行方式:analyzer 管线 → term → postings

一个 text 字段被写入时,它的内容先过一条 analyzer 管线,分三段:

  • char filter:在分词之前对原始字符做替换,例如剥掉 HTML 标签、把 & 换成 and。
  • tokenizer:按规则把字符流切成 token,最常见的 standard tokenizer 按词边界切,并丢掉大部分标点。
  • token filters:对切出的 token 逐个规范化——小写化(lowercase)、去停用词(stop,如 the / a)、词干还原(stemmer,把 foxes 还原成 fox)。

管线的产物是一串规范化后的 term。索引之所以叫"倒排",是因为它不存"文档 → 它含哪些词",而是反过来存 每个 term → 一个 postings list:一个按文档 id 升序排列的列表,每个条目还附带词频(term frequency)和位置(position,供短语查询用)。所有 term 本身又被收进一个有序的 term 词典。

管线 "The Quick Fox" char filter 清洗字符 tokenizer 切词 token filters 小写 / 去停用词 / 词干 [ quick, fox ] 倒排映射 term postings (doc id) quick → [1, 4, 9] fox → [4, 7, 9] brown → [2, 4] 写入映射
图 2.1一段文本经 analyzer 管线切成规范化 term,再倒排成"term → 有序 doc id 列表"。注意:词典与每个 postings list 都是有序的——正是这份有序让 quick AND fox 退化成两个排好序列表的归并,而非全表扫描。

搜索时为什么快

查询 quick AND fox 时,查询词先过同一条 analyzer得到 term quick 和 fox,在有序 term 词典里二分定位到各自的 postings,然后两个已排序的 doc id 列表做归并求交集——两个指针各自只向前走,命中相同 id 的就是结果。整个过程不碰任何一篇原文,代价只与命中文档数相关,与索引里文档总数基本无关,这就是"亚线性"。行存数据库走的是相反路线:扫每一行、对字段做子串匹配,代价随行数线性增长。倒排索引把这份代价从"每次查询"挪到了"一次写入"。

带来的代价

预付不是免费的。写入时要跑完整条 analyzer 管线并构建索引结构,因此写比"直接落一行"重。更隐蔽的代价是一致性约束:查询分词器必须和索引分词器一致,否则查询词规范化出的 term 和存储的 term 对不上,结果就是"字段里明明有这个词却搜不到"。

洞察 · 呼应 01 章 text / keyword

这正是 01 章 text 与 keyword 那条分水岭的底层原因。text 字段过 analyzer、被切成多个 term,所以要用 match(查询词也过同一 analyzer、再匹配 term);keyword 字段不分词、整段当成一个 term 存,所以要用 term(精确匹配整个值)。对 text 用 term 去查一整句话,等于拿"没分过词的原句"去比"分过词的单个 term"——永远对不上。term vs match 的区别,本质是"绕不绕过 analyzer"。

例

字段值 "Running Foxes" 经 standard analyzer + lowercase + 英文 stemmer,存进去的 term 是 [run, fox](停用词无、running 词干还原成 run、foxes 还原成 fox)。用 match: "ran" 查,若查询侧用同一 stemmer,ran 也还原成 run,命中;用 term: "Running" 查,拿未规范化的 Running 直接比 term 词典里的 run,不命中。

想一想

给一个 text 字段写入 "E-mail",standard analyzer 会把它切成几个 term?之后用 term: "e-mail" 能精确命中吗?

展开答案(先停 10 秒再点)

standard tokenizer 在连字符处断开并丢弃标点,"E-mail" 切成两个 term:e 和 mail(再经 lowercase)。词典里根本不存在 e-mail 这个整体 term,所以 term: "e-mail"(不过 analyzer、精确比对整串)匹配不到。

它指向的设计点:text 字段存的是切碎并规范化后的 term 集合,不是原文。要按原值精确匹配 / 排序 / 聚合,得用 keyword——这又回到 01 章那条分水岭。

2.2不可变的 segment 与 refresh:近实时从哪来

新文档先进内存 buffer;一次 refresh 把 buffer 写成一个新 segment 并重开 reader——"近实时"就是这个 reader 重开的节奏,默认约 1 秒,不是实时。

为什么这样设计

segment 一旦写出就不可变(01 章公理)。"不可变"意味着新文档不能塞进已有 segment,只能攒在内存里、再整批写成一个新 segment。而 Lucene 的搜索是针对"已打开的 segment 集合"做的——只有重新打开一个包含新 segment 的 reader,新文档才进入搜索的视野。这一步重开就是 refresh。

运行方式:buffer → refresh → 新 segment → reader 重开

一条文档被索引后,先落进一个 in-memory buffer(同时也写了 translog,见 2.3)。此时它还搜不到——搜索看的是已打开的 segment,而 buffer 不是 segment。直到一次 refresh 发生:buffer 里积攒的文档被写成一个新的磁盘 segment,并打开一个新的 reader,把这个新 segment 纳入可搜索集合。从此刻起,这批文档才能被搜到。

refresh 默认每 1 秒触发一次,但有个常被忽略的条件:自 ES 7.0 起,只有最近 30 秒内被搜索过的 index 才会自动周期 refresh;完全没有查询打进来的 index 不会空跑 refresh,避免给纯写入负载白白制造小 segment。这就是 near-real-time(近实时) 的全部含义——不是写完即可见的实时,而是"reader 按节奏重开"带来的、上限约 1 秒的可见延迟。

写入一条文档 可见性轴 in-memory buffer 搜不到 refresh ~1s 新 segment 打开新 reader 可被搜索 near-real-time 持久化轴 追加 translog 每个 op 都写 崩溃可重放 flush (fsync) 默认周期 / translog 满 落盘并清空 translog 两条轴各管各的:refresh 管"可见",flush 管"落盘"
图 2.2一条文档写入后分头走两条正交的轴:左边可见性轴(refresh)决定它何时能被搜到,右边持久化轴(flush)决定它何时落盘。注意:朱红色的 refresh 链条和黑色的 flush 链条互不依赖——这就是为什么"可见"和"落盘"是两件事,下一节展开。

带来的代价

refresh 越频繁,单位时间内生成的小 segment 越多,而段越多越碎,后续 merge(2.4)要重写合并的负担就越重——代价不在 refresh 当下,而滞后到合并阶段。?refresh=true 能强制一次立即可见的 refresh,但每调一次就多新建一个小 segment;在高频写入路径上滥用,等于主动制造段爆炸、把成本压给 merge。

提示

需要"写完立刻读回"时,优先用 ?refresh=wait_for 而非 ?refresh=true:前者让请求挂起、等到下一次自然的周期 refresh 再返回,不额外制造 segment;后者强制立刻 refresh,多一个小段。两者都能保证读回,代价档次不同。

想一想

写入一条文档后立刻发一个 GET _search,默认配置下搜得到吗?

展开答案(先停 10 秒再点)

默认搜不到。文档此刻还在 in-memory buffer 里,没被写成 segment、reader 也没重开,不在可搜索集合中。要等下一次 refresh——最长约 1 秒——它才可见。

要立刻读回,有两条路:按 _id 走 GET /index/_doc/{id}(实时读,直接查 translog + segment,不受 refresh 影响),或写入时带 ?refresh=wait_for 让请求等到下一次 refresh 完成再返回。注意"实时 GET"和"近实时 search"是两条不同的路径——前者按主键点查,后者走倒排索引。

2.3持久化 ≠ 可见性:translog 与 flush

可见性(refresh)和持久化(durability)是两条独立的轴:每个写操作先追加 translog 保证崩溃不丢,flush 才把内存 segment fsync 落盘并清空 translog。

为什么需要 translog

refresh 把 buffer 变成 segment,但这个新 segment 起初只在文件系统缓存里,还没 fsync 到物理磁盘——此时断电,它会丢。把每个 segment 都立刻 fsync 又太慢(fsync 是昂贵的磁盘同步)。translog 是这道矛盾的解:每个写操作在进 buffer 的同时顺序追加到一个事务日志,顺序写很快;万一在 flush 之前崩溃,重启时重放 translog 就能把尚未落盘的操作全部补回来。持久化由 translog 兜底,落盘的昂贵动作则可以攒着批量做。

运行方式:两条轴各走各的

把图 2.2 的两条轴拆开看:

  • 可见性轴(refresh):buffer → 新 segment → reader 重开 → 可搜索。管"什么时候能搜到"。
  • 持久化轴(durability):每个 op → 追加 translog → flush 时 fsync segment 落盘并清空 translog。管"崩溃了丢不丢"。

两条轴由不同的触发条件驱动、互不等待。refresh 默认 1 秒一次;flush 则在 translog 达到阈值(默认 512MB)或周期到点时触发,把内存里的 segment fsync 到磁盘、然后清空已落盘部分对应的 translog。一次 flush 之后,那段 translog 的使命完成、可以丢弃。

translog 持久化策略 JSON
PUT /logs/_settings
{
  "index.translog.durability": "request",
  "index.translog.flush_threshold_size": "512mb"
}
// durability=request(默认):每个写请求返回前,translog 先 fsync
//   → 单条写确认即不丢,代价是每请求一次磁盘同步
// durability=async:translog 每 5s 才 fsync 一次
//   → 写吞吐更高,但崩溃可能丢掉最近 5s 的已确认写
洞察 · 头号写入困惑的根因

因为这两条轴正交,一条文档可以处在它们的任意组合态。一种是已持久化、但还不可见:操作进了 translog(崩溃能重放回来),但还没 refresh,所以搜不到。另一种是已可见、但还没 fsync 落盘:refresh 了、能搜到,但那个新 segment 仍在文件系统缓存里,尚未 flush——此刻真断电,segment 本身会丢,但数据并不丢,因为它还在 translog 里,重启重放即可恢复。把 refresh 和 flush 当成同一回事——以为"搜得到就等于落了盘"或"落了盘就等于搜得到"——是绝大多数写入困惑的头号根因。

带来的代价

translog 不是没有成本的。translog 攒得越大,崩溃后重启要重放的操作越多,恢复时间越长;这也是 flush 阈值不能设得过大的原因。反过来,flush 太频繁则把"攒批落盘"的优化吃掉了,每次 fsync 都是一次昂贵的磁盘同步,增加 I/O 压力。durability: request(默认)保证每条已确认的写都不丢,但代价是每个写请求都要等一次 translog fsync;改成 async 能换吞吐,但崩溃时会丢掉最近一个 fsync 间隔内的已确认写——这是一道明确的"持久化保证 vs 写吞吐"权衡。

2.4删除不真删、更新=删+插:merge

段不可变,所以删除只是在位图里打个标记(soft delete),被删文档的空间要等后台 merge 把若干段重写成新段时才真正回收;更新 = 旧文档标删 + 新文档进新段。

为什么删除不能"真删"

从一个不可变的 segment 里抠掉一条文档,等于原地改写它——这与"段不可变"直接冲突。于是 ES 走了另一条路:删除时不动 segment,只在一个伴随该 segment 的位图(.liv 文件,每个文档一个 bit)里把对应文档标记为已删(soft delete)。搜索时跳过被标记的文档,对外看起来"删掉了",但它仍占着 segment 的物理空间。

运行方式:标删 → 后台 merge 才真正回收

被 soft-delete 的文档一直躺在原 segment 里,直到后台的 merge 发生:merge 选若干个小 segment,把其中仍存活的文档重写进一个新的、更大的 segment,然后丢弃旧 segment。被标删的文档在重写时不会被带进新段——这一刻,它们的空间才真正释放。merge 因此身兼两职:把碎段合并成大段(减少段数、加快搜索),以及顺带清理掉积压的已删文档。

更新(包括用相同 _id 重新索引)在底层就是"删 + 插":把旧版本文档 soft-delete,把新版本写进当前 buffer、等下次 refresh 进新 segment。所以高频更新同一批文档,会持续制造"标删的旧版本",把回收压力堆给 merge。

3 个小 segment(含 soft-deleted) = soft-deleted(占空间) merge 重写 只搬存活文档 1 个新 segment 被删文档消失 · 空间回收
图 2.3merge 选若干小 segment,把存活文档重写进一个新段,旧段连同被标删文档一起丢弃。注意:灰格子(soft-deleted)在 merge之前一直占着磁盘——删除命令本身不回收空间,回收发生在 merge 那一刻。
反直觉

批量删除一半文档后,磁盘占用不会立刻下降,甚至会先涨:删除产生的标记、以及为承接后续写入而新建的 segment,都和旧 segment 并存,要等 merge 完成才回收。merge 是 ES 里最容易被忽略、却最重的隐藏 I/O 成本——它在后台默默重写整段整段的数据,能在写入高峰把磁盘读写吃满。"删了为什么磁盘没降""明明没怎么写为什么 I/O 这么高",根都在这里。

带来的代价

merge 把"读到稳定快照、写走顺序追加"的好处,换成了一项后台开销:被删 / 被更新文档在 merge 前持续占用磁盘(空间回收滞后),而 merge 本身是重 I/O 的整段重写,与正常读写争抢磁盘带宽。ES 用一套策略限制并发 merge 数与速率来平摊这份成本,但它无法被消除——只能被调度。这也是为什么"段不可变"虽然让读路径干净,却必然附带一个永远在后台运转的 merge。

想一想

对一个 index 执行 DELETE _by_query 删掉一半文档,磁盘占用会立刻降一半吗?

展开答案(先停 10 秒再点)

不会。这些文档只是被 soft-delete——在各自 segment 的 .liv 位图里标了一位,文档数据原封不动留在 segment 里继续占空间。磁盘占用要等后台 merge 把这些段重写、把存活文档搬进新段、丢弃旧段时,才逐步下降。短期内甚至可能因为删除操作本身写入 translog、以及并发的新写入制造新段而略升。

它指向的设计点:删除的"逻辑生效"(搜不到了)和"物理回收"(磁盘降了)是分离的两件事,由 merge 这条后台通路连接。想强制立刻回收可以触发 _forcemerge,但那是一次极重的全量重写,只适合冷数据。

为什么是"不可变段 + 后台合并",而不是原地更新

把前三节的代价摊开看,会自然冒出一个问题:既然不可变带来了近实时延迟、删除滞后、merge 重 I/O 这么多麻烦,为什么不干脆让 segment 可原地更新?下表对比三条路线,解释为什么 ES(及其底层 Lucene)选了不可变这条。

表 2.1 · 为什么用"不可变段 + 后台合并"而不是原地更新
方案优势为什么没选
原地更新倒排索引 立即可见、无合并开销 postings 是有序压缩结构,在中间插 / 删一个 doc id 要重排整段、极难高效;并发读写必须加锁;且无法给搜索一个稳定不变的快照——读到一半底层数据就变了
每次写都建独立小索引 实现简单、天然不可变 段爆炸:一个查询要把成百上千个小段的结果合并,读极慢,小段的固定开销也压垮内存
不可变段 + 后台 merge 顺序追加写得快、读到稳定快照、读路径可无锁、filter 结果可缓存且不失效 选中。代价是近实时延迟(refresh)、删除空间滞后回收、merge 占 I/O——本章前三节正是这些代价
洞察 · 前向引用 03 章

"读路径可无锁 + filter 结果可缓存且不失效"这条优势,到 03 章会兑现成一个具体机制:filter 查询的结果被缓存成按 segment 的 bitset,因为 segment 不可变,这份缓存永远不需要失效——细节在 03 章 query 与 filter。本章只需记住:不可变这条公理在写路径上付出了延迟和 merge 的代价,到读路径上会以"缓存免失效"的形式连本带利还回来。

2.5把三件事拼回一条链路

refresh / flush / merge 是三件不同的事,触发条件、负责的轴、产出各不相同。混淆它们是写入困惑的总根源,所以单独列一张表钉死区别:

表 2.2 · refresh / flush / merge 三者对照
操作负责哪条轴默认触发产出 / 效果
refresh 可见性 ~1s(且 index 近 30s 被查过) buffer → 新 segment、重开 reader,新文档可被搜到
flush 持久化 translog 达 512MB 或周期到点 把内存 segment fsync 落盘,清空已落盘部分的 translog
merge 空间 / 段数 后台按段数与大小启发式 小段重写成大段,真正丢弃 soft-deleted 文档、回收空间

把一条文档的完整旅程串起来:被索引 → 同时进 buffer 和 translog(已持久化、未可见)→ 下次 refresh 写成新 segment、可被搜到(已可见、仍在文件缓存)→ 下次 flush 把 segment fsync 落盘、清 translog(已落盘)→ 若干次写入后,它所在的小段被 merge 进大段;若期间它被删 / 被更新,旧版本在这次 merge 里被真正丢弃。四段路、三件事,全是"segment 不可变"这一条公理的连续推论。

§本章 self-check

先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。

  1. 用一句话说清 refresh、flush、merge 各自负责什么、各自默认什么时候发生。
  2. 一条刚写入、还没 refresh 的文档,此刻服务器断电,数据会丢吗?为什么?这说明可见性和持久化是什么关系?
  3. 对 text 字段写入 "Quick Foxes"、再用 term: "Quick Foxes" 查,为什么查不到?换成什么查询、或换成什么字段类型能查到?
  4. (设计层)为什么 refresh 和 flush 要拆成两件独立的事,而不是合并成一个"落盘并可见"的单一操作?合并会牺牲掉什么?
答案(先做完再展开)
  1. refresh 负责可见性:把 buffer 写成新 segment、重开 reader,使新文档可搜,默认约 1 秒一次(且该 index 近 30s 被查过)。flush 负责持久化:把内存 segment fsync 落盘、清空 translog,默认在 translog 达阈值(512MB)或周期到点时发生。merge 负责段数与空间:把小段重写成大段、顺带真正丢弃 soft-deleted 文档回收空间,后台按段数 / 大小启发式触发。
  2. 不会丢。每个写操作在进 buffer 的同时已追加到 translog,断电重启后重放 translog 即可恢复这条文档。这说明持久化(translog 兜底)和可见性(refresh)是两条独立的轴:文档可以"已持久化但还不可见"——在 translog 里崩溃不丢,但因为没 refresh 而搜不到。
  3. text 字段过 analyzer,"Quick Foxes" 被切并规范化成 term(如 quick、fox),词典里不存在 Quick Foxes 这个整体;而 term 查询不过 analyzer、拿原串精确比对 term,对不上。改用 match: "Quick Foxes"(查询词过同一 analyzer、再匹配 term)能查到;或把字段设成 keyword(整段当一个 term 存),再用 term 精确匹配整个值。
  4. 因为它们各自换的是不同的东西,合并会逼这两笔交易绑死、互相拖累。可见性想要低延迟(约 1 秒就让用户搜到),但 refresh 生成的新 segment 起初只在文件系统缓存里,并不昂贵;持久化想要的是崩溃不丢,要靠昂贵的 fsync 落盘。若合并成"落盘即可见",要么每秒都 fsync(把 1 秒可见延迟的代价抬成每秒一次磁盘同步,写吞吐崩)、要么把可见延迟拖到 flush 周期那么长(用户要等很久才搜得到)。拆开后,refresh 用便宜的"重开 reader"买低延迟可见,translog 用便宜的"顺序追加"买持久化、把昂贵的 fsync 攒批到 flush——两笔交易各自取到最优。
进阶挑战 · 刚好够不着

每秒百万文档、查询容忍 30s 延迟的日志场景,refresh_interval 怎么调?

一个日志写入场景:每秒约百万条文档涌入,但查询侧完全能容忍最新数据 30 秒后才可见。保持默认 refresh_interval: 1s 会发生什么?把它调成 30s(甚至写入期间设成 -1 关掉自动 refresh)能换来什么?沿着 refresh → segment → merge 这条链路推一遍代价的流向。

提示(卡住再展开)

默认 1s 时,每秒都把 buffer 刷成一个新 segment——百万文档 / 秒意味着海量小段被持续生产,而段越碎,后台 merge 要重写合并的工作量越大,写入高峰期 merge 抢走的磁盘 I/O 越多,反过来拖慢写入本身。把 refresh_interval 拉到 30s,等于让 buffer 多攒 30 倍的数据再一次性刷成一个更大的 segment:单位时间内的段数量骤降,merge 压力随之大幅下降,写吞吐显著回升。代价正好是查询侧愿意付的那个:可见延迟从 1s 变成最长 30s。这就是"用查询能容忍的延迟,去换写入侧的段数和 merge 开销"——refresh 的快慢,最终是在 segment 数量和 merge I/O 上结账。

本章参考