跳至內容
返回

Buffer Pool 與 MySQL Query Cache

發佈於:  at  08:00 上午

前言

上一篇我們談了 Hibernate 的二級快取,那是站在「應用層/ORM」的角度去想快取。這一篇我們往下鑽一層,看看資料庫自己其實也有快取機制。

很多人第一次聽到「資料庫也有快取」會有點疑惑:不是說要快取才不要打資料庫嗎?怎麼資料庫裡面又有快取?其實這一點都不衝突,資料庫要處理的是磁碟 I/O 這個天生很慢的東西,它當然要想辦法把常用的資料留在記憶體裡,減少實際讀寫磁碟的次數。

這篇會聊兩個東西,剛好是一組對照:

  1. Buffer Pool:InnoDB 的核心快取,至今仍是效能的命脈,你幾乎每天都在用它,只是沒意識到。
  2. Query Cache:MySQL 曾經內建的「查詢結果快取」,但在 MySQL 8.0 被正式移除。

這兩個快取機制把它們放在一起看,剛好能理解「什麼樣的快取設計會長久,什麼樣的會被時代淘汰」。

Buffer Pool

什麼是 Buffer Pool?

Buffer Pool 是 InnoDB 儲存引擎在記憶體中開的一大塊區域,用來快取資料頁(page)與索引頁

要理解它,得先知道 InnoDB 存取資料的基本單位不是「一筆 row」,而是頁(page),預設一頁 16KB。當你查一筆資料時,InnoDB 不會只從磁碟撈那一筆,而是把該筆所在的整個 16KB 頁讀進記憶體。

而磁碟 I/O 相對記憶體存取是慢好幾個數量級的事情。所以 InnoDB 的策略很直覺:

把讀過、改過的頁盡量留在記憶體(Buffer Pool)裡,之後再存取同一頁時就不用再碰磁碟。

這件事對讀跟寫都有效

這是它跟等一下要講的 Query Cache 最本質的差異:Buffer Pool 快取的是「資料頁」這種底層結構,不是「某句 SQL 的結果」。 正因為層級這麼低,它幾乎能加速所有操作。

Buffer Pool 怎麼決定要留下誰?LRU 的改良

記憶體是有限的,Buffer Pool 塞滿之後,就得決定「淘汰哪一頁」來空出位置。InnoDB 用的是改良版的 LRU(Least Recently Used,最近最少使用)

單純的 LRU 有一個很經典的問題:全表掃描(full table scan)污染。想像一下,某個報表查詢一次掃過一張很大的表,把一堆「其實只用這一次」的頁全部塞進 LRU 的最前面,反而把真正的熱資料擠出去了,快取命中率直接崩掉。

InnoDB 的解法是把 LRU 串列切成兩段:

關鍵在於:新讀進來的頁不是插在最前面,而是插在「老年代的頭部」(midpoint insertion,中點插入)。一個頁只有在「進入老年代後,又在一段時間內被再次存取」,才會被搬到新生代。這樣一來,全表掃描帶進來的一次性頁就只會待在老年代、很快被淘汰,不會污染到真正的熱資料。

幾個實務上會碰到的設定與觀察

innodb_buffer_pool_size —— 最重要的一個參數,決定 Buffer Pool 有多大。專用資料庫伺服器上,常見的建議是設成實體記憶體的 50%~75%。太小會導致命中率低、一直讀磁碟;太大則可能擠壓到作業系統與其他程序。

命中率(hit rate) —— 可以用 SHOW ENGINE INNODB STATUSBUFFER POOL AND MEMORY 區塊,裡面有一行類似:

Buffer pool hit rate 1000 / 1000

代表最近 1000 次頁面存取全部命中記憶體,一次磁碟都沒讀。健康的 OLTP 系統這個值通常會非常接近滿分。

Change Buffer(附帶一提) —— InnoDB 還有一個相關機制叫 Change Buffer,針對「非唯一的二級索引」的寫入,若對應的頁當下不在 Buffer Pool 裡,會先把變更暫存起來、之後合併,避免為了改索引而頻繁隨機讀磁碟。它跟 Buffer Pool 是搭配運作的,這裡先知道有這回事即可。

一句話總結 Buffer Pool:它是資料庫效能的地基,你不需要「決定要不要用它」,因為你一直都在用。你能做的是把它設得夠大、觀察它的命中率。

Query Cache

什麼是 Query Cache?

講完了活得好好的 Buffer Pool,來看看那個被淘汰的。

Query Cache 是 MySQL(在 8.0 之前)內建的一個功能,它快取的東西跟 Buffer Pool 完全不同層級:它快取的是一整句 SELECT 的「完整結果集」

它的運作邏輯乍看非常吸引人:

  1. 收到一句 SELECT,先拿這句 SQL 的文字去 Query Cache 查有沒有快取過。
  2. 如果有(cache hit),而且這句 SQL 用到的所有表從上次快取後都沒被改過,就直接把上次的結果丟回去,完全不用解析、不用優化、不用執行
  3. 如果沒有,正常執行,並把結果存進 Query Cache,供下次使用。

相關設定大概像這樣(同樣是 8.0 之前):

# 0=關閉, 1=開啟, 2=DEMAND(只快取有 SQL_CACHE 提示的查詢)
query_cache_type = 1

# 快取區大小
query_cache_size = 64M

聽起來是不是很棒?連查詢都不用執行,直接回結果,這不就是最快的嗎?問題就出在,理論很美,實務上它帶來的麻煩往往超過好處。

Query Cache 為什麼被廢除?

Query Cache 在 MySQL 5.7.20 被標記為 deprecated(不建議使用),並在 MySQL 8.0 被正式移除。官方會下這麼重的決定,是因為它有幾個很難解決的結構性問題。

問題一:失效(invalidation)的顆粒度太粗

這是它最致命的問題。Query Cache 的失效規則是:

只要某張表發生「任何」寫入(INSERT/UPDATE/DELETE),所有用到這張表的快取查詢,全部一次失效清光。

注意,不是「被改到的那筆資料相關的查詢」失效,而是整張表相關的所有查詢通通失效,不管你改的是不是它們查的那幾筆。

這在「讀多寫少」的表上還好,但只要這張表寫入稍微頻繁一點,就會變成一場災難:你辛辛苦苦快取起來的一堆查詢結果,可能一個寫入進來瞬間全部作廢,下次又得重新執行、重新快取,然後又被下一個寫入清掉……快取幾乎沒發揮作用,卻一直在做「存進去、清掉」的白工。

問題二:全域鎖造成的並發瓶頸

為了維護這份共用的快取,Query Cache 內部需要一把鎖來保護。問題是這把鎖的顆粒度很粗,幾乎是全域等級的

結果就是:在高並發、多核心的環境下,大量執行緒為了「查快取/寫快取/清快取」而互相爭搶同一把鎖,形成嚴重的競爭(contention)。很諷刺地,一個本來要「加速」的功能,在高並發下反而成了整個系統的序列化瓶頸,把多核心的優勢給抵消掉了。核心越多、並發越高,這個問題越明顯。

問題三:必須「一字不差」才會命中

Query Cache 的比對是拿 SQL 的原始字串去做的,這意味著命中條件極度嚴苛:

也就是說,真實應用裡由 ORM 或不同程式片段組出來、長得稍有差異的 SQL,很多根本吃不到這份快取。

問題四:綜合起來,它常常是「負優化」

把上面幾點加起來就會發現,Query Cache 在很多真實工作負載下是弊大於利的:

於是它常常出現一個尷尬的局面:開了之後反而更慢。這也是為什麼在它還存在的年代,很多資深 DBA 的第一個調校建議就是「把 Query Cache 關掉」。一個「預設最好把它關掉」的功能,被移除也就不難理解了。

那它的位置由誰取代?

Query Cache 消失後,它想解決的「不要重複執行相同查詢」這件事,被拆給更適合的層級去做:

  1. 應用層快取(最主流):用 Redis/Memcached 這類外部快取,把查詢結果或計算結果快取起來。相比 Query Cache,它的失效策略(TTL、主動 evict)由你自己精準掌控、可以跨機器共享、語意也更清楚——這其實跟上一篇「二級快取被 Redis 取代」是同一個趨勢:大家更偏好「顯式、可控」的快取,而不是藏在底層、顆粒度又粗的隱式快取。
// 典型做法:在應用層用 Spring Cache 抽象 + Redis
@Cacheable(value = "userProfile", key = "#id")
public UserProfile getUserProfile(Long id) {
    return userRepository.findProfile(id);
}
  1. 交給 Buffer Pool + 好的索引:很多時候你根本不需要「快取整句結果」。只要資料頁與索引頁都在 Buffer Pool 裡、索引也建得好,查詢本身就已經很快了。與其快取結果,不如讓查詢本身變快、變便宜,這更根本也更穩定。

  2. 中介層(proxy)快取:像 ProxySQL 這類資料庫代理,也能在 MySQL 外面提供可控得多的查詢快取,需要時再導入。

Buffer Pool vs Query Cache

把兩者放在一起對照,會更清楚為什麼一個留下、一個被淘汰:

比較項目Buffer PoolQuery Cache(已移除)
快取的東西資料頁 / 索引頁(16KB 的 page)一整句 SELECT 的完整結果集
層級儲存引擎底層查詢層(執行前先攔截)
加速範圍幾乎所有讀寫只加速「完全相同且表未變」的 SELECT
失效顆粒度以頁為單位,精細以整張表為單位,極粗
並發表現高度優化,可切多個 instance全域鎖,高並發下成為瓶頸
現況核心機制,至今不可或缺MySQL 5.7 deprecated、8.0 移除

一句話點出差別:Buffer Pool 是讓「執行查詢這件事本身變快」,Query Cache 是想「乾脆不要執行查詢」。 前者踏實有效、對所有操作都有幫助;後者的美好只在理想狀況成立,一碰到寫入頻繁與高並發就崩潰。

小結

這篇我們把資料庫這一層的兩個快取機制對照著看了一遍:

如果說上一篇二級快取教會我們的是「快取的前提假設一旦不成立,它的風險就會超過好處」,那 Query Cache 的故事則多補了一課:一個快取設計能不能長久,關鍵往往不在它命中時多快,而在它失效時多痛、以及維護它的代價多高。 Buffer Pool 之所以歷久不衰,正是因為它的失效精準、代價可控;Query Cache 之所以被淘汰,也正是敗在這兩點上。

理解了這一層,你在設計自己的快取時就會多問一句:它什麼時候會失效?失效的代價是什麼? 而這往往比「它命中時有多快」更值得先想清楚。


建議修改
在以下平台分享此文章:

上一篇
後端仔的效能優化實戰(1)、觀測問題
下一篇
Spring Boot 二級快取是什麼?