1. 程式人生 > 其它 >程序相關命令

程序相關命令

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

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

那麼今天,我們就來聊聊count(*)語句到底是怎樣實現的,以及MySQL為什麼會這麼實現。然後,我會再和你說說,如果應用中有這種頻繁變更並需要統計表行數的需求,業務設計上可以怎麼做。

count(*)的實現方式

你首先要明確的是,在不同的MySQL引擎中,count(*)

有不同的實現方式。

  • MyISAM引擎把一個表的總行數存在了磁碟上,因此執行count(*)的時候會直接返回這個數,效率很高;
  • InnoDB引擎就麻煩了,它執行count(*)的時候,需要把資料一行一行地從引擎裡面讀出來,然後累積計數。

這裡需要注意的是,我們在這篇文章裡討論的是沒有過濾條件的count(*),如果加了where 條件的話,MyISAM表也是不能返回得這麼快的。

在前面的文章中,我們一起分析了為什麼要使用InnoDB,因為不論是在事務支援、併發能力還是在資料安全方面,InnoDB都優於MyISAM。我猜你的表也一定是用了InnoDB引擎。這就是當你的記錄數越來越多的時候,計算一個表的總行數會越來越慢的原因。

那為什麼InnoDB不跟MyISAM一樣,也把數字存起來呢?

這是因為即使是在同一個時刻的多個查詢,由於多版本併發控制(MVCC)的原因,InnoDB表“應該返回多少行”也是不確定的。這裡,我用一個算count(*)的例子來為你解釋一下。

假設表t中現在有10000條記錄,我們設計了三個使用者並行的會話。

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

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

你會看到,在最後一個時刻,三個會話ABC會同時查詢表

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命令顯示的行數也不能直接使用。

到這裡我們小結一下:

  • MyISAM表雖然count(*)很快,但是不支援事務;
  • show table status命令雖然返回很快,但是不準確;
  • InnoDB表直接count(*)會遍歷全表,雖然結果準確,但會導致效能問題。

那麼,回到文章開頭的問題,如果你現在有一個頁面經常要顯示交易系統的操作記錄總數,到底應該怎麼辦呢?答案是,我們只能自己計數。

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

這裡,我先和你說一下這些方法的基本思路:你需要自己找一個地方,把操作記錄表的行數存起來。

用快取系統儲存計數

對於更新很頻繁的庫來說,你可能會第一時間想到,用快取系統來支援。

你可以用一個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的更新順序換一下,再看看執行結果。

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

在併發系統裡面,我們是無法精確控制不同執行緒的執行時刻的,因為存在圖中的這種操作序列,所以,我們說即使Redis正常工作,這個計數值還是邏輯上不精確的。

在資料庫儲存計數

根據上面的分析,用快取系統儲存計數有丟失資料和計數不精確的問題。那麼,如果我們把這個計數直接放到資料庫裡單獨的一張計數表C中,又會怎麼樣呢?

首先,這解決了崩潰丟失的問題,InnoDB是支援崩潰恢復不丟資料的。

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

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

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

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

我們這篇文章要解決的問題,都是由於InnoDB要支援事務,從而導致InnoDB表不能把count(*)直接存起來,然後查詢的時候直接返回形成的。

所謂以子之矛攻子之盾,現在我們就利用“事務”這個特性,把問題解決掉。

我們來看下現在的執行結果。雖然會話B的讀操作仍然是在T3執行的,但是因為這時候更新事務還沒有提交,所以計數值加1這個操作對會話B還不可見。

因此,會話B看到的結果裡, 查計數值和“最近100條記錄”看到的結果,邏輯上就是一致的。

不同的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的總個數。

至於分析效能差別的時候,你可以記住這麼幾個原則:

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引擎備受青睞的原因之一。