先講一個你一定看過的畫面。

商品頁上有一塊「這個分類的熱賣排行」,每次有人打開頁面,後端就吭哧吭哧跑一次跨三張表的 JOIN 加排序。查詢本身沒寫錯——欸但問題是,它每秒被叫幾百次,算出來的東西卻幾乎一模一樣。同一份排行,你算了幾百遍給它看。

DB 的 CPU 就這樣,被同一個問題的同一個答案慢慢磨光。這不是 SQL 寫爛,是你架構上少了一層。那層東西,就叫快取。


它到底是什麼,一句話講完

快取就是「算過一次的結果,先擺在一個比較好拿的地方,下次直接拿」。

有沒有發現,這句話裡根本沒提到 Redis、沒提到記憶體?對,因為快取不是某個工具,它是一種拿空間換時間的想法,而且它到處都是:

  • CPU 在算東西,常用的數值丟 L1/L2 cache,不要每次都跑去主記憶體搬
  • 作業系統讀檔案,讀過的就先留在 page cache,下次不用再讀硬碟
  • 後端把查過的結果塞 Redis,不要每個請求都去煩 DB
  • CDN 把圖片快取在離使用者最近的節點,不要每次都飛回源站拿

從 CPU 一路到 CDN,講的其實是同一件事:越靠近「要用的地方」、越快的儲存,就越小越貴;那就把最常被要的東西,往近的地方放。 後端工程師天天碰的是「Redis 擋在 DB 前面」這層,但你把整條光譜看懂,才知道自己加的那層快取,到底站在系統的哪個位置。


「那我存一個變數不就好了」——欸還真的不行

很多人第一次想到「結果不要重算」,直覺都是:那我把它存成一個變數、一個 dict,不就解決了嗎?

# 土法煉鋼版
_rankings = {}
 
def get_rankings(category_id):
    if category_id not in _rankings:
        _rankings[category_id] = db.query(那個很重的 JOIN)
    return _rankings[category_id]

在你自己電腦上跑 demo,這完全 OK。但它一上 production 就會在三個地方爆給你看——而這三個爆點,剛好就是「你為什麼需要一個真正的快取系統」的答案。

第一,它永遠不會更新。 排行變了,這個 dict 完全狀況外。你沒有任何辦法讓它過期——快取最難的那part(怎麼讓舊資料失效)你直接跳過,下場就是資料永遠是錯的。

第二,它只活在一個 process 裡。 Production 哪可能只開一台?你開兩個 instance,就有兩份各玩各的 _rankings,使用者重新整理一下看到 A 版、一下看到 B 版。記憶體裡的變數,跨不出去 process、更跨不到別台機器

第三,它跟 process 一起生一起死。 重新部署、被 OOM 殺掉、容器重啟——這份你辛苦算好的東西「啪」全部歸零,重啟那一瞬間所有請求一起回頭撲向 DB。

Redis 這種快取系統,說穿了就是把那個 dict 搬到一個獨立、大家共用、可以設過期、還能存到硬碟的地方。你真正想要的從來不是「一個變數」,是「一個所有 instance 一起共享、會自己過期、重啟還在」的變數——這不就正是一台快取服務在幹的事嗎。


加快取等於簽了一張帳單,記得它有利息

這段是整篇我最想塞進你腦袋的一句:快取不是免費的速度,它是一筆貸款。

你是借到了「延遲大幅下降」沒錯,但利息要還的——你的資料現在變成兩份了,DB 一份、快取一份。只要有兩份,就一定有「對不起來」的時候。DB 改了、快取還是舊的,使用者就吃到過期資料。然後你就開始要煩 TTL 要設多久、更新的時候到底該刪快取還是改快取、熱門的 key 同時過期會不會把 DB 一秒打爆⋯⋯

Phil Karlton 有句名言:「電腦科學只有兩件難事——快取失效,跟命名。」這真的不是在開玩笑,是每個加過快取的人身上共同的疤。

所以你要決定「該不該加快取」,第一個該問的不是「加了能快多少」,而是:我的 DB 真的已經痛了嗎? 如果你的查詢有索引、單次幾毫秒就回、流量又不大,那加快取只是平白多養一隻會出包的怪。記住順序——先有痛,才有快取;沒痛硬加,是自己給自己埋一致性的雷。

個人經驗:我自己第一次「不得不加快取」,是被爬蟲逼的。那陣子有人拿爬蟲狂打我們自家後台,量大到 server 直接被打死——而當時我們既沒開 rate limit、也沒有任何一層快取(別說 Redis 了,連最基本的都沒有)。後來把快取補上去,server 才終於不再三天兩頭躺平。

但你看,那筆貸款的利息馬上就來了:止血是止住了,帳單卻變貴了——多養的那層基礎設施是要一直付錢的。快取從來不是免費的速度,這件事我是拿伺服器的命跟雲端帳單一起學會的。(另外那次其實還有「沒限流」這個更前面的洞,那是另一條防線了。)


後端最常用的那招:Cache-Aside

進階寫法後面的文章會慢慢挖,但入門你先認得一個就夠——Cache-Aside(旁路快取),因為後端日常九成的快取都長這樣:

def get_rankings(category_id):
    key = f"rankings:{category_id}"
 
    cached = redis.get(key)          # 1. 先問快取有沒有
    if cached:
        return json.loads(cached)
 
    data = db.query(那個很重的 JOIN)  # 2. 沒有,才去打 DB
    redis.setex(key, 300, json.dumps(data))  # 3. 回填,順便設 5 分鐘過期
    return data

讀的時候就「先問快取、沒有才回源、拿到順手回填」。那更新資料的時候呢?記一個有點反直覺的原則:直接刪快取,不要手動去改它——讓下一次讀取自然回填最新的值,比你自己同步兩份資料安全太多了。為什麼刪比改安全?那就是下一篇的事了。


接下來往哪走

這篇只想幹一件事:讓你搞懂快取在解什麼問題、又順便給你惹了什麼問題。真正的深淺在後面:


反思

快取是後端效能的救星,也是事故報告裡的常客。這兩件事一點都不矛盾——它們本來就是同一個決定的一體兩面。

所以下次手癢想加快取之前,先誠實回答自己三題:

  1. 我的 DB 是真的痛了(你有 metrics 為證),還是我「感覺」這樣比較快?
  2. 這份資料,能忍受幾秒鐘的不一致嗎?不能的話,快取大概不是你要的答案。
  3. 它變舊的時候,我有沒有辦法讓它失效?想不出來,那就先別存。

三題都答得出來,再加也不遲。答不出來,那層快取遲早會挑某個半夜,變成你的事故。