後台外掛設定裡勾選「每天自動清除暫存」,過了好幾天打開資料庫一看,暫存筆數還是紋風不動——排程好像形同虛設。不少人第一次踩到這種狀況,會直覺以為是外掛寫壞了,其實問題常常出在對 WP-Cron 運作方式的誤解,它跟你在主機上熟悉的系統 cron,運作邏輯完全不一樣。
WP-Cron 是 WordPress 核心內建的排程機制,讓你可以指定某個函式在未來的某個時間點、或每隔固定間隔重複執行,不必碰主機設定就能做到。想寫一個「每天清一次快取」「使用者註冊 3 天後寄提醒信」這類自動化功能,遲早都會用到 wp_schedule_event() 這個函式。搞懂它的觸發原理跟正確的註冊、清除寫法,才不會出現排程設定了卻沒反應,或反過來把同一個任務註冊了幾千次這種常見錯誤。
先從 WP-Cron 是怎麼被觸發的講起,弄懂這個底層機制之後,再一步步拆解怎麼註冊、怎麼清除、怎麼加自訂間隔。
WP-Cron 是什麼?
多數人聽到「排程」,直覺會想到主機上的 Unix cron,系統時鐘走到某個精確時刻,就準時觸發一次任務。WP-Cron 的運作邏輯不是這樣。它不是 WordPress 自己在背後滴答計時,而是每次有人瀏覽網站、頁面被載入時,才去檢查排程佇列裡有沒有任務已經到期,到期就順便執行。也就是說,一個完全沒有人造訪的網站,理論上排定的任務也不會被觸發,這跟系統 cron「時間到就一定跑」的假設不同。

為了模擬「固定間隔重複執行」的效果,WP-Cron 用的是「間隔」而不是「精確時刻」,你告訴它第一次執行的時間,以及以秒為單位的間隔,之後每隔這段秒數就重複一次。假設第一次排定在下午 2 點,間隔設定為 300 秒(5 分鐘),接下來就會依序落在 2 點、2 點 05 分、2 點 10 分,只是這些時間點能不能真的準時觸發,還是得等剛好有人在那個時間點之後瀏覽網站。WordPress 開發者文件裡 wp_schedule_event() 的函式說明也把這件事講得很直白,排定的時間一旦過了,任務要等有人瀏覽網站時才會觸發。
WordPress 內建四種預設排程間隔,不需要自己額外註冊就能直接拿來用:hourly(每小時,3600 秒)、twicedaily(一天兩次,12 小時)、daily(每天,24 小時),以及自 WordPress 5.4 版起新增的 weekly(每週,604800 秒)。這四種之外還能自訂更細的間隔,例如每 5 分鐘、每 3 小時,靠的是 cron_schedules 這個過濾器。先建立好「WP-Cron 靠頁面瀏覽觸發、用間隔模擬時刻」這兩個認知,後面要註冊的 wp_schedule_event() 才不會用錯方式去理解它。
步驟一,掛上一個動作鉤子當作排程的執行入口
wp_schedule_event() 本身不接受 PHP 的 callback,它只認一件事,鉤子(hook)的名稱。這是第一次寫 WP-Cron 排程最容易誤解的地方,很多人以為排程函式可以直接塞一個函式名稱進去,執行到時間就會呼叫那個函式,但實際上排程真正記住的只有一個字串,也就是鉤子名稱。要讓排程真正發生作用,第一步永遠是自己先建立一個獨有的動作鉤子,再用 add_action() 把要執行的函式綁上去。WordPress 的外掛開發手冊用相當直接的語氣提醒,忘記這一步,任務就永遠不會執行。
具體寫法很單純,先想一個沒人用過的鉤子名稱,再把它跟要執行的函式綁在一起:
add_action( 'bl_cron_hook', 'bl_cron_exec' );Code language: PHP (php)
這裡有一個容易被忽略卻很關鍵的細節,鉤子命名要加上自己專屬的前綴。像 cleanup、run_task 這種通用名稱,很容易跟別的外掛或主題已經在用的鉤子撞名,一旦撞名,執行的可能就不是你以為的那個函式。命名時比照函式命名的習慣,掛上外掛或主題自己的英文縮寫當前綴,例如 myplugin_daily_cleanup,之後不管排程排得多細,都不用擔心互相干擾。
步驟二,用 wp_schedule_event 把任務排進執行佇列
建好鉤子跟對應的函式之後,下一步才是真正把任務排進執行佇列,呼叫 wp_schedule_event(),告訴 WordPress 這個鉤子要在什麼時候第一次觸發、之後多久重複一次。
這裡有一個規則常被忽略,那就是註冊排程的程式碼不能寫在每次頁面載入都會跑到的地方,例如直接放在 functions.php 最外層、或掛在某個一般的 hook 上。如果程式碼每次頁面載入都執行一次 wp_schedule_event(),同一個排程就會被反覆註冊,量一大甚至可能被排進去幾千次。正確的作法是把註冊動作放在只會跑一次的時機,最常見的是外掛啟用時的 register_activation_hook();呼叫前再搭配 wp_next_scheduled() 先檢查排程是不是已經存在,可以再多一層防呆。
wp_schedule_event 四個參數各自的作用
wp_schedule_event() 完整的函式簽名接受五個參數:$timestamp、$recurrence、$hook、$args,以及選用的 $wp_error。$timestamp 是 Unix 時間戳,代表下一次要執行的時間點,最常見的寫法是直接用 time() 取得現在的時間,或是在現在的時間戳上加減秒數,往未來推算。$recurrence 則必須是一個已經註冊過的排程名稱,內建的 hourly、daily 這幾種可以直接用,或是透過 cron_schedules 過濾器自己註冊過的自訂間隔;如果填了一個還沒註冊過的名稱,函式會直接失敗,什麼都不會被排進去。
$hook 就是上一步建立的那個動作鉤子名稱,時間到了就是觸發它。$args 則是選填的陣列,用來識別這是哪一個排程,如果排程需要帶參數(例如針對某個使用者 ID 執行清除),之後不管是要檢查、還是要清除這個排程,都必須用同一組 $args 值才能對上,值不一致,WordPress 就會把它當成完全不同的另一個排程。最後的 $wp_error 是 WordPress 5.7 版新增的參數,設為 true 時,如果排程失敗,函式就會回傳一個 WP_Error 物件、附上失敗原因,而不是單純回傳 false 讓你摸不著頭緒。寫排程時建議一律打開這個參數,除錯會輕鬆很多。
最基本的呼叫方式,把上面幾個參數兜起來就是這樣:
wp_schedule_event( time(), 'daily', 'my_scheduled_task' );Code language: PHP (php)
這行程式碼的意思是,從現在開始,往後每天執行一次 my_scheduled_task 這個鉤子。
用 wp_next_scheduled 判斷任務是否已經排定
前面提到,呼叫 wp_schedule_event() 的程式碼一旦放錯位置、跑了不只一次,同一個排程就會被重複註冊,這是實務上最常見的錯誤來源之一。解法是在真正呼叫排程函式之前,先用 wp_next_scheduled() 檢查同一個鉤子是不是已經排定過了。這個函式接受鉤子名稱(與選填的 $args)當參數,如果查得到下一次執行的時間戳,就回傳那個時間戳;查不到,回傳 false。
標準的防重複寫法,是把 wp_schedule_event() 包進一個條件判斷裡:
if ( ! wp_next_scheduled( 'bl_cron_hook' ) ) {
wp_schedule_event( time(), 'five_seconds', 'bl_cron_hook' );
}Code language: PHP (php)
只有在這個鉤子還沒被排定過的情況下,才會真的去註冊。把這段程式碼放進 register_activation_hook(),就能確保排程只在外掛啟用的那一刻被建立一次,而不是每次有人瀏覽網站都跑一遍,正好回應前面「註冊時機不能放在一般頁面載入」的提醒。

步驟三,外掛停用時要用 wp_unschedule_event 清除排程
排程建立起來之後,還有一件容易被忽略的收尾工作,外掛停用、或不再需要這個排程時,要記得把它清乾淨。WordPress 的外掛開發手冊特別強調,忘記清除排程,WordPress 會持續嘗試執行任務,即使外掛已經停用或移除,鉤子綁定的函式可能早就不存在了,排程卻還在,結果就是每次符合條件的頁面載入,都白白多做一次沒有意義的檢查。
清除排程有兩種函式可以用。wp_unschedule_event() 是取消單一個時間點的事件,但因為 WP-Cron 的重複事件其實是同一個排程規則,取消它會連帶取消未來所有依這個規則產生的重複事件;呼叫它需要先用 wp_next_scheduled() 查出目前的時間戳,再把時間戳跟鉤子名稱一起傳進去:
$timestamp = wp_next_scheduled( 'bl_cron_hook' );
wp_unschedule_event( $timestamp, 'bl_cron_hook' );Code language: PHP (php)
另一個函式 wp_clear_scheduled_hook() 更直接、也更常用,不用先查時間戳。但它清除的範圍一樣是「鉤子名稱+$args」這組組合,如果排程時沒有帶 $args(多數簡單情境都是這樣),只給鉤子名稱就能清掉這個鉤子底下排定的事件;若同一個鉤子曾用不同的 $args 分別排過好幾筆事件(例如針對不同使用者各排一筆),就得逐一帶著各自的 $args 個別清除,只給鉤子名稱沒辦法一次全部清光。實務上把清除動作掛在 register_deactivation_hook() 上,就能確保外掛一被停用,排程立刻跟著清乾淨:
register_deactivation_hook( __FILE__, 'bl_deactivate' );
function bl_deactivate() {
$timestamp = wp_next_scheduled( 'bl_cron_hook' );
wp_unschedule_event( $timestamp, 'bl_cron_hook' );
}Code language: PHP (php)
若 $args 在原本註冊時有填值,取消排程時也要填一模一樣的值才能精準定位到那一筆事件,值兜不起來,wp_unschedule_event() 會找不到對應的排程可以清除。
四種內建間隔不敷使用時的 cron_schedules 過濾器
內建的 hourly、twicedaily、daily、weekly 四種間隔,涵蓋不了每一種需求。如果任務需要每 5 分鐘執行一次、或每 3 小時、每 2 天執行一次,就得靠 cron_schedules 這個過濾器,自己註冊一個新的間隔名稱。
add_filter( 'cron_schedules', 'example_add_cron_interval' );
function example_add_cron_interval( $schedules ) {
$schedules['five_seconds'] = array(
'interval' => 5,
'display' => esc_html__( 'Every Five Seconds' ),
);
return $schedules;
}Code language: PHP (php)
每個自訂間隔,是這個關聯陣列裡的一個元素,鍵是間隔的識別名稱(之後 wp_schedule_event() 的 $recurrence 參數就填這個名字),值則是一個包含 interval(秒數)與 display(顯示名稱)兩個鍵的陣列。cron_schedules 這個過濾器預設收到的是一個空陣列,你要做的就是把自己的間隔加進去,再回傳整個合併後的陣列。
秒數寫死成數字不容易一眼看懂,WordPress 內建了幾個時間常數可以搭配使用:MINUTE_IN_SECONDS 等於 60、HOUR_IN_SECONDS 等於 3600、DAY_IN_SECONDS 等於 86400、WEEK_IN_SECONDS 等於 604800。用常數取代寫死的數字,例如 2 * DAY_IN_SECONDS 代表每 2 天,程式碼會更好讀,也比較不容易在換算秒數的時候算錯。
自訂間隔要在使用它之前就先註冊好,不然 wp_schedule_event() 會因為找不到對應的間隔名稱而直接失敗,這也是為什麼註冊 cron_schedules 過濾器的程式碼,通常直接寫在外掛主檔的最外層、每次載入外掛都會執行,而不是包在 init 這類鉤子裡(外掛啟用的那一刻 init 早就跑過了,寫在裡面的話,啟用時呼叫排程會找不到自訂間隔),確保不管什麼時候呼叫排程函式,這個自訂間隔都已經存在。另外間隔也不建議設得太短,間隔越短,觸發檢查的頻率就越高,對伺服器負擔越大,而且再怎麼縮短間隔,也不會解決 WP-Cron 本身「不保證準時觸發」的先天限制,真正需要秒級精準度的任務,靠頁面瀏覽觸發的機制本來就不是合適的選擇。
只執行一次的任務,適合改用 wp_schedule_single_event
不是所有排程都需要重複執行。很多人第一次接觸 WP-Cron,會直覺把每一種定時任務都套用 wp_schedule_event(),但如果需求只是「未來某個時間點跑一次就好」,更合適的函式其實是 wp_schedule_single_event()。它接受的參數跟 wp_schedule_event() 很像,一樣是先建鉤子、掛上 add_action(),再呼叫排程函式,差別只在它不需要填 $recurrence,參數是時間戳、鉤子名稱、$args,以及同樣選用的 $wp_error。
一個常見的情境是,某個動作發生之後,間隔一段時間要自動執行後續處理。例如訂單建立之後,間隔 2 天要自動把狀態更新為完成:
add_action( 'my_plugin_order_created', 'my_plugin_schedule_followup' );
function my_plugin_schedule_followup( $order_id ) {
wp_schedule_single_event(
time() + 2 * DAY_IN_SECONDS,
'my_plugin_run_followup',
array( $order_id )
);
}Code language: PHP (php)
這裡的邏輯跟前面完全一致,排程真正記住的還是鉤子名稱,不是函式本身,所以還是得先用 add_action() 把 my_plugin_run_followup 這個鉤子綁到實際要執行的函式上。防止重複註冊的思路也一樣,只是因為只執行一次,通常不需要像重複排程那樣包一層 wp_next_scheduled() 判斷,除非同一個動作可能被觸發多次(例如同一張訂單被儲存了兩次),那還是得加上判斷,避免同一個後續處理被排了兩次。
排程的起跑時間要對齊站台時區,不是絕對時間戳
第一次寫 WP-Cron 排程,很容易犯一個時區上的錯誤。假設你想讓某個任務在明天凌晨 3 點執行,直覺會用當地時間去算出對應的時間戳,再傳給 wp_schedule_event()。問題是 Unix 時間戳本身是絕對值,沒有時區的概念;而凌晨 3 點這種時刻,指的是相對於某個時區的在地時間。如果算時間戳的方式跟 WordPress 後台設定的時區對不上,排程實際觸發的時間就會跟你以為的差了好幾個小時。
正確做法是用 wp_timezone() 取得 WordPress 後台「設定」裡設定的時區物件,再交給 DateTimeImmutable 去計算對齊時區的時間戳:
$site_timezone = wp_timezone();
$first_run = new DateTimeImmutable( 'tomorrow 03:00', $site_timezone );
$first_run_timestamp = $first_run->getTimestamp();Code language: PHP (php)
這樣算出來的時間戳,才會真的對應到站台時區的明天 3 點,而不是伺服器所在地區、或 UTC 的 3 點。
這個做法能解決的是第一次起跑時間算對,但沒辦法完全解決另一個先天限制。如果任務需要長期橫跨日光節約時間的切換點,固定秒數的重複間隔是照 UTC 時間一格一格往下排,切換之後,實際觸發的在地時刻就會整整錯開一個小時,這正好呼應前面提到的「WP-Cron 用間隔模擬時刻」這件事本身的限制,不是寫法能完全解決的問題。真的需要長期精準卡在同一個在地時刻,比較務實的作法是每次執行完後重新排定下一次的單次事件,而不是仰賴同一組重複排程從頭跑到尾。排查排程明明存在卻沒觸發這類問題,牽涉的因素更多,不是靠改一行程式碼就能解決,這裡談的時區問題只是常見成因之一。
高頻或大量任務更適合交給 Action Scheduler 處理
WP-Cron 是 WordPress 核心內建的機制,不需要額外安裝任何東西,拿來處理大多數輕量、低頻的定時任務綽綽有餘。但如果一個網站每天有大量待處理的排程事件,例如電商網站每天新增上百筆訂單、每一筆都各自帶著自己的後續排程任務,WP-Cron 的設計就會開始吃緊。它把所有排定的事件,全部塞進同一筆儲存在 wp_options 資料表裡的資料,量一大,每次查詢與寫入這筆資料,都會變成效能瓶頸。
這種情境下,更適合的替代方案是 Action Scheduler,這是隨 WooCommerce 一起釋出、但任何外掛或佈景主題都可以直接引用的開源排程函式庫,並不是只有 WooCommerce 才能用。它跟 WP-Cron 最關鍵的差異,在於它不是像 WP-Cron 那樣讓每個任務各自佔一筆排程項目,而是用獨立的資料庫資料表儲存整個佇列,並且主要靠 WP-Cron 上單一個每分鐘執行一次的排程動作,去驅動內部的佇列運作。
註冊排程任務時,Action Scheduler 也內建了去重機制,不用像 wp_schedule_event() 那樣自己寫 wp_next_scheduled() 判斷,只要把 $unique 參數設為 true 就能達成:
as_schedule_recurring_action(
time() + 2 * DAY_IN_SECONDS,
2 * DAY_IN_SECONDS,
'my_plugin_run_followup_batch',
array(),
'my-plugin-followups',
true
);Code language: PHP (php)
Action Scheduler 完整的 API 用法牽涉的細節不少,值得另外花時間研究,這裡你只要記得,任務量一大到讓 WP-Cron 吃力時,還有這個選項可以用。
排程寫入後用 WP-CLI 或外掛確認任務是否已經存在
前面的步驟都做完之後,還有一個常被跳過的動作,確認排程真的成功寫進佇列裡了。這一步做的只是確認排程有沒有寫入成功,還沒有牽涉到為什麼沒有被執行那種更複雜的排查。
如果主機有 shell 存取權限,最直接的方式是用 WP-CLI。wp cron event list 會列出目前所有排定的事件,包含鉤子名稱、下次執行時間跟遞迴頻率:
wp cron event list --fields=hook,next_run_gmt,next_run_relative,recurrenceCode language: Bash (bash)
想立刻測試某個鉤子的 callback 能不能正常執行,不用等排定的時間到,可以用 wp cron event run 手動觸發:
wp cron event run my_scheduled_taskCode language: Bash (bash)
沒有主機 shell 權限的話,可以在後台安裝 WP Crontrol 這類外掛,這是 WordPress.org 外掛目錄裡收錄的公開外掛,能直接在後台視覺化查看所有排定事件、下次執行時間與遞迴頻率,不需要碰任何指令列。不論用哪一種方式,都要留意一件事,確認排程已經註冊,跟確認 callback 真的執行成功,是兩回事,WP-CLI 或後台外掛能告訴你事件存在、下次什麼時候觸發,但沒辦法證明 callback 本身跑得順不順利,那還是得回頭看 callback 自己記錄的結果或日誌。
回呼函式要處理好的冪等與執行時間
排程能不能準時觸發是一回事,被排定的那個函式(callback)本身怎麼寫,是另一件同樣關鍵的事。因為 WP-Cron 有重複觸發、延遲執行、多個頁面請求同時符合觸發條件等特性,callback 必須設計成就算被重複執行,也不會產生錯誤的結果,也就是所謂的冪等。
具體來說,callback 不要寫成每次執行都疊加一次的邏輯,例如每次都把某個數字加 1、或每次都新增一筆一模一樣的紀錄,而是要能判斷這件事是不是已經做過了,重複觸發的時候直接跳過,而不是重複執行一次。大量資料的處理也不要一次做完,拆成小批次,每次記住處理到哪裡,下一次排程觸發時接著上次的進度往下做,可以避免單次執行時間拖得太長。
執行時間也要注意。預設情況下,WordPress 是在訪客載入頁面時,另外發出一個不等待回應的背景請求去執行排程,callback 跑得久通常不會直接拖慢訪客那一頁,但會長時間佔用主機資源,也可能碰上主機對單次請求的執行時間限制、跑到一半就被中斷,這也是大量資料要拆成小批次的原因。callback 裡發生的錯誤,應該自己記錄下來,寫進日誌或資料庫,背景請求的畫面輸出沒有人會看到,不記下來就無從追查。鉤子與函式的命名,也要延續前面提過的原則,加上自己專屬的前綴,避免跟其他外掛或主題撞名。
把這些步驟走過一遍,會發現 WP-Cron 真正難的不是語法本身,wp_schedule_event()、wp_unschedule_event() 這些函式的參數都不複雜,難的是理解它背後那套靠頁面瀏覽觸發、用間隔模擬時刻的運作邏輯。搞懂這一點,才知道為什麼註冊排程的程式碼要放對位置、為什麼清除排程不能漏掉,也才知道什麼時候該換一套更適合的排程工具。
下次要幫網站加一個自動化任務,不妨先問自己幾個問題,這個任務要重複執行還是只跑一次、需要多精準的時間點、負載量大不大。答案會直接指向該用 wp_schedule_event()、wp_schedule_single_event(),還是乾脆交給 Action Scheduler,排程這件事,選對工具往往比把程式碼寫得多漂亮更重要。
