vatt'ghern jaskier's ballads
本文 4 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

同一張表、同一筆資料、同一台機器——只是把主鍵從 integer 換成隨機 UUID4,每百萬列的插入時間從穩定的 700ms 一路惡化到 12,586ms。慢掉的那 11 秒,profiler 說全花在「平衡一棵樹」。

為什麼 UUID 主鍵在 SQLite 會慢 14 倍——從 B-tree 頁面分裂講起

大多數資料庫的腦內模型裡,主鍵是個唯一性約束——保證沒有兩列共用同一個 id,順帶提供一個查找入口。在 SQLite,這個模型是錯的。SQLite 的每一張表本身就是一棵以主鍵為 key 的 B-tree,主鍵決定的不是「能不能重複」,而是「這列資料實際躺在磁碟的哪個位置」。換句話說,主鍵就是 clustered index,而 clustered index 的定義是「決定表中資料列實體儲存順序的索引」。一張表只能有一個 clustered index,因為資料在實體上只能照一種順序排。當你把主鍵設成隨機產生的 UUID4,你等於要求 SQLite 把每一筆新資料插進一個隨機的實體位置——而 B-tree 在隨機位置插入的代價,就是這篇要拆解的東西。Anders Murphy 跑了一組基準:integer 主鍵、UUID4、UUID7,各跑到 1 億列,數字落差大到不像同一個引擎。

這不是「UUID 比較大所以比較慢」那種一句話可以打發的事。UUID blob 是 16 bytes,integer 主鍵是 8 bytes,大一倍——但大一倍解釋不了 14 到 16 倍的落差。落差的真正來源是順序:integer 主鍵與時間有序的 UUID7 都是遞增的,新資料永遠接在 B-tree 的尾端;隨機的 UUID4 則把新資料撒進樹的任意位置,逼出大量頁面分裂(page split)與重新平衡(rebalancing)。下面四節,我們依序拆開 SQLite 的 clustered B-tree 儲存模型、rowid 的角色、WITH 與 WITHOUT ROWID 的差異,以及隨機 vs 時間有序鍵如何在頁面層級造成寫放大(write amplification)。每一步都用基準數字釘住。

主鍵即實體儲存順序——SQLite 的表是一棵 clustered B-tree

先把這件事說清楚,因為它是後面所有現象的根。在 SQLite,「rowid 表」(也就是預設的、沒有寫 WITHOUT ROWID 的表)的資料儲存在一棵 B-tree 裡,這棵樹「每一列對應一個 entry,用 rowid 的值當作 key」。這句話的關鍵是:表的內容不是另外存在某個 heap,再由索引指過去——表的內容就是這棵 B-tree。樹的 key 決定 entry 的排列順序,而 entry 的排列順序就是磁碟頁面(page)上的實體順序。

這跟 Postgres 之類以 heap table 為主的引擎根本不同。在 Postgres,資料列丟進 heap 裡大致按到達順序堆放,主鍵是另一棵 B-tree 索引,索引的葉節點存的是指向 heap tuple 的指標(ctid)。主鍵在 Postgres 不決定實體順序,所以隨機 UUID 主鍵在 Postgres 造成的痛苦遠小於 SQLite——它頂多讓那一棵索引 B-tree 變得分散,heap 本身照樣 append。本文討論的代價是 SQLite 特有的,因為 SQLite 把表本身做成了 clustered index。把這篇的結論直接套到 Postgres 上會得到錯誤的結論。

那麼一棵 B-tree 在「尾端插入」與「隨機位置插入」的差別在哪?B-tree 由固定大小的 page 組成(SQLite 預設 page 4096 bytes)。每個 leaf page 裝得下若干 entry。當資料照 key 遞增到達,新 entry 永遠落在當前「最右邊」那個 leaf page;這個 page 填滿後,開一個新的 page 接在後面,舊 page 不必動。這是近乎 append-only 的行為,幾乎沒有資料搬移。但若新 entry 的 key 落在某個已經填滿的 page 中間,這個 page 容不下,就必須分裂:配置一個新 page,把原 page 約一半的 entry 搬過去,再更新父節點的指標——若父節點也滿了,分裂往上傳遞,最壞情況一路傳到 root。隨機 key 把每一次插入都變成一場「賭這次會不會踩到滿的 page」的賭局,而當表長到上億列、樹有幾百萬個 page 時,這個賭局幾乎場場都輸。

下面這個互動圖把四種組合的真實基準數字攤開:拖動 row 數、切換主鍵類型,讀出對應的每百萬列插入時間。曲線不是示意——點全部來自 Anders Murphy 跑到 1 億列的實測。

切換主鍵類型、拖動 row 數,讀出每百萬列插入時間 · 4 種組合 × 10 個量測點

100M → 12,586 ms
0 4,000 8,000 12,000 總列數(百萬) 每百萬列插入時間(ms) 102030 405060 708090 100
四條線同軸——integer 與 UUID7 幾乎貼在底部(亞 1,400ms),UUID4 WITHOUT ROWID 一路爬升不收斂。選中的線會被點亮並標出當前 row 數的精確值。資料:Anders Murphy 2026 基準。

四條線同軸——integer 與 UUID7 幾乎貼在底部(亞 1,400ms),UUID4 WITHOUT ROWI…

UUID4 WITHOUT ROWID 在 1 億列時插入比 integer 慢 16 倍,UUID7 只慢 1.8 倍,差異純粹來自 key 的時間有序性。

把任一條曲線滑到底(100M),落差就攤在眼前:integer 基線 715ms、UUID7 1,258ms、UUID4 WITH ROWID 7,119ms、UUID4 WITHOUT ROWID 12,586ms。integer 大約「每秒一百萬筆插入」,而 UUID4 WITHOUT ROWID 在同一規模慢了 16 倍以上。更值得注意的是曲線的形狀:integer 與 UUID7 是水平線(表長大、單位插入成本不變),UUID4 WITHOUT ROWID 卻是一條不收斂的爬升曲線——表愈大,每一筆插入愈貴。形狀本身就是診斷:水平線代表 append-friendly,爬升線代表「樹愈大,隨機插入踩到的重平衡愈深」。

rowid 是什麼——那個你沒宣告卻一直在維護的隱藏 clustered key

要理解 UUID4 WITH ROWID 為什麼卡在中間(2,003ms 到 7,119ms),得先認識 rowid 這個你通常看不見的東西。SQLite 的每一張預設表都有一個隱含的 64-bit 帶符號整數欄位叫 rowid。你沒宣告它,但它在那裡,而且它就是這張表 clustered B-tree 的 key——資料的實體順序由 rowid 決定。當你寫 INTEGER PRIMARY KEY,SQLite 做了一個特別的優化:讓你宣告的那個欄位變成 rowid 的別名,於是你的主鍵直接就是 clustered key,沒有第二棵樹要維護。這就是為什麼 integer 主鍵這麼快——一棵樹、遞增 key、純 append。

但如果你把主鍵宣告成 UUID(一個 blob),而沒有加 WITHOUT ROWID,事情就分岔了。這時 SQLite 仍然偷偷維護那個隱藏的、單調遞增的整數 rowid 當作 clustered key(表照 rowid 排,所以實體寫入仍是 append,便宜);但它同時得為你的 UUID 主鍵建一棵獨立的索引 B-tree,好讓 WHERE id = ? 查得到。於是每插入一列要寫兩棵樹:rowid clustered B-tree(循序、便宜)+ UUID4 index B-tree(隨機、會分裂)。第二棵樹付的就是隨機插入的代價,這就是寫放大。

-- 預設 rowid 表:你的 INTEGER PK 直接成為隱藏 rowid 的別名(一棵樹)
CREATE TABLE IF NOT EXISTS event(id INTEGER PRIMARY KEY, data BLOB);

-- UUID4 + 隱藏 rowid:clustered rowid 樹(循序)+ UUID4 index 樹(隨機)
-- 兩棵樹都要維護 —— 寫放大來源
CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB);

-- WITHOUT ROWID:取消隱藏 rowid,UUID4 直接當 clustered key(一棵樹,但隨機)
CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID;

看出對稱性了嗎?UUID4 WITH ROWID 慢,是因為要維護棵樹,其中一棵隨機。UUID4 WITHOUT ROWID 慢得更兇,是因為只剩棵樹、但那唯一一棵樹的 key 就是隨機的 UUID4,整棵 clustered 樹都在隨機位置被插——而且如下一節所述,WITHOUT ROWID 把內容也塞進中間節點,分裂搬的資料更多。WITH ROWID 之所以「沒那麼慘」,弔詭地正因為它多維護了一棵樹:那棵 clustered rowid 樹是循序的,扛住了實體寫入;隨機的代價被隔離在較瘦的 index 樹裡。

這個寫放大不只是 CPU 上多搬幾個 byte 而已,它最終要落到磁碟的 fsync 與 WAL 成本上。SQLite 在 WAL 模式下,每一筆 transaction 提交都要把這次改動過的整個 page追加進 WAL 檔,然後在 checkpoint 時刷回主資料庫——代價的計價單位是 page,不是 byte。隨機 UUID4 key 的麻煩正在於它放大了「被改動的 page 數」:循序 append 一筆通常只弄髒最右邊那一個 leaf page,而隨機插入碰到分裂時,一次要弄髒原 page、新配置的 page、再加上一路往上更新的父節點 page,全部都得進 WAL、全部都得在 fsync 時落盤。換句話說,那條從 2,649ms 爬到 12,586ms 的曲線,底層是「每筆插入弄髒的 page 數隨樹變大而上升」,而每一個多弄髒的 page 都是一次額外的 WAL 寫入與最終的 fsync 負擔——這才是 11 秒落差真正花掉的地方。

WITH 對 WITHOUT ROWID——內容存在葉節點,還是葉加中間節點

這一節是兩種表在 B-tree 結構上的根本差異,也是 WITHOUT ROWID 在隨機 key 下分裂特別痛的原因。SQLite 官方文件講得很精準:rowid 表「以 B-tree 實作,所有內容都存在樹的葉節點」;WITHOUT ROWID 表則「以一般 B-tree 實作,內容同時存在葉節點與中間節點」。

差別的後果是這樣。在 rowid 表(內容只在葉),中間節點(interior node)只存 key 與指向子節點的指標——非常瘦,一個 interior page 能塞進大量 key,所以樹很矮、扇出(fan-out)很高,而且資料列本體只住在葉子。當某個葉 page 分裂,只有那一層的 row 內容要搬。在 WITHOUT ROWID 表(內容存在葉與中間節點),row 的內容會出現在路徑上的中間節點,這讓中間節點變胖、扇出下降、樹變高;更糟的是當一個含內容的節點分裂,要搬移的 byte 數更多。把這個結構差異疊在隨機 UUID4 key 上——隨機 key 本來就到處戳破滿的 page,而 WITHOUT ROWID 讓每次戳破要搬的東西更重——就得到了那條從 2,649ms 爬到 12,586ms 的曲線。

下面把兩種儲存模型並排:點任一種,看內容(深色塊)落在 B-tree 的哪一層。手機上是可垂直閱讀的卡片,桌機是寬版 B-tree 示意。

選一種儲存模型,看內容(深色)落在 B-tree 哪一層 · 2 種模型

rowid 表:內容只在葉節點 k k k k k k k k k rows rows rows rows interior 瘦 ⇒ 扇出高 ⇒ 樹矮,分裂只搬葉層 WITHOUT ROWID:內容在葉+中間 k+row k+row k+row rows rows rows rows interior 含內容 ⇒ 胖、扇出低、樹高,分裂搬更多 byte

點上方任一儲存模型

rowid 表 · content @ leaves

SQLite 官方原文:「rowid 表以 B-tree 實作,所有內容都存在樹的葉節點。」中間節點只是導航——存 key 與子指標,所以一個 4KB interior page 能容納大量 key,扇出高、樹矮、查找路徑短。葉 page 分裂時,要搬的只有葉層的 row 內容。

WITHOUT ROWID 表 · content @ leaves + interior

官方原文:「WITHOUT ROWID 表以一般 B-tree 實作,內容同時存在葉節點與中間節點。」row 內容散佈到導航層,使 interior 節點變胖、單頁能放的 key 變少、扇出下降、樹變高。當隨機 UUID4 key 戳破一個含內容的節點,分裂要搬移的 byte 數遠多於 rowid 表——這放大了隨機鍵的懲罰。

互動圖表

rowid 表內容只住葉節點,WITHOUT ROWID 表內容也住中間節點,導致隨機 key 的頁面分裂搬移更多資料。

這也解釋了一個容易被忽略的細節:WITHOUT ROWID 不是「比較省」的選項。它省掉了隱藏 rowid 那 8 bytes 與第二棵索引樹,理論上更緊湊;但一旦 key 是隨機的,把內容塞進中間節點的設計反而讓每次分裂更貴。WITHOUT ROWID 真正適合的是「主鍵本身就是天然 clustered、且查詢多半照主鍵範圍掃描」的場景——例如時間有序的 key。這正好把我們帶到最後一節。

隨機對時間有序——UUID7 如何讓那條爬升曲線變回水平線

UUID4 與 UUID7 都是 128-bit、都存成 16-byte blob、都用 WITHOUT ROWID、跑同一段插入程式。唯一的差別是 bit 的排列:UUID4 幾乎全是隨機 bit,相鄰兩次產生的值在排序上毫無關聯;UUID7 把毫秒級 Unix 時間戳放在最高位,低位才是隨機。結果是 UUID7 近乎單調遞增——後產生的值排序上幾乎總是大於先產生的值。對 clustered B-tree 來說,這等於把「隨機位置插入」變回了「尾端 append」,頁面分裂的賭局消失了。

值得把 UUID7 的 bit 預算拆開看,因為它解釋了為什麼時間有序與全域唯一可以同時成立、不必二選一。128 bit 裡,最高的 48 bit 給毫秒級 Unix 時間戳——這 48 bit 足夠表達到西元一萬年以後的時間,且因為它在最高位,排序時時間戳優先、自然形成遞增前綴,這就是插入局部性的來源。接著 4 bit 是版本號、2 bit 是 variant 標記,剩下約74 bit 是隨機熵,專門用來區分「同一毫秒內產生的多筆 key」。74 bit 的隨機空間大到在單一毫秒內碰撞的機率可以忽略,所以即使多台機器同時併發產生 key,也幾乎不可能撞號——這正是 ULID 與 UUIDv7 共享的設計哲學:用高位時間前綴買插入局部性,用低位隨機尾碼買分散式唯一性,兩者各管一段 bit、互不犧牲。UUID4 把全部 122 bit 都拿去當隨機熵,唯一性更寬裕,但完全放棄了排序局部性——而在 SQLite 這個「主鍵即實體順序」的世界裡,那段被放棄的局部性正是最貴的東西。

順帶釐清一個常見混淆:ULID 與 UUIDv7 解決的是同一個問題、走的也是同一條「時間前綴加隨機尾碼」的路,差別主要在外觀與位元配置而非排序行為。ULID 同樣把 48 bit 毫秒時間戳放在最高位、後面接 80 bit 隨機,並用 Crockford Base32 編成 26 個字元的字串;UUIDv7 則塞進標準 16-byte UUID 的版本與 variant 欄位框架裡。對 SQLite 的 clustered B-tree 來說,這兩者帶來的是一模一樣的好處——高位時間前綴讓排序近乎單調遞增,於是插入重新貼回樹的尾端。要注意的是「近乎」這個詞:同一毫秒內產生的多筆 key,其相對順序由低位隨機 bit 決定,是亂的;但這種亂只發生在單一 page 的尺度內(同毫秒的 key 彼此相鄰),不會像 UUID4 那樣把插入點撒到整棵樹的隨機位置。換句話說,時間有序鍵把隨機性從「全樹尺度」收斂到「單頁尺度」,而單頁內的亂序對頁面分裂幾乎沒有影響——這就是為什麼它能拿到接近 integer 主鍵的曲線形狀。

數字證實了這個機制。UUID7 WITHOUT ROWID 從 10M 的 1,372ms 到 100M 的 1,258ms——基本上一條水平線,跟 integer 主鍵同一個量級(只因 16 bytes vs 8 bytes 略慢一點)。對照 UUID4 WITHOUT ROWID 那條從 2,649ms 爬到 12,586ms 的線,差別純粹來自 key 的時間有序性。同樣是 UUID、同樣是 WITHOUT ROWID,把隨機換成時間有序,14 倍的懲罰直接歸零。這也是為什麼 ULID、UUIDv7 這類「時間前綴 + 隨機尾碼」的設計近年在資料庫主鍵場景全面取代 UUID4——它們保留了 UUID 的分散式產生與全域唯一,卻不犧牲 clustered 插入的局部性。

把這個機制接回前面講過的 WAL 與 fsync 帳本,時間有序的好處就更具體了。UUID7 近乎遞增,意味著連續幾筆插入幾乎都落在 clustered B-tree 同一個最右 leaf page 上——同一個 page 在一次 transaction 裡被反覆改寫,最終只需把這一個髒 page 追加進 WAL、在 checkpoint 時 fsync 一次。隨機的 UUID4 則相反:相鄰兩筆插入大概率落在樹的兩個遙遠角落,各自弄髒一個(或因分裂而連帶弄髒數個)互不相鄰的 page,於是每筆插入要進 WAL 的 page 數更多、checkpoint 要刷回的 page 也更分散。fsync 的成本本質上是「等磁碟確認落盤」,而落盤的計價單位是 page——所以時間有序鍵不只是讓 CPU 少搬幾次 entry,它從根本上壓低了每筆寫入要持久化的 page 數,這才是 UUID7 那條水平線在 I/O 層面真正省下的東西。換個角度說,UUID4 的隨機性把「邏輯上一筆小插入」放大成「物理上散落多頁的寫入」,而 WAL 與 fsync 只認物理頁。

下面的 canvas 把這個機制動起來:左邊餵循序 key、右邊餵隨機 key,逐筆插入同一棵 B-tree,看分裂在哪邊爆發。按 play 觀察,或拖 reset 重來。

按 play,循序 vs 隨機逐筆插入同一棵 B-tree,數頁面分裂 · 動畫

循序 splits 0 · 隨機 splits 0
每個 page 容量 6 筆。循序插入永遠落在最右 page,填滿才新增——零搬移;隨機插入持續戳破已滿的 page,每次分裂把半個 page 的 entry 搬走。分裂計數差距就是那 14 倍的微觀來源。

每個 page 容量 6 筆

循序 key 插入永遠落在最右 page、幾乎零分裂,隨機 key 持續戳破已滿的 page、分裂數遠多於循序。

跑完 60 筆插入,循序那棵樹的分裂數通常是個位數(每填滿一個 page 才新增一個,沒有搬移),隨機那棵的分裂數往往是它的好幾倍——而且每次分裂都伴隨半個 page 的資料搬移與父節點指標更新。把 60 筆放大到 1 億筆,page 從十幾個變成幾百萬個,這個分裂率的差距就是 profiler 報告「時間花在平衡樹、讀寫節點」的微觀解釋,也是那條不收斂爬升曲線的成因。

最後把四種組合的完整數字並排,方便你按任一欄排序、直接對照 1 億列規模下的代價。

點欄位標題排序 · 4 列 × 4 欄

主鍵組合 10M(ms) 100M(ms) vs integer
integer PRIMARY KEY8387151.0×
UUID7 WITHOUT ROWID137212581.8×
UUID4 WITH ROWID2003711910.0×
UUID4 WITHOUT ROWID26491258617.6×
「vs integer」一欄以 100M 規模的時間對 integer 基線(715ms)取比值。資料:Anders Murphy,每列為「每百萬列插入時間」。點任一欄標題重新排序。

「vs integer」一欄以 100M 規模的時間對 integer 基線(715ms)取比值

相同 UUID 格式換成時間有序的 UUID7,在 1 億列時插入從 12,586ms 降至 1,258ms,差距達 10 倍。

所以該怎麼決策?把 UUID 主鍵全面打成反模式是過度反應——這篇的代價有明確的適用邊界。如果你的表很小(幾萬、幾十萬列,整棵 B-tree 連同分裂成本都在記憶體裡攤平),或者寫入稀疏、讀取為主,那 UUID4 主鍵的插入懲罰小到不值得在意,分散式產生、不洩漏序號、不可預測這些好處反而是淨賺。代價真正咬人的是高頻寫入、會長到千萬列以上、且跑在 SQLite(或其他以表為 clustered index 的引擎)的場景——append-heavy 的 event log、time-series、audit trail 正是重災區。

真要在這種場景用 UUID,兩個動作幾乎免費地把懲罰拿掉:一,用時間有序的 UUID7 或 ULID 取代 UUID4,把那條 12,586ms 的爬升曲線換回 1,258ms 的水平線;二,用 blob(16) 存而不是 text(36),省掉每列 20 bytes 的字串膨脹與比較成本(128 bit 存成 16-byte blob,而非 36 字元的破折號字串)。兩者疊加,你能保住 UUID 的分散式語意,又把插入成本壓回 integer 主鍵的同一個數量級。

還有第三條路,在你必須沿用既有 UUID4、又不想吞下隨機 clustered 插入的場合特別實用:不要讓 UUID 當主鍵,讓它只當一個次要索引。具體做法是把表宣告成普通的 INTEGER PRIMARY KEY(也就是讓單調遞增的隱藏 rowid 繼續扛 clustered 順序、保住純 append 的循序寫入),再把 UUID4 放進一個獨立的 CREATE INDEX。這跟 Postgres 用 heap table 加一棵 UUID 索引的形態幾乎一樣——clustered 樹照 rowid 循序長,隨機的代價被隔離在那一棵較瘦的次要索引裡,而非整張表。當然這條路不是沒有成本:那棵次要索引本身仍是隨機插入、仍會分裂,且查 WHERE uuid = ? 要先過索引拿到 rowid、再回主樹取資料列,多一次間接跳轉。但對「對外只認 UUID、對內可以接受一個整數 rowid」的系統而言,這個取捨通常划算——把隨機懲罰從整張 clustered 表縮小到一棵不含 row 內容的索引樹,正是 UUID4 WITH ROWID 那條 7,119ms 線之所以遠優於 WITHOUT ROWID 那條 12,586ms 線的同一個道理。

儲存表示法這件事值得多說一句,因為它放大的正是前面講過的扇出問題。一個 UUID 的標準文字形式是 36 個字元——32 個十六進位數字加上 4 個破折號——以 SQLite 的 TEXT 儲存,這是 36 bytes;同一個值轉成 BLOB 只要 16 bytes。對主鍵欄位來說,這個差別不是只多吃一點磁碟那麼簡單:在 SQLite 的 clustered B-tree 裡,key 同時是每一個 interior 節點導航時要存與比較的東西。把 key 從 16 bytes 撐到 36 bytes,等於把每個 interior page 能塞進的 key 數量砍掉一半多,扇出隨之腰斬、樹被迫長高、查找路徑變長——這正是上一節談 WITHOUT ROWID 時的同一個機制,只是這次的胖來源是「文字 key 本身」而非「節點裡的 row 內容」。粗略說,TEXT(36) 的索引體積大約是 BLOB(16) 的兩倍以上,而每一次 key 比較也從比 16 bytes 變成逐字元比 36 bytes 的字串。換句話說,選 TEXT 存 UUID,你是在已經吃了隨機鍵懲罰的傷口上,再額外加一層扇出懲罰——兩個都用 BLOB(16) 一行 schema 就能避開。

WHAT THIS ENABLES:一旦你接受「在 SQLite,主鍵就是實體儲存順序」這條公理,主鍵選擇就從一個唯一性決策升級成一個 I/O 決策——隨機鍵買的是隨機 I/O 加頁面分裂,時間有序鍵買的是循序 append,而這個選擇能在你的表長到一億列之前、用一行 DDL 就定生死。