count 浅析
作者:張政俊,中歐基金DBA
上次打了慢sql日志,發(fā)現有很多包含count邏輯的sql,周末抽空來梳理下mysql里的count。
一. count(*)的實現與執(zhí)行
在mysql中,不同的存儲引擎,count(*)的實現方式是不同的
Myisam:
Myisam會把表的行數存在磁盤上,每當執(zhí)行count(*)的時候,直接返回就行了,所以速度非常快。
Innodb:
Innodb執(zhí)行count(*)的時候,需要一條一條把數據從存儲引擎里讀出來,然后累計計數。
既然myisam的count這么快,為什么innodb不能基于myisam的原理也去把行數存起來呢?
主要還是因為 MVCC。
1. Innodb 引擎下的 count
MVCC限制了innodb存儲引擎不可以記錄行數。
假設同一時間有多個查詢會話,test表共有100條數據:
| begin; | ||
| select count(*) from test; | ||
| insert into test 插入一行 | ||
| begin; | ||
| insert into test 插入一行 | ||
| select count(*) from test;共100 | select count(*) from test;共101 | select count(*) from test;共102 |
可以看到在最后時刻,每個session拿到的總行數是不一樣的。
mysql5.6后默認的隔離級別是RR(目前生產也是使用的RR),它是通過多版本并發(fā)機制實現的。在count的時候,每一行記錄都要判斷自己是否對這個會話可見,所以innodb只能把數據一行一行地讀出來依次判斷,如果判斷為當前session可見行,那就把它加到統(tǒng)計的總行數上。
2. count(*) 執(zhí)行計劃
先模擬點數據,看看 count(*) 時mysql自身給出的執(zhí)行計劃
建表:
CREATE TABLE `count_test` (`id` bigint NOT NULL AUTO_INCREMENT,`var_col` varchar(300) NOT NULL,`int_col` int(11) NOT NULL,`insert_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),KEY `var_col` (`var_col`,`int_col`),KEY `create_time` (`insert_time`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;存儲過程插入數據:
CREATE PROCEDURE insert_person()begindeclare i integer default 1;while i<=200000 doinsert into count_test values(i, concat('var_col',i), i, date_sub(NOW(), interval i second));set i=i+1;end while; endcall insert_count_test();查看執(zhí)行計劃:
select count(*) from count_test; image可以看到,使用了 create_time 的普通索引。這里很多人會有疑惑,執(zhí)行計劃為什么不走主鍵呢?
3. count(*) 的內部優(yōu)化
innodb是索引組織表,主鍵索引的葉子結點存放的是完整數據,普通索引葉子結點存放的是主鍵值。因此,普通索引要比主鍵索引小得多(除非全表所有列設為一個聯合索引)。
在執(zhí)行count(*)的時候,遍歷哪個索引樹得到的結果都是一樣的,所以mysql優(yōu)化器會去尋找最小的那顆樹來遍歷。
數據庫系統(tǒng)設計的原則之一,就是在保證邏輯正確的前提下,盡量減少掃描的數據量。
4. rows 的計算
還有個地方可以很快地返回全表行數:
執(zhí)行計劃或者?show table status?命令會輸出當前表的行數信息(rows),
這個語句結果返回非常非常快,不像是一張張表 count(*) 出來的。那這個rows是不是就是表的真實行數呢?
實際上它類似索引統(tǒng)計值,是通過采樣來估算的,官方文檔說誤差有可能達到40%以上,所以這個不具備使用條件。
采樣統(tǒng)計:innodb默認選擇N個數據頁,統(tǒng)計這些頁上的不同值,得到一個平均值后,再乘以這個索引的頁面數
5. 小結
Mysiam快,但是不支持事務,而且加上where條件判斷后,就沒了快的優(yōu)勢;
show table status 命令雖然返回很快,但是值不準確;
innodb直接count(*)會遍歷全表,性能較差;
count(*) 函數調用時,是先要把表中數據加載到內存緩沖區(qū),然后掃描全表獲得行的總記錄數。如果使用主鍵的話,innodb 先要讀取所有20萬數據到數據緩沖區(qū),而且主鍵葉子結點存有所有字段的數據,這個操作需要消耗很多I/O。
而輔助索引,只保存index的值,不包含其他字段數據,I/O消耗要少很多,所以執(zhí)行速度會更快。
二. Mysql 中各類的count
1. count(主鍵id)
innodb引擎會遍歷全表,把每一行id都取出來,返回給server層,逐條累加。
2. count(1)
innodb引擎會遍歷整張表,但是不取值,server層對于返回的每一行放一個數字“1”進去,逐行累加。
3.count(字段)
如果字段是not null ,一行行從記錄里讀出這個字段,逐行累加;
如果允許為null,取值的時候需要判斷,不為null的,才累加;
4. 小結
因為count(*)是特殊優(yōu)化過的,幾個count性能排序如下:
count(*) 大于等于 count(1) 大于 count(主鍵id) 遠大于 count(字段)
三. 優(yōu)化count統(tǒng)計方案
我們再回過頭來看下,有沒有好一點的方案能解決innodb下count(*)慢的情況呢?優(yōu)化思想應該還是通過存下該數據,需要的時候,可以快速響應。
方案一:緩存
想要快,用緩存。
比如用redis,當表中有數據插入式時,redis計數就加1,刪除數據的時候,redis減1。
這種方案也是存在風險的:
1. 持久化風險:
redis是存在內存中,你可以使用rdb或者aof去持久化,如果剛插入條數據redis在內存中加1了,但是這是redis重啟了,重新啟動后redis加載的備份文件中沒有新加的1,那這時候數據就不一致了。
當然上面的情況可以通過其他方式處理,就是需要額外考慮性能和成本,比如redis重啟后,先去數據庫里count一把,把它寫回redis中。
2. 邏輯風險:
| 插入一條記錄 | |
| 讀取redis的計數,發(fā)現是100 | |
| redis計數加1 |
在并發(fā)系統(tǒng)里,無法精準控制不同線程的執(zhí)行時間,如上圖,即使redis正常工作,個別情況下計數的邏輯還有有點不夠精準的。
方案二:數據庫中保存
新建一張專門計數的表,專門存放表的計數數據。
這樣即使數據庫重啟起碼數據不會丟(redo log保證)。
| begin; | |
| 計數表記錄加1 | |
| begin; | |
| 讀取計數表,查到100條 | |
| commit; | |
| 插入一條數據 | |
| commit; |
session B 是獨立的事務,因為session A沒提交,所以計數表記錄加1這個操作是對B不可見的。
通過數據庫事務的特性,把執(zhí)行時序的問題給解決掉。
方案三:其他數據庫
其他數據庫的話首推 clickhouse,之前測試ch時發(fā)現執(zhí)行count(*)速度非常快,截一張當時的PPT:
image當然異構數據庫最大的問題就是要解決增量同步。mysql 同步至 CH,目前大多數的方案是使用python工具,該方案還不成熟,相信隨著時間推移會有更好的方案,屆時很多 OLAP 或者 count(*) 業(yè)務都可以在 clickhouse 上進行。
小結
如果對行數這種實時性、響應性要求很高,而數據庫本身也已無法滿足,這時候才應該考慮去持久化計數。各種方案都是有利有弊,找到合適自己的才是最好的。
四. 關于查詢成本
在測試count性能時,想到了select操作會涉及查詢成本,于是特意把之前寫的有關查詢成本的內容貼了過來,希望可以幫到大家,也給自己做個知識點回顧。
執(zhí)行計劃
再額外看下mysql的查詢成本,以一條sql為例:
SELECT* FROMcount_test WHEREvar_col > 'var_co1123456' AND insert_time < '2020-10-26 10:10:12' image這條sql不出意外掃了全表,可能是由于用了 select * 需要回表,開銷較大。接下來改成索引覆蓋的形式。
索引覆蓋:
SELECTinsert_time FROMcount_test WHEREvar_col > 'var_co1123456' AND insert_time < '2020-10-26 10:10:12' image執(zhí)行計劃顯示還是用了全表。
索引覆蓋+強制索引:
使用 force index ,讓它強制使用時間索引:
image執(zhí)行計劃用到了時間索引。
查詢成本核算
核算公式:
cost = rows*0.2 + data_length/(1024*16)1. 全表查詢成本
image199644 * 0.2 + 9977856 / (1024 * 16) = 40,537.8
代入公式可以算出,全表的成本約為 40537.8
2. 各索引查詢成本
通過 optimizer_trace 方式查看:
SET optimizer_trace="enabled=on";SELECT insert_time FROM count_test WHERE var_col > 'var_co1123456' AND insert_time < '2020-10-26 10:10:12';SELECT * FROM information_schema.OPTIMIZER_TRACE;SET optimizer_trace="enabled=off";然后看下走索引的預估成本:
optimizer_trace 下全表查詢的預估成本:
40540 和我們之前計算的 40537.8 差不多,這個值要遠小于走索引的成本。
所以 mysql 在執(zhí)行此 sql 的時候會使用全表掃描,都是基于執(zhí)行成本來判斷的。
全文完。
Enjoy MySQL :)
總結
- 上一篇: 【操作系统】分页式虚拟存储系统
- 下一篇: 全系列三极管应用参数和代换大全