当你的 PHP Cron Job 跑了两次:到底发生了什么,以及该怎么避免
这类问题往往一开始并不起眼。
客户收到了两封回执邮件,同一张发票被生成了两次,一个本应只续费一次的订阅任务在 30 秒后又扣了一次款。表面上看,系统似乎没有彻底坏掉:应用还在线,队列还在跑,日志虽然有点吵,但也谈不上立刻告警。
真正麻烦的是,副作用会逐步叠加。
支持团队开始反馈重复通知,财务开始追问为什么同一笔 payout 被发送了两次,第三方 API 的 rate limit 突然也变成了自己的问题。到了这一步,一个原本看似只是简单自动化脚本的 cron job,就会迅速演变成一次生产事故。
从生产环境经验来看,"cron job 跑了两次" 往往并不是单一 bug,而是多种错误假设叠加后的结果:对时间的假设、对 PHP 进程模型的假设、对服务器行为的假设,以及对“每分钟执行一次”到底意味着什么的误解。
对于会发送邮件、同步数据、调用 API、处理支付或清理记录的 PHP 应用来说,这类问题值得认真理解。
为什么重复执行的 Cron 比想象中更危险
一个 cron job 跑了两次,带来的并不只是“小麻烦”,它会直接改变系统行为,而且代价常常不低。
比较直观的问题包括:
- 重复邮件
- 重复写入数据库
- 重复 API 请求
- 重复扣款
- 重复重建缓存
还有一些问题更隐蔽:
- 破坏状态的 race condition
- 被放大的指标数据
- 被外部 API 封禁或限流
- 重叠任务导致的 CPU 和内存峰值
- 只会在高负载下偶发、难以复现的 bug
实际损害程度,取决于 job 本身在做什么。如果它只是生成一份报表,影响可能有限;但如果它涉及 billing、inventory 或 authentication 这类系统,后果往往会很快升级。
实际上发生了什么
从最基本的层面看,cron 只是一个调度器。它做的事情很简单:在指定时间启动一个命令,仅此而已。
它并不知道上一次执行是否还没结束,也不知道这条命令是否允许重复执行,更不知道 PHP 脚本内部是在发邮件、改动账务记录,还是调用一个脆弱的第三方 API。
它只会再次启动这个进程。
这意味着,如果一个 job 被设为“每分钟执行一次”,但有时候它会跑到 75 秒,那么 cron 完全可能在上一次尚未结束时就启动下一次实例。此时,重叠执行就出现了。
可以用下面这个模型来理解:
- Cron 是闹钟
- PHP 是执行工作的 worker
- 代码本身决定这份工作是否安全
- 基础设施决定了有多少 worker 可能会意外重复处理同一份工作
如果脚本假设“同一时刻只有一个进程存在”,而真实环境允许同时出现两个甚至三个进程,那么重复处理并不意外,它反而是默认结果。
为什么 PHP 环境里更容易忽视这个问题
很多 PHP 开发者长期处在 request-response 的思维里:一次 Web request 开始、执行一些逻辑,然后结束。每次请求之间通常彼此隔离。
Cron job 看起来很像这种短生命周期脚本,所以很容易被当成同一类问题处理。但后台任务的行为其实完全不同:
- 执行时间会波动
- 网络调用可能不可预测地变慢
- 数据库锁会拖慢进度
- 部署过程可能会在 job 运行中途重启它
- 多台服务器可能同时调度同一条命令
换句话说,后台任务所在的世界里,时间因素比很多人预期的更重要。
导致重复执行的常见错误
下面这些问题,是生产环境里最常见的来源。
假设 Cron 能保证“Exactly Once”
这是最经典的误解。
像下面这样的 cron 配置:
* * * * * php /var/www/app/cron/send-reminders.php它并不表示“安全地执行一次,并且不会重叠”,它真正表达的意思只是:每分钟尝试启动一次这条命令。
它会带来的问题包括:
- 重叠执行
- 重复副作用
- 当任务执行时间超出预期时,系统状态变得不可预测
用时间条件查数据,却没有原子化地标记工作
一种很常见的写法如下:
$rows = $pdo->query("
SELECT id, email
FROM reminders
WHERE sent_at IS NULL
LIMIT 100
")->fetchAll();然后脚本再遍历这些结果并发送邮件。
问题也很直接:如果两个 job 实例同时启动,它们都可能在任意一方更新 sent_at 之前,读到同一批尚未发送的数据。
这会导致:
- 重复处理
- 重复邮件或重复 API 调用
- 报表和统计出现不一致
完全没有加锁
有些团队的“锁策略”其实只是:这个脚本平时足够快。
这种做法只有在一切都正常时才勉强成立。一旦数据库变慢、API 延迟升高、DNS 出问题,或者云存储超时,一个平时很快的 job 就可能拖到和下一次调度重叠。
这会带来:
- 同一 job 的并行执行
- 很难在测试环境复现、只会在生产里出现的问题
- 事故期间额外的资源峰值
多台服务器运行了同一个 Cron
随着系统增长,这种情况会越来越常见。
最开始可能只有一台 VM;后来扩容成两台应用服务器,并挂在同一个 load balancer 后面;部署脚本又把同一份 crontab 同时下发到了两台机器上。结果就是,两台服务器都会执行同一条定时任务。
从服务器角度看,这并没有任何异常;但从业务逻辑上看,原本“每分钟执行一次”的任务,已经变成了“每分钟执行两次”。
这会导致:
- 跨节点重复执行后台任务
- 额外的 API 请求
- 事故排查时出现误导,因为每台服务器单独看都像是正常的
Job 本身不是幂等的
所谓 idempotent,指的是一段逻辑即使执行多次,也能得到相同的最终结果。
很多 PHP cron 脚本并不是按这个标准设计的。它们默认只有单一执行路径,一上来就触发副作用,比如:
- 发邮件
- 扣款
- 创建记录
- 调用 webhook
一旦同一份输入被处理两次,副作用也会执行两次。
这类问题直接影响:
- 账务正确性
- 用户信任
- 对外集成在使用次数上的约束
日志很差,也没有 run correlation
很多 cron 脚本今天依然只靠 echo、var_dump(),或者几乎没有日志。
可一旦出现重复执行,就必须快速回答这些问题:
- 一共启动了多少个 job 实例?
- 分别由哪台主机执行?
- 哪些记录被取走了?
- 从哪里开始发生重叠?
- 第二次运行到底有没有真正重复触发副作用?
如果没有结构化日志,最后通常只能靠猜。
这会直接拖慢:
- 调试效率
- postmortem 质量
- 事故处理中对系统状态的判断信心
一个糟糕的示例
下面这段代码看起来很常见,但正是最容易在生产环境里引发问题的那类脚本:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$rows = $pdo->query("
SELECT id, email
FROM reminders
WHERE sent_at IS NULL
LIMIT 100
")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
mail($row['email'], 'Reminder', 'Your reminder message');
$stmt = $pdo->prepare("
UPDATE reminders
SET sent_at = NOW()
WHERE id = ?
");
$stmt->execute([$row['id']]);
}它的问题包括:
- 没有锁来防止重叠执行
- 工作是在被真正 claim 之前就先查询出来的
mail()很难观测,也不利于安全重试- 数据库凭证直接写在代码里
- 没有错误处理
- 没有结构化日志
- 在多服务器环境里不安全
如果两个进程同时启动,它们都可能读到同一批记录,也都可能发出同样的提醒邮件。
更稳妥的做法
修复这类问题,通常不是靠单一技巧,而是一组工程习惯的组合。
更安全的设计通常应当包含:
- 防止并发运行的锁
- 原子化的工作认领(claiming)
- 在可行时尽量保证幂等
- 结构化日志
- 明确的失败处理路径
下面是一个更实际的例子:使用文件锁配合数据库更新。
更好的示例:Lock、Claim、Process、Log
<?php
declare(strict_types=1);
$lockFile = fopen(sys_get_temp_dir() . '/send-reminders.lock', 'c');
if ($lockFile === false || !flock($lockFile, LOCK_EX | LOCK_NB)) {
error_log(json_encode([
'event' => 'job_skipped',
'reason' => 'another_instance_running',
'job' => 'send_reminders',
]));
exit(0);
}
$pdo = new PDO(
dsn: 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
username: $_ENV['DB_USER'],
password: $_ENV['DB_PASS'],
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$runId = bin2hex(random_bytes(8));
error_log(json_encode([
'event' => 'job_started',
'job' => 'send_reminders',
'run_id' => $runId,
]));
$pdo->beginTransaction();
$stmt = $pdo->prepare("
SELECT id, email
FROM reminders
WHERE sent_at IS NULL
AND processing_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE
");
$stmt->execute();
$rows = $stmt->fetchAll();
$claimStmt = $pdo->prepare("
UPDATE reminders
SET processing_at = NOW()
WHERE id = :id
AND processing_at IS NULL
");
foreach ($rows as $row) {
$claimStmt->execute(['id' => $row['id']]);
}
$pdo->commit();
$sendStmt = $pdo->prepare("
UPDATE reminders
SET sent_at = NOW(), processing_at = NULL
WHERE id = :id
");
foreach ($rows as $row) {
// Replace with your mailer or API client
error_log(json_encode([
'event' => 'sending_reminder',
'run_id' => $runId,
'reminder_id' => $row['id'],
'email' => $row['email'],
]));
$sendStmt->execute(['id' => $row['id']]);
}
error_log(json_encode([
'event' => 'job_finished',
'job' => 'send_reminders',
'run_id' => $runId,
'processed' => count($rows),
]));这段代码并不适用于所有工作负载,但相比前面的例子已经安全得多,因为它至少做对了几件关键的事:
- 用
flock()防止重叠执行 - 记录
run_id - 在真正处理前先 claim 工作
- 使用 prepared statement
- 用环境变量保存敏感信息
- 产出可搜索的日志
为什么加锁有帮助
在单机环境里,flock() 往往已经够用。
它适合这些场景:
- 只有一台机器负责运行 cron
- 锁文件位于本地存储
- 只是需要一个简单的重叠保护
但如果任务会在多台服务器上执行,那么 flock() 就不够了。这时需要共享锁策略,例如:
- 数据库 advisory lock
- 基于 Redis 的锁
- 带 worker 协调能力的队列系统
- 单独的 scheduler 实例
让 Job 具备幂等性
即使已经加了锁,也应该按“重复执行仍有可能发生”的前提来设计。
这听起来有些悲观,但它才更符合生产环境现实。
典型的幂等设计包括:
- 在调用支付 API 前先保存唯一外部请求 ID
- 用唯一索引阻止数据库里的重复写入
- 使用稳定的业务键标记任务已完成
- 让 webhook 处理逻辑可以安全重放
比如,假设是创建发票,不应该只依赖“这段代码本来只会执行一次”,还应该在数据库层强制唯一性:
<?php
$stmt = $pdo->prepare("
INSERT INTO invoices (order_id, total_amount, created_at)
VALUES (:order_id, :total_amount, NOW())
");
try {
$stmt->execute([
'order_id' => $orderId,
'total_amount' => $totalAmount,
]);
} catch (PDOException $e) {
if ((int) $e->errorInfo[1] === 1062) {
error_log("Invoice already exists for order {$orderId}");
} else {
throw $e;
}
}这段写法默认数据库里已经对 order_id 建了唯一索引。这个索引本身就是安全模型的一部分,而不只是一个数据库细节。
一个简短的生产案例
原文提到,一个团队曾经有一项夜间 PHP job,用来把用户数据同步到第三方 CRM。在只有一台服务器时,这套流程连续几个月都运转正常。后来因为流量增长,系统扩容到两台应用节点,并把同一份 cron 配置同步到了两台机器上。结果就是,这个同步任务每天夜里都会执行两次,CRM API 也随之开始对请求限流。
更麻烦的是,最初的修复方向还放在“API retry 有问题”上,而不是真正根因:跨主机重复调度。
这也是为什么 cron 事故往往很贵。真正可见的故障,很多时候只是更底层设计错误的下游表现。
现代 Web 应用里的生产注意事项
今天的 cron job 早已不只是清理临时文件。它们还会和 API、队列、云服务、缓存、计费系统以及安全敏感流程发生交互。这也意味着,对它们的设计方式必须升级。
Security
Cron 脚本往往拥有较高权限,因此如果处理得太随意,风险并不低。
建议保持以下默认做法:
- 使用环境变量或 secret manager 管理凭证
- 不要在代码里硬编码数据库密码或 API key
- 限制脚本和锁文件的文件系统权限
- 向外部系统发送数据前先做校验
- 如果任务结果会出现在管理后台或邮件中,输出时要做好转义
如果一个 cron job 会触及 authentication、password reset 或 user export 等流程,就应该按敏感后端代码来对待,而不是把它视为普通维护脚本。
Scaling
单机时代的假设,扩容后通常会悄悄失效。
随着应用变大,需要确认:
- 究竟哪台机器是 scheduler
- 容器或实例是否会重复运行同一个 cron
- 部署过程是否可能触发并发执行
- 这类任务是否其实更适合进入 queue,而不是直接交给裸 cron
一个很常见的现代模式是:让 cron 只负责 enqueue 工作,再由 worker 以可控批次和更安全的方式处理这些任务。
Observability
提升可观测性,不一定需要一整套庞大的平台。
最少也应该记录这些信息:
- job 名称
- run ID
- host name
- 开始和结束时间
- 选中的记录数
- 成功处理的记录数
- 失败的记录数
- 在需要时记录 retry 次数
只要每次运行都能被明确追踪,调试时间通常就会显著缩短。
Caching and Performance
有些 cron job 会负责重建缓存、预热页面,或者预先计算一些代价较高的结果。
如果这些 job 发生重叠,就可能带来:
- 重复 CPU 开销
- cache stampede
- 更高的数据库压力
- 对在线流量造成额外延迟
对于这类工作,应该把它当作真实生产流量来对待:限速、分批处理,并避免同一份昂贵计算被重复执行。
Deployment and Release Safety
部署过程经常会制造一些奇怪的时间窗口。
例如:
- 新旧代码同时处理同一批记录
- 发布过程重启了一个尚未执行完成的 job
- schema 变更时,仍在运行的旧 cron 脚本与新结构不兼容
相对稳妥的习惯包括:
- 使用 backward-compatible migration
- 在必要时对 job 做显式版本管理
- 在高风险发布前先 drain worker
- 在重大变更期间暂停某些 schedule
API Usage
外部 API 通常是重复执行问题里成本上升最快的地方。
可以通过以下方式保护自己:
- 如果 API 支持,就使用 idempotency key
- 做请求去重
- retry 要带 backoff,而不是盲目重试
- 区分“已尝试”和“已确认成功”的动作追踪
如果一个 job 需要和 Stripe、CRM、webhook consumer 或云服务交互,就应该默认认为网络异常一定会发生,而重复执行也必须被显式处理。
一个实用的调试辅助函数
当 cron 相关问题真正发生时,需要的是易于 grep 和对比的日志,而不是零散的 var_dump()。
像下面这样一个简单 helper,通常就比散落在各处的调试输出更有用:
<?php
function logEvent(string $event, array $context = []): void
{
error_log(json_encode([
'ts' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
'event' => $event,
...$context,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
logEvent('job_started', [
'job' => 'sync_users',
'run_id' => bin2hex(random_bytes(8)),
'host' => gethostname(),
]);它可以在不引入完整日志栈的前提下,先得到一份可搜索、结构化的输出。
排查清单
当怀疑某个 PHP cron job 跑了两次时,可以按下面顺序检查。
先确认是否真的发生了重复
先找证据,而不是先做假设。
- 副作用是否真的重复出现?
- 同一条记录是否被处理了两次?
- 是否存在来自不同进程 ID 或不同主机的重复日志?
检查调度来源
先确认这条 job 究竟是从哪里被调度的:
- system crontab
- user crontab
- container entrypoint
- cloud scheduler
- platform automation
相当多的事故,最后都是同一条命令被配置在两个地方导致的。
检查是否存在重叠执行
对比运行时长与调度频率。
- 是否每分钟执行一次,但有时会跑超过一分钟?
- 是否确实存在两个实例同时活跃?
- 进程列表和日志里能否看到并发运行痕迹?
检查是否存在多服务器执行
确认是不是有不止一台机器在启动同一个 job:
- VM 副本
- Kubernetes Pod
- blue-green deployment 环境
- autoscaled 实例
在现代基础设施里,这通常是最先该排查的一项。
检查数据认领逻辑
直接去看“选取待处理数据”的那段查询。
- 工作是否被原子化地 claim?
- 两个 runner 是否可能在更新发生前读到同一批数据?
- 有没有事务或锁参与其中?
审视幂等性
问一个不太舒服但必须面对的问题:如果这段脚本真的执行两次,究竟是什么在阻止损害发生?
- unique constraint
- idempotency key
- 状态流转控制
- 安全重试机制
如果答案是“没有”,那通常就说明设计上已经存在缺口。
在下一次事故前补齐日志
即使已经找到根因,也应该借这次事故补强可观测性。
至少增加这些信息:
run_idhost name- 耗时
- claim 到的记录 ID 或数量
- 结构化错误细节
FAQ
Cron 自己有 bug,才会导致任务跑两次吗?
通常不是。绝大多数情况下,cron 只是在按预期工作:按时间启动命令。重复行为真正的来源,往往是运行时重叠、多处调度,或者应用逻辑本身不具备并发安全性。
flock() 够用吗?
在很多单机部署里,够用。但如果系统是分布式的,或者同一个 job 可能在多台服务器、多份容器实例上运行,那么它就不够了。此时需要共享锁或中心化调度器。
应该用 cron,还是应该上队列?
cron 适合做调度;如果工作本身很重、执行慢、需要 retry,或者需要更强的并发控制,那么更适合交给 queue。一个非常常见的模式就是:cron 只负责安排任务,真正执行交给 worker。
Cron job 多久记一次日志比较合适?
最少应该记录:开始、结束、错误,以及可追踪的运行标识。只要 job 带有副作用,就还应该记录 claim 了什么,以及最终真正完成了什么。
只靠数据库事务能解决问题吗?
不一定。事务有助于保证一致性,但它并不能自动解决重复调度或重复副作用问题。系统仍然需要明确的并发控制策略。
结语
Cron job 的危险性,并不在于 PHP 本身不够强,而在于后台任务会把很多在请求型代码里被掩盖的假设直接暴露出来。
理解这一点之后,修复路径通常就会清晰很多。
可以把关键结论概括为:
- cron 不保证 exactly-once execution
- 当任务执行时间超过调度频率时,重叠执行是正常现象
- 在多服务器和云环境里,重复执行问题会更严重
- locking 很重要
- 原子化的工作认领很重要
- 即使已经加锁,idempotency 仍然很重要
- 结构化日志会大幅缩短排障时间
- API 调用、账务流程、缓存重建和安全相关工作流都需要额外谨慎