在開發系統的時候,你可能常常須要計算一個表的行數,好比一個交易系統的全部變動記錄總數。這時候你可能會想,一條select count(*) from t 語句不就解決了嗎?redis
可是,你會發現隨着系統中記錄數愈來愈多,這條語句執行得也會愈來愈慢。而後你可能就想了,MySQL怎麼這麼笨啊,記個總數,每次要查的時候直接讀出來,不就行了嗎。數據庫
那麼今天,咱們就來聊聊count(*)語句究竟是怎樣實現的,以及MySQL爲何會這麼實現。而後,我會再和你說說,若是應用中有這種頻繁變動並須要統計表行數的需求,業務設計上能夠怎麼作。緩存
你首先要明確的是,在不一樣的MySQL引擎中,count(*)有不一樣的實現方式。安全
這裏須要注意的是,咱們在這篇文章裏討論的是沒有過濾條件的count(*),若是加了where 條件的話,MyISAM表也是不能返回得這麼快的。併發
在前面的文章中,咱們一塊兒分析了爲何要使用InnoDB,由於不管是在事務支持、併發能力仍是在數據安全方面,InnoDB都優於MyISAM。我猜你的表也必定是用了InnoDB引擎。這就是當你的記錄數愈來愈多的時候,計算一個表的總行數會愈來愈慢的緣由。分佈式
那爲何InnoDB不跟MyISAM同樣,也把數字存起來呢?函數
這是由於即便是在同一個時刻的多個查詢,因爲多版本併發控制(MVCC)的緣由,InnoDB表「應該返回多少行」也是不肯定的。這裏,我用一個算count(*)的例子來爲你解釋一下。性能
假設表t中如今有10000條記錄,咱們設計了三個用戶並行的會話。優化
咱們假設從上到下是按照時間順序執行的,同一行語句是在同一時刻執行的。spa
你會看到,在最後一個時刻,三個會話A、B、C會同時查詢表t的總行數,但拿到的結果卻不一樣。
這和InnoDB的事務設計有關係,可重複讀是它默認的隔離級別,在代碼上就是經過多版本併發控制,也就是MVCC來實現的。每一行記錄都要判斷本身是否對這個會話可見,所以對於count(*)請求來講,InnoDB只好把數據一行一行地讀出依次判斷,可見的行纔可以用於計算「基於這個查詢」的表的總行數。
備註:若是你對MVCC記憶模糊了,能夠再回顧下第3篇文章《事務隔離:爲何你改了我還看不見?》和第8篇文章《事務究竟是隔離的仍是不隔離的?》中的相關內容。
固然,如今這個看上去笨笨的MySQL,在執行count(*)操做的時候仍是作了優化的。
你知道的,InnoDB是索引組織表,主鍵索引樹的葉子節點是數據,而普通索引樹的葉子節點是主鍵值。因此,普通索引樹比主鍵索引樹小不少。對於count(*)這樣的操做,遍歷哪一個索引樹獲得的結果邏輯上都是同樣的。所以,MySQL優化器會找到最小的那棵樹來遍歷。在保證邏輯正確的前提下,儘可能減小掃描的數據量,是數據庫系統設計的通用法則之一。
若是你用過show table status 命令的話,就會發現這個命令的輸出結果裏面也有一個TABLE_ROWS用於顯示這個表當前有多少行,這個命令執行挺快的,那這個TABLE_ROWS能代替count(*)嗎?
你可能還記得在第10篇文章《 MySQL爲何有時候會選錯索引?》中我提到過,索引統計的值是經過採樣來估算的。實際上,TABLE_ROWS就是從這個採樣估算得來的,所以它也很不許。有多不許呢,官方文檔說偏差可能達到40%到50%。因此,show table status命令顯示的行數也不能直接使用。
到這裏咱們小結一下:
那麼,回到文章開頭的問題,若是你如今有一個頁面常常要顯示交易系統的操做記錄總數,到底應該怎麼辦呢?答案是,咱們只能本身計數。
接下來,咱們討論一下,看看本身計數有哪些方法,以及每種方法的優缺點有哪些。
這裏,我先和你說一下這些方法的基本思路:你須要本身找一個地方,把操做記錄表的行數存起來。
對於更新很頻繁的庫來講,你可能會第一時間想到,用緩存系統來支持。
你能夠用一個Redis服務來保存這個表的總行數。這個表每被插入一行Redis計數就加1,每被刪除一行Redis計數就減1。這種方式下,讀和更新操做都很快,但你再想一下這種方式存在什麼問題嗎?
沒錯,緩存系統可能會丟失更新。
Redis的數據不能永久地留在內存裏,因此你會找一個地方把這個值按期地持久化存儲起來。但即便這樣,仍然可能丟失更新。試想若是剛剛在數據表中插入了一行,Redis中保存的值也加了1,而後Redis異常重啓了,重啓後你要從存儲redis數據的地方把這個值讀回來,而剛剛加1的這個計數操做卻丟失了。
固然了,這仍是有解的。好比,Redis異常重啓之後,到數據庫裏面單獨執行一次count(*)獲取真實的行數,再把這個值寫回到Redis裏就能夠了。異常重啓畢竟不是常常出現的狀況,這一次全表掃描的成本,仍是能夠接受的。
但實際上,將計數保存在緩存系統中的方式,還不僅是丟失更新的問題。即便Redis正常工做,這個值仍是邏輯上不精確的。
你能夠設想一下有這麼一個頁面,要顯示操做記錄的總數,同時還要顯示最近操做的100條記錄。那麼,這個頁面的邏輯就須要先到Redis裏面取出計數,再到數據表裏面取數據記錄。
咱們是這麼定義不精確的:
一種是,查到的100行結果裏面有最新插入記錄,而Redis的計數裏還沒加1;
另外一種是,查到的100行結果裏沒有最新插入的記錄,而Redis的計數裏已經加了1。
這兩種狀況,都是邏輯不一致的。
咱們一塊兒來看看這個時序圖。
圖2中,會話A是一個插入交易記錄的邏輯,往數據表裏插入一行R,而後Redis計數加1;會話B就是查詢頁面顯示時須要的數據。
在圖2的這個時序裏,在T3時刻會話B來查詢的時候,會顯示出新插入的R這個記錄,可是Redis的計數還沒加1。這時候,就會出現咱們說的數據不一致。
你必定會說,這是由於咱們執行新增記錄邏輯時候,是先寫數據表,再改Redis計數。而讀的時候是先讀Redis,再讀數據表,這個順序是相反的。那麼,若是保持順序同樣的話,是否是就沒問題了?咱們如今把會話A的更新順序換一下,再看看執行結果。
你會發現,這時候反過來了,會話B在T3時刻查詢的時候,Redis計數加了1了,但還查不到新插入的R這一行,也是數據不一致的狀況。
在併發系統裏面,咱們是沒法精確控制不一樣線程的執行時刻的,由於存在圖中的這種操做序列,因此,咱們說即便Redis正常工做,這個計數值仍是邏輯上不精確的。
根據上面的分析,用緩存系統保存計數有丟失數據和計數不精確的問題。那麼,若是咱們把這個計數直接放到數據庫裏單獨的一張計數表C中,又會怎麼樣呢?
首先,這解決了崩潰丟失的問題,InnoDB是支持崩潰恢復不丟數據的。
備註:關於InnoDB的崩潰恢復,你能夠再回顧一下第2篇文章《日誌系統:一條SQL更新語句是如何執行的?》中的相關內容。
而後,咱們再看看能不能解決計數不精確的問題。
你會說,這不同嗎?無非就是把圖3中對Redis的操做,改爲了對計數表C的操做。只要出現圖3的這種執行序列,這個問題仍是無解的吧?
這個問題還真不是無解的。
咱們這篇文章要解決的問題,都是因爲InnoDB要支持事務,從而致使InnoDB表不能把count(*)直接存起來,而後查詢的時候直接返回造成的。
所謂以子之矛攻子之盾,如今咱們就利用「事務」這個特性,把問題解決掉。
咱們來看下如今的執行結果。雖然會話B的讀操做仍然是在T3執行的,可是由於這時候更新事務尚未提交,因此計數值加1這個操做對會話B還不可見。
所以,會話B看到的結果裏, 查計數值和「最近100條記錄」看到的結果,邏輯上就是一致的。
在前面文章的評論區,有同窗留言問到:在select count(?) from t這樣的查詢語句裏面,count(*)、count(主鍵id)、count(字段)和count(1)等不一樣用法的性能,有哪些差異。今天談到了count(*)的性能問題,我就藉此機會和你詳細說明一下這幾種用法的性能差異。
須要注意的是,下面的討論仍是基於InnoDB引擎的。
這裏,首先你要弄清楚count()的語義。count()是一個聚合函數,對於返回的結果集,一行行地判斷,若是count函數的參數不是NULL,累計值就加1,不然不加。最後返回累計值。
因此,count(*)、count(主鍵id)和count(1) 都表示返回知足條件的結果集的總行數;而count(字段),則表示返回知足條件的數據行裏面,參數「字段」不爲NULL的總個數。
至於分析性能差異的時候,你能夠記住這麼幾個原則:
server層要什麼就給什麼;
InnoDB只給必要的值;
如今的優化器只優化了count(*)的語義爲「取行數」,其餘「顯而易見」的優化並無作。
這是什麼意思呢?接下來,咱們就一個個地來看看。
對於count(主鍵id)來講,InnoDB引擎會遍歷整張表,把每一行的id值都取出來,返回給server層。server層拿到id後,判斷是不可能爲空的,就按行累加。
對於count(1)來講,InnoDB引擎遍歷整張表,但不取值。server層對於返回的每一行,放一個數字「1」進去,判斷是不可能爲空的,按行累加。
單看這兩個用法的差異的話,你能對比出來,count(1)執行得要比count(主鍵id)快。由於從引擎返回id會涉及到解析數據行,以及拷貝字段值的操做。
對於count(字段)來講:
若是這個「字段」是定義爲not null的話,一行行地從記錄裏面讀出這個字段,判斷不能爲null,按行累加;
若是這個「字段」定義容許爲null,那麼執行的時候,判斷到有多是null,還要把值取出來再判斷一下,不是null才累加。
也就是前面的第一條原則,server層要什麼字段,InnoDB就返回什麼字段。
可是count(*)是例外,並不會把所有字段取出來,而是專門作了優化,不取值。count(*)確定不是null,按行累加。
看到這裏,你必定會說,優化器就不能本身判斷一下嗎,主鍵id確定非空啊,爲何不能按照count(*)來處理,多麼簡單的優化啊。
固然,MySQL專門針對這個語句進行優化,也不是不能夠。可是這種須要專門優化的狀況太多了,並且MySQL已經優化過count(*)了,你直接使用這種用法就能夠了。
因此結論是:按照效率排序的話,count(字段)<count(主鍵id)<count(1)≈count(*),因此我建議你,儘可能使用count(*)。
今天,我和你聊了聊MySQL中得到錶行數的兩種方法。咱們提到了在不一樣引擎中count(*)的實現方式是不同的,也分析了用緩存系統來存儲計數值存在的問題。
其實,把計數放在Redis裏面,不可以保證計數和MySQL表裏的數據精確一致的緣由,是這兩個不一樣的存儲構成的系統,不支持分佈式事務,沒法拿到精確一致的視圖。而把計數值也放在MySQL中,就解決了一致性視圖的問題。
InnoDB引擎支持事務,咱們利用好事務的原子性和隔離性,就能夠簡化在業務開發時的邏輯。這也是InnoDB引擎備受青睞的緣由之一。
最後,又到了今天的思考題時間了。
在剛剛討論的方案中,咱們用了事務來確保計數準確。因爲事務能夠保證中間結果不被別的事務讀到,所以修改計數值和插入新記錄的順序是不影響邏輯結果的。可是,從併發系統性能的角度考慮,你以爲在這個事務序列裏,應該先插入操做記錄,仍是應該先更新計數表呢?
你能夠把你的思考和觀點寫在留言區裏,我會在下一篇文章的末尾給出個人參考答案。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。
上期我給你留的問題是,何時使用alter table t engine=InnoDB會讓一個表佔用的空間反而變大。
在這篇文章的評論區裏面,你們都提到了一個點,就是這個表,自己就已經沒有空洞的了,好比說剛剛作過一次重建表操做。
在DDL期間,若是恰好有外部的DML在執行,這期間可能會引入一些新的空洞。
@飛翔 提到了一個更深入的機制,是咱們在文章中沒說的。在重建表的時候,InnoDB不會把整張表佔滿,每一個頁留了1/16給後續的更新用。也就是說,其實重建表以後不是「最」緊湊的。
假如是這麼一個過程:
將表t重建一次;
插入一部分數據,可是插入的這些數據,用掉了一部分的預留空間;
這種狀況下,再重建一次表t,就可能會出現問題中的現象。