01

從一行筆記到雜湊索引

世界上最簡單的資料庫只有兩個函式——寫快、讀慢,而解法就藏在記憶體裡的一張對照表

資料庫的本質,其實只做兩件事

說穿了,一個資料庫只需要做到:你給它資料,它存起來;你之後再問它要,它把資料還給你。聽起來簡單到不行,那我們就先做一個「簡單到不行」的資料庫試試看。

鍵值儲存 的概念,兩支 Bash 函式就能做出一個能用的資料庫:一支負責寫入、一支負責讀取。寫入的那支只做一件事——把新的「鍵,值」直接貼到檔案的最後面,這就是 追加寫入

📖
先給你一個畫面

想像一本家傳菜譜,阿嬤、媽媽、姑姑陸續在上面寫菜。規矩很簡單:不准塗改舊頁,新菜式或舊菜的新版本,一律寫在菜譜的最後面。媽媽想記一道改良版滷蛋?翻到最後一頁寫上去就好,幾秒搞定——這就是追加寫入為什麼這麼快。

這支寫入函式背後其實藏著一個更大的概念,資料庫世界把這種「只增不減、按時間順序累積」的檔案叫做 紀錄檔(Log)。 別被名字騙了,它不只是程式印出來給人看的「應用程式日誌」,而是更廣義的「只能往後加」的一串紀錄——本章接下來會一直看到它的身影。

直接讀原文,旁邊就是白話

這一章開頭引用了一句德國諺語,再帶出整章的問題意識。原文放左邊,白話放右邊,一句對一句:

原文 · DDIA Ch.3 "Wer Ordnung hält, ist nur zu faul zum Suchen." (If you keep things tidily ordered, you're just too lazy to go searching.) — German proverb On the most fundamental level, a database needs to do two things: when you give it some data, it should store the data, and when you ask it again later, it should give the data back to you. Every call to db_set appends to the end of the file, so if you update a key several times, the old versions of the value are not overwritten. In algorithmic terms, the cost of a lookup is O(n): if you double the number of records n in your database, a lookup takes twice as long.
白話翻譯

「把東西排得整整齊齊的人,只是懶得去找而已。」

(這句德國諺語其實在挖苦——花力氣維護整齊的索引,到底值不值得?整章都在回答這個問題。)— 德國諺語

最根本的層次上,資料庫只需要做兩件事:你給它資料,它存起來;你之後再問它要,它把資料還給你。

每次呼叫 db_set 都是把資料加到檔案最後面,所以如果你更新同一個鍵很多次,舊版本的值不會被覆蓋掉。

用演算法的語言來說,查找一筆資料的成本是 O(n):資料庫的紀錄數量 n 加倍,查找時間也大概跟著加倍。

💡
諺語在虧誰?

表面上在說「愛整齊的人只是懶得找東西」,但放在這一章的脈絡裡,其實是在問你:維護一份排得整整齊齊的索引,是不是也是一種「偷懶」——用一點寫入的代價,換來之後永遠不用辛苦找的輕鬆?

寫超快,讀卻超慢——問題出在哪?

負責讀取的那支函式,做法是用 grep 把整個檔案從頭到尾找一遍,挑出所有符合這個鍵的紀錄,再用 tail -n 1 只留下最後寫的那一筆(因為最後寫的才是最新版本)。

資料量小的時候你根本不會察覺異狀。但若這份檔案有幾百萬、幾千萬行,每查一筆都要重新從頭翻一遍,這種「掃過一遍」的讀法,效率會隨資料量等比例變差,也就是 O(n) 複雜度—— 這對「即時查詢」來說是場災難。

db_set:永遠加在最後面

不用找位置、不用搬動任何舊資料,磁碟連續寫入是最有效率的操作方式,所以快到不行。

db_get:從頭翻到尾

用 grep 找出所有符合的紀錄,再取最後一筆當作最新值。檔案越長,翻找的時間就越久。

O(n) 的代價

紀錄數量翻倍,查找時間大概也跟著翻倍。幾百萬筆資料時,這種「全文件掃描」會慢到無法忍受。

🍳
回到家傳菜譜

你想做「滷蛋」,打開那本只增不改的菜譜——沒有目錄,你只能從第一頁開始,一頁一頁翻,把所有寫著「滷蛋」的食譜都找出來,挑最後寫的那份當作最新做法。菜譜越厚,你翻到手軟的機率就越高。

加一張「對照表」,讀取就能一步到位

要解決全文件掃描的問題,不能去動寫入的方式(那是它快的原因),而是要在旁邊另外維護一份 索引(Index)。 索引就像書本的目錄——它本身不是內容,卻能指引你直接跳到內容所在的位置。

最簡單的索引做法,是在記憶體裡放一張 哈希索引: 每個鍵對應到它在檔案裡的 位元組偏移量。 寫入時,除了把資料加到檔案尾端,也同步更新這張表;查詢時,直接查表拿到位置,跳過去讀,完全不用掃描。

下面跑一遍對比動畫:先看「沒有索引」的最簡單資料庫,再看「加了雜湊索引」之後,讀取怎麼從翻遍全檔案變成一步到位。

🧑‍💻
你(db_set / db_get)
📄
資料檔案(紀錄檔)
🐌
全文件掃描 O(n)
記憶體哈希對照表
按「下一步」看兩種讀取方式的差別
⚖️
天下沒有白吃的午餐

索引能大幅加速讀取,但維護它需要付出 寫入開銷—— 每多寫一筆資料,不只要更新檔案,還要同步更新索引。每多加一個索引,寫入就會被多拖慢一點,所以索引不是越多越好,而是要看你的查詢模式來決定要不要加。

檔案會一直長大,雜湊索引怎麼收尾?

哈希索引很適合「鍵的數量有限、但同一個鍵常常被更新」的情境——例如貓咪影片的播放次數,鍵的種類不多,但每次有人按播放就要更新一次值。這種場景下,所有鍵都塞得進記憶體,讀寫都飛快。

但如果只是一直往同一個檔案後面加,檔案總有一天會大到塞爆磁碟,舊版本的值也一直占著空間沒人清。解法是把檔案切成多個 分段, 舊的分段定期在背景做 壓縮, 只留下每個鍵最新的值,再把壓縮過的小分段 合併 成更整齊的大分段。

📚
分段 Segment

像圖書館把書架按月份分區,「六月新書區」滿了就封存,新書都放到「七月新書區」去。

🧹
壓縮 Compaction

定期大掃除:同一本書有新版就丟掉舊版,只留最新、最有用的那一本。

🔗
合併 Merging

把幾個已經整理過的小區域併成一個更大、更乾淨的新區域,之後要找的地方更少。

🧠
哈希索引的天花板

它有兩個明顯的限制:第一,所有鍵都得放進記憶體,鍵的數量一旦多到記憶體裝不下就吃不消;第二,它只擅長「點對點」查詢,碰到「找出所有價格介於 100 到 200 之間」這種範圍查詢,哈希對照表完全幫不上忙,只能一個個慢慢查。

小試身手

看懂「追加寫入快、全文件掃描慢、索引換取讀取速度」這條線索,你就掌握了本章的起點。來兩題:

教材中的簡單資料庫,db_set 為什麼能做到「極速寫入」?
哈希索引最不擅長處理下列哪一種查詢?
📈
下一站

append-only 寫起來很快,但檔案會無限長大,該怎麼辦?下一站我們繼續往下捲,看分段與壓縮怎麼演化成更完整的儲存引擎。

02

LSM-Tree:分層儲存的效率之道

把「隨機亂寫」變成「排隊循序寫」——靠的是排序、合併、再加上後台默默打掃

上一招的痛點:哈希索引記不住「順序」

上一節我們認識了最簡單的儲存引擎——靠 哈希索引 記住每個鍵在磁碟上的位置。它快,但有兩個老毛病:鍵一多,記憶體裡的索引就跟著爆量;而且它完全不擅長「範圍查詢」——你沒辦法跟它說「幫我找鍵介於 A 到 F 之間的所有資料」,因為雜湊表壓根不知道誰比誰大。

這一節要介紹的,是一個只改了「一個小規則」、卻換來巨大效益的設計:把同一份檔案裡的鍵值對,硬性規定按照鍵排序。這個格式有個名字,叫 SSTable

📚
先給你一個畫面:圖書館的主題書架

舊方法像一個塞滿圖書卡片的抽屜,卡片隨手亂塞,書名(鍵)完全沒順序——找一本書要翻很久。SSTable 則像把書架按書名字母排得整整齊齊:書一多不可怕,因為你永遠知道「往哪個方向翻」。

直接讀原文,旁邊就是白話

SSTable 這個名字、還有它「合併」的核心招式,原文是這樣寫的——一句對一句看:

原文 · DDIA Ch.3 We require that the sequence of key-value pairs is sorted by key. We call this format Sorted String Table, or SSTable for short. Merging segments is simple and efficient, even if the files are bigger than the available memory. You start reading the input files side by side, look at the first key in each file, copy the lowest key to the output file, and repeat. This produces a new merged segment file, also sorted by key.
白話翻譯

我們要求檔案裡的鍵值對,必須按照鍵排好序。

這種格式有個名字,叫做「排序字串表」,簡稱 SSTable。

合併這些檔案既簡單又有效率,就算檔案比你電腦的記憶體還大也沒問題。

做法是:把要合併的檔案並排打開,各看一眼當前最前面的那個鍵,把最小的那個複製到輸出檔案,然後重複這個動作。

這樣就生出一份新的、同樣按鍵排序好的合併檔案。

🗂️
這招其實你國中就學過

「並排比對、取較小者」正是合併排序(mergesort)的精神。神奇的是,因為兩邊都已經排好序,完全不需要把整個檔案塞進記憶體,用「串流」方式邊讀邊比對就能合併——這就是為什麼檔案再大也不怕。

排序帶來的兩個額外紅利

「鍵值對必須排序」這一個規則,除了讓合併變簡單,還順手解決了另外兩個問題。

🗺️
稀疏索引 Sparse Index

不必記下每個鍵的位置,只要每隔幾百、幾千個鍵記一個「地標」(例如「B 開頭在這裡」)。要找資料時先跳到大致範圍,因為資料已排序,剩下用小範圍掃描就能找到,記憶體佔用大幅下降。

📦
資料壓縮 Compression

排序之後,相似的鍵值常常擠在一起,很適合打包成一個個壓縮塊再寫入磁碟。稀疏索引只要指向壓縮塊的開頭,既省磁碟空間,也省下讀取時的 I/O 頻寬。

🧹
合併即斷捨離

合併多份 SSTable 時,同一個鍵如果出現好幾次,系統只會留下最新那個值,舊的、過時的自動被甩掉——合併的同時,順便完成了資料的「斷捨離」。

⚠️
別忘了一個前提

稀疏索引能用的前提是資料「已經排序」。如果鍵值對是隨便亂塞的(像哈希索引那樣),你就只能精確記住每一筆的位置,稀疏索引的「猜大概方向」這招就完全失效了。

招牌互動:跟著一筆寫入,走一趟 LSM-Tree

那麼,排序好的 SSTable 是怎麼「長出來」的?按「下一步」,看一筆新寫入怎麼從記憶體一路走到磁碟、又怎麼被後台悄悄整理。

📝
Memtable(記憶體)
🧾
WAL 日誌(磁碟)
🗄️
新 SSTable
🗄️
舊 SSTable 們
🧹
壓縮整理工
按「下一步」開始這趟寫入之旅
🛡️
查資料時,要倒著找

讀取的順序剛好跟寫入相反:先看 memtable(最新),沒有就找最新的 SSTable,還沒有就往更舊的 SSTable 一路找下去——因為新版本永遠先被寫到「離記憶體最近」的地方。

這整套設計的真正野心:把隨機寫變成循序寫

你可能會問:為什麼要繞這一大圈,先寫記憶體、再整批落地、還要後台一直合併?答案就藏在這個關鍵字裡—— 循序寫入

1
如果直接寫磁碟……

每一筆獨立的寫入請求,都要去磁碟上「找到」這個鍵原本的位置再更新——這是到處跳著寫的隨機寫入,硬碟和 SSD 都慢。

2
先囤在 memtable……

大量零散的寫入先在記憶體裡排好隊,根本不碰磁碟,速度當然飛快。

3
整批一次寫成 SSTable

等累積到一定量,才把已經排序好的一大份資料「一口氣」循序寫進磁碟——一次到位,不必東跳西跳。

💡
合併壓縮也是同一套邏輯

後台合併多個 SSTable 時,靠的也是「並排掃描、依序輸出」,整個過程同樣是循序讀、循序寫,不會在磁碟上亂跳——這正是 LSM-Tree 寫入效能能這麼亮眼的核心祕密。

小試身手

消化一下這節的兩個重點:SSTable 為什麼好查、LSM-Tree 又是怎麼一回事。來兩題:

物聯網系統需要極省記憶體的索引,又要能快速查某個時間區間的資料(範圍查詢)。SSTable 的哪個核心特性最直接解決了這個需求?
隨著時間累積,磁碟上會堆出愈來愈多 SSTable 檔案。後台的「壓縮(compaction)」機制主要在解決什麼問題?
🌳
下一站:另一套完全不同的設計哲學

LSM-Tree 是「先寫日誌再排序」的思路。資料庫界還有另一套同樣經典、但設計哲學完全不同的索引結構——往下捲,去看看它怎麼想。

03

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 設計分道揚鑣。

原文 · DDIA Ch.3 B-trees break the database down into fixed-size blocks or pages, traditionally 4 KB in size, and read or write one page at a time. This design corresponds more closely to the underlying hardware, as disks are also arranged in fixed-size blocks. One page is designated as the root of the B-tree; whenever you want to look up a key in the index, you start here. If there isn't enough free space in the page to accommodate the new key, it is split into two half-full pages, and the parent page is updated to account for the new subdivision of key ranges. This algorithm ensures that the tree remains balanced: a B-tree with n keys always has a depth of O(log n).
白話翻譯

B-Tree 把資料庫切成固定大小的區塊或頁面,傳統上是 4KB,每次讀寫剛好一頁。

這個設計刻意貼近底層硬體——畢竟磁碟本身也是用固定大小的區塊排列的。

其中一頁被指定為 B-Tree 的「根」;每次要查一個關鍵字,都從這裡出發。

如果頁面空間不夠塞下新的關鍵字,它就會分裂成兩個半滿的頁面,父頁面也跟著更新,反映出新的範圍劃分。

這套演算法保證了樹永遠維持平衡:一棵有 n 個關鍵字的 B-Tree,深度永遠是 O(log n)。

💡
關鍵金句

「頁面大小貼近硬體區塊」這句話不是巧合——B-Tree 從一開始就是為磁碟而生的資料結構,這也是它能撐過半世紀的原因之一。

拆解 B-Tree 的解剖圖:點點看每一塊

一棵 B-Tree 由幾種角色組成,從上到下分工明確。點下面每個元件,看它在整棵樹裡扮演什麼角色。

🏛️ 根頁面 Root
🌿 分支節點 Branch
🍃 葉節點 Leaf
🧾 預寫日誌 WAL
點上面任一個元件,看它在 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 被稱為「平衡樹」,最核心的原因是什麼?
B-Tree 在「分裂」這種多頁面寫入操作中途當機,靠什麼機制讓資料庫重開機後能恢復一致狀態?
🔮
下一站

B-Tree 與 LSM-Tree 是兩大主流,但「索引」這件事其實還有更多花樣。往下捲,繼續看下去。

04

索引大觀園:次要索引到記憶體資料庫

同一份資料,換個查法就要換一種索引——還有一招直接把硬碟甩開

身分證查得到你,但查不到「住台北的人」

前面幾站講的 B-Tree、LSM-Tree,預設的查詢方式都很單純:拿一個獨一無二的鍵,找回一筆資料。這種靠唯一識別碼查找的索引,叫做 主要索引 ——就像拿身分證字號找一個人,又快又準。

但真實世界的查詢往往不長這樣。你可能想找「所有住在台北市的人」、「所有姓陳的客戶」——這些欄位的值會重複,不獨一無二,這時候就輪到 次要索引 上場。

🪪
兩者的差別,一句話

主要索引回答「這個鍵對應到誰」,永遠唯一、查一筆;次要索引回答「符合這個條件的有哪些」,鍵值常常重複、可能查回一堆。沒有次要索引,你就得把整批資料掃過一遍才找得到答案。

次要索引一樣可以用 B-Tree 或 LSM-Tree 來實作,差別只在於:它除了告訴你「值在哪」,更得想辦法告訴你「這個值對應的完整資料在哪裡」。這就牽涉到三種不同的資料存放策略。

次要索引存資料的三種方式

索引裡的「值」可以是完整資料本身,也可以只是一個指向資料的參考。這個選擇,決定了查詢快不快、寫入貴不貴。

1
堆積檔案 Heap File

實際資料像書堆一樣隨意堆放,索引只記一個「位置參考」。好處是資料只存一份、好幾個次要索引可以共用;代價是查詢要先看索引、再跳去書堆拿資料,多一道手續。

2
叢集索引 Clustered Index

資料本身就按照索引鍵的順序物理排好。索引本身就是資料,查到位置資料就在那,不必再跳轉——範圍查詢特別快。代價是一張表只能有一個。

3
覆蓋索引 Covering Index

介於前兩者之間:索引除了鍵,還順便多存幾個常被一起查的欄位。如果一個查詢要的東西剛好都在索引裡,資料庫就完全不用回頭翻資料表了。

📚
圖書館的版本

堆積檔案像「作者索引卡」只寫著書在哪個書堆角落,你得自己跑一趟;叢集索引像直接照 ISBN 排好的實體書架,找到位置書就在手上;覆蓋索引則像一份迷你主題目錄,書名、作者、年份全列在卡片上,你常常根本不用碰到書本。

直接讀原文,旁邊就是白話

這段是 DDIA 介紹「次要索引」與三種儲存策略時最關鍵的幾句,我們把它和白話翻譯並排放。

原文 · DDIA Ch.3 It is also very common to have secondary indexes. A secondary index can easily be constructed from a key-value index. The main difference is that keys are not unique. In the latter case, the place where rows are stored is known as a heap file, and it stores data in no particular order. In some situations, the extra hop from the index to the heap file is too much of a performance penalty for reads, so it can be desirable to store the indexed row directly within an index. This is known as a clustered index.
白話翻譯

資料庫裡也非常常見「次要索引」。

次要索引其實可以直接從一般的鍵值索引改造而來,主要差別是它的鍵不是唯一的。

如果索引只存參考,那存放實際資料列的地方就叫「堆積檔案」,資料在裡面沒有特定順序。

但有些情況下,「從索引跳到堆積檔案」這一步太傷讀取效能了,所以乾脆把整筆資料直接存進索引裡——這就叫「叢集索引」。

一個條件不夠用?換多欄位索引上場

如果你常常要「同時」用好幾個欄位篩選,或要查地圖上的範圍,單一欄位的索引就不夠看了。

🔗
串聯索引

把幾個欄位「串」成一把鑰匙,例如先城市、再姓氏。查「某城市某姓氏」飛快,但只查姓氏、跳過城市,這把鑰匙就插不進去了。

🗺️
多維索引

經度範圍 A 到 B、緯度範圍 C 到 D,這種「好幾個維度同時要範圍篩選」的查詢,傳統索引難以一次優化兩個維度,得靠 R-tree 這類專門結構。

🔍
全文檢索 / 模糊索引

想搜關鍵字、容許打錯字、找意思相近的詞,就要靠這種索引。背後常用「編輯距離」這把尺,衡量兩個字串差幾步才能變成一樣。

⚠️
索引不是越多越好

每多一個索引,讀取查詢確實會變快,但每次寫入資料,資料庫都得連帶更新所有相關索引——這就是讀寫之間永遠的拔河。挑你「真正常查」的欄位建索引就好,不要每個欄位都來一份。

乾脆不碰硬碟:記憶體資料庫

前面所有索引招式,本質上都是為了「減少碰硬碟的次數」而設計的。那如果資料量小到一個程度,乾脆連硬碟都不碰呢?這就是 記憶體資料庫 的想法——資料整批放進 RAM,速度直接跳級。

RAM 比硬碟快,除了少了機械尋道時間,還少了「把資料從硬碟格式轉成記憶體格式」這一道轉換手續,加上能用更直接的資料結構,不必再為了遷就硬碟而妥協設計。

🧠
那斷電怎麼辦?

記憶體資料庫一樣要顧 持久性 ——常見做法是先把每次變更寫進硬碟上的「預寫式日誌」(像隨手記下每一步操作,當機後重播一遍就能復原),再搭配定期把整個記憶體狀態拍張「快照」存檔,兩者合作就能加速復原。資料量真的塞不進 RAM 時,還有「反快取」這招:把最少用的資料趕去硬碟,但索引留在記憶體裡,要用的時候才知道去哪挖。

動手配配看:什麼查詢需求,配什麼索引技巧?

把下面四種「查詢需求」拖到它最該對應的索引技巧上,配完按「對答案」。

主鍵索引
多欄位串接索引
全文或空間索引
記憶體資料庫
用主鍵精準查一筆資料,例如依訂單編號找出那筆訂單
拖到這裡
同時用多個欄位篩選,例如查「某城市又姓陳」的客戶
拖到這裡
模糊查詢或地理位置查詢,例如打錯字也要找到商品、或查地圖上某範圍內的店家
拖到這裡
資料量小到能整個放進記憶體,追求毫秒級的極致速度
拖到這裡

小試身手

這一站資訊量不小,來兩題確認你抓到重點了。

某資料表建了五個次要索引,都採用堆積檔案策略。相較於把資料複製進每個索引,這個設計的主要優點是什麼?
外送平台要找「使用者目前位置半徑 3 公里內」的餐廳,這種同時牽涉經度與緯度兩個維度的範圍查詢,最適合用哪種索引技術?
📊
下一站

以上講的都是「一筆一筆處理交易」的索引思路,但企業還有另一種完全不同的查詢需求——往下捲就會看到。

05

OLTP vs OLAP:兩種工作型態

同一份資料,兩種完全不同的使命——收銀檯要快,分析室要廣

資料庫其實有兩種「人格」

你可能以為「資料庫」就是一個東西,裝資料、讀資料,僅此而已。但本書前半段討論的索引、 B-Tree 這些設計,骨子裡其實只服務一種使用模式:用某個鍵,找出少數幾筆紀錄,然後新增或更新它。

這種「依使用者操作、一筆一筆來」的存取模式,有個專門名字—— OLTP。 但資料庫早就被拿去做另一件事:分析。分析查詢要做的,是掃過大量紀錄,只挑幾個欄位出來,算總和、算平均——這種模式被稱為 OLAP

🏬
先給你一個畫面

OLTP 像百貨公司的收銀櫃檯:刷條碼、收錢、找零,每一筆都要快、都要準,顧客不會想排隊等十分鐘。OLAP 像總部的市場分析師辦公室:不管今天哪一筆交易,而是把全年所有櫃檯的數字攤開來,問「哪個品類去年聖誕節賣最好」。

直接讀原文,旁邊就是白話

這個區分是本章承先啟後的關鍵轉折——前半章談的索引結構,從這裡開始要分道揚鑣。我們挑幾句原文,一句對一句看:

原文 · DDIA Ch.3 An application typically looks up a small number of records by some key, using an index. Records are inserted or updated based on the user's input. Because these applications are interactive, the access pattern became known as online transaction processing (OLTP). Usually an analytic query needs to scan over a huge number of records, only reading a few columns per record, and calculates aggregate statistics (such as count, sum, or average) rather than returning the raw data to the user. In order to differentiate this pattern of using databases from transaction processing, it has been called online analytic processing (OLAP).
白話翻譯

應用程式通常是靠某個鍵、用索引去查少數幾筆紀錄;紀錄則是依使用者的輸入被新增或更新。

因為這類應用是互動式的,這種存取模式就被稱為「線上交易處理」(OLTP)。

分析查詢通常得掃過大量紀錄,每筆只讀幾個欄位,算出聚合統計(例如計數、加總或平均),而不是把原始資料整批丟回給使用者。

為了把這種用法跟交易處理區分開來,這種模式被稱為「線上分析處理」(OLAP)。

💡
同一份資料,兩種完全不同的「讀法」

OLTP 是「精準打擊」——靠鍵找到那一筆;OLAP 是「地毯式搜索」——掃過一大片,只為了算出一個數字。難怪兩者需要的儲存方式天差地遠。

把兩種工作型態並排看清楚

同一句「查資料庫」,OLTP 和 OLAP 想做的事完全不同。看看這幾個對比:

🧾
OLTP · 交易處理

少量讀寫,依某個鍵抓出一筆紀錄;資料永遠是「當下最新狀態」;回應要快到使用者點下去馬上有反應,毫秒等級。服務的是終端使用者與應用程式本身。

📊
OLAP · 分析處理

大量掃描歷史紀錄,只挑幾個欄位出來算總和、平均、計數;查詢可以等上幾秒到幾分鐘;服務的是商業分析師,產出的是趨勢與洞察,而不是單筆資料。

⚠️
別把兩種工作硬塞進同一套系統

本章前半段講的 B-Tree、LSM-Tree 這些索引,是為 OLTP 的「依鍵查找」設計的,碰上 OLAP 那種「掃過幾百萬列、只看幾欄」的查詢,效率會非常差——這正是兩者要分家的根本原因。

分家的解法:另蓋一座資料倉儲

資料庫管理員通常不放心讓分析師在 OLTP 資料庫上隨便下查詢——萬一那條查詢要掃過大半個資料表,正在進行中的交易就會被拖慢。解法是另外蓋一座 資料倉儲, 把資料定期「搬」過去,這個搬運、整理的過程叫 ETL。 點下面每個環節,看它在流程裡負責什麼:

🧾 OLTP 資料庫群
📤 Extract 萃取
🧹 Transform 轉換
📥 Load 載入
🏛️ 資料倉儲
點選上面任一環節,看看它在 ETL 管線裡扮演什麼角色。
🕵️
換個比喻:CIA 的秘密檔案室

各地特務(OLTP 系統)忙著執行即時任務,沒空理會總部的研究問題;秘密檔案室(資料倉儲)專門彙整所有人上報的情報副本,讓分析師慢慢研究趨勢,完全不干擾前線行動。

資料倉儲裡,資料怎麼擺?星狀模式

進了資料倉儲,資料通常用一種很公式化的設計排列,叫 星狀模式。 中心是一張記錄「事件」的 事實表, 周圍環繞著好幾張提供背景脈絡的 維度表。 點點看每個元件:

📅 dim_date
🙋 dim_customer
⭐ fact_sales(事實表)
📦 dim_product
🏷️ dim_promotion
點選上面任一張表,看看它在星狀模式裡負責記錄什麼。
🧱
樂高比喻:紀錄總部 + 背景小冊子

事實表像一本《玩樂紀錄總部》,只記事件和編號,不重複寫細節;維度表像一本本《背景資訊小冊子》(花名冊、積木組合大全),告訴你編號對應的真實內容。如果連小冊子裡的「主題」欄位都再拆成獨立一本,就變成更細的雪花模式——資料更不重複,但查起來要多翻幾層。

小試身手

分清楚 OLTP、OLAP、資料倉儲三者的角色,你就抓到本節的骨架了。來兩題:

如果某公司執意「直接在線上交易(OLTP)資料庫上」跑複雜的歷史分析報表,最可能發生什麼事?
在分析電商銷售的星狀模式中,「每一筆銷售的金額、數量,以及指向商品、顧客、日期維度的外來鍵」最適合放在哪裡?
📐
下一站:行式儲存撐不住的時候

資料倉儲裡動輒掃描整欄整欄的資料,行式儲存如何讓這種分析查詢飛起來?往下捲,看列式儲存怎麼接手。

06

行式儲存與資料方塊

同一張表,換個「躺法」儲存——分析查詢就從翻遍全身照,變成只抽身高那一欄

事實表動輒上百欄,但你每次只看 3、4 欄

進到分析的世界,資料表的長相會變得很誇張。一張典型的 事實表 可能寬達上百欄,因為每多一個跟業務事件有關的細節,就多開一欄。

但奇妙的是,一個典型的分析查詢——例如「上個月各商品類別的總銷售額」——往往只會碰到其中 3 到 5 欄:日期、商品、數量。其餘九十幾欄,這次查詢根本不關心。

📸
全身照 vs 只要身高

如果資料庫照傳統方式,把每一筆訂單的「所有欄位」整條存在一起,那麼即使你只想要「銷售額」,它也得先把整條訂單(含顧客姓名、付款方式……你用不到的東西)整個讀進來,再從裡面挑出你要的那幾欄。這就像你只想知道全班同學的身高總和,手上卻是一疊全身照,你得張張翻開找身高,看完還是得把其他不相關的細節一起搬進記憶體。

這一節要拆給你看:同一份資料,換一種「躺法」儲存,分析查詢可以快上一個量級。

直接讀原文:行式儲存的核心想法

這段是書裡介紹「行式儲存」最關鍵的幾句話。左邊原文,右邊白話,一句對一句看。

原文 · DDIA Ch.3 In most OLTP databases, storage is laid out in a row-oriented fashion: all the values from one row of a table are stored next to each other. A row-oriented storage engine still needs to load all of those rows from disk into memory, parse them, and filter out those that don't meet the required conditions. That can take a long time. The idea behind column-oriented storage is simple: don't store all the values from one row together, but store all the values from each column together instead. If each column is stored in a separate file, a query only needs to read and parse those columns that are used in that query, which can save a lot of work.
白話翻譯

在大多數 OLTP 資料庫裡,儲存方式是「按列排列」的:同一行(一筆記錄)裡的所有欄位值,會緊挨著存在一起。

按列存的儲存引擎,還是得把那些整行資料從磁碟讀進記憶體、解析開來,再從中篩掉不符合條件的——這很花時間。

行式儲存的想法很單純:不要把「同一行」的所有值存在一起,而是把「同一欄」的所有值存在一起。

如果每一欄各自存成一個檔案,查詢就只需要讀、解析「這次查詢真正用到」的那幾欄,省下大量工夫。

🔄
注意書裡的用字

原文用「row-oriented」對比「column-oriented」。中文課程裡你會看到「列式儲存」「按列存」跟「行式儲存」「按行(欄)存」混用——指的都是同一組對比:一個是把「一筆記錄」存在一起,一個是把「同一個欄位」存在一起。

跑一次給你看:同一個「加總某欄」查詢,兩種存法怎麼讀

假設我們要對一張銷售表做「加總金額」查詢。下面用同一份資料,分別示範「按列存」與「按行(欄)存」會怎麼讀磁碟。按「下一步」一步步看。

💽
磁碟上的原始表
📄
按列存:整筆訂單一起讀
📊
按行存:只讀「金額」欄
🗜️
欄位壓縮,再省一波
按「下一步」開始比較
⚖️
這不是「誰天生比較好」

按列存在 OLTP 場景超好用——你下單時要的就是「這筆訂單」的完整資訊,整塊讀剛好。按行存則是為了 OLAP 而生——只看少數幾欄、但掃過的列數很多。選哪種,要看你的查詢長什麼樣。

行式儲存的附加紅利:天生好壓縮

把同一欄位的值集中放在一起,還會帶來一個意外驚喜:這些值通常型態一致、又常常重複(例如「商品類別」欄位裡反覆出現「牛奶」「麵包」),剛好是壓縮演算法最愛的素材。

🗳️
位元圖編碼

欄位裡獨特值不多時(例如商品類別只有幾十種),幫每個獨特值做一張「簽到表」:這一列是不是這個值,是就標 1、不是標 0。一長串重複文字,瞬間變成一串 0 與 1。

📏
執行長度編碼

位元圖裡常常一大串連續的 0 或連續的 1。與其老老實實寫一長串,不如寫成「連續 5 個 1」,把同樣的資訊塞進更小的空間。

向量化處理

資料壓縮、變精簡之後,CPU 可以一次把一整塊欄位資料載進高速快取,「一整批」算完,而不是一筆一筆算。像收銀機從「一件一件刷」升級成「一次刷一整籃」。

🧮
兩股力量疊加

「只讀需要的欄位」加上「那些欄位本身又被壓得更小」,兩件事疊在一起,分析查詢的磁碟讀取量可以掉到原本的一個零頭——這就是 資料倉儲 普遍採用行式儲存的原因。

更狠一招:乾脆把答案先算好

就算只讀需要的欄位、又壓得很小,有些聚合查詢還是得掃過大量資料列。如果某個聚合問題天天有人問,例如「每天各商品類別賣了多少」,那何必每次都重新加總一遍?

1
實體化視圖

把某個常被問的聚合查詢,結果先算好存著。下次有人問,直接把存好的答案端出來,不必重新跑一次聚合。

2
資料方塊

更進一步,把「日期 × 地區 × 商品類別」這類多個 維度 的各種組合,全部預先算好聚合結果,存成一個可以隨意切換角度查詢的「方塊」。

3
代價:空間換時間

這兩招都得多佔儲存空間,而且原始資料一變動,預先算好的結果就得跟著刷新——犧牲一些寫入端的複雜度,換查詢端的速度。

🪄
預先算好的答案

實體化視圖跟資料方塊,本質上都是「提前把老闆明天會問的問題算好放著」。代價是:如果老闆問的問題剛好沒被算進去(例如資料方塊沒包含「客戶職業」這個維度),它就答不出來——快是有條件的快。

小試身手

把「按列存 vs 按行存」「預先算好的答案」這兩條線並起來想,來兩題。

某分析查詢只需要事實表 100 欄裡的 3 欄。為什麼行式(欄位式)儲存能讓這個查詢大幅變快?
電商平台每天早上都要一份「昨日各商品類別總銷售額」報表,原始交易量龐大、直接查詢很慢。哪一種做法最對症下藥?
🗺️
下一站:把三條線攤開成一張選型地圖

到這裡,我們已經分頭認識了索引結構(B-Tree/LSM-Tree)、OLTP/OLAP 雙軌、還有這一節的行式儲存。下一站,我們把這三條線全部攤開,整理成一張選型地圖,讓你看一眼就知道「這種場景該往哪邊靠」。

07

大局:儲存引擎選型地圖

索引、樹、欄位——把這一章的零件攤開來,看清楚什麼情境該選誰

捲了這麼久,我們到底在解一個什麼問題?

整章其實只問了一件事:資料存進去之後,怎麼讓「找回來」這件事不要變慢? 開頭我們看到最笨的資料庫——循序掃描, 資料量一多,找一筆東西就要把全部東西看過一遍,這叫 O(N),慢到沒辦法用。

解法是加一份「目錄」——索引。 但索引不是萬靈丹,它讓讀取變快,也讓每次寫入多一份要同步更新的功課——這就是貫穿全章的核心權衡:讀快和寫快,往往不能兩者通吃。

⚖️
一句話收斂整章

不同的儲存引擎,其實就是對「讀快 vs. 寫快 vs. 範圍掃描 vs. 海量分析」這幾個目標,做出不同取捨的具體實作。沒有一個引擎全部都贏,只有「最適合你情境」的引擎。

作者自己怎麼收尾這一章?

很巧,DDIA 原書在這章結尾也做了同樣的事——把所有儲存引擎收進兩條路線、兩種情境。直接讀原文收尾段,旁邊放白話:

原文 · DDIA Ch.3 On a high level, we saw that storage engines fall into two broad categories: those optimized for transaction processing (OLTP), and those optimized for analytics (OLAP). OLTP systems are typically user-facing, which means that they may see a huge volume of requests. The log-structured school, which only permits appending to files and deleting obsolete files, but never updates a file that has been written. The update-in-place school, which treats the disk as a set of fixed-size pages that can be overwritten.
白話翻譯

總的來說,儲存引擎可以分成兩大類:一類為「交易處理」(OLTP)優化,一類為「分析」(OLAP)優化。

OLTP 系統通常直接面對使用者,這代表它們可能會面臨龐大的請求量。

「日誌結構」這一派,只允許往檔案後面追加、刪掉過時檔案,從不回頭去改寫已經寫好的內容。

「原地更新」這一派,把磁碟當成一堆固定大小、可以被覆寫的頁面。

🗺️
畫重點

作者自己畫的地圖,其實就是一張「兩個維度」的表:橫軸是 OLTP 還是 OLAP,縱軸(在 OLTP 內)是日誌結構還是原地更新。接下來我們把這張地圖具體攤開。

先複習 OLTP 內的兩條路線

同樣是「交易系統用的索引」,LSM-TreeB-Tree 給出了完全不同的答案。

📝
LSM-Tree:寫得快

新資料只追加寫進記憶體的 memtable,滿了就整批排序、寫成 SSTable,背景再慢慢合併壓縮(compaction)。寫入幾乎不用找位置,所以特別適合「寫入頻繁」的場景。代價是讀取有時要查好幾個 SSTable,還有寫入放大的開銷。

📖
B-Tree:讀得穩

資料切成固定頁面,原地覆寫,從根頁面到葉子頁面的路徑長度幾乎一樣,查找效率是穩定的 O(log N)。也因為鍵是排序好、頁面之間又互相連結,特別擅長「範圍查詢」——找一段區間的資料時,沿著葉子頁面一路讀下去就好。

🔀
經驗法則,不是鐵律

「LSM-Tree 通常寫得比較快,B-Tree 通常讀得比較快」只是經驗法則,實際表現對工作負載很敏感,務必拿你自己的場景去實測,不要只看 benchmark 數字。

再複習:交易系統 vs. 分析系統,根本是兩種生物

上面兩種索引,解決的都還是OLTP 的問題——一次只碰少少幾筆資料,但請求量爆炸多。可是OLAP 要的完全是另一回事:請求很少,但每次都要在短時間內掃過幾百萬筆資料做加總、平均這類聚合計算。

把 OLTP 的資料定期搬到一個獨立的資料倉儲,分析師才不會拖垮正在處理交易的系統。而資料倉儲的內部,又常常換上完全不同的儲存方式—— 列式儲存, 而不是交易系統慣用的「一行資料放在一起」的行式儲存

🏬
回到百貨公司比喻

收銀櫃檯(OLTP)要的是快速結帳每一筆交易;分析師辦公室(OLAP)要的是看懂整季的銷售趨勢。要分析師也擠在收銀檯旁邊跑報表,結帳隊伍只會越排越長——這就是兩者要分開的理由。

動手畫一張你的選型地圖

把下面四種「使用情境」,拖到你認為最適合的儲存引擎上。這題把整章四個主角都叫出來了,配完按「對答案」。

寫入頻繁的交易系統
需要範圍掃描的時序資料
龐大唯讀分析報表
全部能放進記憶體的小資料集
LSM-Tree:寫入只管追加,背景再合併,特別吃得下高頻寫入
拖到這裡
B-Tree:鍵排序、葉子頁面互相連結,找一段區間的資料特別順
拖到這裡
行式儲存(列式資料倉儲):只讀需要的欄位,海量資料聚合也飛快
拖到這裡
記憶體資料庫:資料量小到能整批放進記憶體,速度直接跳過磁碟瓶頸
拖到這裡
🧭
這張地圖怎麼用

下次選資料庫,先別管品牌名稱,先問自己:「我的場景是寫多還是讀多?要不要範圍查詢?資料量大到放不進記憶體嗎?」答完這幾題,地圖上的位置就出來了。

整章總複習

來兩題跨小節的題目,檢查你是不是真的把這張地圖看懂了。

為什麼說「索引越多,資料庫不會只有好處」?
為什麼企業通常會把分析查詢搬到獨立的資料倉儲,而不是直接在 OLTP 資料庫上跑報表?
📐
下一站(其他章)

資料怎麼存好、找得到了,下一個問題是——這份資料的「格式」要怎麼隨著系統演化而不崩潰?當你改了 schema、換了程式版本,新舊資料還能不能互相讀懂?這是下一章要拆給你看的東西。