MySQL實戰45講學習筆記:第十四講

1、引子

在開發系統的時候,你可能常常須要計算一個表的行數,好比一個交易系統的全部變動記錄總數。這時候你可能會想,一條 select count(*) from t 語句不就解決了嗎?java

可是,你會發現隨着系統中記錄數愈來愈多,這條語句執行得也會愈來愈慢。而後你可能就想了,MySQL 怎麼這麼笨啊,記個總數,每次要查的時候直接讀出來,不就行了嗎。redis

那麼今天,咱們就來聊聊 count(*) 語句究竟是怎樣實現的,以及 MySQL 爲何會這麼實現。而後,我會再和你說說,若是應用中有這種頻繁變動並須要統計表行數的需求,業務
設計上能夠怎麼作。數據庫

2、count(*) 的實現方式

你首先要明確的是,在不一樣的 MySQL 引擎中,count(*) 有不一樣的實現方式緩存

一、MyISAM 表雖然 count(*) 很快,可是不支持事務;

  1. MyISAM 引擎把一個表的總行數存在了磁盤上,所以執行 count(*) 的時候會直接返回這個數,效率很高;
  2. 而 InnoDB 引擎就麻煩了,它執行 count(*) 的時候,須要把數據一行一行地從引擎裏面讀出來,而後累積計數。

這裏須要注意的是,咱們在這篇文章裏討論的是沒有過濾條件的 count(*),若是加了where 條件的話,MyISAM 表也是不能返回得這麼快的。安全

在前面的文章中,咱們一塊兒分析了爲何要使用 InnoDB,由於不管是在事務支持、併發能力仍是在數據安全方面,InnoDB 都優於 MyISAM。我猜你的表也必定是用了 InnoDB
引擎。這就是當你的記錄數愈來愈多的時候,計算一個表的總行數會愈來愈慢的緣由。bash

二、show table status 命令雖然返回很快,可是不許確;

若是你用過 show table status 命令的話,就會發現這個命令的輸出結果裏面也有一個TABLE_ROWS 用於顯示這個表當前有多少行,這個命令執行挺快的,那這個
TABLE_ROWS 能代替 count(*) 嗎?併發

你可能還記得在第 10 篇文章《 MySQL 爲何有時候會選錯索引?》中我提到過,索引統計的值是經過採樣來估算的。實際上,TABLE_ROWS 就是從這個採樣估算得來的,因
此它也很不許。有多不許呢,官方文檔說偏差可能達到 40% 到 50%。因此,show tablestatus 命令顯示的行數也不能直接使用。框架

三、InnoDB 表直接 count(*) 會遍歷全表,雖然結果準確,但會致使性能問題

那爲何 InnoDB 不跟 MyISAM 同樣,也把數字存起來呢?分佈式

這是由於即便是在同一個時刻的多個查詢,因爲多版本併發控制(MVCC)的緣由,InnoDB 表「應該返回多少行」也是不肯定的。這裏,我用一個算 count(*) 的例子來爲你
解釋一下。函數

假設表 t 中如今有 10000 條記錄,咱們設計了三個用戶並行的會話。

咱們假設從上到下是按照時間順序執行的,同一行語句是在同一時刻執行的。

MyISAM 引擎把一個表的總行數存在了磁盤上,所以執行 count(*) 的時候會直接返回這個數,效率很高;

而 InnoDB 引擎就麻煩了,它執行 count(*) 的時候,須要把數據一行一行地從引擎裏面讀出來,而後累積計數。

  1. 會話 A 先啓動事務並查詢一次表的總行數;
  2. 會話 B 啓動事務,插入一行後記錄後,查詢表的總行數;
  3. 會話 C 先啓動一個單獨的語句,插入一行記錄後,查詢表的總行數。

圖 1 會話 A、B、C 的執行流程

你會看到,在最後一個時刻,三個會話 A、B、C 會同時查詢表 t 的總行數,但拿到的結果卻不一樣。

這和 InnoDB 的事務設計有關係,可重複讀是它默認的隔離級別,在代碼上就是經過多版本併發控制,也就是 MVCC 來實現的。每一行記錄都要判斷本身是否對這個會話可見,因
此對於 count(*) 請求來講,InnoDB 只好把數據一行一行地讀出依次判斷,可見的行纔可以用於計算「基於這個查詢」的表的總行數。

備註:若是你對 MVCC 記憶模糊了,能夠再回顧下第 3 篇文章《事務隔離:爲何你改了我還看不見?》和第 8 篇文章《事務究竟是隔離的仍是不隔離的?》中的相關內容。

固然,如今這個看上去笨笨的 MySQL,在執行 count(*) 操做的時候仍是作了優化的。

你知道的,InnoDB 是索引組織表,主鍵索引樹的葉子節點是數據,而普通索引樹的葉子節點是主鍵值。因此,普通索引樹比主鍵索引樹小不少。對於 count(*) 這樣的操做,遍歷
哪一個索引樹獲得的結果邏輯上都是同樣的。所以,MySQL 優化器會找到最小的那棵樹來遍歷。在保證邏輯正確的前提下,儘可能減小掃描的數據量,是數據庫系統設計的通用法則之一。

到這裏咱們小結一下:

  1. MyISAM 表雖然 count(*) 很快,可是不支持事務;
  2. show table status 命令雖然返回很快,可是不許確;
  3. InnoDB 表直接 count(*) 會遍歷全表,雖然結果準確,但會致使性能問題。

那麼,回到文章開頭的問題,若是你如今有一個頁面常常要顯示交易系統的操做記錄總數,到底應該怎麼辦呢?答案是,咱們只能本身計數。

接下來,咱們討論一下,看看本身計數有哪些方法,以及每種方法的優缺點有哪些。

這裏,我先和你說一下這些方法的基本思路:你須要本身找一個地方,把操做記錄表的行數存起來。

3、用緩存系統保存計數

對於更新很頻繁的庫來講,你可能會第一時間想到,用緩存系統來支持。

你能夠用一個 Redis 服務來保存這個表的總行數。這個表每被插入一行 Redis 計數就加1,每被刪除一行 Redis 計數就減 1。這種方式下,讀和更新操做都很快,但你再想一下這種方式存在什麼問題嗎?

沒錯,緩存系統可能會丟失更新。

Redis 的數據不能永久地留在內存裏,因此你會找一個地方把這個值按期地持久化存儲起來。但即便這樣,仍然可能丟失更新。試想若是剛剛在數據表中插入了一行,Redis 中保
存的值也加了 1,而後 Redis 異常重啓了,重啓後你要從存儲 redis 數據的地方把這個值讀回來,而剛剛加 1 的這個計數操做卻丟失了。

固然了,這仍是有解的。好比,Redis 異常重啓之後,到數據庫裏面單獨執行一次count(*) 獲取真實的行數,再把這個值寫回到 Redis 裏就能夠了。異常重啓畢竟不是常常
出現的狀況,這一次全表掃描的成本,仍是能夠接受的。

但實際上,將計數保存在緩存系統中的方式,還不僅是丟失更新的問題。即便 Redis 正常工做,這個值仍是邏輯上不精確的。

你能夠設想一下有這麼一個頁面,要顯示操做記錄的總數,同時還要顯示最近操做的 100條記錄。那麼,這個頁面的邏輯就須要先到 Redis 裏面取出計數,再到數據表裏面取數據記錄。
咱們是這麼定義不精確的:

1. 一種是,查到的 100 行結果裏面有最新插入記錄,而 Redis 的計數裏還沒加 1;
2. 另外一種是,查到的 100 行結果裏沒有最新插入的記錄,而 Redis 的計數裏已經加了1。

這兩種狀況,都是邏輯不一致的。咱們一塊兒來看看這個時序圖。

圖 2 會話 A、B 執行時序圖

圖 2 中,會話 A 是一個插入交易記錄的邏輯,往數據表裏插入一行 R,而後 Redis 計數加 1;會話 B 就是查詢頁面顯示時須要的數據。

在圖 2 的這個時序裏,在 T3 時刻會話 B 來查詢的時候,會顯示出新插入的 R 這個記錄,可是 Redis 的計數還沒加 1。這時候,就會出現咱們說的數據不一致。

你必定會說,這是由於咱們執行新增記錄邏輯時候,是先寫數據表,再改 Redis 計數。而讀的時候是先讀 Redis,再讀數據表,這個順序是相反的。那麼,若是保持順序同樣的
話,是否是就沒問題了?咱們如今把會話 A 的更新順序換一下,再看看執行結果。

圖 3 調整順序後,會話 A、B 的執行時序圖

你會發現,這時候反過來了,會話 B 在 T3 時刻查詢的時候,Redis 計數加了 1 了,但還查不到新插入的 R 這一行,也是數據不一致的狀況。

在併發系統裏面,咱們是沒法精確控制不一樣線程的執行時刻的,由於存在圖中的這種操做序列,因此,咱們說即便 Redis 正常工做,這個計數值仍是邏輯上不精確的。

4、在數據庫保存計數

根據上面的分析,用緩存系統保存計數有丟失數據和計數不精確的問題。那麼,若是咱們把這個計數直接放到數據庫裏單獨的一張計數表 C 中,又會怎麼樣呢?

首先,這解決了崩潰丟失的問題,InnoDB 是支持崩潰恢復不丟數據的。

備註:關於 InnoDB 的崩潰恢復,你能夠再回顧一下第 2 篇文章《日誌系統:一條 SQL 更新語句是如何執行的?》中的相關內容。

而後,咱們再看看能不能解決計數不精確的問題。

你會說,這不同嗎?無非就是把圖 3 中對 Redis 的操做,改爲了對計數表 C 的操做。只要出現圖 3 的這種執行序列,這個問題仍是無解的吧?

這個問題還真不是無解的。

咱們這篇文章要解決的問題,都是因爲 InnoDB 要支持事務,從而致使 InnoDB 表不能把count(*) 直接存起來,而後查詢的時候直接返回造成的。

所謂以子之矛攻子之盾,如今咱們就利用「事務」這個特性,把問題解決掉。

圖 4 會話 A、B 的執行時序圖

咱們來看下如今的執行結果。雖然會話 B 的讀操做仍然是在 T3 執行的,可是由於這時候更新事務尚未提交,因此計數值加 1 這個操做對會話 B 還不可見。

所以,會話 B 看到的結果裏, 查計數值和「最近 100 條記錄」看到的結果,邏輯上就是一致的。

5、不一樣的 count 用法

在前面文章的評論區,有同窗留言問到:在 select count(?) from t 這樣的查詢語句裏面,count(*)、count(主鍵 id)、count(字段) 和 count(1) 等不一樣用法的性能,有哪些差
別。今天談到了 count(*) 的性能問題,我就藉此機會和你詳細說明一下這幾種用法的性能差異。

須要注意的是,下面的討論仍是基於 InnoDB 引擎的。

這裏,首先你要弄清楚 count() 的語義。count() 是一個聚合函數,對於返回的結果集,一行行地判斷,若是 count 函數的參數不是 NULL,累計值就加 1,不然不加。最後返回累計值。

因此,count(*)、count(主鍵 id) 和 count(1) 都表示返回知足條件的結果集的總行數;而count(字段),則表示返回知足條件的數據行裏面,參數「字段」不爲 NULL 的總個數。

至於分析性能差異的時候,你能夠記住這麼幾個原則:

  • 1. server 層要什麼就給什麼;
  • 2. InnoDB 只給必要的值;
  • 3. 如今的優化器只優化了 count(*) 的語義爲「取行數」,其餘「顯而易見」的優化並無作。

這是什麼意思呢?接下來,咱們就一個個地來看看。

對於 count(主鍵 id) 來講,InnoDB 引擎會遍歷整張表,把每一行的 id 值都取出來,返回給 server 層。server 層拿到 id 後,判斷是不可能爲空的,就按行累加。

對於 count(1) 來講,InnoDB 引擎遍歷整張表,但不取值。server 層對於返回的每一行,放一個數字「1」進去,判斷是不可能爲空的,按行累加。

單看這兩個用法的差異的話,你能對比出來,count(1) 執行得要比 count(主鍵 id) 快。由於從引擎返回 id 會涉及到解析數據行,以及拷貝字段值的操做。

對於 count(字段) 來講:

  • 1. 若是這個「字段」是定義爲 not null 的話,一行行地從記錄裏面讀出這個字段,判斷不能爲 null,按行累加;
  • 2. 若是這個「字段」定義容許爲 null,那麼執行的時候,判斷到有多是 null,還要把值取出來再判斷一下,不是 null 才累加。

也就是前面的第一條原則,server 層要什麼字段,InnoDB 就返回什麼字段。

count(*) 是例外

可是 count(*) 是例外,並不會把所有字段取出來,而是專門作了優化,不取值。count(*) 確定不是 null,按行累加。

看到這裏,你必定會說,優化器就不能本身判斷一下嗎,主鍵 id 確定非空啊,爲何不能按照 count(*) 來處理,多麼簡單的優化啊。

固然,MySQL 專門針對這個語句進行優化,也不是不能夠。可是這種須要專門優化的狀況太多了,並且 MySQL 已經優化過 count(*) 了,你直接使用這種用法就能夠了。

因此結論是:按照效率排序的話,count(字段)<count(主鍵 id)<count(1)≈count(*),因此我建議你,儘可能使用 count(*)。

6、小結

今天,我和你聊了聊 MySQL 中得到錶行數的兩種方法。咱們提到了在不一樣引擎中count(*) 的實現方式是不同的,也分析了用緩存系統來存儲計數值存在的問題。

其實,把計數放在 Redis 裏面,不可以保證計數和 MySQL 表裏的數據精確一致的緣由,是這兩個不一樣的存儲構成的系統,不支持分佈式事務,沒法拿到精確一致的視圖。而把計
數值也放在 MySQL 中,就解決了一致性視圖的問題。

InnoDB 引擎支持事務,咱們利用好事務的原子性和隔離性,就能夠簡化在業務開發時的邏輯。這也是 InnoDB 引擎備受青睞的緣由之一。

最後,又到了今天的思考題時間了。

在剛剛討論的方案中,咱們用了事務來確保計數準確。因爲事務能夠保證中間結果不被別的事務讀到,所以修改計數值和插入新記錄的順序是不影響邏輯結果的。可是,從併發系
統性能的角度考慮,你以爲在這個事務序列裏,應該先插入操做記錄,仍是應該先更新計數表呢?

你能夠把你的思考和觀點寫在留言區裏,我會在下一篇文章的末尾給出個人參考答案。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

7、上期問題時間

上期我給你留的問題是,何時使用 alter table t engine=InnoDB 會讓一個表佔用的空間反而變大。

在這篇文章的評論區裏面,你們都提到了一個點,就是這個表,自己就已經沒有空洞的了,好比說剛剛作過一次重建表操做。

在 DDL 期間,若是恰好有外部的 DML 在執行,這期間可能會引入一些新的空洞。

@飛翔 提到了一個更深入的機制,是咱們在文章中沒說的。在重建表的時候,InnoDB 不會把整張表佔滿,每一個頁留了 1/16 給後續的更新用。也就是說,其實重建表以後不
是「最」緊湊的。

假如是這麼一個過程:

1. 將表 t 重建一次;
2. 插入一部分數據,可是插入的這些數據,用掉了一部分的預留空間;
3. 這種狀況下,再重建一次表 t,就可能會出現問題中的現象。

8、經典留言

一、阿健

從併發系統性能的角度考慮,應該先插入操做記錄,再更新計數表。

知識點在《行鎖功過:怎麼減小行鎖對性能的影響?》
由於更新計數表涉及到行鎖的競爭,先插入再更新能最大程度地減小了事務之間的鎖等待,提高了併發度。

做者回復:

好幾個同窗說對,你第一個標明出處

二、果真如此

1、請問計數用這個MySQL+redis方案如何:
1.開啓事務(程序中的事務)
2.MySQL插入數據
3.原子更新redis計數
4.若是redis更新成功提交事務,若是redis更新失敗回滾事務。

2、.net和java程序代碼的事務和MySQL事務是什麼關係,有什麼相關性?

做者回復:

1. 好問題,不會仍是沒解決咱們說的一致性問題。若是在三、4之間插入了 Session B的邏輯呢2. 我估計就是啓動事務(執行begin),結束時提交(執行commit)吧,沒有了解過全部框架,不肯定哈

相關文章
相關標籤/搜索