資料庫主鍵:AUTO_INCREMENT、UUID 與 UUIDv7
# 開發筆記主鍵要用 auto increment 還是 UUID?最近在看相關討論的時候發現,這個問題的答案好像已經慢慢收斂到 UUIDv7 了。但收斂歸收斂,背後的取捨跟脈絡還是值得好好釐清,不然只是換了一種跟風而已。
TL;DR
- 用
UUIDv4幾乎沒什麼好處 - 確定是單資料庫架構、ID 不對外暴露、未來不會有跨資料庫合併需求,用 Auto Increment 最簡單
- 其他所有情況都用 UUIDv7
UUID 是什麼
UUID(Universally Unique Identifier)是一個 128 位元的識別碼,標準格式長這樣:
550e8400-e29b-41d4-a716-446655440000
36 個字元,用 - 分成 5 段(8-4-4-4-12)。這個分段格式源自最早的 UUIDv1,每一段對應不同的內部欄位。到了 v4 和 v7,大部分欄位都被亂數填滿了,但格式為了向下相容保留下來。
UUID 有好幾個版本,這篇主要會討論到的是:
- UUIDv41:122 位元的純亂數,完全沒有結構。目前最廣泛使用的版本,但對資料庫不太友善。
- UUIDv72:前 48 位元放毫秒級的 timestamp,剩下約 74 位元放亂數。兼顧時間排序跟隨機性,是目前的推薦選擇。
兩者的外觀一模一樣,差別在內部結構。辨識方式是看第三段的開頭:4xxx 是 v4,7xxx 是 v7。
UUIDv4: 550e8400-e29b-41d4-a716-446655440000
^
UUIDv7: 018f3a3b-7a5d-7a3b-8b3a-3b7a5d7a3b8b
^
為什麼主鍵那麼重要?
根據使用場景,會影響到資料寫入、查詢的效率,系統未來能不能擴展,未來搬遷時會不會遇到地雷。從下面的 benchmark 來看,甚至有可能出現七倍以上的落差。
Auto Increment vs UUID:到底差在哪
先從最基本的決策點開始。在決定 Primary Key 時可以從幾個角度思考:
- 插入的效能
- INDEX 的效能
- 搜尋的效能
Auto Increment 的好處很直觀:資料庫自動幫你遞增,8 bytes 的整數,小巧、快速、B-Tree 索引友善。因為永遠往末端追加,INSERT 效能基本上是最好的。
但 Auto Increment 有兩個根本性的限制:
1. 對分散式架構不友善
在分散式系統裡,如何產生一個全域唯一的 ID 是個難題。這可以透過實作中央發號器解決,確保每次拿到的 ID 是全域唯一,像是 Twitter 所推出的 Snowflake 演算法。
然而中央發號器有效能瓶頸與單點故障風險,為了避免單點故障,在實務上會同時部署多個 worker 做協調,這增加了複雜度跟與要維護的元件。
2. 可預測性
把數字改成 1024 或 1022,就有機會看到別人的訂單(如果實作沒寫好),競爭對手可以從 ID 的增長速度推算出你的用戶量或交易量。(也可以反過來當作欺騙手法就是了)
UUID(以 UUIDv4 為代表)解決了這兩個問題:128 位元的亂數空間,任何節點都能獨立生成,不需要中央協調,也幾乎不可能被猜到。
UUIDv4 是完全隨機的,但是在 INSERT 到 B-Tree 的時候會到處插入已經塞滿的 page,觸發大量 page split,寫入效能會明顯下降,這也是為什麼很多人用了 UUID 之後覺得「怎麼資料庫變慢了」。
所以在選擇上,要考量的其實就是這幾件事:
- 系統是否需要分散式生成?
- ID 是否會暴露在外部(URL、API Response)?
- 對資料庫寫入效能的要求有多高?
如果你的系統是單體架構、ID 不對外暴露,auto increment 完全沒問題。但只要涉及分散式、或是 ID 會出現在 URL 裡,UUID 系的方案就幾乎是必要的。
碰撞怎麼辦?
這大概是每次討論 UUID 時一定會被問到的問題。
UUIDv4 有 122 位元的亂數空間,組合數大約是 5.3 × 1036。
要讓碰撞機率達到 50%,你需要產生大約 2.7 × 1018 個 UUID——這個數字大概是「全世界每秒產生 10 億個 UUID,連續產生 85 年」的量級。
而 UUIDv7 的亂數空間「看起來」從 122 位元縮減到了約 74 位元,但碰撞只可能發生在同一毫秒內。也就是說,只有在同一毫秒內產生的 UUID 才需要靠 74 位元的亂數來避免碰撞,而 74 位元提供了約 1.8 × 1022 種組合——對單一毫秒來說綽綽有餘。
UUIDv7 vs created_at + index:實測效能
UUIDv7 最吸引人的特性是:前 48 位元放的是毫秒級的 Unix timestamp,所以它天生就是按時間排序。
UUIDv7 (128 bits)
┌──────────────────────┬────┬──────────────────────────────┐
│ Unix Timestamp (ms) │ ver│ Random │
│ 48 bits │ 4b │ ~74 bits │
└──────────────────────┴────┴──────────────────────────────┘
↑ 可按時間排序 ↑v7 ↑ 亂數防碰撞
如果你用 UUIDv7 當主鍵,ORDER BY id 本身就等於 ORDER BY created_at。你不再需要額外的 created_at 欄位跟對應的索引來做時間排序。
聽起來不錯,但到底差多少?我用 Docker 跑了一個 PostgreSQL 17 的實例,塞了 2000 萬筆資料實際測一下。
測試設定
三張表,同樣的資料結構,差別只在主鍵策略:
-- 方案 A:UUIDv7 主鍵(不需要額外的 created_at)
CREATE TABLE orders_uuidv7 (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id int NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL
);
-- 方案 B:Serial 主鍵 + created_at 索引
CREATE TABLE orders_serial (
id bigserial PRIMARY KEY,
user_id int NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX idx_orders_serial_created_at ON orders_serial (created_at);
-- 方案 C:UUIDv4 主鍵(對照組)
CREATE TABLE orders_uuidv4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id int NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL
);
測試環境:
- Mac Mini M4
- Docker PostgreSQL 17
shared_buffers = 256MB
每輪寫入 100 萬筆,共 20 輪,累積到 2000 萬筆。UUID 在每輪中即時生成(不是預先產生),所以包含了生成函數的開銷。
測試一:INSERT 效能趨勢(每 100 萬筆,單位 ms)
| 累計行數 | Serial | UUIDv7 | UUIDv4 |
|---|---|---|---|
| 1M | 1,132 | 3,185 | 2,998 |
| 5M | 1,474 | 2,661 | 2,972 |
| 10M | 1,165 | 2,560 | 6,497 |
| 13M | 1,123 | 2,745 | 9,668 |
| 15M | 1,411 | 2,630 | 9,188 |
| 16M | 1,133 | 2,552 | 15,857 |
| 20M | 1,017 | 2,539 | 20,845 |
重點不是絕對值,而是趨勢。
UUIDv4 從 3 秒一路飆升到 21 秒。
因為 B-Tree 索引越大,隨機插入命中已 flush 到 disk 的 page 的機率越高,每次 INSERT 都可能觸發:把舊 page 從 disk 讀回 buffer pool → page split → 寫回 disk。
每輪 INSERT 100 萬筆,WAL 量直接反映了資料庫實際對磁碟的寫入負擔:
| 累計行數 | Serial WAL | UUIDv7 WAL | UUIDv4 WAL |
|---|---|---|---|
| 1M | 146 MB | 171 MB | 744 MB |
| 5M | 147 MB | 171 MB | 1,227 MB |
| 8M | 148 MB | 171 MB | 1,715 MB |
| 10M | 147 MB | 171 MB | 2,418 MB |
Serial 跟 UUIDv7 的 WAL 量從頭到尾不變(~147 vs ~171 MB),兩者的差距(24 MB)就是 UUID 16 bytes 比 bigint 8 bytes 大的純資料量差異。
但 UUIDv4 的 WAL 從 744 MB 膨脹到 2,418 MB——同樣寫入 100 萬筆,到 round 10 的時候 WAL 寫入量已經是 UUIDv7 的 14 倍。
這就是 high write amplification3。
UUIDv7 從頭到尾穩定在 2.5~2.8 秒,完全沒退化。
因為 time-ordered 的特性讓新的 key 幾乎都 append 到 B-Tree 最右側的 leaf page,跟 auto-increment 的行為一致——DB 可以 sequentially write,page split 極少。
Serial 穩定在 1.0~1.5 秒。
Serial 毫無疑問最快,但 UUIDv7 跟 Serial 的差距主要來自
- UUID 本身 16 bytes,比 bigint 的 8 bytes 大一倍,索引 page 能塞的 entry 數更少
- 實際應用中 UUID 會在 application layer 用原生函式生成,差距會再縮小(生成 UUID 時間就轉交給 application 身上了)
關於要不要用 Postgres 原生函數生成 UUID,我以前喜歡用「把資料庫能做的事情都交給資料庫」思考,能省一事是一事。
然而將一部分的業務邏輯寫在資料庫裡頭,未來就很難注意到,因此我現在更偏向UUID 的生成應該交給應用層來做。
測試二:儲存空間(20M rows)
| 方案 | Heap | Index | 總大小 |
|---|---|---|---|
| Serial | 1,149 MB | 428 MB | 1,578 MB |
| UUIDv7 | 1,302 MB | 749 MB | 2,051 MB |
| UUIDv4 | 1,302 MB | 770 MB | 2,072 MB |
UUID 系的 heap 比 Serial 大 153 MB(因為每行的 PK 從 8 bytes 變 16 bytes),index 則大了約 1.75 倍,但以儲存空間來說並沒有明顯的落差。
測試三:Cursor-Based Pagination
Cursor-based pagination 的原理是用上一頁最後一筆的值作為「游標」,下一頁的查詢用 WHERE > cursor ORDER BY ... LIMIT N。相比 OFFSET,cursor-based 不管翻到第幾頁都能維持穩定的效能。
在 110 萬筆的規模下,我測了三種情境,每種跑 100 次,游標位置在第 90 萬筆(模擬深層分頁):
| 查詢方式 | 100 次總耗時 |
|---|---|
| Serial + OFFSET 900000(對照組) | 7,012 ms |
| Serial + cursor on created_at index | 1.9 ms |
| UUIDv7 + cursor on PK | 1.6 ms |
幾個觀察:
- UUIDv7 的 cursor pagination 直接用主鍵就好。 不需要額外的索引。Serial 方案如果要做時間排序的 cursor pagination,你得走
created_at索引,這是一個 secondary index,查詢時需要先查索引再回表取資料(index scan → heap fetch)。 - UUIDv7 比 created_at 索引稍快。 因為 UUIDv7 的 cursor 走的是 primary key,而 created_at 是 secondary index。
- OFFSET 在深層分頁是災難。 7 秒 vs 不到 2 毫秒,差了 3,600 倍。OFFSET 需要掃描並丟棄前面所有的行。這個結論跟本篇文章關係較小,但如果要做分頁功能不要用
OFFSET,很容易拖垮效能。
小結
UUIDv7 當主鍵的優勢有幾點:
- 寫入效能不退化:跟 auto-increment 一樣是 sequential append,不會隨資料量增長變慢。UUIDv4 在 2000 萬筆時已經慢了 7 倍,而且只會越來越嚴重。
- 用一個索引做兩件事:既是主鍵約束,又是時間排序的依據。省下了
created_at欄位跟它的索引,cursor-based pagination 直接用WHERE id > :last_id ORDER BY id就搞定(在實務上可能還是需要)
如果 query pattern 很依賴時間排序(例如 feed、timeline、訂單列表),UUIDv7 可以讓 schema 更簡潔、索引更少。
但如果你需要精確到微秒的建立時間,或是 created_at 有業務邏輯上的意義(例如需要用時間區間查詢),那 created_at 欄位還是該留著。UUIDv7 的 timestamp 精度是毫秒,而且在同一毫秒內的排序是由亂數決定的,不保證嚴格的插入順序。
外部 URL 怎麼辦?
UUIDv7 當內部主鍵沒問題,但直接暴露在 URL 裡有幾個缺點:太長(36 字元),而且 timestamp 部分仍然洩漏了建立時間。
常見的做法有幾種:
- 把 16 bytes 的 UUID 用 Base62 或 Base58 編碼,壓縮到 21~22 字元。資料本質不變,只是換一種更短的表示法。Base58 額外排除了
0/O、I/l這些容易混淆的字元,手動輸入的場景比較友善 - 外部另外使用獨立的 ID:用 NanoID 另外產生一個跟內部 ID 完全無關的外部識別碼。NanoID 是純亂數,沒有時間結構,預設 21 字元約 126 位元的熵,衝突耐性跟 UUIDv4 同等級。
我自己比較偏好做法 2。原因是 Base62/58 編碼雖然縮短了字串,但本質上還是同一個 UUID,有心的人解碼回去就能拿到 timestamp。NanoID 跟內部 ID 完全脫鉤,就算外部 ID 被拿到,也無法反推出資料庫裡的任何資訊。代價是需要多一個欄位跟索引來存放外部 ID,但這個 trade-off 通常是值得的。
如果真的需要「絕對不重複」呢?
UUID(不管哪個版本)的唯一性都是基於機率的——碰撞機率極低,但理論上不是零。對絕大多數系統來說這完全足夠了,但某些場景確實需要 100% 的保證:金融交易序號、發票號碼、醫療記錄 ID 等等。
這種時候就需要回到「中央集權」的模式——由一個單一的權威來源負責發號。常見的做法有:
- 資料庫序列 + 分散式鎖。 最直接的方式,用 PostgreSQL 的
SEQUENCE或 Redis 的INCR搭配分散式鎖來確保全局唯一。缺點是有單點故障的風險,而且在高併發下鎖的競爭會成為瓶頸。
Twitter Snowflake 及其變體。 前面提過的 Snowflake 本質上也是中央集權的——Worker ID 的分配需要中央協調(ZooKeeper / etcd)。百度的 UidGenerator、Sony 的 Sonyflake 都是類似的思路,只是在 bit 分配或 Worker ID 管理上做了調整。
資料庫原生的分散式 ID。 CockroachDB 跟 TiDB 這類 NewSQL 資料庫內建了分散式唯一 ID 生成機制,在資料庫層面就解決了這個問題,不需要應用層額外處理。
不管選哪種方案,本質上都是用可用性或運維複雜度去換取絕對唯一性。如果你的業務場景沒有法規或合規要求必須保證唯一,UUIDv7 的機率性唯一就夠了。
其他小知識
00000000-0000-0000-0000-000000000000是一個有效且被稱作 Nil UUID 的 UUID4FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF是一個有效且被稱作 Max UUID 的 UUID- 不要自己寫 UUID 產生器函數
從事故現場來看,往往不是因為發生衝突而故障,十之八九都是應用端沒寫好而已。唉,如果把這件事也考慮進去的話,或許最安全的還是 Serial 了吧。