11 | 怎麼給字串欄位加索引?
維護一個支援郵箱登入的系統,使用者表是這麼定義的:
mysql> create table SUser(
ID bigint unsigned primary key,
email varchar(64),
...
)engine=innodb;
登入操作,有類似這樣的語句
mysql> select f1, f2 from SUser where email='xxx';
如果 email 這個欄位上沒有索引,那麼這個語句就只能做全表掃描。
同時,MySQL 是支援字首索引的,也就是說,你可以定義字串的一部分作為索引。預設地,如果你建立索引的語句不指定字首長度,那麼索引就會包含整個字串。
建立字首索引
比如,這兩個在 email 欄位上建立索引的語句:
mysql> alter table SUser add index index1(email);
或
mysql> alter table SUser add index index2(email(6));
字首索引佔用的空間會更小,這同時帶來的損失是,可能會增加額外的記錄掃描次數。
分析語句的執行流程:
select id,name,email from SUser where email='[email protected]';
如果使用的是 index1(即 email 整個字串的索引結構),這個過程中,只需要回主鍵索引取一次資料,所以系統認為只掃描了一行。
如果使用的是 index2(即 email(6) 索引結構),可能要掃描多行。
使用字首索引,定義好長度,就可以做到既節省空間,又不用額外增加太多的查詢成本。
更好的索引長度
語句:
mysql> select count(distinct email) as L from SUser;
可以一同使用 DISTINCT 和 COUNT 關鍵詞,來計算非重複結果的數目。
mysql> select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,
from SUser;
當然,使用字首索引很可能會損失區分度,所以你需要預先設定一個可以接受的損失比例。
字首索引對覆蓋索引的影響
語句:
select id,email from SUser where email='[email protected]';
select id,name,email from SUser where email='[email protected]';
如果使用 index1(即 email 整個字串的索引結構)的話,可以利用覆蓋索引,從index1 查到結果後直接就返回了,不需要回到 ID 索引再去查一次。
而如果使用 index2(即email(6) 索引結構)的話,就不得不回到 ID 索引再去判斷 email 欄位的值。
使用字首索引就用不上覆蓋索引對查詢效能的優化了,這也是你在選擇是否使用字首索引時需要考慮的一個因素。
其他方式
第一種方式是使用倒序儲存。
如果你儲存身份證號的時候把它倒過來存,每次查詢的時候,你可以這麼寫:
mysql> select field_list from t where id_card = reverse('input_id_card_string');
第二種方式是使用 hash 欄位。
你可以在表上再建立一個整數字段,來儲存身份證的校驗碼,同時在這個欄位上建立索引。
mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc);
使用倒序儲存和使用 hash 欄位這兩種方法的異同點:
- 從佔用的額外空間來看,倒序儲存方式在主鍵索引上,不會消耗額外的儲存空間,
而 hash欄位方法需要增加一個欄位。
當然,倒序儲存方式使用 4 個位元組的字首長度應該是不夠的,如果再長一點,這個消耗跟額外這個 hash 欄位也差不多抵消了。
- 在 CPU 消耗方面,倒序方式每次寫和讀的時候,都需要額外呼叫一次 reverse 函式,
而hash 欄位的方式需要額外呼叫一次 crc32() 函式。
如果只從這兩個函式的計算複雜度來看的話,reverse 函式額外消耗的 CPU 資源會更小些。
- 從查詢效率上看,使用 hash 欄位方式的查詢效能相對更穩定一些。
因為 crc32 算出來的值雖然有衝突的概率,但是概率非常小,可以認為每次查詢的平均掃描行數接近 1。
而倒序儲存方式畢竟還是用的字首索引的方式,也就是說還是會增加掃描行數。
小結
- 直接建立完整索引,這樣可能比較佔用空間;
- 建立字首索引,節省空間,但會增加查詢掃描次數,並且不能使用覆蓋索引;
- 倒序儲存,再建立字首索引,用於繞過字串本身字首的區分度不夠的問題;
- 建立 hash 欄位索引,查詢效能穩定,有額外的儲存和計算消耗,跟第三種方式一樣,都不
支援範圍掃描。
12 | 為什麼我的MySQL會“抖”一下?
當記憶體資料頁跟磁碟資料頁內容不一致的時候,我們稱這個記憶體頁為“髒頁”。
記憶體資料寫入到磁碟後,記憶體和磁碟上的資料頁的內容就一致了,稱為“乾淨頁”。
MySQL 偶爾“抖”一下的那個瞬間,可能就是在刷髒頁(flush)。
例如這幾種場景:
- 對應的就是 InnoDB 的 redo log 寫滿了。這時候系統會停止所有更新操作,把checkpoint 往前推進,redo log 留出空間可以繼續寫。
系統記憶體不足。當需要新的記憶體頁,而記憶體不夠用的時候,就要淘汰一些資料頁,空出記憶體給別的資料頁使用。如果淘汰的是“髒頁”,就要先將髒頁寫到磁碟。
MySQL 認為系統“空閒”的時候,也要見縫插針地找時間,只要有機會就刷一點“髒頁”。
MySQL 正常關閉的情況。這時候,MySQL 會把記憶體的髒頁都 flush到磁碟上,這樣下次 MySQL 啟動的時候,就可以直接從磁碟上讀資料,啟動速度會很快。
其中第三第四種情況不需要多考慮,
第一種是“redo log 寫滿了,要 flush 髒頁”,這種情況是 InnoDB 要儘量避免的。
因為出現這種情況的時候,整個系統就不能再接受更新了,所有的更新都必須堵住。如果你從監控上看,這時候更新數會跌為 0。
第二種是“記憶體不夠用了,要先將髒頁寫到磁碟”,這種情況其實是常態。
InnoDB 用緩衝池(buffer pool)管理記憶體,緩衝池中的記憶體頁有三種狀態:
- 還沒有使用的;
- 使用了並且是乾淨頁;
- 使用了並且是髒頁。
InnoDB 的策略是儘量使用記憶體,因此對於一個長時間執行的庫來說,未被使用的頁面很少。
所以,刷髒頁雖然是常態,但是出現以下這兩種情況,都是會明顯影響效能的:
- 一個查詢要淘汰的髒頁個數太多,會導致查詢的響應時間明顯變長;
- 日誌寫滿,更新全部堵住,寫效能跌為 0,這種情況對敏感業務來說,是不能接受的。
InnoDB 刷髒頁的控制策略
要知道 InnoDB 所在主機的 IO 能力,這樣 InnoDB 才能知道需要全力刷髒頁可以刷多快。
這就要用到 innodb_io_capacity 這個引數了,它會告訴 InnoDB 你的磁碟能力。
這個值我建議你設定成磁碟的 IOPS。磁碟的 IOPS 可以通過 fio 這個工具來測試。
InnoDB 的刷盤速度就是要參考這兩個因素:
- 一個是髒頁比例
- 一個是 redo log 寫盤速度。
現在你知道了,InnoDB 會在後臺刷髒頁,而刷髒頁的過程是要將記憶體頁寫入磁碟。
所以,無論是你的查詢語句在需要記憶體的時候可能要求淘汰一個髒頁,還是由於刷髒頁的邏輯會佔用 IO 資源並可能影響到了你的更新語句,都可能是造成你從業務端感知到 MySQL“抖”了一下的原因。
要儘量避免這種情況,你就要合理地設定 innodb_io_capacity 的值,並且平時要多關注髒頁比例,不要讓它經常接近 75%。
其中,髒頁比例是通過 Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total
得到的。
接下來,我們再看一個有趣的策略。
一旦一個查詢請求需要在執行過程中先 flush 掉一個髒頁時,這個查詢就可能要比平時慢了。而
MySQL 中的一個機制,可能讓你的查詢會更慢:在準備刷一個髒頁的時候,如果這個資料頁旁
邊的資料頁剛好是髒頁,就會把這個“鄰居”也帶著一起刷掉;而且這個把“鄰居”拖下水的邏
輯還可以繼續蔓延,也就是對於每個鄰居資料頁,如果跟它相鄰的資料頁也還是髒頁的話,也會
被放到一起刷。
在 InnoDB 中,innodb_flush_neighbors 引數就是用來控制這個行為的,值為 1 的時候會有上
述的“連坐”機制,值為 0 時表示不找鄰居,自己刷自己的。
找“鄰居”這個優化在機械硬碟時代是很有意義的,可以減少很多隨機 IO。機械硬碟的隨機
IOPS 一般只有幾百,相同的邏輯操作減少隨機 IO 就意味著系統性能的大幅度提升。
而如果使用的是 SSD 這類 IOPS 比較高的裝置的話,我就建議你把 innodb_flush_neighbors
的值設定成 0。因為這時候 IOPS 往往不是瓶頸,而“只刷自己”,就能更快地執行完必要的刷
髒頁操作,減少 SQL 語句響應時間。
在 MySQL 8.0 中,innodb_flush_neighbors 引數的預設值已經是 0 了。
13 | 為什麼表資料刪掉一半,表文件大小不變?
一個 InnoDB 表包含兩部分,即:表結構定義和資料。
在 MySQL 8.0 版本以前,表結構是存在以.frm 為字尾的檔案裡。而 MySQL 8.0 版本,則已經允許把表結構定義放在系統資料表中了。因為表結構定義佔用的空間很小,所以我們今天主要討論的是表資料。
引數 innodb_file_per_table
表資料既可以存在共享表空間裡,也可以是單獨的檔案。
這個行為是由引數 innodb_file_per_table 控制的:
這個引數設定為 OFF 表示的是,表的資料放在系統共享表空間,也就是跟資料字典放在一起;
這個引數設定為 ON 表示的是,每個 InnoDB 表資料儲存在一個以 .ibd 為字尾的檔案中。
從 MySQL 5.6.6 版本開始,它的預設值就是 ON 了。
我建議你不論使用 MySQL 的哪個版本,都將這個值設定為 ON。
因為,一個表單獨儲存為一個檔案更容易管理,而且在你不需要這個表的時候,通過 drop table 命令,系統就會直接刪除這個檔案。
而如果是放在共享表空間中,即使表刪掉了,空間也是不會回收的。
資料刪除流程
在B+樹的結構中就算是刪除了,它還是沒有釋放這個空間,而是可能會複用這個位置,所以磁碟的檔案大小不會縮小。
(就算是一頁的資料給刪除了,那也只是說明,這一頁可以片被複用了)
但是,資料頁的複用跟記錄的複用是不同的。
delete 命令其實只是把記錄的位置,或者資料頁標記為了“可複用”,但磁碟檔案的大小是不會變的。
也就是說,通過 delete 命令是不能回收表空間的。這些可以複用,而沒有被使用的空間,看起來就像是“空洞”。
實際上,不止是刪除資料會造成空洞,插入資料也會。
重建表
alter table A engine=InnoDB 命令來重建表,重建之後更緊湊,資料頁的利用率也更高。
在 MySQL 5.5 版本之前,這個命令的執行流程跟我們前面描述的差不多,區別只是這個臨時表 B 不需要你自己建立,
MySQL 會自動完成轉存資料、交換表名、刪除舊錶的操作。
但是這個 DDL 不是 Online 的。
而在MySQL 5.6 版本開始引入的 Online DDL,對這個操作流程做了優化。
1. 建立一個臨時檔案,掃描表 A 主鍵的所有資料頁;
2. 用資料頁中表 A 的記錄生成 B+ 樹,儲存到臨時檔案中;
3. 生成臨時檔案的過程中,將所有對 A 的操作記錄在一個日誌檔案(row log)中
4. 臨時檔案生成後,將日誌檔案中的操作應用到臨時檔案,得到一個邏輯資料上與表 A 相同的資料檔案
5. 用臨時檔案替換表 A 的資料檔案。
簡單來說就是:+ row log,把DDL過程中的操作弄進去了。
14 | count(*)這麼慢,我該怎麼辦?
count(*) 的實現方式
- MyISAM 引擎把一個表的總行數存在了磁碟上,因此執行 count() 的時候會直接返回這個數,效率很高;
- 而 InnoDB 引擎就麻煩了,它執行 count(*) 的時候,需要把資料一行一行地從引擎裡面讀出來,然後累積計數。
這裡需要注意的是,我們在這篇文章裡討論的是沒有過濾條件的 count(*),
如果加了 where 條件的話,MyISAM 表也是不能返回得這麼快的。
那為什麼 InnoDB 不跟 MyISAM 一樣,也把數字存起來呢?
這是因為即使是在同一個時刻的多個查詢,由於多版本併發控制(MVCC)的原因,
InnoDB表“應該返回多少行”也是不確定的。
count(*) 操作的時候還是做了優化的:
在保證邏輯正確的前提下,儘量減少掃描的資料量,是資料庫系統設計的通用法則之一。
- MyISAM 表雖然 count() 很快,但是不支援事務;
- show table status 命令雖然返回很快,但是不準確;(官方文件說誤差可能達到 40% 到 50%)
- InnoDB 表直接 count() 會遍歷全表,雖然結果準確,但會導致效能問題。
用快取系統儲存計數
你可以用一個 Redis 服務來儲存這個表的總行數。這個表每被插入一行 Redis 計數就加 1,每被刪除一行 Redis 計數就減 1。
問題:可能會丟失更新。
還有:將計數儲存在快取系統中的方式,還不只是丟失更新的問題。即使 Redis 正常工作,這個值還是邏輯上不精確的。
在資料庫儲存計數
如果我們把這個計數直接放到資料庫裡單獨的一張計數表 C 中,又會怎麼樣呢?
- 首先,這解決了崩潰丟失的問題,InnoDB 是支援崩潰恢復不丟資料的。
- 利用“事務”這個特性,解決計數不準確的問題。
不同的 count 用法
count() 的語義:
count() 是一個聚合函式,對於返回的結果集,一行行判斷,如果 count 函式的引數不是 NULL,累計值就加 1,否則不加。最後返回累計值。
分析效能差別的時候,你可以記住這麼幾個原則:
1. server 層要什麼就給什麼;
2. InnoDB 只給必要的值;
3. 現在的優化器只優化了 count(*) 的語義為“取行數”,其他“顯而易見”的優化並沒有做。
對於 count(主鍵 id) 來說,
InnoDB 引擎會遍歷整張表,把每一行的 id 值都取出來,返回給server 層。server 層拿到 id 後,判斷是不可能為空的,就按行累加。
對於 count(1) 來說,
InnoDB 引擎遍歷整張表,但不取值server 層對於返回的每一行,放一個數字“1”進去,判斷是不可能為空的,按行累加。
對於 count(欄位) 來說:
如果這個“欄位”是定義為 not null 的話,一行行地從記錄裡面讀出這個欄位,判斷不能為 null,按行累加;
如果這個“欄位”定義允許為 null,那麼執行的時候,判斷到有可能是 null,還要把值取出來再判斷一下,不是 null 才累加。
但是 count() 是例外,
並不會把全部欄位取出來,而是專門做了優化,不取值。count() 肯定不是 null,按行累加。
小結
把計數放在 Redis 裡面,不能夠保證計數和 MySQL 表裡的資料精確一致的原因,
是這兩個不同的儲存構成的系統,不支援分散式事務,無法拿到精確一致的檢視。
而把計數值也放在MySQL 中,就解決了一致性檢視的問題。
InnoDB 引擎支援事務,我們利用好事務的原子性和隔離性,就可以簡化在業務開發時的邏輯。這也是 InnoDB 引擎備受青睞的原因之一。
15 | 答疑文章(一):日誌和索引相關問題
這一章沒有我記載,因為自己還有些不明白,抱歉。