前言
上一篇我們談了 Hibernate 的二級快取,那是站在「應用層/ORM」的角度去想快取。這一篇我們往下鑽一層,看看資料庫自己其實也有快取機制。
很多人第一次聽到「資料庫也有快取」會有點疑惑:不是說要快取才不要打資料庫嗎?怎麼資料庫裡面又有快取?其實這一點都不衝突,資料庫要處理的是磁碟 I/O 這個天生很慢的東西,它當然要想辦法把常用的資料留在記憶體裡,減少實際讀寫磁碟的次數。
這篇會聊兩個東西,剛好是一組對照:
- Buffer Pool:InnoDB 的核心快取,至今仍是效能的命脈,你幾乎每天都在用它,只是沒意識到。
- 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)裡,之後再存取同一頁時就不用再碰磁碟。
這件事對讀跟寫都有效:
- 讀取:要的頁如果已經在 Buffer Pool 裡(cache hit),直接從記憶體回傳,完全不碰磁碟。
- 寫入:修改先寫在記憶體中的頁上(此時該頁變成「髒頁 / dirty page」),並不會馬上寫回磁碟,而是之後由背景執行緒批次刷回(flush)。這讓寫入不用每次都等磁碟。
這是它跟等一下要講的 Query Cache 最本質的差異:Buffer Pool 快取的是「資料頁」這種底層結構,不是「某句 SQL 的結果」。 正因為層級這麼低,它幾乎能加速所有操作。
Buffer Pool 怎麼決定要留下誰?LRU 的改良
記憶體是有限的,Buffer Pool 塞滿之後,就得決定「淘汰哪一頁」來空出位置。InnoDB 用的是改良版的 LRU(Least Recently Used,最近最少使用)。
單純的 LRU 有一個很經典的問題:全表掃描(full table scan)污染。想像一下,某個報表查詢一次掃過一張很大的表,把一堆「其實只用這一次」的頁全部塞進 LRU 的最前面,反而把真正的熱資料擠出去了,快取命中率直接崩掉。
InnoDB 的解法是把 LRU 串列切成兩段:
- New / Young sublist(新生代):放真正的熱頁。
- Old sublist(老年代):放剛讀進來、還沒被證明是熱資料的頁。預設佔整個串列約 3/8(37%),可由
innodb_old_blocks_pct調整。
關鍵在於:新讀進來的頁不是插在最前面,而是插在「老年代的頭部」(midpoint insertion,中點插入)。一個頁只有在「進入老年代後,又在一段時間內被再次存取」,才會被搬到新生代。這樣一來,全表掃描帶進來的一次性頁就只會待在老年代、很快被淘汰,不會污染到真正的熱資料。
幾個實務上會碰到的設定與觀察
innodb_buffer_pool_size —— 最重要的一個參數,決定 Buffer Pool 有多大。專用資料庫伺服器上,常見的建議是設成實體記憶體的 50%~75%。太小會導致命中率低、一直讀磁碟;太大則可能擠壓到作業系統與其他程序。
命中率(hit rate) —— 可以用 SHOW ENGINE INNODB STATUS 看 BUFFER 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 的「完整結果集」。
它的運作邏輯乍看非常吸引人:
- 收到一句 SELECT,先拿這句 SQL 的文字去 Query Cache 查有沒有快取過。
- 如果有(cache hit),而且這句 SQL 用到的所有表從上次快取後都沒被改過,就直接把上次的結果丟回去,完全不用解析、不用優化、不用執行。
- 如果沒有,正常執行,並把結果存進 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 的原始字串去做的,這意味著命中條件極度嚴苛:
- 大小寫不同、多一個空白、多一個換行、多一段註解 → 視為不同查詢,不命中。
- 用了不確定性函式,例如
NOW()、RAND()、CURRENT_TIMESTAMP等 → 結果本來就會變,根本不會被快取。 - 不同資料庫(schema)、不同協定版本、不同字元集的連線 → 也可能被視為不同。
也就是說,真實應用裡由 ORM 或不同程式片段組出來、長得稍有差異的 SQL,很多根本吃不到這份快取。
問題四:綜合起來,它常常是「負優化」
把上面幾點加起來就會發現,Query Cache 在很多真實工作負載下是弊大於利的:
- 為了維護快取要付出鎖競爭、記憶體管理、失效判斷的成本。
- 但因為失效太粗、命中條件太嚴,真正省下來的執行次數卻很有限。
於是它常常出現一個尷尬的局面:開了之後反而更慢。這也是為什麼在它還存在的年代,很多資深 DBA 的第一個調校建議就是「把 Query Cache 關掉」。一個「預設最好把它關掉」的功能,被移除也就不難理解了。
那它的位置由誰取代?
Query Cache 消失後,它想解決的「不要重複執行相同查詢」這件事,被拆給更適合的層級去做:
- 應用層快取(最主流):用 Redis/Memcached 這類外部快取,把查詢結果或計算結果快取起來。相比 Query Cache,它的失效策略(TTL、主動 evict)由你自己精準掌控、可以跨機器共享、語意也更清楚——這其實跟上一篇「二級快取被 Redis 取代」是同一個趨勢:大家更偏好「顯式、可控」的快取,而不是藏在底層、顆粒度又粗的隱式快取。
// 典型做法:在應用層用 Spring Cache 抽象 + Redis
@Cacheable(value = "userProfile", key = "#id")
public UserProfile getUserProfile(Long id) {
return userRepository.findProfile(id);
}
-
交給 Buffer Pool + 好的索引:很多時候你根本不需要「快取整句結果」。只要資料頁與索引頁都在 Buffer Pool 裡、索引也建得好,查詢本身就已經很快了。與其快取結果,不如讓查詢本身變快、變便宜,這更根本也更穩定。
-
中介層(proxy)快取:像 ProxySQL 這類資料庫代理,也能在 MySQL 外面提供可控得多的查詢快取,需要時再導入。
Buffer Pool vs Query Cache
把兩者放在一起對照,會更清楚為什麼一個留下、一個被淘汰:
| 比較項目 | Buffer Pool | Query Cache(已移除) |
|---|---|---|
| 快取的東西 | 資料頁 / 索引頁(16KB 的 page) | 一整句 SELECT 的完整結果集 |
| 層級 | 儲存引擎底層 | 查詢層(執行前先攔截) |
| 加速範圍 | 幾乎所有讀寫 | 只加速「完全相同且表未變」的 SELECT |
| 失效顆粒度 | 以頁為單位,精細 | 以整張表為單位,極粗 |
| 並發表現 | 高度優化,可切多個 instance | 全域鎖,高並發下成為瓶頸 |
| 現況 | 核心機制,至今不可或缺 | MySQL 5.7 deprecated、8.0 移除 |
一句話點出差別:Buffer Pool 是讓「執行查詢這件事本身變快」,Query Cache 是想「乾脆不要執行查詢」。 前者踏實有效、對所有操作都有幫助;後者的美好只在理想狀況成立,一碰到寫入頻繁與高並發就崩潰。
小結
這篇我們把資料庫這一層的兩個快取機制對照著看了一遍:
- ✅ Buffer Pool 是 InnoDB 在記憶體裡快取資料頁/索引頁的核心機制,用改良版 LRU(midpoint insertion 抵抗全表掃描污染)管理,對讀寫都有效,是效能的地基。你要做的是把
innodb_buffer_pool_size設得夠大,並觀察命中率。 - ✅ Query Cache 快取的是「整句 SELECT 的結果」,理論上很美,但因為 失效顆粒度太粗、全域鎖並發瓶頸、必須一字不差才命中,在真實負載下常常弊大於利,最終在 MySQL 8.0 被移除。
- ✅ 它的定位被 應用層快取(Redis)+ 好的索引讓查詢本身變快 接手,這跟二級快取被 Redis 取代是同一種趨勢:從「隱式、粗粒度、藏在底層」走向「顯式、可控、放在對的層級」。
如果說上一篇二級快取教會我們的是「快取的前提假設一旦不成立,它的風險就會超過好處」,那 Query Cache 的故事則多補了一課:一個快取設計能不能長久,關鍵往往不在它命中時多快,而在它失效時多痛、以及維護它的代價多高。 Buffer Pool 之所以歷久不衰,正是因為它的失效精準、代價可控;Query Cache 之所以被淘汰,也正是敗在這兩點上。
理解了這一層,你在設計自己的快取時就會多問一句:它什麼時候會失效?失效的代價是什麼? 而這往往比「它命中時有多快」更值得先想清楚。