admin-ajax.php 負載過高排查與替代方案

打開 GTmetrix 或主機後台的資源報告,看到一筆 POST admin-ajax.php 占掉好幾百毫秒,甚至整張 CPU 使用率圖被它頂到滿格——這是很多 WordPress 與 WooCommerce 站長都遇過的畫面。admin-ajax.php 負載過高不是單一原因造成的,它比較像一個總出入口,前台的購物車、後台的編輯器、第三方外掛全都從這扇門進出,塞車了卻很難一眼看出是誰在塞。

這篇會把問題拆成兩條線來查:前台訪客觸發的請求,跟後台登入者觸發的請求,兩者的成因、診斷工具與解法完全不同。查清楚源頭之後,再談三種替代方案——調節、改寫成 REST API、自建輕量端點——以及它們實測差多少。全程用繁體中文、以台灣常見的 WooCommerce 購物站情境為例,給可直接複製的程式碼。

admin-ajax.php 到底在做什麼,為什麼會變成瓶頸

admin-ajax.php 是 WordPress 處理所有非同步請求的單一路由檔,位於網站的 /wp-admin/ 目錄下。它的角色是讓瀏覽器不重新整理整頁的情況下,跟伺服器交換資料,例如自動儲存草稿、即時搜尋、商品篩選、無限捲動、購物車數量更新。這個檔案最早在 WordPress 2.1 就存在,是核心與無數外掛、佈景主題共用的非同步管道。

問題的根源在於它的運作方式:每一次打到 admin-ajax.php 的請求,WordPress 都會跑一次完整的啟動流程。依序載入 wp-load.phpwp-config.phpwp-settings.php,後者會把大部分核心檔案、所有啟用中的外掛與佈景主題全部載入,接著還會載入 /wp-admin/includes/admin.php 並觸發 admin_init 這個動作鉤子。換句話說,就算回傳的只是一個「購物車有幾件」的小數字,伺服器也得把整套 WordPress 環境重新初始化一遍。

更關鍵的是這些請求多半是 POST、又指向後台,會直接繞過頁面快取。一般靜態頁面被快取後幾乎不吃 PHP 資源,但每一次 admin-ajax 的 POST 都得占用一個 PHP worker 從頭跑到尾。當同時湧入的這類請求一多,PHP worker 被占滿,留給真正訪客頁面的資源就變少,網站開始變慢,嚴重時回傳 HTTP 504 或 502。負載過高的本質不是這個檔案壞了,而是「不必要的請求太頻繁,每一筆又太重」。

怎麼判斷負載是前台還是後台造成的

第一步先分清楚請求來源是前台訪客還是後台登入者,因為兩邊的解法南轅北轍。最快的判斷依據是請求的 referrer(來源網址):如果 admin-ajax 的請求大量帶著 /wp-admin 的 referrer,代表是後台編輯活動觸發的;如果 referrer 是前台文章頁、商品頁,那就是訪客端的外掛或佈景主題在發。

前台型的請求通常會在速度測試報告裡現形。用 GTmetrix 或 WebPageTest 跑一次首頁或商品頁,到 Waterfall(瀑布圖)找 POST admin-ajax.php 那一列,點開可以看到 Headers、Post、Response 三個分頁。Response 裡常常藏著線索,例如某個帶有外掛專屬命名的 nonce 欄位或 HTML 片段,把那串字拿去搜尋,往往就能對應到是哪一支外掛或哪一套主題在發請求。瀏覽器的開發者工具 Network 分頁也能看同樣的東西,還能在 Timings 看單筆請求花了多久。

後台型的請求不會出現在速度測試裡,因為測試工具不會登入你的後台。這類問題要看主機的存取記錄(access log):如果記錄裡充斥著對 /wp-admin/admin-ajax.php、帶 action=heartbeat 參數的重複 POST,那幾乎可以確定是心跳(Heartbeat)API 在發。曾有一個 WooCommerce 站在上電視節目前,後台有多位編輯同時改商品,單日累積超過 4,100 筆 admin-ajax 請求,當天卻只有約 2,000 名不重複訪客——請求數比訪客數還多,這就是典型的後台心跳風暴。

診斷外掛來源最直接的工具是免費的 Query Monitor。它會在後台工具列加一個面板,列出當前請求觸發了哪些 AJAX action、跑了哪些鉤子、每筆資料庫查詢花多久。在心跳會觸發的頁面上,面板會明確顯示 heartbeat_received 這條路徑,是看出「哪些外掛掛了心跳處理函式、每一拍各吃多少成本」最快的方法。

心跳 API 為什麼是後台負載的最大來源

心跳 API 是後台 admin-ajax 請求最大的單一來源。它在 WordPress 3.6 引入,目的是讓瀏覽器在使用者登入後台時,能跟伺服器即時通訊,負責的功能包括文章自動儲存、文章鎖定(避免兩位編輯同時覆蓋彼此的修改)、以及登入狀態與通知。這些功能本身有價值,問題出在預設頻率與不可快取的特性疊在一起。

預設情況下,在文章編輯器裡心跳每 15 秒就會對 admin-ajax.php 發一次 POST。算一下:一位編輯把編輯頁開著一小時,光是心跳就能產生約 240 筆請求。而且如同前面說的,這些 POST 全部繞過快取、每一筆都要占用一個 PHP worker 跑完整套 WordPress 啟動。一位編輯也許還撐得住,但想像新聞編輯台、線上教學平台或多人協作的購物站,五十個人同時開著後台,每分鐘就能堆出數百筆不可快取的請求,把伺服器容量吃乾。

WordPress 後來有部分緩解:心跳在鍵盤、滑鼠或觸控閒置約一小時後會自行暫停,前端的頻率也比編輯器內低。但這些機制不足以應付高併發的後台。要真正控制它,得主動調節頻率或在用不到的地方停掉,而不是被動等它自己睡著。

用程式碼調節與停用心跳的兩種做法

控制心跳最乾淨的方法是 heartbeat_settings 過濾器,這是核心內建的鉤子,不需要任何外掛,外掛被棄用或 WordPress 升級都不會影響它。心跳的間隔允許設定在 15 到 120 秒之間,把它從預設的 15 秒拉長到上限,就能大幅降低請求頻率。把下面這段放進佈景主題的 functions.php 或一支功能型外掛裡:

/**
 * 將心跳間隔調節到 60 秒。
 */
add_filter( 'heartbeat_settings', function ( $settings ) {
    $settings['interval'] = 60; // 允許範圍 15 到 120 秒
    return $settings;
} );

如果你比較傾向用介面操作、不想碰程式碼,免費的 Heartbeat Control 外掛能讓你分別針對前台、後台儀表板、文章編輯器三個情境設定頻率或直接關閉。常見的設定是:前台完全停用、儀表板拉長到 60 秒以上、只在文章編輯器保留較高頻率以維持自動儲存。

更進一步是依情境停用。前台對非管理員訪客來說,心跳幾乎沒有用途,可以在不是後台的情況下直接取消註冊心跳的 JavaScript。注意調節與停用的取捨:把間隔拉長會讓自動儲存與文章鎖定的反應變慢,完全停用前台心跳則對純瀏覽的訪客毫無副作用。下面這段只關前台、保留後台功能:

/**
 * 前台不是管理頁時,取消註冊心跳腳本。
 */
add_action( 'init', function () {
    if ( ! is_admin() ) {
        wp_deregister_script( 'heartbeat' );
    }
} );

把這類調節標準化寫進佈景主題或一支核心功能外掛,伺服器負載會變得更可預測,後台的管理活動就不會再變成拖慢前台的瓶頸。

WooCommerce 購物車片段為什麼該特別處理

對 WooCommerce 站來說,前台 admin-ajax 負載最常見的兇手是購物車片段(cart fragments)。WooCommerce 用 ?wc-ajax=get_refreshed_fragments 這個請求,在不重新整理頁面的情況下更新右上角的購物車數量與小計。立意良好,代價卻是這個請求幾乎在每一個頁面都會發——包括根本沒有「加入購物車」按鈕的頁面,例如關於我們、部落格文章、條款頁。

換句話說,一位只是來看部落格的訪客,每開一頁都在替你發一筆繞過快取的 admin-ajax 請求,依主題實作不同,最差情況可能造成明顯的載入延遲。許多佈景主題其實沒有用到即時更新的購物車計數,或是只在商店相關頁面才需要,這時把全站的片段請求關掉就是純收益。

比較粗暴的做法是直接在 functions.php 取消佇列購物車片段腳本,但這會讓「加入購物車後計數即時跳動」的效果失效。更聰明的做法是判斷 woocommerce_cart_hash 這個 cookie 是否存在:購物車為空(cookie 不存在)時才停用片段腳本,一旦使用者真的加了東西進購物車,再讓它恢復運作。這樣瀏覽階段不發多餘請求,結帳流程又不受影響,是兼顧速度與功能的折衷。Perfmatters、WP Rocket 等優化外掛也都內建了這個「智慧停用」選項,不想寫程式碼的話可以直接勾。

REST API 與自建端點,三種架構實測差多少

如果負載來自你自己或外掛開發的功能,而那段程式碼仍在用 admin-ajax.php,把它改寫成 REST API 通常能拿到效能與架構上的雙重好處。REST API 自 WordPress 4.7(2016 年)就進入核心,它的請求由重寫 API 導向 /index.php 處理,不會載入 /wp-admin/includes/admin.php,也不會觸發 admin_init 鉤子。少掉這段後台專屬的初始化開銷,回應就會比 admin-ajax 快一些。

到底快多少,有一組常被引用的實測數字。在一台 1GB 記憶體、單核心的 DigitalOcean 主機、乾淨的 WordPress 環境、關閉頁面快取的條件下,用 ApacheBench 各打 100 筆請求,回傳同樣的內容:

處理方式 乾淨環境平均回應 有外掛與自訂主題環境
admin-ajax.php 約 70 毫秒 約 92 毫秒
REST API 約 61 毫秒 約 89 毫秒
自建 MU 外掛端點 約 6 毫秒 約 7 毫秒

乾淨環境下 REST API 比 admin-ajax 快約 17%,這個差距主要來自省掉的後台初始化。但要注意第二欄:一旦裝了一堆外掛、換上自訂主題,admin-ajax 與 REST API 的差距縮小到只剩幾毫秒。原因是兩者都會載入大部分核心、所有啟用中的外掛與佈景主題,真正吃掉時間的是那些外掛本身,而不是入口檔的差異。所以「改用 REST API 就會變快」這個說法只在輕量站台成立;在外掛繁多的站,換入口帶來的提升相當有限。

REST API 的好處不只在速度。它強制你寫 permission_callback,把權限檢查放在最前面,安全性比 admin-ajax 用字串 action 更明確;它支援 GET、POST、PUT、DELETE 等正確的 HTTP 動詞,端點語意自我描述;WordPress 還會在 /wp-json/ 自動列出你註冊的端點,方便測試與整合。一個最小可用的端點長這樣:

add_action( 'rest_api_init', function () {
    register_rest_route( 'app/v1', '/endpoint', array(
        'methods'             => 'GET',
        'callback'            => 'my_endpoint_callback',
        'permission_callback' => function () {
            return current_user_can( 'manage_options' );
        },
    ) );
} );

function my_endpoint_callback() {
    return array( 'status' => 'ok' );
}

那為什麼自建 MU 外掛端點能快到只有 6 毫秒?因為它把整套 WordPress 啟動流程都跳過了。必用(must-use)外掛會在核心載入後、其他外掛與佈景主題載入之前就執行,這讓你能在請求極早期攔截、直接回傳並結束,不必把所有外掛都跑一遍。下面這支 MU 外掛只認 hfm-ajax 這個參數,命中就回傳結果並中止:

<?php
/**
 * Plugin Name: 自訂輕量 AJAX 處理器
 * Description: 在外掛載入前攔截特定請求並直接回應
 */
if ( ! isset( $_GET['hfm-ajax'] ) ) {
    return;
}
if ( ! defined( 'DOING_AJAX' ) ) {
    define( 'DOING_AJAX', true );
}
wp_send_json( array( 'time' => time() ) );

代價是它跑得太早,拿不到大部分 WordPress 核心函式與外掛功能,開發與安全防護都得自己扛——nonce 驗證、權限檢查、資料消毒一樣都不能少。這條路只適合對非同步效能極度要求、且願意自行維護安全的進階情境,例如高流量的即時查詢端點。同樣的「跳過不相關外掛」思路也能反過來用:透過掛入 option_active_plugins 過濾器,在處理某個非同步請求時只載入真正需要的外掛,減少每筆請求的負擔。

排查與替代方案怎麼搭配才合理

回到最開始那張被頂滿的 CPU 圖,正確的順序是先診斷、後改架構,不要一上來就大改程式碼。先用 referrer 分前後台:後台型優先處理心跳,用 heartbeat_settings 把間隔拉到 60 秒、前台直接停用,這一步不改任何功能就能砍掉大量請求。前台型先跑 GTmetrix 看 Response 線索鎖定外掛,WooCommerce 站特別檢查購物車片段是否在非商店頁亂發,能用智慧停用就先停。

外掛逐一停用、再逐一啟用的笨方法在找不到線索時仍然有效,記得在測試或預備環境(staging)操作,避免影響正式站。確定是自家程式碼造成、且站台外掛不算太多時,再考慮把 admin-ajax 改寫成 REST API;只有在效能要求極端、團隊有能力維護安全的前提下,才動用自建 MU 端點。當以上都做完負載仍降不下來,問題可能單純是資源不足,這時升級主機方案或換到對 WordPress 效能較有經驗的代管環境,才是該花的錢。

把這套流程跑順,admin-ajax.php 就會回到它原本該有的樣子——一個安靜處理非同步請求的後端管道,而不是吃光 PHP worker、把訪客擋在門外的瓶頸。下次再看到那筆 POST 占滿報告,你已經知道該先問「它從哪來」,而不是急著刪檔。

相關文章
標籤: REST API, WooCommerce, WordPress 效能, Heartbeat API, admin-ajax