Elasticsearch 駭客級的底層運作原理 — Indexing, Searching, Translog, Segment
你打一行 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 | PUT /menu/_settings |
改成 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 架構。







