我就想加個索引,怎麼就這麼難?
阿新 • • 發佈:2020-08-07
> 領導讓我`SQL`優化,我直接把服務幹掛了...
## 前言
`MySQL`大表加欄位或者加索引,是有一定風險的。
大公司一般有`DBA`,會幫助開發解決這個痛點,可是`DBA`是怎麼做的呢?
小公司沒有`DBA`,作為開發我們的責任就更大了。那麼我們怎麼才能安全的加個索引呢?
今天,我們通過模擬案例以及原理分析,去弄清楚`MySQL`中`DDL`的風險,以及如何避免事故發生。
## 準備
### 軟體以及專案
1. 安裝本地版本MySQL。
2. 一個簡單的增刪改查專案。
3. 使用JMeter進行併發請求測試。
### 建立表
```SQL
# 如果存在user表則刪除
DROP TABLE IF EXISTS user;
# 建立user表
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
`name` varchar(10) DEFAULT NULL COMMENT '姓名',
`age` int(2) DEFAULT NULL COMMENT '年齡',
`address` varchar(30) DEFAULT NULL COMMENT '地址',
`description` varchar(100) DEFAULT NULL COMMENT '描述',
`test_id` bigint DEFAULT NULL COMMENT '測試 id',
`create_time` timestamp NULL DEFAULT NULL COMMENT '建立時間',
`modify_time` timestamp NULL DEFAULT NULL COMMENT '修改時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='mysql ddl測試表';
```
### 建立儲存過程
```SQL
# 如果存在test儲存過程則刪除
DROP PROCEDURE IF EXISTS `test`;
# 建立無參儲存過程,名稱為test
CREATE PROCEDURE test()
BEGIN
# 宣告變數
DECLARE i INT;
# 變數賦值
SET i = 0;
# 結束迴圈的條件: 當i等於100萬時跳出while迴圈
WHILE i < 1000000 DO
# 往t_test表新增資料
INSERT INTO `test`.user (`name`, `age`, `address`,
`description`, `test_id`, `create_time`, `modify_time`)
VALUES ('iisheng', 26, '北京', '如逆水行舟', LAST_INSERT_ID() + 1,
'2020-05-17 16:01:44', '2020-05-17 16:01:51');
# 迴圈一次, i加1
SET i = i + 1;
# 結束while迴圈
END WHILE;
END
```
> 下面的建立儲存過程語句,是在`IDE`內選擇程式碼塊執行的,如果在`Terminal`中執行,需要使用`DELIMITER`關鍵字,更改語句結束標誌。
### 呼叫儲存過程,生成百萬資料
```SQL
CALL test();
```
### 開啟慢SQL日誌
```SQL
# 檢視MySQL是否開啟慢日誌記錄
SHOW VARIABLES LIKE 'slow_query_log';
# 開啟慢SQL日誌記錄
SET GLOBAL slow_query_log = 'ON';
# 檢視慢SQL日誌位置
SHOW VARIABLES LIKE 'slow_query_log_file';
# 檢視執行多久的SQL才算慢SQL
SHOW VARIABLES LIKE 'long_query_time';
# 設定慢SQL執行時間 只有新session才生效
SET GLOBAL long_query_time = 1;
```
> 通常情況下這些會在MySQL的配置檔案中配置,啟動時生效。
### 幾個有用的SQL語句
```SQL
# 展示哪些執行緒正在執行
SHOW PROCESSLIST;
# 檢視正在執行的事務
SELECT * FROM information_schema.INNODB_TRX;
# 檢視正在鎖的事務
SELECT * FROM information_schema.INNODB_LOCKS;
# 檢視正在等待鎖的事務
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
# 顯示innodb儲存引擎狀態的大量資訊,包含死鎖日誌
SHOW ENGINE INNODB STATUS ;
# 展示資料庫最大連線數的配置
SHOW VARIABLES LIKE 'max_connections';
# 檢視存在哪些觸發器
SELECT * FROM information_schema.TRIGGERS;
# 檢視MySQL版本
SELECT VERSION();
```
> 後面我們會主要用前兩條。
## 事故現場
### 說明
1. 我建立的`user`表除了主鍵是沒有其他索引的。
2. 測試的`user`表資料量為一百萬。
3. 測試`MySQL`版本為`5.7.28`。
4. 測試專案的邏輯:隨機get()、list()、update()、create(),每個操作都開啟事務,並且休眠500毫秒。
### 步驟
> 執行測試專案
![專案啟動圖](https://img.iisheng.cn/project-start-success.png)
這裡我們可以看到,專案已經正常啟動了。
> `postman`呼叫一下介面
![介面請求圖](https://img.iisheng.cn/postman-success.png?1)
這裡我們隨便測試一個介面,請求時間2秒左右。
> 執行JMeter的Test Plan,觀察專案日誌
![JMeter配置圖](https://img.iisheng.cn/jmeter-config.png)
這裡我們建立了四個執行緒組,每個執行緒組呼叫一個我們的介面。模擬10個人迴圈1000次的訪問。
![正常專案日誌圖](https://img.iisheng.cn/project-jmeter-part.png)
這裡我們看到該請求頻率下,日誌無異常。
> 慢SQL日誌
![慢SQL日誌圖](https://img.iisheng.cn/long-sql-log.png)
這裡我們看到,百萬級的SQL,如果沒加索引SQL執行時間還是比較長的,有的已經達到了2s。
> 加個索引,再觀察專案日誌
![加索引過程日誌圖](https://img.iisheng.cn/after-add-index-project-log.png)
這裡我們看到,專案已經開始報錯了,大量的`Connection is not available, request timed out after 30001ms`。
> `SHOW PROCESSLIST `一下
![PROCESSLIST圖](https://img.iisheng.cn/alter-table-waiting.png)
這裡我們看到,有大量的`Waiting for table metadata lock`。
> `postman`再次呼叫一下介面
![請求介面報錯圖](https://img.iisheng.cn/request-fail.png)
這個時候,呼叫介面已經報錯了,響應時間也比較久。此時,服務對使用者來說,已經基本不可用了。
## 為什麼會這樣?
> 我就想加個索引,怎麼就這麼難?
看吧,就因為我加了個索引,服務就掛了,我沒加之前還是好好的。遇到問題,我們要冷靜,不是我們的鍋堅決不能背,真的是我們的問題,下次一定要記得改正。那麼,此刻的服務為什麼就不可用了呢?
首先我們要知道,在`InnoDB`事務中,鎖是在需要的時候才加上的,但並不是不需要了就立刻釋放,而是要等到事務結束時才釋放。這個就是**兩階段鎖協議**。
然後,在`MySQL5.5`版本中引入了`MDL(Metadata Lock)`,當對一個表做增刪改查操作的時候,加`MDL`讀鎖;當要對錶做結構變更操作的時候,加`MDL`寫鎖。
我們可以簡單的嘗試一下下面的情況。
![DDL鎖等待圖](https://img.iisheng.cn/ddl-lock-example.png)
`Session A`開啟一個事務,執行了一個簡單的查詢語句。此時,`Session B`,執行另一個查詢語句,可以成功。接著,`Session C`執行了一個`DDL`操作,加了個欄位,因為`Session A`的事務沒有提交,而且`Session A`持有`MDL`讀鎖,`Session C`獲取不到`MDL`寫鎖,所以`Session C`堵塞等待`MDL`寫鎖。又由於`MDL`寫鎖獲取優先順序高於`MDL`讀鎖,因此`Session D`這個時候也獲取不到`MDL`讀鎖,等待`Session C`獲取到`MDL`寫鎖之後它才能獲取到`MDL`讀鎖。
我們發現,DDL操作之前如果存在長事務,一直不提交,DDL操作就會一直被堵塞,還會間接的影響後面其他的查詢,導致所有的查詢都被堵塞。
這也就是為什麼我們把服務幹掛的原因了。
## 目前主流解決方案
針對上面出現的情況,我們怎麼解決呢?
### MySQL5.6的`Online DDL`
`MySQL`從`5.6`開始,支援`Online DDL`。類似於這種的語句`ALTER TABLE user ADD INDEX idx_test_id (test_id), ALGORITHM=INPLACE, LOCK=NONE`在普通的`ALTER TABLE`或者`CREATE INDEX`語句後面新增`ALGORITHM`引數和`LOCK`引數。
> 實際上,`ALTERT TABLE`語句如果不加`ALGORITHM`引數,預設就會選擇`ALGORITHM=INPLACE`的形式,如果執行的語句支援`INPLACE`,否則,會使用`ALGORITHM=COPY`。
以前寫`SQL`只會`ALTER TABLE`不知道後面還可以加`ALGORITHM`引數,後來知道了`Online DDL`,知道了可以加`ALGORITHM=INPLACE`,結果兩種寫法有的時候是一樣的...
![MySQL官網截圖](https://img.iisheng.cn/dev-mysql.png)
這裡順便提一句,學習的途徑有很多,但是官網,的確可以多看看。
### 使用`pt-online-schema-change`
> 簡單說一下怎麼安裝這個東西
首先官網下載,然後校驗以及安裝,執行下面命令
```shell
perl Makefile.PL
make
make install
```
然後使用`CPAN`安裝相關依賴(適用`Unix`),`CentOS`下直接`yum`更簡單
```shell
perl -MCPAN -e shell
cpan> install DBI
cpan> install DBD::mysql
```
> 我自己Mac安裝沒啥問題,公司Mac安裝失敗了,然後升級了一下Perl版本就可以了。
### 語法
```shell
pt-online-schema-change --charset=utf8 --no-check-replication-filters --no-version-check --user=user --password=pass --host=host_addr P=3306,D=database,t=table --alter "ADD INDEX idx_name(field_name)" --execute
```
### 我的指令碼新增索引
```shell
pt-online-schema-change --charset=utf8 --no-check-replication-filters --no-version-check --user=root --password=mGy6GAzdawFPTJ7R --host=127.0.0.1 P=3306,D=test,t=user --alter "add INDEX idx_test_id(test_id)" --execute
```
## 使用`pt-osc`測試
![pt-osc執行圖](https://img.iisheng.cn/pt-osc-success02.png)
這裡我們看到,`pt-osc`建立觸發器的時候卡在那了。實際上這裡也是在等待鎖。
最終成功了,但是整個過程時間比較久。過程中我們也發現了一些死鎖的日誌。
![pt-osc死鎖日誌](https://img.iisheng.cn/pt-osc-deadlock.png)
其實,這個跟我的程式碼有一定的關係,我的測試程式碼隨機數生成的範圍是`[0, 20000]`,然後我根據生成的隨機數,去查詢資料庫,鎖的衝突會比較多。把範圍修改為`[0, 1000000]`會好很多。
## 再看`Online DDL`
因為剛才我們發現了,自己程式碼寫的有一些問題,所以我們剛才的結論也有一些影響。我們把隨機數的範圍改到100萬,重新測試一遍。
![Online DDL 成功](https://img.iisheng.cn/online-ddl-success.png)
這次`Online DDL`也成功了。但是也是有一些連線超時的日誌。之前的測試如果一直執行下去,也會成功,只不過堵塞時間太長,對使用者影響太大,我就停止算執行失敗了。
> 實際效果跟機器效能也是有一些關係的,這裡的關鍵點在於拿`MDL`寫鎖的等待時間,這個時間稍微久一些就會對使用者造成很大的影響。
## `pt-osc`執行過程
1. 建立一個和原表表結構一樣的臨時表(`_tablename_new`),執行`alter`修改臨時表表結構。
2. 在原表上建立3個與`insert` `delete` `update`對應的觸發器,用於`copy`資料的過程中,在原表的更新操作,更新到新表。
3. 從原表拷貝資料到臨時表,拷貝過程中在原表進行的寫操作都會更新到新建的臨時表。
4. `rename`原資料表為`old`表,把新表`rename`為原表名,並將`old`表刪除。
5. 刪除觸發器。
這裡面建立、刪除觸發器和`rename`表的時候都會嘗試獲取`DML`寫鎖,如果獲取不到會等待。就是我們看到的`Waiting for table metadata lock`。
所以,這些時間段如果長時間獲取不到鎖,就會一直堵塞,還是會出現問題的。
## `Online DDL`執行過程
1. 拿`MDL`寫鎖
2. 降級成`MDL`讀鎖
3. 真正做`DDL`
4. 升級成`MDL`寫鎖
5. 釋放`MDL`鎖
`1、4`如果沒有鎖衝突,執行時間非常短。第3步佔用了`DDL`絕大部分時間,這期間這個表可以正常讀寫資料,因此稱為**online**。
但是,如果拿鎖的時候沒拿到,或者升級`MDL`寫鎖不能成功,就會等待,我們又會看到`Waiting for table metadata lock`,然後就接著的一系列問題了。
## 總結
加個索引,說難也難,說不難也不難。如果資料量大,又存在長事務,加索引的過程又有使用者訪問,`Online DDL`和`pt-osc`都不能保證對業務沒有影響。但是如果我們`SQL`的執行時間比較短,或者我們加索引的時候,對應的業務沒有多少請求。那麼我們就可以很快的加完索引。
加欄位也是類似的過程,但是如果我們能保證沒有慢`SQL`,那麼就不會存在長事務,那麼執行時間就會很快,對使用者就可以做到幾乎沒有影響。至於選擇`Online DDL`還是`pt-osc`就要看他們的一些限制以及自己的場景需求了。感興趣的同學,自己嘗試一下。
## 最後想說
當萬丈高樓崩塌的時候,超人也不能將它復原。我們應該做的,是有一個好的規範,好的認知,好的監控,在問題沒有出現的時候,就將問題扼殺在搖籃中。而不是讓問題,日漸壯大,大到覆水難收...
參考文獻:
[1]:《MySQL實戰45講》
[2]: https://dev.mysql.com/doc/refman/5.7/en/
[3]: https://www.percona.com/doc/percona-toolkit/3.0/pt-online-schema-change.html
> 歡迎關注個人微信公眾號【如逆水行舟】,用心輸出基礎、演算法、原始碼系列文章。
[1]:《MySQL實戰45講》
[2]: https://dev.mysql.com/doc/refman/5.7/en/
[3]: https://www.percona.com/doc/percona-toolkit/3.0/pt-online-schema-change.html
> 歡迎關注個人微信公眾號【如逆水行舟】,用心輸出基礎、演算法、原始碼系列文章。