從一行筆記到雜湊索引
世界上最簡單的資料庫只有兩個函式——寫快、讀慢,而解法就藏在記憶體裡的一張對照表
資料庫的本質,其實只做兩件事
說穿了,一個資料庫只需要做到:你給它資料,它存起來;你之後再問它要,它把資料還給你。聽起來簡單到不行,那我們就先做一個「簡單到不行」的資料庫試試看。
用 鍵值儲存 的概念,兩支 Bash 函式就能做出一個能用的資料庫:一支負責寫入、一支負責讀取。寫入的那支只做一件事——把新的「鍵,值」直接貼到檔案的最後面,這就是 追加寫入。
想像一本家傳菜譜,阿嬤、媽媽、姑姑陸續在上面寫菜。規矩很簡單:不准塗改舊頁,新菜式或舊菜的新版本,一律寫在菜譜的最後面。媽媽想記一道改良版滷蛋?翻到最後一頁寫上去就好,幾秒搞定——這就是追加寫入為什麼這麼快。
這支寫入函式背後其實藏著一個更大的概念,資料庫世界把這種「只增不減、按時間順序累積」的檔案叫做 紀錄檔(Log)。 別被名字騙了,它不只是程式印出來給人看的「應用程式日誌」,而是更廣義的「只能往後加」的一串紀錄——本章接下來會一直看到它的身影。
直接讀原文,旁邊就是白話
這一章開頭引用了一句德國諺語,再帶出整章的問題意識。原文放左邊,白話放右邊,一句對一句:
「把東西排得整整齊齊的人,只是懶得去找而已。」
(這句德國諺語其實在挖苦——花力氣維護整齊的索引,到底值不值得?整章都在回答這個問題。)— 德國諺語
最根本的層次上,資料庫只需要做兩件事:你給它資料,它存起來;你之後再問它要,它把資料還給你。
每次呼叫 db_set 都是把資料加到檔案最後面,所以如果你更新同一個鍵很多次,舊版本的值不會被覆蓋掉。
用演算法的語言來說,查找一筆資料的成本是 O(n):資料庫的紀錄數量 n 加倍,查找時間也大概跟著加倍。
表面上在說「愛整齊的人只是懶得找東西」,但放在這一章的脈絡裡,其實是在問你:維護一份排得整整齊齊的索引,是不是也是一種「偷懶」——用一點寫入的代價,換來之後永遠不用辛苦找的輕鬆?
寫超快,讀卻超慢——問題出在哪?
負責讀取的那支函式,做法是用 grep 把整個檔案從頭到尾找一遍,挑出所有符合這個鍵的紀錄,再用 tail -n 1 只留下最後寫的那一筆(因為最後寫的才是最新版本)。
資料量小的時候你根本不會察覺異狀。但若這份檔案有幾百萬、幾千萬行,每查一筆都要重新從頭翻一遍,這種「掃過一遍」的讀法,效率會隨資料量等比例變差,也就是 O(n) 複雜度—— 這對「即時查詢」來說是場災難。
不用找位置、不用搬動任何舊資料,磁碟連續寫入是最有效率的操作方式,所以快到不行。
用 grep 找出所有符合的紀錄,再取最後一筆當作最新值。檔案越長,翻找的時間就越久。
紀錄數量翻倍,查找時間大概也跟著翻倍。幾百萬筆資料時,這種「全文件掃描」會慢到無法忍受。
你想做「滷蛋」,打開那本只增不改的菜譜——沒有目錄,你只能從第一頁開始,一頁一頁翻,把所有寫著「滷蛋」的食譜都找出來,挑最後寫的那份當作最新做法。菜譜越厚,你翻到手軟的機率就越高。
加一張「對照表」,讀取就能一步到位
要解決全文件掃描的問題,不能去動寫入的方式(那是它快的原因),而是要在旁邊另外維護一份 索引(Index)。 索引就像書本的目錄——它本身不是內容,卻能指引你直接跳到內容所在的位置。
最簡單的索引做法,是在記憶體裡放一張 哈希索引: 每個鍵對應到它在檔案裡的 位元組偏移量。 寫入時,除了把資料加到檔案尾端,也同步更新這張表;查詢時,直接查表拿到位置,跳過去讀,完全不用掃描。
下面跑一遍對比動畫:先看「沒有索引」的最簡單資料庫,再看「加了雜湊索引」之後,讀取怎麼從翻遍全檔案變成一步到位。
索引能大幅加速讀取,但維護它需要付出 寫入開銷—— 每多寫一筆資料,不只要更新檔案,還要同步更新索引。每多加一個索引,寫入就會被多拖慢一點,所以索引不是越多越好,而是要看你的查詢模式來決定要不要加。
檔案會一直長大,雜湊索引怎麼收尾?
哈希索引很適合「鍵的數量有限、但同一個鍵常常被更新」的情境——例如貓咪影片的播放次數,鍵的種類不多,但每次有人按播放就要更新一次值。這種場景下,所有鍵都塞得進記憶體,讀寫都飛快。
但如果只是一直往同一個檔案後面加,檔案總有一天會大到塞爆磁碟,舊版本的值也一直占著空間沒人清。解法是把檔案切成多個 分段, 舊的分段定期在背景做 壓縮, 只留下每個鍵最新的值,再把壓縮過的小分段 合併 成更整齊的大分段。
像圖書館把書架按月份分區,「六月新書區」滿了就封存,新書都放到「七月新書區」去。
定期大掃除:同一本書有新版就丟掉舊版,只留最新、最有用的那一本。
把幾個已經整理過的小區域併成一個更大、更乾淨的新區域,之後要找的地方更少。
它有兩個明顯的限制:第一,所有鍵都得放進記憶體,鍵的數量一旦多到記憶體裝不下就吃不消;第二,它只擅長「點對點」查詢,碰到「找出所有價格介於 100 到 200 之間」這種範圍查詢,哈希對照表完全幫不上忙,只能一個個慢慢查。
小試身手
看懂「追加寫入快、全文件掃描慢、索引換取讀取速度」這條線索,你就掌握了本章的起點。來兩題:
append-only 寫起來很快,但檔案會無限長大,該怎麼辦?下一站我們繼續往下捲,看分段與壓縮怎麼演化成更完整的儲存引擎。
LSM-Tree:分層儲存的效率之道
把「隨機亂寫」變成「排隊循序寫」——靠的是排序、合併、再加上後台默默打掃
上一招的痛點:哈希索引記不住「順序」
上一節我們認識了最簡單的儲存引擎——靠 哈希索引 記住每個鍵在磁碟上的位置。它快,但有兩個老毛病:鍵一多,記憶體裡的索引就跟著爆量;而且它完全不擅長「範圍查詢」——你沒辦法跟它說「幫我找鍵介於 A 到 F 之間的所有資料」,因為雜湊表壓根不知道誰比誰大。
這一節要介紹的,是一個只改了「一個小規則」、卻換來巨大效益的設計:把同一份檔案裡的鍵值對,硬性規定按照鍵排序。這個格式有個名字,叫 SSTable。
舊方法像一個塞滿圖書卡片的抽屜,卡片隨手亂塞,書名(鍵)完全沒順序——找一本書要翻很久。SSTable 則像把書架按書名字母排得整整齊齊:書一多不可怕,因為你永遠知道「往哪個方向翻」。
直接讀原文,旁邊就是白話
SSTable 這個名字、還有它「合併」的核心招式,原文是這樣寫的——一句對一句看:
我們要求檔案裡的鍵值對,必須按照鍵排好序。
這種格式有個名字,叫做「排序字串表」,簡稱 SSTable。
合併這些檔案既簡單又有效率,就算檔案比你電腦的記憶體還大也沒問題。
做法是:把要合併的檔案並排打開,各看一眼當前最前面的那個鍵,把最小的那個複製到輸出檔案,然後重複這個動作。
這樣就生出一份新的、同樣按鍵排序好的合併檔案。
「並排比對、取較小者」正是合併排序(mergesort)的精神。神奇的是,因為兩邊都已經排好序,完全不需要把整個檔案塞進記憶體,用「串流」方式邊讀邊比對就能合併——這就是為什麼檔案再大也不怕。
排序帶來的兩個額外紅利
「鍵值對必須排序」這一個規則,除了讓合併變簡單,還順手解決了另外兩個問題。
不必記下每個鍵的位置,只要每隔幾百、幾千個鍵記一個「地標」(例如「B 開頭在這裡」)。要找資料時先跳到大致範圍,因為資料已排序,剩下用小範圍掃描就能找到,記憶體佔用大幅下降。
排序之後,相似的鍵值常常擠在一起,很適合打包成一個個壓縮塊再寫入磁碟。稀疏索引只要指向壓縮塊的開頭,既省磁碟空間,也省下讀取時的 I/O 頻寬。
合併多份 SSTable 時,同一個鍵如果出現好幾次,系統只會留下最新那個值,舊的、過時的自動被甩掉——合併的同時,順便完成了資料的「斷捨離」。
稀疏索引能用的前提是資料「已經排序」。如果鍵值對是隨便亂塞的(像哈希索引那樣),你就只能精確記住每一筆的位置,稀疏索引的「猜大概方向」這招就完全失效了。
招牌互動:跟著一筆寫入,走一趟 LSM-Tree
那麼,排序好的 SSTable 是怎麼「長出來」的?按「下一步」,看一筆新寫入怎麼從記憶體一路走到磁碟、又怎麼被後台悄悄整理。
讀取的順序剛好跟寫入相反:先看 memtable(最新),沒有就找最新的 SSTable,還沒有就往更舊的 SSTable 一路找下去——因為新版本永遠先被寫到「離記憶體最近」的地方。
這整套設計的真正野心:把隨機寫變成循序寫
你可能會問:為什麼要繞這一大圈,先寫記憶體、再整批落地、還要後台一直合併?答案就藏在這個關鍵字裡—— 循序寫入。
每一筆獨立的寫入請求,都要去磁碟上「找到」這個鍵原本的位置再更新——這是到處跳著寫的隨機寫入,硬碟和 SSD 都慢。
大量零散的寫入先在記憶體裡排好隊,根本不碰磁碟,速度當然飛快。
等累積到一定量,才把已經排序好的一大份資料「一口氣」循序寫進磁碟——一次到位,不必東跳西跳。
後台合併多個 SSTable 時,靠的也是「並排掃描、依序輸出」,整個過程同樣是循序讀、循序寫,不會在磁碟上亂跳——這正是 LSM-Tree 寫入效能能這麼亮眼的核心祕密。
小試身手
消化一下這節的兩個重點:SSTable 為什麼好查、LSM-Tree 又是怎麼一回事。來兩題:
LSM-Tree 是「先寫日誌再排序」的思路。資料庫界還有另一套同樣經典、但設計哲學完全不同的索引結構——往下捲,去看看它怎麼想。
B-Tree:資料庫界的平衡藝術
不追加、用覆寫——另一套撐起幾乎所有資料庫的索引哲學
同樣是排好序的索引,B-Tree 走另一條路
還記得上一站的 LSM-Tree 嗎?它把資料庫切成一段段大小不一的 segment, 永遠只「追加」寫入。B-Tree 完全是另一套設計哲學:它把資料庫切成一個個固定大小的 頁面(Page), 一次讀寫一頁,而且修改資料時是直接「覆寫」舊頁面,不是另開新檔案。
1970 年被提出、不到十年就被稱為「無所不在」——B-Tree 撐了超過半世紀,至今仍是幾乎所有 關聯式資料庫(MySQL、PostgreSQL)的標準索引實作,連不少 NoSQL(例如 MongoDB 的 WiredTiger 引擎)也用它。
把 B-Tree 想成一間超級智慧圖書館:沒有紙本書架,但有一塊塊固定尺寸的「智慧索引板」彼此連接——大廳一塊總導覽,往下是各樓層分區索引,最底層直接告訴你書在哪格。你永遠不必把整間圖書館翻過一遍,只要照著板子一路縮小範圍。
直接讀原文,旁邊就是白話
這一段是 DDIA 介紹 B-Tree 設計哲學的關鍵幾句——它怎麼跟 LSM-Tree 的 segment 設計分道揚鑣。
B-Tree 把資料庫切成固定大小的區塊或頁面,傳統上是 4KB,每次讀寫剛好一頁。
這個設計刻意貼近底層硬體——畢竟磁碟本身也是用固定大小的區塊排列的。
其中一頁被指定為 B-Tree 的「根」;每次要查一個關鍵字,都從這裡出發。
如果頁面空間不夠塞下新的關鍵字,它就會分裂成兩個半滿的頁面,父頁面也跟著更新,反映出新的範圍劃分。
這套演算法保證了樹永遠維持平衡:一棵有 n 個關鍵字的 B-Tree,深度永遠是 O(log n)。
「頁面大小貼近硬體區塊」這句話不是巧合——B-Tree 從一開始就是為磁碟而生的資料結構,這也是它能撐過半世紀的原因之一。
拆解 B-Tree 的解剖圖:點點看每一塊
一棵 B-Tree 由幾種角色組成,從上到下分工明確。點下面每個元件,看它在整棵樹裡扮演什麼角色。
這叫分支因子(Branching Factor), 實務上常是幾百。分支因子夠大,B-Tree 就能保持「矮胖」——四層、分支因子 500 的 4KB 頁面樹,理論上能存到 256TB 資料,而且查一筆只要跳 3、4 次頁面。
頁面滿了怎麼辦?看「分裂」一步步發生
B-Tree 維持平衡的關鍵動作叫 分裂(Splitting)。 按「下一步」,看一頁滿了之後怎麼變成兩個半滿頁面,又怎麼一路往上影響到父節點,甚至根節點。
分裂往往要同時覆寫好幾個頁面(兩個新頁+父頁面的指標)。如果資料庫在寫到一半時當機,可能留下只改了一半的頁面,甚至冒出沒有任何父頁面指向的「孤兒頁」——這就是下一張投影片要解決的問題。
覆寫的代價:B-Tree 怎麼撐過「斷電」
B-Tree 的核心寫入動作是原地更新(Overwrite)—— 就像在紙本食譜上拿橡皮擦擦掉舊字、寫上新字,而不是換一頁新紙。效率很好,但分裂這種「一次要改好幾頁」的操作,如果寫到一半系統當機,就可能讓索引壞掉。
在任何原地更新真正寫進頁面之前,B-Tree 會先把這次修改完整記到一個只會往後追加的 WAL 裡。當機重開後,資料庫讀 WAL,把沒做完的操作「重做」一遍,索引就能恢復一致。
如果多個執行緒同時讀寫同一棵 B-Tree,需要靠 鎖存器(Latches) 這種輕量鎖,告訴別的執行緒「這頁正在改,請稍候」,避免有人看到改到一半、不一致的樹。
像 LMDB 這類資料庫採用 寫時複製(Copy-on-Write), 不直接改原頁面,而是複製一份在新頁面上修改,改完才切換指標——原頁面整個沒被動過,簡化了當機恢復與併發控制。
銀行轉帳「扣 A 戶、加 B 戶」要嘛一起成功要嘛一起失敗——正是因為 B-Tree 配合 WAL,就算系統在操作到一半時斷電,重開機後也能照著日誌回滾或重做,讓你不必自己在程式碼裡處理這種一致性地獄。
小試身手
B-Tree 的結構與可靠性機制,來兩題檢查一下:
B-Tree 與 LSM-Tree 是兩大主流,但「索引」這件事其實還有更多花樣。往下捲,繼續看下去。
索引大觀園:次要索引到記憶體資料庫
同一份資料,換個查法就要換一種索引——還有一招直接把硬碟甩開
身分證查得到你,但查不到「住台北的人」
前面幾站講的 B-Tree、LSM-Tree,預設的查詢方式都很單純:拿一個獨一無二的鍵,找回一筆資料。這種靠唯一識別碼查找的索引,叫做 主要索引 ——就像拿身分證字號找一個人,又快又準。
但真實世界的查詢往往不長這樣。你可能想找「所有住在台北市的人」、「所有姓陳的客戶」——這些欄位的值會重複,不獨一無二,這時候就輪到 次要索引 上場。
主要索引回答「這個鍵對應到誰」,永遠唯一、查一筆;次要索引回答「符合這個條件的有哪些」,鍵值常常重複、可能查回一堆。沒有次要索引,你就得把整批資料掃過一遍才找得到答案。
次要索引一樣可以用 B-Tree 或 LSM-Tree 來實作,差別只在於:它除了告訴你「值在哪」,更得想辦法告訴你「這個值對應的完整資料在哪裡」。這就牽涉到三種不同的資料存放策略。
次要索引存資料的三種方式
索引裡的「值」可以是完整資料本身,也可以只是一個指向資料的參考。這個選擇,決定了查詢快不快、寫入貴不貴。
實際資料像書堆一樣隨意堆放,索引只記一個「位置參考」。好處是資料只存一份、好幾個次要索引可以共用;代價是查詢要先看索引、再跳去書堆拿資料,多一道手續。
資料本身就按照索引鍵的順序物理排好。索引本身就是資料,查到位置資料就在那,不必再跳轉——範圍查詢特別快。代價是一張表只能有一個。
介於前兩者之間:索引除了鍵,還順便多存幾個常被一起查的欄位。如果一個查詢要的東西剛好都在索引裡,資料庫就完全不用回頭翻資料表了。
堆積檔案像「作者索引卡」只寫著書在哪個書堆角落,你得自己跑一趟;叢集索引像直接照 ISBN 排好的實體書架,找到位置書就在手上;覆蓋索引則像一份迷你主題目錄,書名、作者、年份全列在卡片上,你常常根本不用碰到書本。
直接讀原文,旁邊就是白話
這段是 DDIA 介紹「次要索引」與三種儲存策略時最關鍵的幾句,我們把它和白話翻譯並排放。
資料庫裡也非常常見「次要索引」。
次要索引其實可以直接從一般的鍵值索引改造而來,主要差別是它的鍵不是唯一的。
如果索引只存參考,那存放實際資料列的地方就叫「堆積檔案」,資料在裡面沒有特定順序。
但有些情況下,「從索引跳到堆積檔案」這一步太傷讀取效能了,所以乾脆把整筆資料直接存進索引裡——這就叫「叢集索引」。
一個條件不夠用?換多欄位索引上場
如果你常常要「同時」用好幾個欄位篩選,或要查地圖上的範圍,單一欄位的索引就不夠看了。
把幾個欄位「串」成一把鑰匙,例如先城市、再姓氏。查「某城市某姓氏」飛快,但只查姓氏、跳過城市,這把鑰匙就插不進去了。
經度範圍 A 到 B、緯度範圍 C 到 D,這種「好幾個維度同時要範圍篩選」的查詢,傳統索引難以一次優化兩個維度,得靠 R-tree 這類專門結構。
想搜關鍵字、容許打錯字、找意思相近的詞,就要靠這種索引。背後常用「編輯距離」這把尺,衡量兩個字串差幾步才能變成一樣。
每多一個索引,讀取查詢確實會變快,但每次寫入資料,資料庫都得連帶更新所有相關索引——這就是讀寫之間永遠的拔河。挑你「真正常查」的欄位建索引就好,不要每個欄位都來一份。
乾脆不碰硬碟:記憶體資料庫
前面所有索引招式,本質上都是為了「減少碰硬碟的次數」而設計的。那如果資料量小到一個程度,乾脆連硬碟都不碰呢?這就是 記憶體資料庫 的想法——資料整批放進 RAM,速度直接跳級。
RAM 比硬碟快,除了少了機械尋道時間,還少了「把資料從硬碟格式轉成記憶體格式」這一道轉換手續,加上能用更直接的資料結構,不必再為了遷就硬碟而妥協設計。
記憶體資料庫一樣要顧 持久性 ——常見做法是先把每次變更寫進硬碟上的「預寫式日誌」(像隨手記下每一步操作,當機後重播一遍就能復原),再搭配定期把整個記憶體狀態拍張「快照」存檔,兩者合作就能加速復原。資料量真的塞不進 RAM 時,還有「反快取」這招:把最少用的資料趕去硬碟,但索引留在記憶體裡,要用的時候才知道去哪挖。
動手配配看:什麼查詢需求,配什麼索引技巧?
把下面四種「查詢需求」拖到它最該對應的索引技巧上,配完按「對答案」。
小試身手
這一站資訊量不小,來兩題確認你抓到重點了。
以上講的都是「一筆一筆處理交易」的索引思路,但企業還有另一種完全不同的查詢需求——往下捲就會看到。
OLTP vs OLAP:兩種工作型態
同一份資料,兩種完全不同的使命——收銀檯要快,分析室要廣
資料庫其實有兩種「人格」
你可能以為「資料庫」就是一個東西,裝資料、讀資料,僅此而已。但本書前半段討論的索引、 B-Tree 這些設計,骨子裡其實只服務一種使用模式:用某個鍵,找出少數幾筆紀錄,然後新增或更新它。
這種「依使用者操作、一筆一筆來」的存取模式,有個專門名字—— OLTP。 但資料庫早就被拿去做另一件事:分析。分析查詢要做的,是掃過大量紀錄,只挑幾個欄位出來,算總和、算平均——這種模式被稱為 OLAP。
OLTP 像百貨公司的收銀櫃檯:刷條碼、收錢、找零,每一筆都要快、都要準,顧客不會想排隊等十分鐘。OLAP 像總部的市場分析師辦公室:不管今天哪一筆交易,而是把全年所有櫃檯的數字攤開來,問「哪個品類去年聖誕節賣最好」。
直接讀原文,旁邊就是白話
這個區分是本章承先啟後的關鍵轉折——前半章談的索引結構,從這裡開始要分道揚鑣。我們挑幾句原文,一句對一句看:
應用程式通常是靠某個鍵、用索引去查少數幾筆紀錄;紀錄則是依使用者的輸入被新增或更新。
因為這類應用是互動式的,這種存取模式就被稱為「線上交易處理」(OLTP)。
分析查詢通常得掃過大量紀錄,每筆只讀幾個欄位,算出聚合統計(例如計數、加總或平均),而不是把原始資料整批丟回給使用者。
為了把這種用法跟交易處理區分開來,這種模式被稱為「線上分析處理」(OLAP)。
OLTP 是「精準打擊」——靠鍵找到那一筆;OLAP 是「地毯式搜索」——掃過一大片,只為了算出一個數字。難怪兩者需要的儲存方式天差地遠。
把兩種工作型態並排看清楚
同一句「查資料庫」,OLTP 和 OLAP 想做的事完全不同。看看這幾個對比:
少量讀寫,依某個鍵抓出一筆紀錄;資料永遠是「當下最新狀態」;回應要快到使用者點下去馬上有反應,毫秒等級。服務的是終端使用者與應用程式本身。
大量掃描歷史紀錄,只挑幾個欄位出來算總和、平均、計數;查詢可以等上幾秒到幾分鐘;服務的是商業分析師,產出的是趨勢與洞察,而不是單筆資料。
本章前半段講的 B-Tree、LSM-Tree 這些索引,是為 OLTP 的「依鍵查找」設計的,碰上 OLAP 那種「掃過幾百萬列、只看幾欄」的查詢,效率會非常差——這正是兩者要分家的根本原因。
分家的解法:另蓋一座資料倉儲
資料庫管理員通常不放心讓分析師在 OLTP 資料庫上隨便下查詢——萬一那條查詢要掃過大半個資料表,正在進行中的交易就會被拖慢。解法是另外蓋一座 資料倉儲, 把資料定期「搬」過去,這個搬運、整理的過程叫 ETL。 點下面每個環節,看它在流程裡負責什麼:
各地特務(OLTP 系統)忙著執行即時任務,沒空理會總部的研究問題;秘密檔案室(資料倉儲)專門彙整所有人上報的情報副本,讓分析師慢慢研究趨勢,完全不干擾前線行動。
資料倉儲裡,資料怎麼擺?星狀模式
進了資料倉儲,資料通常用一種很公式化的設計排列,叫 星狀模式。 中心是一張記錄「事件」的 事實表, 周圍環繞著好幾張提供背景脈絡的 維度表。 點點看每個元件:
事實表像一本《玩樂紀錄總部》,只記事件和編號,不重複寫細節;維度表像一本本《背景資訊小冊子》(花名冊、積木組合大全),告訴你編號對應的真實內容。如果連小冊子裡的「主題」欄位都再拆成獨立一本,就變成更細的雪花模式——資料更不重複,但查起來要多翻幾層。
小試身手
分清楚 OLTP、OLAP、資料倉儲三者的角色,你就抓到本節的骨架了。來兩題:
資料倉儲裡動輒掃描整欄整欄的資料,行式儲存如何讓這種分析查詢飛起來?往下捲,看列式儲存怎麼接手。
行式儲存與資料方塊
同一張表,換個「躺法」儲存——分析查詢就從翻遍全身照,變成只抽身高那一欄
事實表動輒上百欄,但你每次只看 3、4 欄
進到分析的世界,資料表的長相會變得很誇張。一張典型的 事實表 可能寬達上百欄,因為每多一個跟業務事件有關的細節,就多開一欄。
但奇妙的是,一個典型的分析查詢——例如「上個月各商品類別的總銷售額」——往往只會碰到其中 3 到 5 欄:日期、商品、數量。其餘九十幾欄,這次查詢根本不關心。
如果資料庫照傳統方式,把每一筆訂單的「所有欄位」整條存在一起,那麼即使你只想要「銷售額」,它也得先把整條訂單(含顧客姓名、付款方式……你用不到的東西)整個讀進來,再從裡面挑出你要的那幾欄。這就像你只想知道全班同學的身高總和,手上卻是一疊全身照,你得張張翻開找身高,看完還是得把其他不相關的細節一起搬進記憶體。
這一節要拆給你看:同一份資料,換一種「躺法」儲存,分析查詢可以快上一個量級。
直接讀原文:行式儲存的核心想法
這段是書裡介紹「行式儲存」最關鍵的幾句話。左邊原文,右邊白話,一句對一句看。
在大多數 OLTP 資料庫裡,儲存方式是「按列排列」的:同一行(一筆記錄)裡的所有欄位值,會緊挨著存在一起。
按列存的儲存引擎,還是得把那些整行資料從磁碟讀進記憶體、解析開來,再從中篩掉不符合條件的——這很花時間。
行式儲存的想法很單純:不要把「同一行」的所有值存在一起,而是把「同一欄」的所有值存在一起。
如果每一欄各自存成一個檔案,查詢就只需要讀、解析「這次查詢真正用到」的那幾欄,省下大量工夫。
原文用「row-oriented」對比「column-oriented」。中文課程裡你會看到「列式儲存」「按列存」跟「行式儲存」「按行(欄)存」混用——指的都是同一組對比:一個是把「一筆記錄」存在一起,一個是把「同一個欄位」存在一起。
跑一次給你看:同一個「加總某欄」查詢,兩種存法怎麼讀
假設我們要對一張銷售表做「加總金額」查詢。下面用同一份資料,分別示範「按列存」與「按行(欄)存」會怎麼讀磁碟。按「下一步」一步步看。
按列存在 OLTP 場景超好用——你下單時要的就是「這筆訂單」的完整資訊,整塊讀剛好。按行存則是為了 OLAP 而生——只看少數幾欄、但掃過的列數很多。選哪種,要看你的查詢長什麼樣。
行式儲存的附加紅利:天生好壓縮
把同一欄位的值集中放在一起,還會帶來一個意外驚喜:這些值通常型態一致、又常常重複(例如「商品類別」欄位裡反覆出現「牛奶」「麵包」),剛好是壓縮演算法最愛的素材。
欄位裡獨特值不多時(例如商品類別只有幾十種),幫每個獨特值做一張「簽到表」:這一列是不是這個值,是就標 1、不是標 0。一長串重複文字,瞬間變成一串 0 與 1。
位元圖裡常常一大串連續的 0 或連續的 1。與其老老實實寫一長串,不如寫成「連續 5 個 1」,把同樣的資訊塞進更小的空間。
資料壓縮、變精簡之後,CPU 可以一次把一整塊欄位資料載進高速快取,「一整批」算完,而不是一筆一筆算。像收銀機從「一件一件刷」升級成「一次刷一整籃」。
「只讀需要的欄位」加上「那些欄位本身又被壓得更小」,兩件事疊在一起,分析查詢的磁碟讀取量可以掉到原本的一個零頭——這就是 資料倉儲 普遍採用行式儲存的原因。
更狠一招:乾脆把答案先算好
就算只讀需要的欄位、又壓得很小,有些聚合查詢還是得掃過大量資料列。如果某個聚合問題天天有人問,例如「每天各商品類別賣了多少」,那何必每次都重新加總一遍?
把某個常被問的聚合查詢,結果先算好存著。下次有人問,直接把存好的答案端出來,不必重新跑一次聚合。
更進一步,把「日期 × 地區 × 商品類別」這類多個 維度 的各種組合,全部預先算好聚合結果,存成一個可以隨意切換角度查詢的「方塊」。
這兩招都得多佔儲存空間,而且原始資料一變動,預先算好的結果就得跟著刷新——犧牲一些寫入端的複雜度,換查詢端的速度。
實體化視圖跟資料方塊,本質上都是「提前把老闆明天會問的問題算好放著」。代價是:如果老闆問的問題剛好沒被算進去(例如資料方塊沒包含「客戶職業」這個維度),它就答不出來——快是有條件的快。
小試身手
把「按列存 vs 按行存」「預先算好的答案」這兩條線並起來想,來兩題。
到這裡,我們已經分頭認識了索引結構(B-Tree/LSM-Tree)、OLTP/OLAP 雙軌、還有這一節的行式儲存。下一站,我們把這三條線全部攤開,整理成一張選型地圖,讓你看一眼就知道「這種場景該往哪邊靠」。
大局:儲存引擎選型地圖
索引、樹、欄位——把這一章的零件攤開來,看清楚什麼情境該選誰
捲了這麼久,我們到底在解一個什麼問題?
整章其實只問了一件事:資料存進去之後,怎麼讓「找回來」這件事不要變慢? 開頭我們看到最笨的資料庫——循序掃描, 資料量一多,找一筆東西就要把全部東西看過一遍,這叫 O(N),慢到沒辦法用。
解法是加一份「目錄」——索引。 但索引不是萬靈丹,它讓讀取變快,也讓每次寫入多一份要同步更新的功課——這就是貫穿全章的核心權衡:讀快和寫快,往往不能兩者通吃。
不同的儲存引擎,其實就是對「讀快 vs. 寫快 vs. 範圍掃描 vs. 海量分析」這幾個目標,做出不同取捨的具體實作。沒有一個引擎全部都贏,只有「最適合你情境」的引擎。
作者自己怎麼收尾這一章?
很巧,DDIA 原書在這章結尾也做了同樣的事——把所有儲存引擎收進兩條路線、兩種情境。直接讀原文收尾段,旁邊放白話:
總的來說,儲存引擎可以分成兩大類:一類為「交易處理」(OLTP)優化,一類為「分析」(OLAP)優化。
OLTP 系統通常直接面對使用者,這代表它們可能會面臨龐大的請求量。
「日誌結構」這一派,只允許往檔案後面追加、刪掉過時檔案,從不回頭去改寫已經寫好的內容。
「原地更新」這一派,把磁碟當成一堆固定大小、可以被覆寫的頁面。
作者自己畫的地圖,其實就是一張「兩個維度」的表:橫軸是 OLTP 還是 OLAP,縱軸(在 OLTP 內)是日誌結構還是原地更新。接下來我們把這張地圖具體攤開。
先複習 OLTP 內的兩條路線
同樣是「交易系統用的索引」,LSM-Tree 和 B-Tree 給出了完全不同的答案。
新資料只追加寫進記憶體的 memtable,滿了就整批排序、寫成 SSTable,背景再慢慢合併壓縮(compaction)。寫入幾乎不用找位置,所以特別適合「寫入頻繁」的場景。代價是讀取有時要查好幾個 SSTable,還有寫入放大的開銷。
資料切成固定頁面,原地覆寫,從根頁面到葉子頁面的路徑長度幾乎一樣,查找效率是穩定的 O(log N)。也因為鍵是排序好、頁面之間又互相連結,特別擅長「範圍查詢」——找一段區間的資料時,沿著葉子頁面一路讀下去就好。
「LSM-Tree 通常寫得比較快,B-Tree 通常讀得比較快」只是經驗法則,實際表現對工作負載很敏感,務必拿你自己的場景去實測,不要只看 benchmark 數字。
再複習:交易系統 vs. 分析系統,根本是兩種生物
上面兩種索引,解決的都還是OLTP 的問題——一次只碰少少幾筆資料,但請求量爆炸多。可是OLAP 要的完全是另一回事:請求很少,但每次都要在短時間內掃過幾百萬筆資料做加總、平均這類聚合計算。
把 OLTP 的資料定期搬到一個獨立的資料倉儲,分析師才不會拖垮正在處理交易的系統。而資料倉儲的內部,又常常換上完全不同的儲存方式—— 列式儲存, 而不是交易系統慣用的「一行資料放在一起」的行式儲存。
收銀櫃檯(OLTP)要的是快速結帳每一筆交易;分析師辦公室(OLAP)要的是看懂整季的銷售趨勢。要分析師也擠在收銀檯旁邊跑報表,結帳隊伍只會越排越長——這就是兩者要分開的理由。
動手畫一張你的選型地圖
把下面四種「使用情境」,拖到你認為最適合的儲存引擎上。這題把整章四個主角都叫出來了,配完按「對答案」。
下次選資料庫,先別管品牌名稱,先問自己:「我的場景是寫多還是讀多?要不要範圍查詢?資料量大到放不進記憶體嗎?」答完這幾題,地圖上的位置就出來了。
整章總複習
來兩題跨小節的題目,檢查你是不是真的把這張地圖看懂了。
資料怎麼存好、找得到了,下一個問題是——這份資料的「格式」要怎麼隨著系統演化而不崩潰?當你改了 schema、換了程式版本,新舊資料還能不能互相讀懂?這是下一章要拆給你看的東西。