WordPress 高流量承載——快取分層與佇列防雪崩

開賣鈴一響,三分鐘內湧入的訪客把首頁打到 502,購物車按下去轉圈圈,等你發現時轉換率已經掉了一截。這不是 WordPress 本身扛不住,而是平常運作正常的架構,在流量瞬間放大十倍、二十倍的那幾分鐘裡,每一個沒做好的環節都會同時爆開。

WordPress 高流量承載的關鍵,從來不是換一台更貴的主機就能解決。真正決定你撐不撐得住的,是請求進來之後被攔在哪一層、有多少請求必須走到最慢的資料庫,以及當快取剛好失效的那一刻,後端會不會被同一批請求集體輾過。這篇會把快取分層的攔截順序講清楚,再帶到多數教學略過的兩件事:併發數該怎麼算,以及怎麼用佇列與防雪崩機制,讓尖峰流量不會在同一秒鐘壓垮 PHP 與資料庫。

WordPress 為什麼撐不住瞬間湧入的流量

問題幾乎不在 WordPress 核心,而在它預設的工作方式。沒有任何快取時,WordPress 對每一個請求都重做一次完整流程:連資料庫、跑一輪 PHP、組出 HTML,再送回瀏覽器。平常每分鐘幾十個訪客,這套流程綽綽有餘;一旦每分鐘湧入幾千人,等於要求資料庫在短時間內重複回答同樣的問題上萬次。

瓶頸通常照固定順序倒下。最先撐不住的是 PHP 的工作行程(PHP-FPM worker),它的數量有限,當所有 worker 都卡在處理請求、後面的請求只能排隊,瀏覽器就開始收到 502 或 504。緊接著是資料庫,連線數被吃滿、慢查詢堆積,連帶把還在運作的 worker 一起拖慢。再往下,CPU 與記憶體被推到八、九成,任何一個額外的尖峰都會讓整台機器失去反應。

放大這個過程的,往往是平常看不出問題的幾個地方。外掛在每個頁面都重複打資料庫、肥大的佈景主題載入幾十個資源、沒有清理的 wp_options 自動載入欄位在每次請求都被讀進記憶體,再加上一定比例的爬蟲與惡意流量。低流量時這些都無傷大雅,流量一來則會同時引爆。先理解是什麼在倒,後面每一層優化才知道在救什麼。

快取四層怎麼分工:從 CDN 到 OPcache 的攔截順序

承載優化的核心是分層攔截:把請求盡可能擋在離訪客最近、成本最低的那一層,愈少請求走到資料庫,同一台主機能扛的流量就愈高。一套認真的 WordPress 架構通常有四層快取,每一層負責接住前一層漏掉的請求。

  • CDN(最外層):把靜態資源(圖片、CSS、JS),理想情況下連同 HTML 頁面,複製到全球各地的節點。訪客就近從邊緣節點取得內容,你的主機根本看不到這部分流量。如果有八成請求被 CDN 接走,主機只需處理剩下的兩成,等於用同樣的硬體把承載量放大數倍。
  • 頁面快取(整頁 HTML):把渲染完成的 HTML 存起來,下一個未登入訪客直接拿檔案,完全跳過 PHP 與資料庫。一次頁面快取命中的成本,比重新組頁面低上兩個數量級。在主機層用 Nginx 的 FastCGI Cache 或 Varnish 執行,會比任何外掛都快。
  • 物件快取(資料庫查詢結果):就算頁面快取很完整,登入使用者、購物車、搜尋結果這些動態內容還是得碰資料庫。物件快取用 Redis 或 Memcached 把查詢結果存在記憶體,讓 WordPress 不必對同一個問題問資料庫兩次。一個原本要跑兩百次查詢的後台頁面,開啟物件快取後可能只剩二十次。
  • OPcache(PHP 編譯結果):PHP 預設每次請求都重新編譯程式碼,OPcache 把編譯後的版本存在記憶體,省下這一步。較新的 PHP 版本預設就開著,若你的主機沒開,那是另一個該處理的問題。

這四層的運作邏輯是一條由外往內的瀑布:請求先問 CDN,沒命中就問頁面快取,再沒命中才真的進到 WordPress,由物件快取吸收重複查詢、OPcache 省下編譯。第一個打到新頁面的人付全額成本,之後每個訪客都愈來愈便宜。架構配置正確的小主機,承載量經常贏過配置鬆散的大主機。

需要注意的是頁面快取的失效協調。文章更新後,對應的快取必須過期,訪客才看得到新版本。設定良好的環境會自動處理;沒處理好的,就會在最不該出錯的時候端出舊內容。

動態頁不能快取時的承載策略

整頁快取救得了首頁與文章頁,救不了購物車、結帳、會員中心這類因人而異的頁面。WooCommerce 商店在開賣時最容易崩在這裡——每次瀏覽商品都觸發多次資料庫查詢,整頁快取又不能套用在帶有個人化內容的頁面上。這時承載的重擔落在物件快取與 Session 處理上。

第一條規則是明確劃出不可快取的範圍。購物車、結帳、會員中心,以及帶有 WooCommerce 登入 Cookie 的請求,都必須繞過整頁快取,否則會出現購物車變空、別人的帳號資料被快取給你看這類錯誤。在 Nginx 的 FastCGI Cache 設定裡,用一個 $skip_cache 變數判斷請求方法是 POST、帶有登入 Cookie,或網址落在 /cart/checkout/my-account 這些路徑,命中就跳過快取。

繞過整頁快取之後,這些動態請求仍會打到資料庫,這就是 Redis 物件快取上場的地方。它的設定有個常被略過的細節:不是把所有東西都丟進持久化快取就好,而是分群處理。商品的中繼資料、屬性、分類這類「對所有人都一樣」的資料可以積極快取、設成全域群組;購物車、Session、結帳這類「每個人都不同」的資料則要設成非持久化群組,避免把某個使用者的狀態錯誤地分享出去。

Session 本身也是一個容易被忽略的壓力點。WooCommerce 預設把 Session 存進資料庫,高流量時這些讀寫會把資料庫淹掉。把 Session 改存到 Redis,能明顯減輕資料庫負擔。Redis 的記憶體淘汰策略建議用 allkeys-lru,記憶體滿時自動淘汰最久沒用到的資料,把熱門商品與活躍 Session 留在快取裡;若用了會在記憶體滿時直接報錯的策略,反而會在尖峰時造成快取失效。

PHP-FPM 與 Nginx 的併發數該怎麼算

多數承載教學會叫你「裝快取、清資料庫」,卻很少講清楚同時能服務多少人這個數字是怎麼來的。承載量的上限,本質上是 Nginx 與 PHP-FPM 兩組併發參數決定的,算錯了,前面的快取做得再好也會在動態請求上塞車。

Nginx 這端有兩個關鍵參數。worker_processes 決定啟動多少工作行程,建議直接設成 auto,讓它對齊 CPU 核心數,避免行程在核心之間切換的損耗。worker_connections 是每個工作行程能同時處理的連線數,合理的起始值是 1024 或 2048。理論上的最大連線數是兩者相乘,但別忘了同步調高系統層級的檔案描述符上限 worker_rlimit_nofile,讓它至少是 worker_connections 的兩倍,否則連線會卡在作業系統那一關。

真正決定動態頁承載量的是 PHP-FPM 的 pm.max_children,也就是同一時間最多有幾個 PHP 行程在跑。很多預設值只給到 4,高流量網站遠遠不夠。估算方式是看可用記憶體:用「分配給 PHP 的記憶體」除以「單一 PHP 行程的平均耗用量」。舉例來說,如果主機留 2GB 給 PHP,每個行程平均吃 80MB,pm.max_children 大約抓在 25 上下,再依實測微調。設太低,請求會排隊;設太高,記憶體會被吃爆反而觸發更嚴重的問題。同時把 pm 模式從 ondemand 改成 dynamic,讓 PHP-FPM 預先留著一定數量的閒置行程,新請求進來時反應更快。

當所有 PHP 行程都忙碌,後續請求會進到 PHP-FPM 的等待佇列。佇列長度由 listen.backlog 控制,這個值決定「還能排多少請求」而不是「直接被拒」。理解這條排隊邏輯很重要:502 通常代表後端直接掛了或佇列滿了,504 則代表請求排太久、超過 Nginx 的等待上限。看到哪種錯誤,就知道該往哪個參數調。

快取雪崩怎麼防

快取雪崩(業界也稱 thundering herd,驚群效應)是高流量網站最兇的隱形殺手。情境是這樣的:某個熱門頁面的快取剛好過期,此時同時有上千個請求打進來,發現沒有快取,於是全部同時湧向後端重新生成同一個頁面。本來只該由一個請求承擔的重建成本,瞬間被放大上千倍,PHP 與資料庫在這一刻被自己的快取機制壓垮。多數承載教學頂多提一句驚群效應,卻很少給出具體的防法。

第一道防線是快取重建鎖(cache lock)。當快取失效、第一個請求開始重建頁面時,鎖住這個項目,讓後續同樣的請求等待或暫時拿舊內容,而不是每個都去重建。在 Nginx 的 FastCGI Cache 裡,這對應 fastcgi_cache_lock on,效果是把「上千個請求同時重建」收斂成「一個請求重建、其餘等它完成」。

第二道是邊更新邊回舊內容(stale-while-revalidate)。設定上對應 fastcgi_cache_use_stale,讓 Nginx 在後端忙碌、逾時或回傳 5xx 錯誤時,先把上一份還沒被清掉的舊快取端給訪客,同時在背景悄悄重新生成。訪客拿到的是稍微舊一點的內容,但頁面是通的;比起讓他們對著錯誤頁乾等,這幾乎永遠是更好的選擇。

第三是快取預熱與錯開過期時間。預熱指的是在已知的流量事件之前,主動把重要頁面跑過一遍,讓快取在真正的訪客抵達前就已經是熱的,而不是讓第一批真實使用者去承擔冷快取的代價。錯開過期時間則是給 TTL 加上一點隨機抖動,避免大量頁面在同一秒集體失效、引發另一波小型雪崩。讓快取的更新頻率高於它的過期頻率,是防雪崩的核心心法。

用佇列把尖峰流量攤平

承載優化還有一個少被談到的面向:不是每件事都得在訪客等待的當下完成。把可以延後的工作從「請求週期內」搬到「背景佇列」,等於把尖峰時段的瞬間負載攤平到後面幾分鐘,讓 PHP 行程更快騰出來服務下一個訪客。

WordPress 自己就有一個常被忽略的負擔:WP-Cron。它的排程是在頁面載入時觸發的,也就是說每一次訪客造訪都會順帶檢查一輪有沒有到期的排程任務。安靜的網站無所謂,繁忙的網站等於在尖峰時段不斷做這件多餘的檢查。正確做法是在 wp-config.phpDISABLE_WP_CRON 設為 true,改用作業系統層級的真實 cron,每隔幾分鐘固定觸發一次,與當下有沒有訪客脫鉤。

更進一步,是把耗時的工作丟進背景佇列。WooCommerce 內建的 Action Scheduler 就是現成的佇列系統,寄送通知信、同步庫存、產生報表、呼叫外部 API 這類不需要在訪客面前即時完成的任務,都可以排進去由背景慢慢消化。對外部 API 的呼叫尤其值得這樣處理——它的回應時間不在你控制範圍內,一旦對方變慢,卡住的是你寶貴的 PHP 行程。把這類工作非同步化,尖峰時段每個請求停留的時間就會明顯縮短。

這個思路也適用於個人化內容。整頁不能快取、但頁面上大部分區塊其實是共用的時候,可以用片段快取(fragment caching)或邊緣端組裝(ESI)的概念,只把真正因人而異的小區塊留給後端即時產生,其餘照常吃快取。這樣就不必為了頁面上一小塊動態內容,放棄整頁的快取效益。

開賣前該做的承載演練與監控

承載能力不是上線那天才驗證的東西,而是在已知的流量事件之前就要演練過。雙十一、開賣、團購、媒體報導引爆的流量,都是可以預期的尖峰;在真實訪客抵達之前先模擬一次,遠比在當下手忙腳亂地查問題划算。

事前演練至少包含三件事。第一、用 k6、Apache JMeter 或 Loader.io 這類工具做負載測試,目標壓到「預期尖峰的兩倍」,撐得住代表有餘裕,撐不住正好趁現在發現裂縫在哪。第二、活動開始前預熱快取,把最重要的頁面先跑過一遍。第三、確認 CDN 的快取策略夠積極:靜態資源設長 TTL(三十天以上),HTML 視內容性質設短一點。

監控要在事件之前就架好,而且要看對指標。值得盯的包括:關鍵頁面的回應時間、首位元組時間(TTFB),是否規律地超過 600 毫秒、5xx 錯誤率(502、503、504)有沒有在尖峰時竄高、CPU 與記憶體在尖峰時段是否逼近上限,以及有沒有出現慢查詢堆積。一個寄到沒人會看的信箱的警報等於沒有警報,把告警接到隨時看得到的管道,緊急狀況留電話。

最後,事件前要備好退路。把任何排程中的程式變更準備好快速回滾的方案,更理想的是在事件前的二十四到四十八小時內不要上任何有風險的東西。同時準備一個狀態頁或一則預先寫好的公告,萬一真的出狀況,第一時間讓使用者知道你正在處理,遠比沉默更能守住信任。

撐住瞬間湧入的流量,靠的不是某一個神奇外掛,而是讓每一層各司其職:CDN 與頁面快取擋下絕大多數請求,物件快取與 Redis 接住動態頁,PHP-FPM 與 Nginx 的併發數算對,再用 cache lock、邊更新邊回舊內容與佇列把尖峰攤平、把雪崩擋住。把這套分層與防雪崩機制在開賣前演練到位,你的下一次流量高峰就會從「擔心會不會掛」,變成「期待轉換能衝多高」的好問題。

相關文章
標籤: WordPress 效能, Redis 物件快取, PHP-FPM, 快取分層, 快取雪崩