· 10 分鐘閱讀

資料庫主鍵: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)

累計行數SerialUUIDv7UUIDv4
1M1,1323,1852,998
5M1,4742,6612,972
10M1,1652,5606,497
13M1,1232,7459,668
15M1,4112,6309,188
16M1,1332,55215,857
20M1,0172,53920,845

重點不是絕對值,而是趨勢

UUIDv4 從 3 秒一路飆升到 21 秒。

因為 B-Tree 索引越大,隨機插入命中已 flush 到 disk 的 page 的機率越高,每次 INSERT 都可能觸發:把舊 page 從 disk 讀回 buffer pool → page split → 寫回 disk。

每輪 INSERT 100 萬筆,WAL 量直接反映了資料庫實際對磁碟的寫入負擔:

累計行數Serial WALUUIDv7 WALUUIDv4 WAL
1M146 MB171 MB744 MB
5M147 MB171 MB1,227 MB
8M148 MB171 MB1,715 MB
10M147 MB171 MB2,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 的差距主要來自

  1. UUID 本身 16 bytes,比 bigint 的 8 bytes 大一倍,索引 page 能塞的 entry 數更少
  2. 實際應用中 UUID 會在 application layer 用原生函式生成,差距會再縮小(生成 UUID 時間就轉交給 application 身上了)

關於要不要用 Postgres 原生函數生成 UUID,我以前喜歡用「把資料庫能做的事情都交給資料庫」思考,能省一事是一事。

然而將一部分的業務邏輯寫在資料庫裡頭,未來就很難注意到,因此我現在更偏向UUID 的生成應該交給應用層來做

測試二:儲存空間(20M rows)

方案HeapIndex總大小
Serial1,149 MB428 MB1,578 MB
UUIDv71,302 MB749 MB2,051 MB
UUIDv41,302 MB770 MB2,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 index1.9 ms
UUIDv7 + cursor on PK1.6 ms

幾個觀察:

  1. UUIDv7 的 cursor pagination 直接用主鍵就好。 不需要額外的索引。Serial 方案如果要做時間排序的 cursor pagination,你得走 created_at 索引,這是一個 secondary index,查詢時需要先查索引再回表取資料(index scan → heap fetch)。
  2. UUIDv7 比 created_at 索引稍快。 因為 UUIDv7 的 cursor 走的是 primary key,而 created_at 是 secondary index。
  3. OFFSET 在深層分頁是災難。 7 秒 vs 不到 2 毫秒,差了 3,600 倍。OFFSET 需要掃描並丟棄前面所有的行。這個結論跟本篇文章關係較小,但如果要做分頁功能不要用 OFFSET,很容易拖垮效能。

小結

UUIDv7 當主鍵的優勢有幾點:

  1. 寫入效能不退化:跟 auto-increment 一樣是 sequential append,不會隨資料量增長變慢。UUIDv4 在 2000 萬筆時已經慢了 7 倍,而且只會越來越嚴重。
  2. 用一個索引做兩件事:既是主鍵約束,又是時間排序的依據。省下了 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 部分仍然洩漏了建立時間。

常見的做法有幾種:

  1. 把 16 bytes 的 UUID 用 Base62 或 Base58 編碼,壓縮到 21~22 字元。資料本質不變,只是換一種更短的表示法。Base58 額外排除了 0/OI/l 這些容易混淆的字元,手動輸入的場景比較友善
  2. 外部另外使用獨立的 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 的 UUID4
  • FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF是一個有效且被稱作 Max UUID 的 UUID
  • 不要自己寫 UUID 產生器函數

從事故現場來看,往往不是因為發生衝突而故障,十之八九都是應用端沒寫好而已。唉,如果把這件事也考慮進去的話,或許最安全的還是 Serial 了吧。

Footnotes

  1. https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4

  2. https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-7

  3. 單次插入可能導致多個 page 的重寫,顯著超過所寫入的數據

  4. https://www.rfc-editor.org/rfc/rfc9562.html#name-nil-uuid