Zsens Admin 插件定时任务踩雷:当 `wp_schedule_event` 的"静默失败"让我凌晨三点收到二十封重复邮件
上周上线了一个自动清理过期日志的功能,本地测试一切正常,结果生产环境凌晨开始疯狂发邮件。查日志发现同一个任务被注册了十几遍,每分钟都在跑。复盘之后发现是 wp_schedule_event 的"幂等性陷阱"——这货不会自动去重,重复调用就重复注册,而且失败时连句报错都没有。
先贴我最初的错误写法,估计不少人踩过:
// ❌ 错误:每次插件激活/页面加载都直接注册,无任何防护
function zsens_register_cleanup_hook() {
wp_schedule_event(time(), 'hourly', 'zsens_log_cleanup');
}
add_action('wp', 'zsens_register_cleanup_hook');
// 回调里发通知邮件
function zsens_log_cleanup() {
$deleted = zsens_purge_old_logs();
wp_mail(get_option('admin_email'), '日志清理完成', "删除了 {$deleted} 条记录");
}
add_action('zsens_log_cleanup', 'zsens_log_cleanup');
问题一:wp_schedule_event 不会检查是否已存在同名任务,每次执行都新建一个 cron 条目。我插件里又在 wp 钩子上触发的,页面访问量一上来,任务表直接爆炸。
问题二:没有返回值校验。wp_schedule_event 失败时返回 false 或 WP_Error,但代码完全没处理,连日志都没打。
问题三:时间戳用了 time(),导致每次注册的实际触发点都不一样,任务队列里东一个西一个,根本没法追踪。
修正后的写法,核心就三句话:先查再注、固定锚点、失败即落盘。
// ✅ 正确:带幂等校验与错误处理的注册逻辑
function zsens_register_cleanup_hook() {
$hook = 'zsens_log_cleanup';
$next = wp_next_scheduled($hook);
// 已存在则直接跳过,绝不重复注册
if ($next !== false) {
return;
}
// 用整点锚定,方便排查与对齐业务周期
$anchor = strtotime(date('Y-m-d H:00:00'));
$result = wp_schedule_event($anchor, 'hourly', $hook);
if ($result === false || is_wp_error($result)) {
// 落盘到自有日志,别依赖 WP_Cron 的静默
zsens_log_error('cron_register_failed', [
'hook' => $hook,
'anchor' => $anchor,
'result' => $result,
]);
}
}
// 只在特定场景触发注册,绝不用 wp 这种高频钩子
register_activation_hook(ZSENS_PLUGIN_FILE, 'zsens_register_cleanup_hook');
// 卸载时显式清理,防止残留幽灵任务
register_deactivation_hook(ZSENS_PLUGIN_FILE, function () {
$hook = 'zsens_log_cleanup';
$timestamp = wp_next_scheduled($hook);
while ($timestamp) {
wp_unschedule_event($timestamp, $hook);
$timestamp = wp_next_scheduled($hook);
}
});
回调侧也做了防御,防止任务重叠执行(服务器卡顿时 WP-Cron 可能并发触发):
// ✅ 带执行锁的回调
function zsens_log_cleanup() {
$lock_key = 'zsens_cleanup_running';
$locked = get_transient($lock_key);
if ($locked !== false) {
zsens_log_error('cron_overlap_detected', ['time' => time()]);
return; // 已有实例在跑,直接放弃
}
set_transient($lock_key, time(), 300); // 5分钟锁
try {
$deleted = zsens_purge_old_logs();
// 邮件改到每天汇总发,别每次清理都轰炸
if (date('H') === '02') {
wp_mail(get_option('admin_email'), '日志清理日报', "本日删除 {$deleted} 条");
}
} finally {
delete_transient($lock_key); // 确保锁释放
}
}
几个血泪细节:
1. wp_next_scheduled 的返回值
成功返回时间戳(int),未找到返回 false。注意不是 0 不是 null,必须用 === false 判断,否则 1970 年的时间戳会被误判。
2. 锚定时间戳的选择
别用 time() 或 strtotime('+1 hour'),每次计算结果不同,任务表里的 time 字段会乱成一锅粥。固定整点/整分,排查时一眼能对上。
3. 卸载清理的 while 循环
如果已经炸了、同一个 hook 有多个条目,wp_clear_scheduled_hook 在部分 WP 版本里有漏删的 bug(老版本只删第一个匹配项)。手动 while + wp_next_scheduled + wp_unschedule_event 更稳妥。
4. 别在 wp / init 里注册 cron
这些钩子每次请求都跑,访问量大的站点瞬间给你造出几百个任务。只在激活时注册一次,或者挂到 admin_init 并加页面条件判断。
最后补个快速诊断命令,进数据库直接看:
SELECT * FROM wp_options WHERE option_name = 'cron'
AND option_value LIKE '%zsens_log_cleanup%';
如果看到同一个 hook 出现多次,就是重复注册了。也可以装 WP Crontrol 插件图形化排查,但生产环境建议直接查表,避免装额外插件。
你们 cron 任务还踩过哪些静默坑?比如 spawn_cron 返回 0 但实际没执行、或者服务器时区跟 WP 时区对不上导致触发时间漂移的,欢迎丢案例。

