你打一行 POST /menu/_doc,ES 回你一個 201 Created。中間發生了什麼?

如果你的回答是「存進去了」,那你只看到表面。從 HTTP 請求到資料真正可搜尋,中間至少經過六個階段、跨越記憶體和磁碟兩個世界。每個階段的設計決策都直接影響效能天花板。

今天把引擎蓋打開,逐個零件看。


寫入:一筆資料的旅程

一筆文件從 client 送出到「可被搜尋」,經過的站點比大多數人想的多。

第一站:Coordinating Node 派工

請求打到叢集中任一個 Node,這個 Node 自動成為本次請求的 Coordinating Node。它不存資料,只做一件事——算出這筆資料該去哪個 shard。

1
shard_number = hash(routing) % number_of_primary_shards

routing 預設是文件的 _id,hash 用的是 MurmurHash3。為什麼不用 SHA-256?因為這裡不需要加密等級的安全性,只需要分布均勻且計算快。MurmurHash3 在這兩點上幾乎是最佳選擇。

Custom routing 有一個很實用的場景:按 user_id 路由。同一個用戶的資料集中在同一個 shard,查詢時不用廣播到所有 shard,單用戶查詢效能可以差到數倍。

第二站:Primary Shard 寫入

Coordinating Node 把請求轉到 Primary Shard 所在的 Data Node。這裡是真正做事的地方,三件事同時發生:

先寫 Translog。 每筆操作先 append 到磁碟上的 Translog 檔案。預設每筆都 fsync(index.translog.durability: request)。這是斷電不丟資料的第一道防線。

再寫 In-Memory Buffer。 文件被加入記憶體中的 Index Buffer。此時它還不可搜尋——只是在排隊等被處理。

最後並行複製到 Replica。 Primary 寫入成功後,同時發給所有 Replica Shard。注意是「並行」不是「序列」——3 個 Replica 同時收到。預設要等所有 Replica 回報成功才回覆 client。

第三站:Refresh — 從不可見到可搜尋

每 1 秒(預設),In-Memory Buffer 裡的資料被「沖刷」成一個新的 Lucene Segment。這個 Segment 住在 OS Page Cache——還是記憶體,還沒到磁碟。但它已經建好了倒排索引、Doc Values、Stored Fields,已經可以被搜尋了。

這就是 Near Real-Time(NRT)的核心:你寫入的資料最多等 1 秒就能被搜到。不是因為磁碟寫得快——是因為它根本還沒寫到磁碟。

Refresh 完成後 Buffer 清空,等待下一批。

第四站:Flush — 真正落碟

定期(或 Translog 太大時),ES 執行 Flush:把 Page Cache 中的 Segment fsync 到磁碟、寫入新的 Commit Point、清空 Translog。Flush 之後資料才算真正持久化。

在 Refresh 和 Flush 之間的空窗期,資料的安全網是 Translog。


Translog:斷電不丟資料的守護神

Translog 就是 Write-Ahead Log(WAL),跟 MySQL 的 redo log、PostgreSQL 的 WAL 是同一套哲學:先寫日誌,再做事。

設計規則很單純:

  • 每筆寫入都先寫 Translog(磁碟)
  • 再寫 In-Memory Buffer(記憶體)
  • Refresh 後 Segment 在 Page Cache(記憶體)
  • Flush 後 Segment 寫入磁碟 + 清空 Translog

Translog 只有在 Flush 之後才被清空。在此之前,它是所有尚未持久化 Segment 的唯一備份。

三種斷電場景

寫入後、Refresh 前斷電: Buffer 裡的資料消失,但 Translog 在磁碟上完好。重啟後 ES 從 Translog 重播所有操作,重建 Segment。資料不丟。

Refresh 後、Flush 前斷電: Page Cache 裡的 Segment 消失,Translog 存活。同樣重播恢復。資料不丟。

Flush 後斷電: Segment 已在磁碟上,Translog 已清空。什麼都不會丟。

不管哪個階段斷電,資料都不丟。這個設計的優雅之處在於它把「可搜尋」和「已持久化」拆開了——Refresh 讓資料可搜尋但不保證持久,Translog 保證持久但不管可不可搜。兩條線各司其職。

效能調校

1
2
3
4
5
PUT /menu/_settings
{
"index.translog.durability": "async",
"index.translog.sync_interval": "5s"
}

改成 async 後,Translog 每 5 秒才 fsync 一次,寫入效能提升 20-30%。代價是斷電可能丟最近 5 秒的資料。

日誌類索引(丟幾秒可以接受)用 async。訂單、金融(一筆都不能丟)用預設的 request。曾經有團隊為了效能把訂單系統改成 async,結果機房斷電丟了 5 秒訂單,補不回來。根據業務場景選。


搜尋:Scatter-Gather 兩階段

寫入是單行道——專走 Primary Shard。搜尋是廣播戰——所有 Shard 一起來。

Query Phase(散布)

Coordinating Node 把查詢廣播到所有相關 Shard。每個 Shard 搜尋自己的所有 Segment,回傳本地 Top N 的 _id + _score。注意只有 ID 和分數,不帶完整文件。

Coordinating Node 收到所有 Shard 的結果後,合併排序,選出全域 Top N。

Fetch Phase(收集)

確定了全域 Top N 之後,Coordinating Node 回頭向各 Shard 要那幾筆的完整 _source

為什麼分兩個 Phase?因為一次就拿完整文件的話,每個 Shard 都得回傳 Top N 的全部 _source,網路傳輸量 = Shard 數 × N × 文件大小,深分頁時直接爆炸。先只傳 ID + score,排序後只取真正需要的幾筆,效率高得多。

Shard 內部怎麼搜

一個 Shard 可能有幾十個 Segment。搜尋時每個 Segment 都查一遍——查倒排索引、取 posting list、計算 _score、排除被標記刪除的文件——最後合併成本 Shard 的 Top N。

Segment 越多,搜尋越慢。這就是為什麼需要 Merge。


Segment:不可變的天才設計

Lucene 的 Segment 是不可變的(Immutable)。寫完就不改。這不是偷懶——這是整個系統效能的基石。

五個理由

不需要鎖。 讀取不可變的資料不用加任何鎖。多個搜尋執行緒同時讀同一個 Segment,零爭用。這是 ES 搜尋快的根本原因之一。

OS Page Cache 親和力極高。 不可變的檔案可以被 OS 安全快取,且不會過期——因為內容永遠不變。熱點資料幾乎一直住在記憶體裡。

壓縮效率好。 不可變 = 可以一次性選定最佳壓縮策略。Lucene 用 LZ4、DEFLATE 等壓縮,檔案大小減少 40-70%。

預計算永遠有效。 寫入時預先算好 skip list、FST、doc values 等搜尋用的資料結構。因為寫完不改,這些預計算永遠不會過期。

並行模型簡單。 不需要處理讀寫衝突、髒讀、幻讀。比 B-Tree 的並行模型簡單一個數量級。

代價

不可變也有代價。刪除不是真刪——只在 .liv 檔案中標記,搜尋時跳過,磁碟空間不釋放。更新要寫兩次——標記刪除舊版 + 寫入新版到新 Segment。每次 Refresh 都產生新 Segment,越積越多。

Merge:不可變設計的垃圾回收

解決方案是 Merge(段合併)。Lucene 在背景定期把多個小 Segment 合併成一個大 Segment,過程中物理刪除被標記的文件。

合併前:Segment 0(100 docs, 5 deleted)、Segment 1(80 docs, 3 deleted)、Segment 2(50 docs)。合併後:一個 Segment 3(222 docs, 0 deleted)。更乾淨、搜尋更快。

合併期間舊 Segment 仍可搜尋,新 Segment 寫完才切換。零停機。

1
GET _cat/segments/menu?v&h=index,shard,segment,generation,docs.count,docs.deleted,size

機械磁碟的 max_thread_count 設 1——多執行緒並行 Merge 會把 IO 榨乾。SSD 可以開大。

_forcemerge 只對唯讀索引用。對活躍索引跑 forcemerge 會跟新寫入搶 IO,曾有團隊寫了 cron job 每天對所有索引跑一次,結果搜尋延遲飆升 10 倍。


一個 Segment 裡面有什麼

打開一個 Segment 的目錄,你會看到一堆檔案:

檔案 內容
.si Segment Info:文件數、版本、建立時間
.fdx / .fdt Stored Fields:_source 原始文件
.tim / .tip Term Dictionary + Index:倒排索引的詞彙表
.doc Frequencies:每個 term 在每個 doc 出現幾次
.pos Positions:term 在文件中的位置(phrase query 用)
.dvd / .dvm Doc Values:排序和聚合用的列式儲存
.nvd / .nvm Norms:欄位長度正規化因子(算 _score 用)
segments_N Commit Point:記錄哪些 Segment 有效
.liv Live Docs:哪些文件活著、哪些被刪除

每種檔案服務不同的查詢模式。全文搜尋走 .tim + .doc,排序走 .dvd,回傳結果走 .fdt。這就是為什麼 ES 能同時做好搜尋和分析——它不是用一種資料結構硬撐,而是每種需求各備一份最佳結構。


五個業界踩雷經驗

Translog 設 async 用在訂單系統。 斷電丟了 5 秒資料,補不回來。金融和交易場景必須用 request 模式。

Segment 數量失控。 大量寫入 + refresh_interval: 1s = 每秒一個 Segment。一天 86,400 個。搜尋慢到不行。大量寫入時一定要調大 refresh_interval 或直接設 -1。

把 Refresh 和 Flush 搞混。 「可搜尋」不等於「已持久化」。Refresh 後 Segment 還在 Page Cache(記憶體),Flush 才落碟。中間的安全網是 Translog。

對活躍索引跑 forcemerge。 Merge 和寫入搶 IO,搜尋延遲爆炸。forcemerge 只用在唯讀的歷史索引上。

深分頁炸 Coordinating Node。 from: 10000, size: 10 代表每個 Shard 回傳 10,010 筆的 ID + score,Coordinating Node 要合併 N × 10,010 筆。max_result_window 預設 10,000 是有原因的。


把剛才的六個站點串起來看,整條路線其實就兩個核心設計決策在支撐。

第一個是可搜尋與持久化的分離。Refresh 讓資料 1 秒內可搜尋,但不碰磁碟。Flush 才落碟,但不影響搜尋。Translog 橫跨兩者,保證任何階段斷電都不丟資料。三個機制各司其職,不是一個機制硬扛三件事。

第二個是不可變性。Segment 寫完就不改。不加鎖、不衝突、不過期。代價是刪除和更新變貴了,但 Merge 在背景默默收拾。

大部分效能問題——Segment 太多、深分頁、Merge 打架——都可以回到這兩個決策去理解。不是去記「哪個參數調多少」,而是理解「為什麼這個參數存在」。搞懂原理,調參數就變成填空題。

本文改寫自 Elasticsearch 課程筆記,基於 Elasticsearch 8.x 架構。