生產環境中真實使用的 5 種 Cron 表達式模式
Cron 表達式看似簡單 — 五個欄位、幾個運算符 — 但生產團隊卻反覆犯相同的錯誤:在某些月份默默跳過的工作、會破壞共享狀態的重疊執行、以及數百個工作同時在同一秒甦醒並讓資料庫不堪重負。表達式本身鮮少是問題;問題在於沒有思考清楚邊界情況。
本指南介紹工程團隊在生產環境中實際使用的五種 cron 模式,並解釋每一種的考量與應避免的陷阱。在部署前,使用 Toova Cron 解析器驗證任何表達式,並查看接下來十次的執行時間。
Cron 表達式結構
標準 cron 表達式由五個以空格分隔的欄位組成:
*/15 * * * *
| | | | |
| | | | +---- 星期幾 (0-6,0=星期日)
| | | +------- 月份 (1-12)
| | +---------- 月中第幾日 (1-31)
| +------------- 小時 (0-23)
+----------------- 分鐘:每 15 分(0、15、30、45)
每個欄位接受:特定值(5)、萬用字元(*)、範圍(1-5)、列表(1,3,5),或步進值(*/15)。某些實作會新增第六個欄位表示秒數;其他(例如 GitHub Actions)則嚴格要求五個欄位。在撰寫複雜表達式前,請務必確認你的排程器使用的方言。
模式 1:每 15 分鐘 — */15 * * * *
*/15 * * * *
| | | | |
| | | | +---- 星期幾 (0-6,0=星期日)
| | | +------- 月份 (1-12)
| | +---------- 月中第幾日 (1-31)
| +------------- 小時 (0-23)
+----------------- 分鐘:每 15 分(0、15、30、45) 這是佇列處理器、心跳檢查與輪詢工作的首選表達式 — 需要頻繁但不連續執行的工作。它會在每小時的 :00、:15、:30 與 :45 觸發,每天皆如此。
在 crontab 中:
# crontab 條目 — 每 15 分鐘執行批次處理器
*/15 * * * * /opt/app/bin/process-queue.sh >> /var/log/queue.log 2>&1 在 Node.js 中:
// 使用 node-cron 的 Node.js
import cron from 'node-cron';
cron.schedule('*/15 * * * *', async () => {
const batch = await db.query(
'SELECT * FROM jobs WHERE status = $1 LIMIT 100',
['pending']
);
await processBatch(batch);
}); 雷鳴群問題。當你以相同的 */15 表達式執行數百個 worker 時,它們會在 :00、:15、:30 與 :45 同時觸發。如果每個 worker 都連到共享資料庫,你會每小時收到四次尖峰,而非平滑的負載。解決方法是在每次執行前加入隨機抖動:
// 加入抖動以避免分散式系統中的雷鳴群效應
cron.schedule('*/15 * * * *', async () => {
const jitterMs = Math.floor(Math.random() * 30_000); // 最多 30 秒
await sleep(jitterMs);
await processBatch();
}); 最多 30 秒的抖動會將尖峰分散到 30 秒的視窗內,同時讓工作保持在預期的 15 分鐘週期內。若需更精緻的錯開排程,請參考模式 5(Jenkins H 語法)。
使用 時間戳轉換工具,在除錯工作時序時將下次排定執行時間從 Unix 紀元時間轉換為人類可讀的時間。
模式 2:工作日營業時間 — 0 9 * * 1-5
0 9 * * 1-5
| | | | |
| | | | +-- 星期幾:週一至週五 (1-5)
| | | +------ 月份:每月
| | +---------- 月中第幾日:每日
| +-------------- 小時:9
+------------------ 分鐘:0(整點) 這個表達式在每週一至週五早上 9:00 整觸發一次。它是以下情境的標準模式:寄送每日摘要郵件、執行營業時間報表、觸發需要在工作時段由人類審核的工作流程,以及在工程日開始時部署到測試環境。
在 Kubernetes CronJob 中:
# Kubernetes CronJob — 每個工作日早上 9 點 UTC 寄送摘要郵件
apiVersion: batch/v1
kind: CronJob
metadata:
name: digest-email
spec:
schedule: "0 9 * * 1-5"
jobTemplate:
spec:
template:
spec:
containers:
- name: mailer
image: myapp/mailer:latest
command: ["/app/send-digest"]
restartPolicy: OnFailure 時區陷阱。Kubernetes CronJob 排程會以叢集時區執行,預設為 UTC。「早上 9 點 UTC」是冬季美東時間凌晨 5 點,或太平洋時間凌晨 4 點。你的使用者會在半夜收到「晨間摘要」。請使用接受 IANA 時區字串的 cron 函式庫:
// 處理時區 — 「早上 9 點 UTC」幾乎不是使用者所期待的時間
// 使用能理解 IANA 時區的函式庫
import { CronJob } from 'cron';
const job = new CronJob(
'0 9 * * 1-5',
() => sendDailyDigest(),
null,
true,
'America/New_York' // 在美東早上 9 點執行,而非 UTC 早上 9 點
); 時區轉換工具能幫你跨任何 IANA 時區找出特定本地時間的 UTC 等價值。
星期幾編號。標準 cron 定義使用 0 或 7 表示星期日。某些實作(Quartz、cron4j)以 1 表示星期一,而非 0。在部署前請查閱排程器的文件,並用 Cron 解析器進行測試。
模式 3:每晚批次 — 0 22 * * *
0 22 * * *
| | | | |
| | | | +-- 星期幾:任意
| | | +------ 月份:任意
| | +---------- 月中第幾日:任意
| +-------------- 小時:22(晚上 10 點)
+------------------- 分鐘:0 每天晚上 10 點執行一次。每晚批次是資料工程中最常見的模式之一:彙總當日事件、重建搜尋索引、產生報表、封存日誌、發送日終通知。
為什麼選 10 點而非午夜?有兩個原因。第一,10 點在日期變動前留有緩衝,降低撿到延遲抵達事件的機率。第二,在午夜執行繁重工作意味著每月第一天會與月底計費(模式 4)競爭資源。
三種最常見的每日工作寫法:
# 三種「每晚 10 點」的寫法:
0 22 * * * # 標準 cron
@daily # 午夜 — 與晚上 10 點「不同」
0 22 * * * # 對於非午夜的時間,總是優先使用明確時間而非 @macros 避免重疊。一個通常需 45 分鐘的每晚工作偶爾可能耗時 2 小時 — 若它在下一晚的觸發時間仍在執行,兩個工作會同時修改相同的資料。請使用分散式鎖:
// 冪等性檢查 — 防止工作重疊造成雙重處理
async function runNightlyBatch() {
const lock = await redis.set(
'nightly-batch-lock',
process.pid,
'EX', 3600, // TTL:1 小時
'NX' // 僅在不存在時設定
);
if (!lock) {
console.log('批次已在執行,跳過。');
return;
}
try {
await processNightlyData();
} finally {
await redis.del('nightly-batch-lock');
}
}
Redis 的 NX 旗標確保同時只有一個行程能持有鎖。TTL 則防止崩潰的工作永久持有鎖。
模式 4:每月計費 — 0 0 1 * *
0 0 1 * *
| | | | |
| | | | +-- 星期幾:任意
| | | +------ 月份:任意
| | +---------- 月中第幾日:1(每月第一天)
| +-------------- 小時:0(午夜)
+------------------ 分鐘:0 在每月第一天午夜觸發。經典模式用於:產生月度發票、重置使用量配額、執行月底對帳,以及封存前月資料。
每月工作的 cron 陷阱:
# 每月第一天午夜 UTC
0 0 1 * *
# ---- 應避免的陷阱 ----
# 這「無法」可靠地「每月」執行:
0 0 29-31 * * # 天數較少的月份會靜默跳過
# 季度計費(一月、四月、七月、十月):
0 0 1 1,4,7,10 *
# 月底很棘手 — 標準 cron 中沒有「最後一天」
# 使用 @reboot + 應用層邏輯,或每日執行的 cron 並
# 在程式中檢查今天是否為當月最後一天。 月底計費。如果你需要在每月最後一天計費(而非第一天),就沒有乾淨的 cron 表達式。變通方法:
// 應用層的「月底」檢查
cron.schedule('0 23 28-31 * *', async () => {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
// 如果明天是 1 號,今天就是當月最後一天
if (tomorrow.getDate() === 1) {
await runEndOfMonthBilling();
}
}); 這會在每月 28–31 日觸發,然後在程式碼中檢查今天是否確實為當月最後一天。它會多執行最多四次(在天數少於 31 天的月份),但檢查成本很低,邏輯也清晰。
若需季初計費,可在月份欄位使用列表運算符:0 0 1 1,4,7,10 *(一月、四月、七月、十月)。
模式 5:雜湊排程(Jenkins H 語法) — H/30 * * * *
H/30 * * * *
|
+--- H 表示 Jenkins 為每個工作隨機挑選一個分鐘。
H/30 = 每 30 分鐘執行一次,從 H 開始。
不同工作會取得不同的 H 值,分散負載。 H 符號是 Jenkins 對 cron 語法的特有擴充。它會以從工作名稱衍生出的穩定偽隨機數取代 H。分鐘欄位中的 H/30 表示「每 30 分鐘執行一次,起始分鐘由工作名稱的雜湊值決定」。
在 Jenkinsfile 中:
// Jenkinsfile — 每 30 分鐘觸發一次的 pipeline,採用錯開排程
pipeline {
triggers {
cron('H/30 * * * *')
}
stages {
stage('Deploy') {
steps {
sh './deploy.sh'
}
}
}
} 這為什麼重要。Jenkins 部署常常擁有數百個 pipeline。如果每個 pipeline 都使用 */30 * * * *,它們會全部在 :00 與 :30 觸發,對代理機池與任何下游系統造成巨大的負載尖峰。使用 H/30 後,每個工作會取得不同的偏移 — 一個在 :03/:33,另一個在 :17/:47 — 在小時內均勻分散負載。
GitHub Actions 不支援 H。若要錯開多個 Actions 工作流程,請手動選擇不同的固定分鐘偏移:
// GitHub Actions — 不支援 H;需手動以固定偏移錯開排程
// 要錯開兩個工作:選擇不同的固定分鐘
name: Job A
on:
schedule:
- cron: '5 * * * *' # 每小時 :05 執行
---
name: Job B
on:
schedule:
- cron: '35 * * * *' # 每小時 :35 執行 — 比 Job A 晚 30 分鐘 這比 Jenkins 的 H 不那麼優雅,但對於少量工作流程能達到相同效果。對於大量工作流程,請考慮使用原生支援分散式排程的外部編排器。
跨模式共通考量
冪等性無可妥協
每個 cron 工作都應該能安全地執行兩次。基礎設施會出錯、排程器會重啟、分散式鎖偶爾會逾時。設計工作時應確保第二次以相同輸入在相同時間執行時能產生相同結果。請使用 upsert 取代 insert、執行前先檢查的模式,以及為外部 API 呼叫附上冪等性鍵。
失敗工作的告警
默默失敗的 cron 工作比完全不執行還糟糕。當工作失敗或未在預期時間視窗內完成時,請發送告警到失敗佇列或監控系統。Sentry Crons、Healthchecks.io 與 Cronitor 等工具會加入心跳檢查:工作在開始與結束時都會 ping 一個 URL。如果結束的 ping 沒有在期限內抵達,就會觸發告警。
記錄排程
在每次 cron 執行的開始與結束時,輸出結構化的日誌行,包含表達式、觸發時間戳(而非目前時間,因為可能因抖動稍有不同)與執行時長。這能讓你更直接地將下游事件 — 「資料庫在晚上 10 點變慢」 — 與特定批次工作的執行對應起來。
快速參考
使用 Cron 解析器解析與驗證任何表達式。使用 時區轉換工具在不同時區間轉換排程時間。如果你的工作會記錄 Unix 時間戳,時間戳轉換工具能立即將其轉為可讀日期。
若需 cron 語法與常見範例的完整參考,crontab.guru是公認的快速參考來源。若需 Jenkins H 語法及其完整規格,請參閱 Jenkins Pipeline cron 語法文件。