1. UUID 是什么?
UUID(Universally Unique Identifier 通用唯一識別碼)用于標識資源唯一性。理論上說,門牌號、電話號碼、郵編、身份證號都是用來標識資源唯一性的,但為使用方便,不適合用一個無規律的字符串表示,UUID 主要還是在程序中使用。
UUID 源自1980年代的 Apollo 電腦公司,是一個 128 位的標識符,理論上的總數有 2128個,也就是說,哪怕每納秒產生 1 萬億個 UUID,也要 100 億年才能用完。因此,只要保證生成方法的散布足夠好,統計概率上,UUID 重復的可能性約等于 0 。
UUID 的具體規范可以參考 RFC 4122。這個標準定義了 5 個版本的 UUID:
- 版本 1 根據時間和 mac 地址來生成 UUID。MAC 地址用于保證設備唯一性。通過在時間戳后加入 13-14 位的時鐘序列,可以保證在同一臺設備,同 1 秒內生成的 1630 億個 UUID 不重復。 這個版本的 UUID 我們用得相對較少,一個原因是其中攜帶了設備信息。1999 年,著名病毒梅麗莎的作者,因為代碼中的 UUID 暴露了 MAC 地址信息,不到一個星期就被抓住了。另一個原因是這個版本的 UUID 生成有規律,比較容易根據一個 UUID 推斷到下一個 UUID。
- 版本 2 是一個 DEC 安全的版本,RFC4122 中也沒有詳細說明具體實現方式,我們一般也用不到。
- 版本 3 根據字符串和命名空間散列值(HASH)來獲取 UUID。由于 HASH 函數本身的特性,一般不用擔心 UUID 沖突,或者別人根據散列值反推原數據的問題。這個版本采用的是 MD5 散列值。
- 版本 4 根據隨機數生成 UUID,是我們比較常用的版本。
- 版本 5 和版本 3 一樣,也是根據散列值獲取 UUID,不過采用的散列算法是 SHA-1 而不是 MD5,相較而言,RFC4122 推薦大家使用版本 5 而不是版本 3。
我們常看到的 UUID 往往被表示為 16 進制數字和橫杠組成的字符串,比如:a3535b78-69dd-4a9e-9a79-57e2ea28981b,其中第二個橫杠之后的第一個數字表示 UUID 版本,例子中這個 UUID 就是版本 4 的。
2. 為什么在數據庫中使用 UUID?
大多數人在數據庫中存儲 UUID 的直接原因,是需要一個不暴露內部信息的唯一標識。例如博客文章,Title 無法保證不重復,數字 ID 則會暴露內部信息,于是,可以生成一個 UUID 作為唯一標識。類似地,我們在網上請求的許多公開資源,如圖片、音頻、以及其他文件等,都是以 UUID 作為標識的。
第二個原因,是為了方便數據管理:
- 當數據量過大,不得不進行分片管理的時候,數字 ID 的唯一性不好保證;萬一需要重建部分數據,數字 ID 也很難確保與原表一致。
- UUID 作為預先生成的值,可以在插入數據庫之前拿到,會方便一些數據操作。
3. UUID 作為主鍵有什么問題?
一般不推薦把 UUID 作為主鍵,它會帶來不少問題:
3.1 數據碎片化
我們知道,使用自增 ID 作為主鍵時,插入新的數據行往往是連續的,插入多條數據只需要讀寫少數數據頁。但由于 UUID 的隨機性,新插入的數據往往會落在不同的數據頁上,導致數據碎片化,同樣的數據量,可能需要更大的空間才能存儲。
同時,當數據量上升,內存中無法暫存足夠多的數據頁時,每次插入數據都可能涉及硬盤讀寫,極大地拖慢了數據插入效率。
3.2 索引占用空間過大
大多數人會把 UUID 保存為 16 進制數字和橫杠組成的字符串,也就是 char(36),如果采用 UTF-8 字符集,它所占的字節數是 2 + 3 * 36 = 110 字節(前面 2 個字節為長度,后面每個字符 3 個字節)。相較而言,一個整數只有 4 個字節,相差 27 倍。
數據庫采用 B 樹索引,其中主鍵索引的葉子節點指向數據行,而二級索引的葉子節點存儲著主鍵,之后再通過主鍵索引回表查數據。也就是說,有多少個二級索引,主鍵就需要被存儲幾次,因此,索引的空間需求就極速擴張了。
在數據庫運行過程中,為加快查詢速度,這些索引往往需要被加載到內存中。那么,過大的索引導致內存不足,就會嚴重影響數據庫查詢效率。
3.3 字符比較比整數比較慢
CPU 每次最多可以比較 8 個字節的整數值,但對于字符串,必須一個字符一個字符比較過去。有測試說明,在查詢比較時,使用整數的速度比使用字符串的速度快數倍到數十倍之間。
不過,一般來說,數據庫不是一個 CPU 密集的應用,因此這方面的影響不是主要考慮因素。
4. 替代方案討論
4.1 優化 UUID 的存儲
將 UUID 存儲為 16 進制值和橫杠組成的字符串是非常低效的,UUID 本身只有 128 位,也就是 16 字節,存儲成字符串后卻有 110 個字節,膨脹了 7 倍,憑空多占了不少空間。優化的思路就是采用更好的格式,比如直接存儲二進制,或者將二進制值存儲為 base64 字符串。相對復雜一點的是將 UUID 映射到一個整數。
優化存儲格式的具體實現都不困難,也能相當程度地節約存儲空間,但并沒有解決 UUID 的隨機性帶來的數據碎片化的問題。
針對碎片化的問題,有一個思路是控制隨機性,也就是增加一個自己生成的字符串作為前綴,比如說日期(或它的哈希值)。因為字符串排序從前往后走,同樣的前綴也就意味著接近的排序,那么,原本散布在整個數據庫的值,就會集中分布在一定范圍內的數據頁,從而大大緩解了內存壓力。
4.2 同時使用自增 ID 和 UUID
更常見的思路,是使用自增 ID 作為主鍵,同時使用 UUID 作為唯一標識和與其它表關聯的外鍵。好處是有了一個可以比較安全地對外暴露的唯一標識,節約了索引空間,也不用擔心數據分片和數據重建帶來的危險。
但也存在一些問題,因為所有的外鍵關聯都用的 UUID,所以占用的空間自然會大一些,同時,字符串比較速度較慢和所有查詢都要回表也是值得考慮的因素。
4.3 避開 UUID
很多小型應用不需要考慮數據管理的問題,只是需要一個可以對外暴露的唯一標識,于是,干脆放棄 UUID,采用其他思路實現標識的唯一性。
比如說前面說的博客文章,鑒于 Title 沒法保證唯一性,可以在 Title 前后加上一個前綴或者后綴,從而實現唯一性。一個思路是使用隨機字符串,或者作者、日期等信息。
又比如說,將每個數據行映射到一個大整數作為唯一標識。某種意義上等于根據自己的實際需要寫了一套新的唯一標識算法。
END