故障分析 | MySQL 優化案例 - select count(*)

做者:xuty
本文來源:原創投稿
*愛可生開源社區出品,原創內容未經受權不得隨意使用,轉載請聯繫小編並註明來源。

本文關鍵字:count、SQL、二級索引

1、故事背景

項目組聯繫我說是有一張 500w 左右的表作select count(*)速度特別慢。mysql

2、原 SQL 分析

Server version: 5.7.24-log MySQL Community Server (GPL)

SQL 以下,僅僅就是統計 api_runtime_log 這張表的行數,一條簡單的不能再簡單的 SQL:sql

select count(*) from api_runtime_log;

咱們先去運行一下這條 SQL,能夠看到確實運行很慢,要 40 多秒左右,確實很不正常~api

mysql> select count(*) from api_runtime_log;
+----------+
| count(*) |
+----------+
|  5718952 |
+----------+
1 row in set (42.95 sec)

咱們再去看下錶結構,看上去貌似也挺正常的~存在主鍵,表引擎也是 InnoDB,字符集也沒問題。緩存

CREATE TABLE `api_runtime_log_copy` (
  `BelongXiaQuCode` varchar(50) DEFAULT NULL,
  `OperateUserName` varchar(50) DEFAULT NULL,
  `OperateDate` datetime DEFAULT NULL,
  `Row_ID` int(11) DEFAULT NULL,
  `YearFlag` varchar(4) DEFAULT NULL,
  `RowGuid` varchar(50) NOT NULL,
   ......
  `apiid` varchar(50) DEFAULT NULL,
  `apiname` varchar(50) DEFAULT NULL,
  `apiguid` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`RowGuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

3、執行計劃

經過執行計劃,咱們看下是否能夠找到什麼問題點。函數

mysql> explain select count(*) from api_runtime_log \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: api_runtime_log
   partitions: NULL
         type: index
possible_keys: NULL
          key: PRIMARY
      key_len: 152
          ref: NULL
         rows: 5718952
     filtered: 100.00
      Extra: Using index

能夠看到,查詢走的是 PRIMARY,也就是主鍵索引。貌似也沒有什麼問題,走索引了呀!那麼是否是真的就沒問題呢?性能

4、原理

爲了找到答案,經過 Google 查找 MySQL 下select count(*)的原理,找到了答案。這邊省略過程,直接上結果。測試

簡單介紹下原理:優化

  • 聚簇索引:每個 InnoDB 存儲引擎下的表都有一個特殊的索引用來保存每一行的數據,稱爲聚簇索引(一般都爲主鍵),聚簇索引實際保存了 B-Tree 索引和行數據,因此大小實際上約等於爲表數據量
  • 二級索引:除了彙集索引,表上其餘的索引都是二級索引,索引中僅僅存儲了對應索引列及主鍵列

在 InnoDB 存儲引擎中,count(*)函數是先從內存中讀取數據到內存緩衝區,而後進行掃描得到行記錄數。這裏 InnoDB 會優先走二級索引;若是同時存在多個二級索引,會選擇key_len 最小的二級索引;若是不存在二級索引,那麼會走主鍵索引;若是連主鍵都不存在,那麼就走全表掃描!這裏咱們因爲走的是主鍵索引,因此 MySQL 須要先把整個主鍵索引讀取到內存緩衝區,這是個從磁盤讀寫到內存的過程,並且主鍵索引基本等於整個表數據量(10GB+),因此很是耗時!ui

那麼如何解決呢?

答案就是:建二級索引。code

由於二級索引只包含對應的索引列及主鍵列,因此體積很是小。在select  count(*)的查詢過程當中,只須要將二級索引讀取到內存緩衝區,只有幾十 MB 的數據量,因此速度會很是快。舉個形象的比喻,咱們想知道一本書的頁數:

  • 走彙集索引:從第一頁翻到最後一頁,知道總頁數;
  • 走二級索引:經過目錄直接知道總頁數。

5、驗證

建立二級索引後,再次執行 SQL 及查看執行計劃。

mysql> create index idx_rowguid on api_runtime_log(rowguid);
Query OK, 0 rows affected (0.01 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> select count(*) from api_runtime_log;
+----------+
| count(*) |
+----------+
|  5718952 |
+----------+
1 row in set (0.89 sec)

mysql> explain select count(*) from api_runtime_log \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: api_runtime_log
   partitions: NULL
         type: index
possible_keys: NULL
          key: idx_rowguid
      key_len: 152
          ref: NULL
         rows: 5718952
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning (0.00 sec)

能夠看到添加二級索引後,確實速度明顯變快,並且執行計劃也變成了走二級索引。至此這個問題其實已經解決了,就是因爲表上缺乏二級索引致使

6、深刻測試

爲了進一步驗證上述的推論,因此就作了以下的測試。

測試過程以下:

  1. 經過 sysbench 建立了一張 500W 的測試表 sbtest1,表上僅僅包含一個主鍵索引,表大小爲 1125MB;
  2. 調整部分 MySQL 參數,重啓 MySQL,保證目前 innodb buffer pool(內存緩衝區) 中爲空,不緩存任何數據;
  3. 執行select count(*),理論上走主鍵索引,查看當前內存緩衝區中緩存的數據量(理論上會緩存整個聚簇索引);
  4. 在測試表 sbtest1 上添加二級索引,索引大小爲 55MB;
  5. 再次重啓 MySQL,保證內存緩衝區爲空;
  6. 再次執行select count(*),理論上走二級索引;
  7. 再次查看內存緩衝區中緩存的數據量(理論上只會緩存二級索引)。

測試結果以下:

1. 聚簇索引
查詢當前內存緩衝區狀態,結果爲空證實不緩存測試表數據。

mysql> select * from sys.innodb_buffer_stats_by_table where object_schema = 'test';
Empty set (1.92 sec)

mysql>  select count(*) from test.sbtest1;
+----------+
| count(*) |
+----------+
|  5188434 |
+----------+
1 row in set (5.52 sec)

再次查看內存緩衝區,發現緩存了 sbtest1 表上 1G 多的數據,基本等於整個表數據量。

mysql> select * from sys.innodb_buffer_stats_by_table where object_schema = 'test' \G;
*************************** 1. row ***************************
object_schema: test
  object_name: sbtest1
    allocated: 1.08 GiB
         data: 1.01 GiB
        pages: 71081
 pages_hashed: 0
    pages_old: 28119
  rows_cached: 5189798

最後咱們再來看下執行計劃,確實走的是主鍵索引,放在最後執行是爲了不影響緩衝區。

mysql> explain  select count(*) from test.sbtest1 \G;                                          
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: sbtest1
   partitions: NULL
         type: index
possible_keys: NULL
          key: PRIMARY
      key_len: 4
          ref: NULL
         rows: 5117616
     filtered: 100.00
        Extra: Using index                                   

2. 二級索引

建立二級索引 idx_id,查看 sbtest1 表上主鍵索引與二級索引的數據量。

mysql> create index idx_id on sbtest1(id);
Query OK, 0 rows affected (12.97 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> SELECT sum(stat_value) pages ,index_name ,
(round((sum(stat_value) * @@innodb_page_size)/1024/1024)) as MB 
  FROM mysql.innodb_index_stats 
  WHERE table_name = 'sbtest1' 
  AND database_name = 'test' 
  AND stat_description = 'Number of pages in the index' 
  GROUP BY index_name;
+-------+------------+------+
| pages | index_name | MB   |
+-------+------------+------+
| 72000 | PRIMARY    | 1125 |
|  3492 | idx_id     |   55 |
+-------+------------+------+

重啓 MySQL,再次查看緩衝區一樣爲空,證實沒有緩存測試表上的數據。

mysql> select * from sys.innodb_buffer_stats_by_table where object_schema = 'test';
Empty set (1.49 sec)

mysql> select count(*) from test.sbtest1;
+----------+
| count(*) |
+----------+
|  5188434 |
+----------+
1 row in set (2.92 sec)

再次查看內存緩衝區,發現僅僅緩存了 sbtest1 表上的 50M 數據,約等於二級索引的數據量。

mysql> select * from sys.innodb_buffer_stats_by_table where object_schema = 'test' \G;
*************************** 1. row ***************************
object_schema: test
  object_name: sbtest1
    allocated: 49.48 MiB
         data: 46.41 MiB
        pages: 3167
 pages_hashed: 0
    pages_old: 1575
rows_cached: 2599872

最後確認下執行計劃,確實走的是二級索引。

mysql> explain select count(*) from test.sbtest1 \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: sbtest1
   partitions: NULL
         type: index
possible_keys: NULL
          key: idx_id
      key_len: 4
          ref: NULL
         rows: 5117616
     filtered: 100.00
        Extra: Using index

7、案例總結

從上述這個測試結果能夠看出,和以前的推論基本吻合。若是select count(*)走的是主鍵索引,那麼會緩存整個表數據,大量查詢時間會花費在讀取表數據到緩衝區。

若是存在二級索引,那麼只須要讀取索引頁到緩衝區便可,速度天然快。

另:項目上因爲磁盤性能層次不齊,因此當趕上這種狀況時,性能較差的磁盤更會放大這個問題;一張超級大表,統計行數時若是走了主鍵索引,後果可想而知~

8、優化建議

這次測試過程當中咱們僅僅模擬是百萬數據量,此時咱們經過二級索引統計表行數,只須要讀取幾十 M 的數據量,就能夠獲得結果。
那麼當咱們的表數據量是上千萬,甚至上億時呢。此時即使是最小的二級索引也是 幾百 M、過 G 的數據量,若是繼續經過二級索引來統計行數,那麼速度就不會如此迅速了。

這個時候能夠經過避免直接select count(*) from table來解決,方法較多,例如:

  1. 使用 MySQL 觸發器 + 統計表實時計算表數據量;
  2. 使用 MyISAM 替換 InnoDB,由於 MyISAM 自帶計數器,壞處就很少說了;
  3. 經過 ETL 導入表數據到其餘更高效的異構環境中進行計算;
  4. 升級到 MySQL 8 中,使用並行查詢,加快檢索速度。

固然,何時 InnoDB 存儲引擎能夠直接實現計數器的功能就行了!

相關文章
相關標籤/搜索