背景
在程序設計中,我們往往需要確保數據的唯一性,比如在常見的註冊模塊,我們需要確保一個手機號只能註冊爲一個賬號。這種情況下,我們的程序往往是第一道關卡,用戶來註冊之前,首先判斷這個手機號是否已經註冊,如果已經註冊則返回錯誤信息,或直接去登錄。但是我們不能確保同時有兩個人使用同一個手機號註冊到我們的系統中,因此這裏就需要在更深的層次去確保手機號在系統的唯一性了。不同存儲方案,解決方式不一樣。對於常用的MySQL數據庫,我們可以使用唯一索引的方式來作爲我們的最後一道防線。
但是最近在使用數據庫的唯一索引時,發現一個比較奇怪的現象。MySQL數據庫,使用InnoDB存儲引擎,創建了唯一索引時,在insert操作時,如果唯一索引上的字段有爲NULL的情況,則可以無限插入。這有點匪夷所思,但是現實就是這麼一個情況。現在就來具體分析這樣的一個案例,來看看底層對於唯一索引是怎麼設計的,來規避在數據庫設計上犯錯和踩坑。
案例
假設現在有一個用於保存用戶信息的數據表user,是使用email註冊的,當前使用email作爲唯一索引,同時這一基本規則也被其他依賴系統作爲設計數據模型的設計基礎。假設現在設計這樣一個user表:
CREATE TABLE `user` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`email` varchar(32) NOT NULL DEFAULT '' COMMENT 'email',
`name` varchar(11) DEFAULT '' COMMENT 'name',
`age` int(11) DEFAULT NULL COMMENT 'age',
PRIMARY KEY (`id`),
UNIQUE KEY `uk-email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
[email protected]來註冊,執行insert語句,執行成功
INSERT INTO user (email,name,age) VALUES ('[email protected]','h1',18);
[email protected]再來註冊,則再次執行,則報錯。成功規避了用戶多次創建導致系統產生髒數據問題。
Duplicate entry '[email protected]' for key 'uk-email'
從這裏看,user表的設計是符合業務要求的,並沒有出現同一個email出現多行的情況。隨着業務發展,單單email註冊的模式並不適合移動互聯網時代,所以現在的要求在原有基礎上增加了手機號的字段,並要求手機號也是唯一的。於是添加phone字段,並將原有唯一索引刪除,爲email和phone設置新的唯一索引。
ALTER TABLE `user` ADD COLUMN `phone` varchar(11) default NULL AFTER `age`;
DROP INDEX `uk-email` ON `user`;
ALTER TABLE `user` ADD UNIQUE KEY `uk-email-phone` (`email`,`phone`);
假設用戶1再來用同樣的email註冊,可以註冊成功:
INSERT INTO user (email,name,age,phone) VALUES (‘[email protected]’,‘h1’,18,NULL);
查詢數據庫數據,得到以下結果:
有兩個email爲[email protected]的記錄,他們的phone都是NULL,這怎麼可能存在?!難道是MySQL出問題了?!不可能,我們再試另外一個數據
INSERT INTO user (email,name,age,phone) VALUES ('[email protected]','h2',18,'18812345678');
連續執行兩次,第一次執行成功,第二次報錯:
Duplicate entry ‘[email protected]’ for key ‘uk-email-phone’
查詢user結果集,得到
從結果看這樣MySQL的唯一索引也算是正常的啊,那這到底是怎麼一回事呢?
原因探尋
業務中希望建立的唯一索引是email + phone的組合,但是由於phone一開始是沒有數據的,所以新建字段時默認允許爲NULL來兼容老數據。如果程序沒有控制好,數據操作直接打到數據庫,就產生了兩條email爲“[email protected]”且phone爲NULL的數據,那麼就會發生這種數據錯亂的情況。
我從 MySQL 5.7官方文檔 中找到了這個:
Unique Indexes
A UNIQUE index creates a constraint such that all values in the index must be distinct. An error occurs if you try to add a new row with a key value that matches an existing row. If you specify a prefix value for a column in a UNIQUE index, the column values must be unique within the prefix length. A UNIQUE index permits multiple NULL values for columns that can contain NULL.
官方的文檔中明確說明在唯一索引中是允許存在多行值爲NULL的數據存在的。
當然我們會認爲這是MySQL的一個bug,其實早有人這麼認爲了,並給MySQL提出了這個問題https://bugs.mysql.com/bug.php?id=8173。但是MySQL的開發者並不認爲這是一個bug,而是本身的一種設計。額,這麼說,好像也說得過去。那這裏就有一個問題了,我們知道索引是使用B+樹來維護的,但是對於這種非唯一索引是怎麼維護的?
帶着這個問題,我覺得有兩種可能:
(1)唯一索引時另外一種數據類型,正好把有值爲NULL的字段過濾掉了,無需特殊處理。
(2)還是用的B+樹索引,但是對於NULL的索引特殊處理了。
於是我對[email protected]且phone= 18812345678的數據執行了Explain執行計劃
explain select * from user where `email` = '[email protected]' and `phone` = '18812345678';
這個查詢正好用到了唯一索引uk-email-phone,索引長度是134。
對[email protected]且phone爲NULL的執行類似Explain執行計劃
explain select * from user where `email` = '[email protected]' and `phone` is NULL;
對比上面兩次不同數據的explain執行結果,可以看到其實都用了uk-email-phone的唯一索引,不同的是第一個type是const(通過一次索引就可以找到,用於primary key或unique index),第二個type是ref(非唯一性索引掃描),且rows爲2。所以猜測這裏極有可能是對NULL進行的特殊處理,唯一索引樹還是用的和非NULL一樣的唯一索引樹。
源碼分析
上面利用explain,測試結果是符合自己的猜測行爲而已。也許只有源碼中才能比較好的知道答案,基於此,在github上找到MySQL相關的源碼(在此感謝DBA同學在唯一索引源碼分析上的指點)。在這段源碼https://github.com/mysql/mysql-server/blob/8e797a5d6eb3a87f16498edcb7261a75897babae/storage/innobase/row/row0ins.cc中,有一個方法 row_ins_scan_sec_index_for_duplicate()
,這裏會掃描唯一非聚簇索引樹,來確定是否會發生唯一性的衝突。源碼內有一段註釋
/* If the secondary index is unique, but one of the fields in the
n_unique first fields is NULL, a unique key violation cannot occur,
since we define NULL != NULL in this case */
在繼續往下有一段這樣的邏輯
cmp = cmp_dtuple_rec(entry, rec, index, offsets);
if (cmp == 0 && !index->allow_duplicates) {
if (row_ins_dupl_error_with_rec(rec, entry, index, offsets)) {
err = DB_DUPLICATE_KEY;
thr_get_trx(thr)->error_info = index;
/* If the duplicate is on hidden FTS_DOC_ID,
state so in the error log */
if (index == index->table->fts_doc_id_index &&
DICT_TF2_FLAG_IS_SET(index->table, DICT_TF2_FTS_HAS_DOC_ID)) {
ib::error(ER_IB_MSG_958) << "Duplicate FTS_DOC_ID"
" value on table "
<< index->table->name;
}
goto end_scan;
}
} else {
ut_a(cmp < 0 || index->allow_duplicates);
goto end_scan;
}
跳轉到row_ins_dupl_error_with_rec()
方法中有一段這樣的邏輯
/* In a unique secondary index we allow equal key values if they
contain SQL NULLs */
if (!index->is_clustered() && !index->nulls_equal) {
for (i = 0; i < n_unique; i++) {
if (dfield_is_null(dtuple_get_nth_field(entry, i))) {
return (FALSE);
}
}
}
在唯一索引中有字段爲NULL的情況下,返回FALSE,代碼中就沒有拋出DB_DUPLICATE_KEY的異常了。所以從源碼來看,這裏實現了唯一索引允許爲NULL的情況了,而且可以知道,這個唯一索引樹和其他的二級索引基本上是沒什麼區別的。這也是前面explain時及時我們查詢非唯一索引中另一個字段爲空的記錄,也還是用到了同樣的索引和相同的索引長度。
反觀來看,如果是我們在未知實現的情況下,要我們來設計,怎麼實現允許有字段爲NULL的唯一索引呢?是否還有比現有MySQL更好的方式來實現?
結論
所以其實MySQL在唯一索引中允許存在值爲NULL的字段。NULL值在MySQL可以代表是任意值,並且在有字段值爲NULL時,不會參與校驗這個組合的唯一索引,所以可能插入業務上不允許重複的數據,導致髒數據。
因此在創建屬於唯一索引的列時,最好指定字段值不能爲空,在已有值爲NULL的情況下,創建的字段不允許爲空,且默認值爲空字符。如果已經創建了默認值爲NULL的字段,則先將其update爲空字符,然後再修改爲NOT NULL DEFAULT ‘’。如上述情況建表語句改爲
CREATE TABLE `user` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`email` varchar(32) NOT NULL DEFAULT '' COMMENT 'email',
`name` varchar(11) DEFAULT '' COMMENT 'name',
`age` int(11) DEFAULT NULL COMMENT 'age',
`phone` varchar(11) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uk-email-phone` (`email`,`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
並非所有數據庫都是這樣,SQL Server 2005及更老的版本,只允許有一個NULL值出現。從https://sqlite.org/faq.html#q26 瞭解到ANSI SQL-92標準:
A unique constraint is satisfied if and only if no two rows in a table have the same non-null values in the unique columns.(如果且僅當表中沒有兩行在唯一列中具有相同的非空值時,才滿足唯一約束。)
除了MySQL之外,sqlLite、PostgreSQL、Oracle和FireBird也是允許唯一索引上存在多行爲NULL。