同一份原始約 5.6 GiB 的 log,落盤後只剩 513 MiB——但更關鍵的不是壓縮比,而是查一個 error 時,VictoriaLogs 連那 513 MiB 都不必整個讀。它把每個 field 各自存成一欄、各自壓縮、各自配一張詞彙指紋,於是查詢能在碰到任何一行 log 之前,就先把絕大多數 block 跳過去。
VictoriaLogs 怎麼把日誌存成欄式
把幾筆 log 畫在紙上,最自然的樣子是一行一筆:每條 log 是一個 row,所有 field 並排坐在同一行。VictoriaLogs 偏偏反過來——它不把一條 log 的 field 綁在一起,而是讓每個 field 各自成為一欄,獨立編碼、獨立壓縮、獨立存檔。換來的好處用作者的話講就一句:查詢只需要碰到請求真正用到的那幾欄,不必逐行讀過每筆的每個 field。這篇要拆的,就是這個欄式佈局在磁碟上長什麼樣子,以及為什麼它對高基數的 log 特別划算。
欄式不是把 row 旋轉九十度那麼簡單。它牽出一整套落盤結構:日為單位的目錄、目錄裡一堆各司其職的檔案、把值切成 token 後壓成的指紋。底下先用一個可拖曳的對照圖,把 row-oriented 與 column-oriented 的差別放在同一份資料上看——左邊是傳統一行一筆,右邊是 VictoriaLogs 的欄式,拖動分隔線時注意「查一個欄位時,磁碟上要碰的範圍」如何收縮。
drag divider · row-oriented 對 column-oriented 同一份 log
status 只碰那一欄。數字佈局為示意,欄式只讀用到的欄這點出自 VictoriaLogs 官方說明。同一份 log 的兩種落盤方式
欄式讓每個 field 各自成欄,查 status 只碰那一欄,其餘整欄不必讀進來
partition、part、block:三層都是可以整塊跳過的單位
欄式的第一個收穫,其實在碰到欄之前就發生了。VictoriaLogs 把磁碟分成三層巢狀的單位,每一層都設計成「可以整塊判斷要不要看」。最外層是 partition:一個 partition 正好裝一個日曆日的 log,以 UTC 計。這個設計直接決定了 retention 的形狀——清資料不是逐筆刪、不是壓縮重寫,而是直接刪掉整天的目錄。一條 logsRetentionPeriod 到期,對應的就是幾個目錄被 rm 掉,廉價到近乎免費。也因為一個 partition 就是一個日曆日的目錄,過了 retention 期的清理才能簡化成刪整天的目錄、而不必逐筆掃過去標記再回收;資料的生命週期被對齊到目錄邊界,刪除於是退化成一個目錄操作。
partition 裡面是 part。一個 part 是一個目錄,裝著一堆檔案(稍後逐一拆)。part 又分成 in-memory、small、big 三種,新進的 log 先落在記憶體 part,再逐步合併成磁碟上的 small part、big part——這跟 LSM-tree 的 compaction 是同一套思路。part 之下才是 block。block 是真正裝 log row 的最小單位,而且有一條關鍵約束:一個 block 只裝來自單一 stream 的 rows,一個 part 裝很多 block。block 未壓縮約上限 2 MiB,合併 part 時可以拉到接近 4 MiB。合併還順手把壓縮做得更好——把相鄰 part 的同一 stream 併進更大的 block,dictionary 與相鄰值的重複面隨之變寬,這正是 block 容得下接近 4 MiB 時、會壓得比 2 MiB 上限更扁的原因;換句話說,三種 part 的合併不只是收檔案,也是在替欄式累積可壓的同質性。
「block 只裝單一 stream」這條約束是後面所有壓縮收益的前提,所以得先講清楚 stream 是什麼。stream 由你指定的 _stream_fields 定義:設 _stream_fields=pod,container,所有 pod 與 container 相同的 log 就算同一個 stream。官方的操作建議很直白——stream fields 要穩定且低基數,意思是它們只該有少數幾個 distinct 值,例如 host、app、pod、container。為什麼非低基數不可?因為每多一個 distinct 的 stream 組合,就多一批獨立的 block;把 request_id 或 trace_id 這種高基數欄放進 stream fields,等於把資料炸成無數個小 block,每個 block 又各自要一份 bloom 與 header,壓縮與索引的開銷會直接失控。高基數的欄該留在 log 內容裡讓欄式去壓,不該拿來切 stream。
三層巢狀的意義,要等到查詢時才完全顯現。查某個 stream 過去一小時的 log,VictoriaLogs 會直接跳到那個 stream 的 block,再用每個 block header 記的最小與最大 timestamp,跳掉任何時間範圍不可能跟你的查詢窗重疊的 block。換句話說,partition 先按日期把不相干的整天篩掉,block header 再按 stream 與時間把不相干的 block 篩掉——這兩刀下去,往往連 1% 的資料都還沒讀到,搜尋空間已經塌縮到很小。下面那張 part 目錄表,裝的就是支撐這套「先判斷、再讀」的全部檔案。
一個 part 目錄裡,每個檔案各司其職
打開一個 part 目錄,你會看到一串 .bin 檔,名字看似雜亂,其實每個都對應一個「讀小、跳大」的職責。作者把這句話講成原則:part 裡每個檔案存在的理由,就是讀一點小的、跳過一大塊大的。下表把主要的檔案列出來,標明它各自管什麼、在查詢路徑上扮演哪一刀。點欄位標題可以重排,把「在記憶體 / 按 shard 分裂 / 每 part 一份」這幾類分開看。
click column header to sort · 8 files × 3 columns
| 檔案 | 層 | 它管什麼 |
|---|---|---|
| metadata.json | part 摘要 | 每個 part 恰好一份、人類可讀;讓 VictoriaLogs 一瞬間判斷這個 part 值不值得打開(裡頭含 RowsCount、BlocksCount、CompressedSizeBytes 等)。 |
| metaindex.bin | 記憶體索引 | 在記憶體掃描,每筆只是一個 stream range、一個 time range,加一條指向 index.bin 的連結——第一刀的篩選就靠它。 |
| index.bin | block 索引 | 存放整個 part 的所有 block header;header 裡的 min/max timestamp 是按時間跳 block 的依據。 |
| timestamps.bin | 時間專欄 | 專門裝每一筆 row 的 _time 的那一欄——時間本身就被當成一條獨立的欄存。 |
| values.bin0..127 | 值(分 shard) | 每個 block 把一個 field 的值編成一個連續 blob,append 到該 field 所屬 shard 的 values 檔;一個 part 最多 128 個 shard。 |
| bloom.bin0..127 | 指紋(分 shard) | 跟 values 一一對應的詞彙指紋,查關鍵字時先問它、答 definitely not 的 block 直接跳過。 |
| message_values.bin | _msg 專檔 | _msg 通常是最大也最常讀的 field,給它自己的檔,讓它能跟其他小 field 各自獨立讀解、互不拖累。 |
| column_names.bin | 欄名對映 | 每個 distinct field name 在 part 內拿到一個短數字 column ID,這份名字到 ID 的對映就存在這裡。 |
表裡有兩組檔案值得多看一眼。第一組是 column_names.bin 配 column_idxs.bin。VictoriaLogs 給 part 內每個 distinct field name 一個短數字 column ID,名字到 ID 的對映放在 column_names.bin;而 column_idxs.bin 則為每個 field name 挑一個 shard 號、記下這個「欄到 shard」的對映。所以欄不是隨意散落的——它先被編號,再被分配到 128 個 shard 之一,於是同一個 field 跨多個 block 的值,都 append 進同一個 values.binN,讀某一欄時磁碟存取是連續的。
第二組是 block 內部的定位:columns_header.bin 與 columns_header_index.bin。一個 column header 是描述「某 block 裡某 field」的小紀錄,裡頭記著該欄值 blob 的 offset 與 size、bloom blob 的 offset 與 size、還有值的型別。要找某 block 某欄的 header,本來得在 header 區裡一路走訪;columns_header_index.bin 把這段走訪砍掉——它為每個 block 存一張小查表,把 column ID 直接對映到該欄 header 在 blob 裡的精確 byte offset。一句話:連「找到要讀哪段」這件事,也被索引成 O(1) 的查表。整個 part 就是一層套一層的「先讀索引、再讀值」。而且這整串索引住的都是便宜的位置:metaindex.bin 是在記憶體裡掃的,每筆只是一段 stream range、一段 time range 與一條指向 index.bin 的連結;要等 metaindex 篩出 part、index.bin 的 block header 再篩出 block,最後才靠 columns_header_index 直接跳到該欄 header 的 byte offset,去動 values 與 bloom 那些真正的大檔。讀的順序永遠是先小後大、先索引後值。
每一欄各用最合身的編碼,所以高基數 log 反而壓得扁
把 field 拆成欄之後,壓縮才真正開始發威,而原理樸素到有點反高潮:當一欄裡每個值形狀或型別都一樣時,它壓得極好。row-oriented 把 timestamp、整數 status、字串 pod 名、長文字 _msg 混在一條 record 裡,泛用壓縮器看到的是一團異質 bytes;欄式把同型的值排在一起,壓縮器面對的是一條規律的序列。VictoriaLogs 再加一層:每種 value type 用最適合它的方式壓。重複詞很多的欄存成一個小 dictionary 加一堆對它的引用;值是小整數時用 uint16 型;timestamp、IP 這種型別各有自己的緊湊形式。value type 本身(string、dictionary、number、timestamp、IP 等)會記在 column header 裡,告訴 VictoriaLogs 該怎麼解讀那串 bytes。更值得玩味的是這層編碼會跟欄式的跳塊互相加乘:dictionary 把重複詞多的一欄收成一個小字典加一堆引用、uint16 把小整數塞進兩個 byte,本就讓單欄的值 blob 變得很小;而查詢又只挑出用到的那幾欄去解壓,於是該讀的欄已被壓到極小、不該讀的欄連碰都不必碰,省的方向有兩個而且同時生效。
還有一個欄式獨有的加成:同一個 stream 的 log 彼此非常相似,於是一欄裡相鄰的值大量重複,被進一步壓得更扁。這正是前面堅持「block 只裝單一 stream」的回報——把同質的 log 聚在一個 block,dictionary 的命中率與 delta 的可壓性都拉到最高。官方給的一個具體數字:約 5.6 GiB 的原始 log 落盤後約 513 MiB("CompressedSizeBytes": 537919490),壓縮確實在做實事。更關鍵的是,這種收益對高基數 log 不減反增——row 系統的反直覺常是「欄位種類越多越難壓」,但欄式把每個欄位獨立處理,種類多只是多開幾條各自規律的欄,每條照樣壓得扁。而把最大也最常讀的 _msg 另存成一個專檔,更讓它能跟那些小 field 各自獨立讀解、互不拖累——只問 _time 或 status 的查詢,根本不必把巨大的 _msg blob 拉進來。
下面這張 metadata.json 的範例,是上面三層結構的數字側寫。它是人類可讀的 JSON,所以 VictoriaLogs 不必打開任何 .bin 就能先讀它,瞬間判斷這個 part 該不該碰:
{
// 一個 part 的 metadata.json(節錄關鍵欄位)
"RowsCount": 4240299, // 這個 part 裝了 ~424 萬筆 row
"BlocksCount": 1915, // 切成 1,915 個 block,平均每 block ~2,200 筆
"CompressedSizeBytes": 537919490 // 落盤 ~513 MiB,原始約 5.6 GiB
}
把這幾個數字湊在一起,欄式的形狀就具體了:4,240,299 筆 row 切成 1,915 個 block,每個 block 都被約 2 MiB 的上限管著;整份壓到 513 MiB。查詢只要靠 metaindex 與 block header 把搜尋空間縮到少數幾個 block,真正要解壓讀取的,往往是這 513 MiB 裡極小的一塊。換個角度看壓縮:平均每個 block 約裝兩千多筆同一 stream 的相似 log,正因同 stream 的相鄰值重複得多,欄內的 dictionary 與差值編碼才特別省,最後把整份收進 513 MiB。
bloom filter:在讀值之前,先問每個 block 有沒有這個詞
時間與 stream 把搜尋空間切到剩幾個 block 之後,還剩一個問題:你查的是 error 這種關鍵字,它可能落在任何一個時間吻合的 block 裡。逐一解壓每個候選 block 的 _msg 欄去比對,仍然很貴。VictoriaLogs 在這裡放了最後一道篩子——bloom filter。它是一張涵蓋「某個 block 某一欄裡所有詞」的小指紋。作法是把那一欄(比如 _msg)的每個值切成 token,token 就是一段連續的字母、數字或底線;像 GET /cart 200 與 POST /pay 200 會切出 GET、cart、200、POST、pay 這些 token,全數塞進這個 block-column 的 bloom。
bloom 的精髓在它的回答只有兩種:確定沒有(definitely not,百分百準),或者可能有(maybe)。它偶爾會在詞其實不在時答 maybe,但絕不會在詞其實在時答沒有——這個單向保證正是它能拿來安全跳過 block 的關鍵。查 error 時,VictoriaLogs 用同一套規則把查詢也切成 token,先問每個 block 的 bloom「這些 token 在不在」,把所有回答 definitely not 的 block 整個跳過,只對少數回答 maybe 的 block 去讀真正的值。最後那一步讀到的 maybe 裡可能混進幾個誤判(false positive),但那只是多解壓幾個 block,結果的正確性不受影響——誤判的代價是多做一點功,不是漏掉資料。這道單向保證之所以能安全地接在時間與 stream 兩刀之後,靠的就是它只會多讀、不會漏讀:前兩刀已用日期與 block header 的 min/max timestamp 把候選縮到少數 block,bloom 再把這批裡確定沒有該 token 的整個跳掉,剩下答 maybe 的才花成本解壓值。每一層的省都是疊在前一層的結果上接著砍,而不是各自從頭重掃一遍——這正是四層篩子能一路收斂的關鍵。
bloom 的成本好預估到可以心算:每個 unique token 大約只佔 2 bytes。一個有 1,000 個 unique 詞的 block-column,bloom 大約 2 KB;有 20,000 個 unique token 的,大約 40 KB。這個尺寸換來的是——在讀那少說幾 KB、多則上 MB 的值 blob 之前,先花幾 KB 把整個 block 篩掉的能力。下面這個互動圖把這條線畫出來:拖動滑桿改變一個 block-column 的 unique token 數,看 bloom 的大小如何隨之成長,以及在一個 stream 有大量 block 的情境下,bloom 篩掉的 block 比例如何決定你最終要解壓多少值。
drag sliders · unique token 數 與 命中率 對 bloom 大小與掃描量的影響
查詢路徑:每一層都跳過一大塊、只讀一小塊
把前面幾層疊起來,就是 VictoriaLogs 查詢之所以快的全部祕密——而它的快沒有任何一處來自「掃得更用力」,全部來自「掃得更少」。作者把這句話收成一條原則:查詢快,是因為 VictoriaLogs 在每一層都跳過工作——整個 part 按時間範圍跳、block 按 stream 與時間跳、column 按你查的是哪些 field 跳、keyword 按 bloom filter 跳。舉個說明用的查詢,像 _stream:{app="api"} error | fields _time, _msg 這樣,就會依序撞上這四道篩子,每過一道,要讀的 byte 就少一個數量級。值得停下來看清楚的是,這四刀沒有一刀靠「掃得更用力」,全部靠「掃得更少」——parts、blocks、columns、keywords 一層層往下,每過一道篩子,真正要讀進來的 byte 就再掉一截;到最後才對殘存的少數 block 的少數幾欄解壓,前面那幾百 MB 自始至終都沒被碰過。
這套漏斗解釋了 VictoriaLogs 跟傳統 log 系統取捨上的差異。倒排索引(inverted index)型的系統——把每個 term 對映到一串 document id——查單一稀有關鍵字極快,但代價是索引本身龐大、寫入時要維護、高基數欄位會讓 term 字典爆炸。VictoriaLogs 走的是另一條路:它沒有為每個 term 建全域倒排表,而是把篩選拆進「時間、stream、欄、bloom」四層便宜的近似篩子,每層都只求「安全地跳過一大塊」,不求「精確定位」。bloom 的 maybe 容許誤判,正是這種設計的縮影——它不保證精準命中,只保證不漏;精準留給最後解壓比對那一步。對 log 這種「寫得多、欄位型別重複、查詢多半帶時間與 stream 條件」的 workload,這個取捨划算:寫入時只要切欄、編碼、建 bloom,不必維護一張隨資料線性膨脹的倒排表。它的每一層篩子都只是便宜的近似——bloom 容許 maybe 卻不容許漏,每個 unique token 也才約 2 bytes,好預估到可以心算;換來的是不必為每個關鍵字維護一份精準的全域答案,只要在讀那批大得多的值 blob 之前,先用幾 KB 的指紋把整個 block 安全地篩掉。
對要自己跑 observability 的工程師,這幾層直接翻成幾條操作守則。第一,_stream_fields 只放穩定且低基數的欄(host、app、pod、container),把 request_id、user_id 這種高基數欄留在 log 內容裡——它們會被欄式好好壓,但拿去切 stream 會把 block 炸碎。第二,查詢盡量帶緊時間範圍,因為第一刀就是按時間跳整個 part,窗越窄、跳掉的越多。第三,用 | fields 或 | keep 明確指名要的欄,第三刀才有得跳——別習慣性地 SELECT *,那等於放棄欄式最大的一個好處。三條湊在一起其實是同一件事:讓你的查詢盡量早、盡量多地命中那四道便宜的篩子,把要解壓的值壓到最少。
What this enables:把每個 field 各自存欄、各自壓縮、各自配一張 bloom,再把 partition、block header、column 與 bloom 疊成四層可跳過的篩子,VictoriaLogs 讓「查一個 error」這件事,從掃過幾百 MB 變成讀那少數幾個 block 的少數幾欄——而 retention 也只是 rm 掉幾個整天的目錄。