MySQL:Innodb 唯一索引出現重復值的場景分析
最近遇到類似案例,這里將可能出現這種情況的2個場景描述一下,其中一種情況在翻看老葉的公眾號有類似文章,如下,
故障案例:MySQL唯一索引有重復值,官方卻說This is not a bug
我們分別描述。
場景1 unique_checks = 0
1.原理
當我們進行數據插入的時候,對于唯一索引,實際上大概會經歷數據查找,唯一性檢查、數據插入 3個階段。而對于普通索引來講如果page不在buffer pool中則可能在數據查找階段就會寫入到ibuff,這種情況就等待后續的ibuff合并即可。
但是我們一旦設置了unique_checks=0,對于唯一索引(非主鍵)而言也可能走普通索引的方式,我們大概看看是如何改變的,首先根據設置,事務檢查唯一索引的標記會設置為如下,
trx->check_unique_secondary =
!thd_test_options(thd, OPTION_RELAXED_UNIQUE_CHECKS);
然后在插入階段,row_ins_sec_index_entry_low函數首先會根據是否檢查唯一性將search_mode 設置上BTR_IGNORE_SEC_UNIQUE標記,search_mode 的值很多,主要包含2類,A:讀寫鎖模式/B:操作方式,他們各自占用不同的bit位。
if (!thr_get_trx(thr)->check_unique_secondary) {search_mode |= BTR_IGNORE_SEC_UNIQUE;}
接下來就是查找數據調用btr_cur_search_to_nth_level上層函數,進行數據定位,然后在其中判定,
case BTR_INSERT:
btr_op = (latch_mode & BTR_IGNORE_SEC_UNIQUE)
? BTR_INSERT_IGNORE_UNIQUE_OP
: BTR_INSERT_OP;
break;
如果為insert且latch_mode帶有BTR_IGNORE_SEC_UNIQUE,設置btr_op為BTR_INSERT_IGNORE_UNIQUE_OP。
最后在判定是否能夠使用ibuf上,我們看到如下,
if (btr_op != BTR_NO_OP &&
ibuf_should_try(index, btr_op != BTR_INSERT_OP)) { //是否進入 ibuf
/* Try to buffer the operation if the leaf
page is not in the buffer pool. */
fetch = btr_op == BTR_DELETE_OP ? Page_fetch::IF_IN_POOL_OR_WATCH //這里和 WATCH和purge線程有光
: Page_fetch::IF_IN_POOL; //bug page get gen 只看是否在buffer中
}
而函數ibuf_should_try就是判定是否使用ibuf,一旦使用ibuf,當然修改的相關page就不一定非要在buffer pool中,因此對于insert操作定義為Page_fetch::IF_IN_POOL,而函數ibuf_should_try主要包含如下判定:
- A:開啟了change buffer
- B:不等于系統表空間
- C:不能是聚集索引
- D:不能處于export狀態下
- E:insert操作不能是唯一索引
- F:其他操作,唯一索引也可以使用ibuf,這里實際上就只剩下delete和ignore唯一性的insert了
而在底層修改操作實際上只有insert和delete操作,而這里滿足的是F條件因此insert操作的查找page動作被標記為Page_fetch::IF_IN_POOL,接下來buf_page_get_gen函數就不會再去訪問物理磁盤了,這個時候可能返回的page為NULL,那就要走這個邏輯了:
if (block == nullptr) { //如果block沒有在innodb buffer中進行操作
...
switch (btr_op) {
case BTR_INSERT_OP:
case BTR_INSERT_IGNORE_UNIQUE_OP: //注意這里
...
if (ibuf_insert(IBUF_OP_INSERT, tuple, index, page_id, page_size,
cursor->thr)) {
cursor->flag = BTR_CUR_INSERT_TO_IBUF;
goto func_exit;
}
也就是插入到ibuf中,那么我們可以想象,如果設置了unique_checks=0,這個時候如果重復的數據在磁盤上(因為innodb buffer查詢不到page返回NULL),則會將接下來的數據本該重復的數據插入到ibuf,而不會去檢測重復值。然后等到需要讀取這個page到buffer pool的時候比如select,那就需要做ibuf的合并,合并后重復的數據就出現了。
2.測試
測試可以根據老葉公眾號的方式測試,主體思想就是做一個大一點的表,然后重啟數據庫,并且不要開啟啟動時加載page到buffer pool,下面是我測試的結果:
這里b列是一個唯一索引,我們看到了第二查詢出現了2個相同的值。
3.其他和總結
當出現這種情況的時候可以看到,第一個查詢只出現了一行,這看起來好像是對的,但是實際上索引上有2行不同的值,對于唯一索引來講如果訪問到一行值,訪問就會停止,因此出現了這種情況,看起來也是比較奇特。 因此我們在考慮使用unique_checks=0加速導入數據的時候需要特別注意一下這個問題,除非能夠保證數據都是唯一的否則不建議設置,現在我們知道實際上加速就是讓唯一索引也能夠使用ibuf這個特性,這里我們再來會看一下官方的這句話
For big tables, this saves a lot of disk I/O because InnoDB can use its change buffer to write secondary index records in a batch. Be certain that the data contains no duplicate keys.
很顯然和我們分析一致。
場景2 RR隔離級別相關
這個地方主要和隔離級別有關了,雖然提出這個BUG的時間有點久了,但是這不是BUG,并且8.0也能重現,如下, https://bugs.mysql.com/bug.php?id=69979
重現如下:
建表和插入數據
create table testuniq(id int primary key,a int unique key);
insert into testuniq values (10, 100), (20, 200);
mysql> select * from testuniq;
+----+------+
| id | a |
+----+------+
| 10 | 100 |
| 20 | 200 |
+----+------+
TRX1 | TRX2 |
1.begin; | |
2.select * from testuniq; | |
3.update testuniq set a=300 where id=10; | |
4.update testuniq set a=100 where id=20; | |
5.select * from testuniq; |
完成第四步的時候數據就是:
mysql> select * from testuniq;
+----+------+
| id | a |
+----+------+
| 10 | 100 |
| 20 | 100 |
+----+------+
可以看到唯一索引出現了重復值,對于這個問題,只要不阻止第4步的update testuniq set a=100 where id=20操作按照原理上來講就會出現重復值,因為RR有一個read view在begin開始后第一個select語句后一直存在,而update屬于當前讀訪問的當前記錄已經被修改了,因此第4步并沒有訪問歷史記錄,因此update通過,最終出現這種現象。同時在BUG中也詳細描述了這是符合設計的PG也是類似的結果,可以自行參考。