MySQL 統計行數的 count
阿新 • • 發佈:2020-03-17
MySQL count() 函式我們並不陌生,用來統計每張表的函式。但如果你的表越來越大,並且是 InnoDB 引擎的話,會發現計算的速度會越來越慢。在這篇文章裡,會先介紹 count() 實現的原理及原因,然後是 count 不同用法的效能分析,最後給出需要頻繁改變並需要統計表行數的解決方案。
## Count() 的實現
InnoDB 和 MyISAM 是 MySQL 常用的資料引擎,由於兩者實現的不同,導致 count() 操作計算的效率也不同。
對於 MyISAM 來說,它把每個表的總行數都存在了磁碟上,因此使用 count(*) 計算時,效率很高直接返回結果。但如果加入了 where 條件,依然會進行搜尋,所以效率是不高的。
對於 InnoDB 來說,在進行 count(*) 運算時,會把資料從引擎中一行行讀出來,然後累計計數,自然表大了之後,效率就變低了。
那麼,為什麼 InnoDB 不能像 MyISAM 在表中記錄呢?原因就在於 InnoDB 比 MyISAM 多了支援事務的特性,同時也需要一定的取捨。[由於 MVCC 的控制](https://www.cnblogs.com/michael9/p/12294380.html#_label2),使得 MySQL 具有併發的能力,也就是說對於同一時刻,InnoDB 返回的表的行數是不一定的,事務看到的行數與開啟後的一致性檢視有關,換句話說,每個事務能看到的資料版本是不一樣的,只能一行行拿出來進行判斷。
像下面的事務,假設表 t 有 10000 條資料:
| Session A | Session B | Session C |
| ----------------------- | ----------------------- | ----------------------- |
| select count(*) from t; | | |
| | | insert into t (); |
| | begin; | |
| | insert into t(); | |
| select count(*) from t; | select count(*) from t; | select count(*) from t; |
| 10000; | 結果是 10002 | 結果是 10001 |
對於 Session A 來說,Session B 未提交不可見,Session C 提交了,但是在 Session A 啟動後提交的,也不可見。所以是 10000.
而對於 Session B 而言,Session C 在啟動之前提交,自己又插入了一條,所以結果是 10002.
其實 InnoDB 在進行 count(\*) 操作時,還是做了優化的,在進行 count(*) 操作時,由於普通索引會儲存主鍵的 id 值,所以會找到最小的那顆普通索引樹進行查詢,而不是去遍歷主鍵索引樹。
> 在保證邏輯正確的前提下,減少掃描的資料量,是資料庫系統設計的通用法則。
另外在使用 `show table status` 時,也可以查詢出行數,而且速度很快,但需要注意的是,該命令是通過索引統計的值來取樣估算的。官方文件說誤差可以有 40%-50%.
但如果我們真的需要實時的獲取的某個表的行數,應該怎麼辦呢?
## 手動儲存表的數量
### 用快取系統來儲存計數
對於進行更新的表,可能會想到用快取系統來支援。比如 Redis 裡來儲存某個表總行數。
每次插入資料庫時,Redis 計數加一,相反則減一,這樣看起來讀寫操作都很快,但會存在一些問題。
快取系統會丟失更新:
對於 Redis 在記憶體中的資料,需要定期的同步到磁碟中,但對於 Redis 異常重啟,就沒有辦法了。比如在 Redis 中插入後,Redis 重啟,資料沒有持久化到硬碟。這時可以在重啟 Redis 後,從資料庫執行下 count(*) 操作,然後更新到 Redis 中。一次全表掃描還是可行的。
邏輯不精確:
假設一個頁面中,需要顯示一張表的行數,以及每一條資料。在實現時,可以先從 Redis 取數量,然後從資料庫裡取記錄。
但可能會出現這樣的情況:
1. 資料庫查到 100 行結果裡有最新插入的記錄,而 Redis 計數裡少 1.
2. 資料庫查到 100 行結果沒有最新的記錄,但 Redis 計數卻多了 1.
| Session A | Session B | |
| --------------- | ------------------ | ---- |
| 插入一條資料; | | T1 |
| | 讀 Redis 計數; | T2 |
| | 從資料庫中查記錄; | |
| Redis 計數加 1; | | T3 |
對於 Session B 來說,在 T2 時刻,會發現 Redis 的數量比資料庫少 1 條。
| Session A | Session B | |
| --------------- | ------------------ | ---- |
| Redis 計數加 1; | | T1 |
| | 讀 Redis 計數; | T2 |
| | 從資料庫中查記錄; | |
| 插入一條資料; | | T3 |
對於 Session B 來說,在 T2 時刻,會發現 Redis 的數量比資料庫多 1 條。
其實產生問題的原因就是因為 Redis 和資料庫查記錄沒有在同一個事務中。
### 用資料庫儲存
由於 InnoDB 引擎的支援,MySQL 本身是支援事務的,所以將 Redis 的插入操作換成在資料庫的更新操作,就可以利用在RR級別下的事務特性,進而保證資料的精確性。
而且還有一點,由於 [redo log](https://www.cnblogs.com/michael9/p/12497992.html#_label5_0) 的支援,在 MySQL 發生異常時,是可以保證 crash-safe。
## 不同 count 用法的執行效率
count() 本身是一個聚合函式,對於返回的結果集,一行行地判斷。如果引數不是 NULL 的話,會一直累加,最後返回結果。
所以 count(*), count(id), count(1) 表示都是返回滿足條件的結果集總行數。
而 count(欄位),則表示滿足條件的資料行裡,不為 NULL 的欄位。
對於 count(id) 來說,InnoDB 會遍歷整張表,把每行 id 取出來,給 server 層。Server 判斷 id 是否為空,然後累加。
對於 count(1) 來說,InnoDB 會遍歷整張表,但不取值。Server 層會自己放入 1,然後累加。
所以對於 count(1) 的執行會比 count(*) 要快,少了解析資料行以及拷貝欄位值的操作。
對於 count(欄位) 來說,如果欄位定義時是 not null, 會一行行讀出,並判斷不能為 null,然後累加。如果定義時可以為 null,執行時,需要將值去除,判斷不是 null 才累加。
*count(\*)* 除外,專門做了優化,不取值,直接按行累加,並且會找到最小的索引樹進行計算。
## 總結
MySQL count() 函式的執行效率和底層的資料引擎有關。MyISAM 不加 where 條件,查詢會很快,但不支援事務。InnoDB 支援事務,由於 MVCC 的實現,導致每次查詢都需要一行行的掃描,效率不高。
解決方法可以通過設計外部快取如 Redis,儲存記錄。但存在異常重啟和資料不準確的情況。可以通過在 InnoDB 中新建一張表,儲存記錄這樣的解決方案。
最後,InnoDB 對 count(*) 做了獨立的優化,而其他的 count 操作,則需要額外的