最近有個小小專案,該專案用 PostgreSQL,該案按照我們新專案的慣例,採用 UUID 當做主鍵,而不是用資料庫自動產生的序列號 (Sequence Number)。昨天老闆說這迷你專案不用管 scalability,所以不需要用 UUID。但我覺得不妥,當下我只講了用序列號有安全性的顧慮。雖然我想解釋更多的理由,但又陷入理由忘光光的窘境,亂發了一頓脾氣。我想採用 UUID 已經變成我的 muscle memory,燒死在我腦袋的深層紋理了,一時挖不出來。週末比較有空,好好的整理一番,給自己溫習,也給各位參考。
Why use UUID as Primary Key ?
1. 安全
用序列號當做主鍵很方便,但是序列號會曝露太多資訊。一個這樣的網址
http://mybigcompany.com/confidential/132/year.pdf
改變 132 這數字就可以猜到其他機密文件的網址,而且你也知道文件數最少登錄了 132 筆。
反觀用 UUID 就會是:
http://mybigcompany.com/confidential/
550e8400-e29b-41d4-a716-446655440000/year.pdf
(太長所以斷行)
這樣誰也猜不出另一份文件放在什麼網址上。
也許,你們專案一開始是由有經驗的工程師開發的,知道要做授權機制,任何人亂打序號都不能存取。但隨著專案成長,繼續擴增,或者是換人維護開發,這個不顯眼的小洞是最容易漏掉的。安全出包通常都是在最不起眼的地方發生的,因此在架構上我寧可麻煩一點,一開始就加上 防呆措施,讓未來不會再因為這種小漏洞出事。
UUID 做為主鍵是安全與隱私的防呆裝置
2. Dirty Case in Java equals() and hashCode()
第二點就是長篇大論啦,在 Java 裡,所有覆寫 equals()
都要一起實作 hashCode()
。hashCode() 基本上跟 equals() 包含同樣的欄位,我們來看看一個標準的 Entity,採用 id 做為 equals() 為依據的 Company
物件:
//Entity "Company",用資料庫自動產生序列號當 id
static class Company {
//公司名稱。因為公司名很容易重覆,不能當主鍵,
//也不能拿來算 equals
String name;
//主鍵,用序列號所以是 long
Long id;
//下面是標準的 equals/hashCode,以 id 為判斷依據
//由 Eclipse 自動產生
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (getClass() != obj.getClass()) return false;
final Company other = (Company) obj;
if (id == null) {
if (other.id != null) return false;
} else if (!id.equals(other.id)) {
return false;
}
return true;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((id == null) ? 0 : id.hashCode());
return result;
}
}
好了,雖然很長,但現在都是用 IDE 產生,所以沒什麼大不了的。我們來看看一個 很髒 的範例:
@Test
public void surprise() throws Exception {
final HashSet<Company> companies = new HashSet<>();
final Company htc = new Company();
//存 DB 前,先放進去
companies.add(htc);
//假設到了這一步已存進 DB,我們就有自動產生的主鍵,將它設
//回 company 物件裡:
htc.id = 12L;
//後來的邏輯運算裡,又加了同一物件回去,想說沒差,
//因為 HashSet 會幫我處理唯一性
companies.add(htc);
//這裡會印出... ?
System.out.println(companies.size());
}
你猜會印出多少?
答案是 2
,原因是 hashCode() 在設了 id 後的數值變了,而 HashMap
和 HashSet
這類的資料結構內部都是依賴 hash 值將資料分批存放,值不同就會放在不同地方 (bucket),在不同 bucket 內的物件就會省去 equals() 的判斷,造成這個 bug。
那怎麼避免這個問題?在實作 equals/hashCode 時,要選擇 不會改變的欄位 才適合做為比較的依據。而通常這種欄位會是 final
,物件創建後不會再改變。
好了,回到資料庫的討論上。如果你用自動序列號當主鍵,當作 Entity 的 id,那麼該物件的生命週期裡,會有一段時間的 hashCode 是不確定的,直到存入資料庫後才固定。這就很容易踩到上述例子中的 bug,而且這種 bug 還特別難找。
對於這個問題的困境,有幾個解法:
- 解法一:是讓 equals/hashCode 不要用 id 欄位,而是改用像是 username 這種 自然鍵,自然鍵有唯一性,所以可以拿來判斷 equals。但問題是不是所有物件都有這麼好的欄位。再者,這些欄位雖然是唯一,但卻可能事後又修改。
- 解法二:先跟資料庫要一筆序號後,再創建物件,這樣就安全了。但我想這已經違反了使用自動序列號的本意 --- 就是想要進出一次資料庫就搞定。搞成兩步驟實在太麻煩。
- 解法三:就是今天討論的主題 -- Entity id 改用 UUID:
class Company {
final String id;
Company() {
id = UUID.randomUUID().toString();
}
}
按上面的程式,創建時使用 UUID 做為 Entity id,它永遠不變,並拿它做為主鍵,做為 equals/hashCode 的判斷依據,所有的問題都一次解決了!簡單又俐落!
其他的語言我不熟,但如果有類似的 hash 實作,那麼採用自動序列號也會碰到一樣的問題。
只有不會改變的欄位才適合做為 equals/hashCode 的依據。而資料庫自動序列碼會造成 id 有空窗期,容易出現 bug。在 Entity 創建時改採 UUID 做為 id 可解決這個問題。
3. 方便匯整各路資料
資料庫,就是會倒來倒去,東匯入一點,西匯出一些。不要跟我說你從沒手動匯整過資料,我會覺得你寫的專案還不夠多。也不要說你的專案永遠不會遇到這種事,我會覺得你太小看未來的改變。自動序列號在不同的資料庫裡通常會不一樣,匯進匯出很容易衝突。
舉個例子,你在測試資料庫裡將資料修復,或者增添一些新資料,弄好後你將這些新資料 dump,再匯入正式的資料庫服務。如果用自動序列號這時就會相衝啦,因為正式的資料庫還在線上服務,用戶新增的序號跟你增添資料可能會衝突。是可以解決啦,但就是很麻煩。但如果一開始就是用 UUID 當主鍵,那就不會相衝,匯完直接回家睡覺,什麼煩惱都沒有。
4. Polymophic Associations
這是資料庫的延伸應用,我在之前的文章有提到使用這個技巧,來達到切斷深層依賴、來達到多型資料相依。這個技巧需要三個 table 共用同個主鍵,用 UUID 來做就會很簡單,但用序列號想套用就很麻煩,而且在既有資料上套用的話還要修改原有的主鍵,更煩。
這個進階技巧當然不是每天會遇到的,但等到你想用時,自動序列號就是沒這個選項,錯失了一個更好的架構。
5. Scalabilty
最後一個理由,當然是 Scalability。
- Global Lock
自動序列號只能在同一資料庫產生,等同於 Global Lock,傷害 Throughput,而這點也造成 Single Point of Failure,傷害 Availability - Shard
序列號可以做 Shard,但你需要花時間規畫各區間配哪個序列段落,而且隨著資料庫的增減配置會更困難,這點傷害 Elasticity。 - NoSQL
RDBMS 在查詢方面是很強的,但是處理大量的寫入就比較弱了,而這點正是 NoSQL 的強項,而 NoSQL 基本上都是 UUID 的天下。如果一開始在 RDBMS 就使用 UUID,之後服務的某一功能撐不住要轉 NoSQL 時就會比較簡單。
如果一個小步可以讓我未來 scale 時較輕鬆,為何不一開始就踏出去呢?一個 hackson 的小蝦專案,也有變大鯨魚的機會啊。
UUID 主鍵的缺點
好處說盡,來談談缺點吧
- UUID 在下 SQL 指令時很麻煩的,要打 UUID 全碼
基本上用了 UUID 當主鍵就沒機會打where id = 1234
這麼好康的指令了。不是用剪貼就是要多 JOIN 一次。這點挺煩的。 - 資料庫 index 可能比較慢,這點每個資料庫都不相同。
- 用自動序列號當主鍵的話,你就直接有新增順序的 index 可用。用 UUID 的話你要額外開另一個欄位,像是
create_time
之類的來做排序。
小結
大家出門時,門都會上鎖,然後每天要帶鑰匙開門、鎖門,這實在有點煩,但這個小動作換來出門時可以心安。對於採用 UUID 做為主鍵,我也是抱持同樣的心態:一點小麻煩,可以讓我換來安全性、程式正確性、未來的擴充性等等優點。所以在選擇資料庫架構時,我會先預設採用 UUID 當主鍵,讓第一步就先站穩。隨後再視個別功能的需求,可以再退回自動序列號或是自然鍵當主鍵。