做者:Yves Trudeau
翻譯:管長龍
Yves 是 Percona 的首席架構師,專門研究分佈式技術,例如 MySQL Cluster,Pacemaker 和 XtraDB cluster。 他之前是 MySQL 和 Sun 的高級顧問。擁有實驗物理學博士學位。
原文連接: https://www.percona.com/blog/...
若是你在網上快速的作一個關於 UUID 和 MySQL 的搜索,你會獲得至關多的結果。如下是一些例子:php
另:以上文章連接請在文章結尾處查看html
那麼,像這樣一個衆所周知的話題還須要更多關注嗎?顯然是的。mysql
儘管大多數帖子都警告人們不要使用 UUID,但它們仍然很是受歡迎。這種受歡迎的緣由是,這些值能夠很容易地由遠程設備生成,而且衝突的機率很是低。這篇文章,目標是總結其餘人已經寫過的東西,並但願能帶來一些新的想法。git
UUID 表明通用惟一標識符,在 RFC 4122 中定義。它是一個 128 位數字,一般以十六進制表示,並用破折號分紅五組。典型的UUID值以下所示:github
RFC 4122: https://tools.ietf.org/html/r...
yves@laptop:~$ uuidgen 83fda883-86d9-4913-9729-91f20973fa52
一共有 5 種正式的 UUID 值類型(版本 1 - 5),但最多見的是:基於時間的(版本 1 / 2)和純隨機的(版本 3)。 自 1970 年 1 月 1 日起,對 10ns 內基於時間類型的 7.5 個字節(60位)形式的 UUID 數目進行編碼,並以 "time-low"-"time-mid"-"time-hi" 的格式進行劃分。 缺乏的 4 位是用做 time-hi 字段前綴的版本號。前三組的 64 位值就這麼產生了。 最後兩組是時鐘序列,每次修改時鐘都會增長一個值以及一個主機惟一標識符。 大多數狀況下,主機主網絡接口的 MAC 地址用做惟一標識符。web
使用基於時間的 UUID 值時,須要注意如下幾點:算法
這是一個使用 "uuidgen" (Unix 工具)生成基於時間的值的示例:sql
yves@laptop:~$ for i in $(seq 1 500); do echo "$(date +%s): $(uuidgen -t)"; sleep 1; done 1573656803: 572e4122-0625-11ea-9f44-8c16456798f1 1573656804: 57c8019a-0625-11ea-9f44-8c16456798f1 1573656805: 586202b8-0625-11ea-9f44-8c16456798f1 ... 1573657085: ff86e090-0625-11ea-9f44-8c16456798f1 1573657086: 0020a216-0626-11ea-9f44-8c16456798f1 ... 1573657232: 56b943b2-0626-11ea-9f44-8c16456798f1 1573657233: 57534782-0626-11ea-9f44-8c16456798f1 1573657234: 57ed593a-0626-11ea-9f44-8c16456798f1 ...
第一個字段翻轉(t=1573657086),第二個字段遞增。第一個字段再次看到類似的值大約須要 429s。第三個字段每一年大約更改一次。最後一個字段在給定主機上是靜態的,MAC 地址在筆記本電腦上使用:數據庫
yves@laptop:~$ ifconfig | grep ether | grep 8c ether 8c:16:45:67:98:f1 txqueuelen 1000 (Ethernet)
另外一個常常看到的 UUID 是版本 4,即純隨機版本。默認狀況下 "uuidgen" 工具會生成 UUID 版本4 的值:服務器
yves@laptop:~$ for i in $(seq 1 3); do uuidgen; done 6102ef39-c3f4-4977-80d4-742d15eefe66 14d6e343-028d-48a3-9ec6-77f1b703dc8f ac9c7139-34a1-48cf-86cf-a2c823689a91
惟一的 「重複」值是第三個字段開頭的版本 "4"。 其餘 124 位都是隨機的。
爲了瞭解使用 UUID 值做爲主鍵的影響,重要的是要檢查 InnoDB 如何組織數據。 InnoDB 將表的行存儲在主鍵的 b-tree(聚簇索引)中。 聚簇索引經過主鍵自動對行進行排序。
當插入具備隨機主鍵值的新數據時,InnoDB 必須找到該行所屬的頁面,若是尚不存在該頁面,則將其加載到緩衝池中,插入該行,而後最終將頁面刷新回 磁盤。若是使用純隨機值和大表,則全部 b-tree 的葉子頁都易於接收新行,沒有熱頁。不按主鍵順序插入的行會致使頁面拆分,從而致使較低的填充係數。對於比緩衝池大得多的表,插入極可能須要從磁盤讀取表頁。緩衝池中已插入新行的頁面將變爲髒頁。在須要刷新到磁盤以前,該頁面接收第二行的概率很是低。在大多數狀況下,每次插入都會致使兩次 IOP(一讀一寫)。第一個主要是對 IOP 速率的影響,它是可伸縮性的主要限制因素。
所以,得到良好性能的惟一方法是使用具備低延遲和高耐久性的存儲。這是第二個主要的影響因素。對於彙集索引,輔助索引將主鍵值用做指針。主鍵 b-tree 的葉子來存儲行,而二級索引 b-tree 的葉子來存儲主鍵值。
假設一張一百萬行的表格具備 UUID 主鍵和五個輔助索引。經過閱讀上一段,咱們知道每行主鍵值存儲六次。這意味着總共有六百萬個char(36) 類型的值,意味着數據總量 216 GB。這只是冰山一角,由於表一般具備指向其餘表的外鍵(不管是否顯式)。當架構基於 UUID 值時,全部支持的列和索引均爲 char(36) 類型。基於 UUID 的架構,大約 70% 的存儲用於這些值。
若是這還不夠,那麼使用 UUID 值會產生第三個重要影響。CPU 一次最多可比較 8 個字節的整數值,但 UUID 值每一個字符之間都要比較。數據庫不多受到 CPU 的限制,但這仍然增長了查詢的延遲。若是還不肯定,請看一下整數與字符串之間的性能比較:
mysql> select benchmark(100000000,2=3); +--------------------------+ | benchmark(100000000,2=3) | +--------------------------+ | 0 | +--------------------------+ 1 row in set (0.96 sec) mysql> select benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='df878007-80da-11e9-93dd-00163e000003'); +----------------------------------------------------------------------------------------------------+ | benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='df878007-80da-11e9-93dd-00163e000003') | +----------------------------------------------------------------------------------------------------+ | 0 | +----------------------------------------------------------------------------------------------------+ 1 row in set (27.67 sec)
固然,以上示例是最壞的狀況,但至少能夠說明問題的範圍。整數的比較大約快 28 倍。即便差值在 char 值中迅速出現,也仍然比 UUID 慢了約 2.5 倍:
mysql> select benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='ef878007-80da-11e9-93dd-00163e000003'); +----------------------------------------------------------------------------------------------------+ | benchmark(100000000,'df878007-80da-11e9-93dd-00163e000002'='ef878007-80da-11e9-93dd-00163e000003') | +----------------------------------------------------------------------------------------------------+ | 0 | +----------------------------------------------------------------------------------------------------+ 1 row in set (2.45 sec)
讓咱們探索一些解決這些問題的解決方案。
UUID,hash 和 token 的默認表示形式一般是十六進制表示法。對於基數,可能的值數(每一個字節只有 16 個)遠沒有效率。使用其餘表示形式(如 base64 或直接二進制)怎麼辦?咱們能夠節省多少?性能如何受到影響?
讓咱們以 base64 表示法開始。每一個字節的基數爲 64(六十四進制),所以在 3 個字節在 base64 中須要 來表示 2 個字節的實際值。一個 UUID 的值由 16 個字節的數據組成,若是咱們除以 3,則餘數爲 1。爲處理該問題,base64 編碼在末尾添加了 '==' :
mysql> select to_base64(unhex(replace(uuid(),'-',''))); +------------------------------------------+ | to_base64(unhex(replace(uuid(),'-',''))) | +------------------------------------------+ | clJ4xvczEeml1FJUAJ7+Fg== | +------------------------------------------+ 1 row in set (0.00 sec)
若是知道編碼實體的長度(例如 UUID 的長度),咱們就能夠刪除 "==",由於它只是一種長度配重。所以,以 base64 編碼的 UUID 的長度爲 22。
下一步的邏輯步驟是直接以二進制格式存儲值。這是最理想的格式,可是在 MySQL 客戶端中顯示值不太方便。
那麼,尺寸對性能有何影響?爲了說明影響,我在具備如下定義的表中插入了隨機的 UUID 值。
CREATE TABLE `data_uuid` ( `id` char(36) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1;
爲默認的十六進制表示形式。對於 base64,"id" 列定義爲 char(22),而 binary(16) 用於二進制示例。數據庫服務器的緩衝池大小爲 128M,其 IOP 限制爲 500。插入是在單個線程上完成的。
使用 UUID 值的不一樣表示形式的表的插入率
在全部狀況下,插入速率最初都是受 CPU 限制的,可是一旦表大於緩衝池,則插入將很快成爲 IO 限制。對於 UUID 值使用較小的表示形式只會使更多的行進入緩衝池,但從長遠來看,這對性能沒有真正的幫助,由於隨機插入順序占主導地位。若是使用隨機 UUID 值做爲主鍵,則性能會受到您能夠承受的內存量的限制。
如咱們所見,最重要的問題是值的隨機性。新的行可能會在任何表的子頁中結束。所以,除非整個表都已加載到緩衝池中,不然它意味着讀 IOP,最後是寫 IOP。個人同事 David Ducos 爲這個問題提供了一個很好的解決方案,可是一些客戶不想 UUID 值中提取信息,例如生成時間戳。
若是咱們只是稍微減小值的隨機性,以使幾個字節的前綴在一個時間間隔內不變,該怎麼辦? 在該時間間隔內,只須要將整個表的一小部分(對應於前綴的基數)存儲在內存中,以保存讀取的 IOP。 這也將增長頁面在刷新到磁盤以前接收第二次寫入的可能性,從而減小了寫入負載。讓咱們考慮如下 UUID 生成函數:
drop function if exists f_new_uuid; delimiter ;; CREATE DEFINER=`root`@`%` FUNCTION `f_new_uuid`() RETURNS char(36) NOT DETERMINISTIC BEGIN DECLARE cNewUUID char(36); DECLARE cMd5Val char(32); set cMd5Val = md5(concat(rand(),now(6))); set cNewUUID = concat(left(md5(concat(year(now()),week(now()))),4),left(cMd5Val,4),'-', mid(cMd5Val,5,4),'-4',mid(cMd5Val,9,3),'-',mid(cMd5Val,13,4),'-',mid(cMd5Val,17,12)); RETURN cNewUUID; END;; delimiter ;
UUID 值的前四個字符來自當前年份和星期編號的串聯 MD5 哈希值。固然,該值在一個星期內是靜態的。UUID 值的其他部分來自隨機值的 MD5 和當前時間,精度爲 1us。第三個字段以 "4" 爲前綴,表示它是版本 4 的 UUID 類型。有 65536 個可能的前綴,所以在一週內,內存中僅須要錶行的 1/65536,以免在插入時讀取 IOP。這更容易管理,一個 1TB 的表在緩衝池中只須要大約 16MB 的空間便可支持插入。
即便您使用使用 binary(16) 存儲的僞有序的 UUID 值,它仍然是很是大的數據類型,這會增大數據集的大小。請記住,InnoDB 將主鍵值用做輔助索引中的指針。若是咱們將全部的 UUID 值存儲在映射表中怎麼辦? 映射表將定義爲:
CREATE TABLE `uuid_to_id` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `uuid` char(36) NOT NULL, `uuid_hash` int(10) unsigned GENERATED ALWAYS AS (crc32(`uuid`)) STORED NOT NULL, PRIMARY KEY (`id`), KEY `idx_hash` (`uuid_hash`) ) ENGINE=InnoDB AUTO_INCREMENT=2590857 DEFAULT CHARSET=latin1;
重要的是要注意 uuid_to_id 表不會強制 UUID 的惟一性。idx_hash 索引的做用有點像布隆過濾器。若是沒有匹配的哈希值,咱們確定會知道表格中沒有 UUID 值,可是若是有匹配的哈希值,咱們就必須使用存儲的 UUID 值進行驗證。爲幫助咱們,請建立一個 SQL 函數:
DELIMITER ;; CREATE DEFINER=`root`@`%` FUNCTION `f_uuid_to_id`(pUUID char(36)) RETURNS int(10) unsigned DETERMINISTIC BEGIN DECLARE iID int unsigned; DECLARE iOUT int unsigned; select get_lock('uuid_lock',10) INTO iOUT; SELECT id INTO iID FROM uuid_to_id WHERE uuid_hash = crc32(pUUID) and uuid = pUUID; IF iID IS NOT NULL THEN select release_lock('uuid_lock') INTO iOUT; SIGNAL SQLSTATE '23000' SET MESSAGE_TEXT = 'Duplicate entry', MYSQL_ERRNO = 1062; ELSE insert into uuid_to_id (uuid) values (pUUID); select release_lock('uuid_lock') INTO iOUT; set iID = last_insert_id(); END IF; RETURN iID; END ;; DELIMITER ;
該函數檢查 uuid_to_id 表中是否存在經過驗證的 UUID 值,若是確實存在,則返回匹配的 id 值,不然將插入 UUID 值並返回 last_insert_id。爲了防止同時提交相同的 UUID 值,我添加了一個數據庫鎖。數據庫鎖限制瞭解決方案的可伸縮性。若是您的應用程序沒法在很短的時間內提交兩次請求,則能夠刪除該鎖。
如今,讓咱們看一下使用這些替代方案的插入率。
使用 UUID 值做爲主鍵插入表的方法
僞順序結果很好。在這裏,我修改了算法,以使 UUID 前綴保持一分鐘而不是一星期不變,以便更好地適應測試環境。即便僞順序解決方案表現良好,也請記住,它仍然會使架構膨脹,整體而言,性能提高可能不會那麼大。
儘管因爲所需的附加 DML 致使插入率較小,但映射到整數值會使架構與 UUID 值分離。這些表如今使用整數做爲主鍵。此映射幾乎消除了使用 UUID 值的全部可伸縮性問題。儘管如此,即便在 CPU 和 IOP 受限的小型虛擬機上,UUID 映射技術也能夠每秒產生近 4000次插入。在上下文中,這意味着每小時有 1400 萬行,天天 3.45 億行和每一年 1260 億行。這樣的速度可能符合大多數要求。惟一的增加限制因素是哈希索引的大小。當哈希索引太大而沒法容納在緩衝池中時,性能將開始降低。
固然,還有其餘生成惟一 ID 的可能性。MySQL 函數 UUID_SHORT() 使用的方法頗有趣。諸如智能手機之類的遠程設備可使用 UTC 時間而不是服務器正常運行時間。這是一個建議:
(Seconds since January 1st 1970) << 32 + (lower 2 bytes of the wifi MAC address) << 16 + 16_bits_unsigned_int++;
16 位計數器應初始化爲隨機值,並容許翻轉。兩個產生相同 ID 的設備的概率很小。它必須大約同時發生,兩個設備的 MAC 必須具備相同的低字節,而且它們的 16 位計數器必須以相同的增量遞增。
做者的 GitHub:
https://github.com/y-trudeau/...
文章開頭提到的搜索列表連接:
https://www.percona.com/blog/...
https://www.percona.com/blog/...
https://www.percona.com/blog/...
http://www.mysqltutorial.org/...
http://mysql.rjweb.org/doc.ph...
https://www.percona.com/blog/...