22 March 2014

最近有個小小專案,該專案用 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 後的數值變了,而 HashMapHashSet 這類的資料結構內部都是依賴 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 當主鍵,讓第一步就先站穩。隨後再視個別功能的需求,可以再退回自動序列號或是自然鍵當主鍵。