PHP 应用使用 Cron 执行定时任务时,一个非常常见的问题是上一次任务还没有结束,下一次调度已经启动。比如 Cron 每分钟执行一次 PHP 脚本,但脚本有时需要三分钟才能完成,那么同一个任务就可能同时存在多个进程。如果这些进程同时处理数据库记录、生成文件、发送邮件或者调用外部API,就可能造成重复处理。
这里首先要区分两个概念:防止任务同时运行,以及保证任务即使因为异常而重复执行也不会产生错误结果。锁主要解决前一个问题,幂等性主要解决后一个问题。一个可靠的 Cron 系统通常需要两者配合,而不能认为加了一个锁以后任务就绝对不会重复。
对于单台服务器上的 PHP CLI Cron,最简单可靠的方法通常是 flock()。PHP 官方文档将它定义为文件级的 advisory locking,也就是参与竞争的进程都必须遵守这个锁。使用 LOCK_EX | LOCK_NB 可以让第二个进程在发现已有任务运行时立即退出,而不是一直等待。锁由文件句柄持有,文件句柄关闭后锁会释放,因此没有必要因为锁文件本身存在就进行所谓的“残留锁清理”。
$lockFile = '/tmp/my_task.lock';
$fp = fopen($lockFile, 'c');
if ($fp === false) {
fwrite(STDERR, "Cannot open lock file\n");
exit(1);
}
if (!flock($fp, LOCK_EX | LOCK_NB, $wouldBlock)) {
fclose($fp);
exit(0);
}
try {
// 真正的任务逻辑
runTask();
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
这里真正重要的是 LOCK_NB。如果任务已经运行,新的 Cron 进程立即退出,而不是排队等待。对于“同一个任务只允许同时运行一个实例”的场景,这通常就是最简单的解决方案。
但 flock() 有一个重要限制:它依赖共享的文件系统语义。如果 PHP Cron 分布在多台服务器上,而这些服务器没有使用能够正确提供锁语义的共享文件系统,那么每台服务器自己的本地文件锁无法互相看见。此时就需要把锁放到一个所有应用实例都能够访问的共享系统中。
数据库也可以承担任务锁的角色,但这里最容易出现错误。SELECT ... FOR UPDATE 不是一个独立的“抢锁函数”,而是事务中的锁定读取。MySQL InnoDB 中,FOR UPDATE 获得的锁会一直保持到事务提交或回滚;如果另一个事务请求同一行,它通常会等待持锁事务结束。
因此,正确的数据库锁应该建立一条永久存在的任务记录,例如:
CREATE TABLE task_lock (
task_name VARCHAR(100) PRIMARY KEY,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB;
然后在 PHP 中明确开启事务:
$pdo->beginTransaction();
$stmt = $pdo->prepare("
SELECT task_name
FROM task_lock
WHERE task_name = ?
FOR UPDATE
");
$stmt->execute(['daily_news']);
$row = $stmt->fetch();
if (!$row) {
$pdo->rollBack();
exit;
}
// 只有在事务仍然保持期间才能认为锁有效
runTask();
$pdo->commit();
不过这种写法还有一个非常现实的问题:如果整个任务需要运行几分钟,就意味着数据库事务也可能持续几分钟。长事务会持有数据库锁,并增加锁等待、连接占用和死锁处理的复杂度。因此,不应该为了防止 Cron 重复执行,就简单地把整个耗时任务包进一个长事务。
如果数据库只是用来协调任务启动,更适合采用“短事务抢占任务状态”的设计。例如使用一个唯一键和原子 UPDATE,让只有一个进程能够把任务从 pending 改成 processing。数据库完成状态变更后立即提交事务,真正耗时的工作在事务外执行。
这种设计比“整个任务期间一直持有数据库行锁”更加适合长时间运行的 Cron。
对于需要跨服务器运行的 PHP 应用,Redis 也可以用于分布式互斥。Redis 官方推荐的基本模式不是单独使用 SETNX 后再另外设置过期时间,而是使用一个原子操作,例如:
SET task:daily_news random-token NX PX 30000
NX 表示只有键不存在时才设置,PX 设置过期时间,而随机 token 用来确认锁的所有者。这样可以避免某个任务崩溃以后永久占有锁。
但释放 Redis 锁时不能简单执行:
$redis->del($lockKey);
原因很简单:假设任务A获得锁,但执行时间超过了30秒,锁已经自动过期;此时任务B获得了同一个锁。任务A完成以后,如果直接执行 DEL,删除的其实可能已经是任务B的锁。
正确思路是删除之前检查 token 是否仍然属于当前任务,而且检查与删除必须是原子的。Redis 官方文档给出的方案就是使用随机值识别锁的拥有者,并通过脚本只删除仍然属于自己的锁。
因此,Redis 锁的核心不是简单的“SETNX + DEL”,而是:
获取锁:
SET key unique_token NX PX timeout
释放锁:
只有 key 当前保存的 token
仍然等于自己的 unique_token 时
才删除 key
另一个必须解决的问题是锁的过期时间。如果任务通常需要20秒,但偶尔需要两分钟,而锁只设置30秒,那么锁过期以后第二个进程就可能进入,造成两个任务同时运行。对于这种情况,可以设计合理的租约时间,并在任务运行期间续租;如果业务允许,更应该让任务本身具备幂等能力,而不是把全部安全性寄托在一个固定的过期时间上。
“生成 UUID 防止重复执行”则是另一个经常出现的误区。UUID 的价值是唯一标识一次任务实例,但如果每次 Cron 启动时都重新生成一个 UUID,那么两个重复启动的进程得到的是两个不同的 UUID。系统根本不知道它们实际上执行的是同一个业务任务。
真正有意义的做法是使用稳定的业务唯一键。例如任务名称加执行时间窗口:
daily_news:2026-09-20-18:00
然后在数据库建立唯一索引。如果两个进程同时尝试创建同一个任务实例,其中一个成功,另一个因为唯一约束失败。这个机制可以很好地解决“同一个业务事件只能被登记一次”的问题。
例如:
CREATE TABLE task_runs (
task_key VARCHAR(200) PRIMARY KEY,
status VARCHAR(20) NOT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL
) ENGINE=InnoDB;
PHP 可以先尝试插入:
$stmt = $pdo->prepare("
INSERT INTO task_runs
(task_key, status, started_at)
VALUES
(?, 'processing', NOW())
");
try {
$stmt->execute([$taskKey]);
} catch (PDOException $e) {
// 如果 task_key 已经存在,
// 说明这个业务实例已经被其他进程登记
exit;
}
实际项目中应该根据数据库驱动的错误码精确判断是否属于唯一键冲突,而不是把所有数据库异常都当成“任务已经运行”。
状态机同样适合需要记录任务生命周期的系统。例如:
pending
processing
completed
failed
任务开始时通过原子操作把 pending 变成 processing,成功后改成 completed,异常则记录为 failed。如果任务长时间保持 processing,还可以通过 started_at 和心跳时间判断任务是否已经失去执行者。
这比简单删除一条“锁记录”更加可靠,因为系统可以保留历史执行记录,也可以知道任务究竟是正在运行、成功完成还是异常退出。
对于队列型任务,还可以利用数据库锁定读取的 NOWAIT 或 SKIP LOCKED 等能力,让多个消费者竞争任务记录,而不是让所有消费者等待同一条记录。MySQL 文档明确说明,SKIP LOCKED 可以用于避免队列类表中的锁竞争,但返回的数据是不一致视图,因此不适合普通事务查询。
真正成熟的 Cron 系统还必须考虑一个经常被忽略的问题:即使锁完全正确,业务仍然可能重复执行。
例如任务已经向第三方支付接口提交请求,随后 PHP 进程突然崩溃。数据库中的状态可能仍然是 processing,下一次任务重新执行时,如果没有幂等键,就可能再次提交支付请求。这个问题不是文件锁、MySQL 锁或者 Redis 锁能够完全解决的。
因此,对于发送邮件、生成订单、扣款、发布文章、调用第三方API、写入消息队列等操作,应尽量设计业务幂等键。例如:
order_id
payment_id
article_id
task_key
external_request_id
数据库使用唯一索引限制同一个业务操作只能成功一次;外部API如果支持 idempotency key,也应该使用稳定的业务ID,而不是每次重试都生成新的随机ID。
在实际部署中,可以按照任务运行环境选择方案。单服务器 PHP Cron 通常优先考虑 flock();多服务器共享数据库的系统可以使用数据库原子状态变更或任务表;需要跨服务器协调并且已经部署 Redis 的系统可以使用带唯一 token 和过期时间的 Redis 锁;而涉及支付、订单、文章发布和外部API调用的任务,无论使用哪一种锁,都应该进一步设计幂等机制。
还需要注意,Cron 本身并不是“因为服务器负载高就自动把同一个任务执行两次”。更常见的情况是调度周期小于任务实际执行时间,导致下一次调度启动时上一实例仍然存在;或者系统中配置了重复的 Cron、多个调度器实例,甚至同一个任务同时由 Cron 和应用内部 Scheduler 触发。排查重复执行时,应该首先查看 Cron 配置和任务启动日志,而不是一开始就增加锁。
日志也应该记录任务名称、实例ID、任务开始时间、结束时间、服务器主机名、进程ID以及最终状态。例如:
task=daily_news
instance=9c3...
host=server-02
pid=18342
status=started
time=2026-09-20 18:00:01
这样当两个实例同时出现时,可以迅速判断究竟是调度重复、锁失效,还是任务本身被其他程序再次触发。
最终可以把整个设计理解成三层。第一层是调度控制,确保不应该同时运行的任务不会产生多个执行实例;第二层是并发控制,利用 flock()、数据库原子操作或者 Redis 锁协调多个进程;第三层是业务幂等,即使因为崩溃、超时、网络重试或者人工重新执行而再次进入,也不会产生重复订单、重复发布或者重复扣款。
对于一个真正可靠的 PHP Cron 系统来说,锁解决的是“谁现在可以执行”,幂等解决的是“即使执行两次也不能造成错误结果”。把这两个问题分开设计,通常比单纯寻找一个所谓“万能锁”更加可靠。
多个 Cron 同时运行时,PHP 如何防止同一个任务执行两次
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP