☰
PHP8.0定时任务重复执行怎么解决
2026/10/2 4:05:05 网站建设 项目流程

前言

* * * * * php /var/www/app/cron/sync_orders.php写进 crontab 之后,过了一阵子发现:订单被同步了两遍,库存被扣了两次,短信发了两条。查日志,同一个任务在同一个时间点有两份并行输出。

这类问题的机制非常固定:cron 只负责"到点拉起进程",它不关心上一个进程有没有跑完。任务原本只需要 5 秒,某天因为数据量上涨或者某个接口变慢,跑到了 90 秒,而 cron 每分钟都会准时拉起一个新的——于是第 2 分钟的新进程和第 1 分钟还没结束的老进程同时在跑,处理着同一批数据。如果代码里没有幂等保护,重复执行就是必然结果。

需要说明的是版本这件事:"防止定时任务重复执行"不是 PHP 8.0 的特性,PHP 8.0 也没有提供任何防重入机制,这个问题和 PHP 版本基本无关,7.x 和 8.x 的做法完全一样。之所以标题里带 8.0,是因为很多项目在升级到 8.0 之后才暴露这个问题(8.0 起未定义常量变成Error、部分原本静默的行为开始抛异常,让原本"卡住重跑"的任务变得更显眼)。本文按事实讲:防重入靠的是锁,不是版本特性。

三条可行路线,从轻到重:文件锁(flock(),零依赖)、外部锁(Redis / MySQL 的命名锁,适合多机部署)、架构调整(常驻进程 + 调度器,把"每拉起一个进程"改成"一直活着按计划执行")。本文给出前两条的完整代码,第三条只讲思路。

一、为什么"判断上一次跑没跑完"不能靠 ps / pgrep

最直觉的做法是启动时pgrep -f sync_orders.php,有结果就退出。它的问题有三个:


  • 匹配不准:-f匹配整条命令行,sync_orders.php和sync_orders_archive.php会互相命中。

  • 存在竞态:两个进程几乎同时启动、几乎同时pgrep,双方都认为"没有别人在跑",然后一起往下执行。检查与启动之间存在时间窗口。

  • 无法泛化:多机部署时pgrep只看得见本机。


正确做法是用一个原子的获取操作来当"入场券",拿到了才干活,拿不到直接退出。flock()、Redis 的SET NX、MySQL 的GET_LOCK()都是原子的。

二、文件锁:flock() 的用法与边界

flock(resource $stream, int $operation, &$would_block = null): bool是 PHP 内置函数(无需扩展)。关键参数是LOCK_EX | LOCK_NB:排他锁 + 非阻塞。非阻塞意味着"拿不到就立刻返回false",而不是傻等——这正是防重入需要的语义。

<?php declare(strict_types=1); final class FileLock { /** @var resource|null */ private $handle = null; public function __construct(private string $path) {} public function acquire(): bool { $h = @fopen($this->path, 'c'); // 'c' 模式:不存在则创建,存在则打开且不截断 if ($h === false) { return false; } if (!flock($h, LOCK_EX | LOCK_NB, $wouldBlock)) { fclose($h); return false; // 已有人持锁,直接放弃 } // 拿到锁之后再写自己的 pid,便于排查是谁在跑 ftruncate($h, 0); rewind($h); fwrite($h, (string) getmypid()); fflush($h); $this->handle = $h; return true; } public function release(): void { if ($this->handle === null) { return; } flock($this->handle, LOCK_UN); fclose($this->handle); // 关闭句柄即释放锁 $this->handle = null; } }

三个必须知道的边界:


  • 锁随句柄释放:脚本正常结束、fclose()、甚至进程被kill,锁都会释放。所以不会出现"进程崩了锁永远拿不回来"的死锁。

  • 不要unlink()锁文件:删掉文件并不能释放你持有的锁,反而让后续进程在新 inode 上创建新文件并成功加锁,锁形同虚设。锁文件应该一直留着。

  • NFS / 部分网络文件系统上flock()不可靠:在容器共享卷、跨主机挂载的目录上,锁语义可能退化。多机部署请直接用下面的外部锁。


还有一个容易忽略的点:如果脚本里调用了pcntl_fork(),子进程会继承父进程的文件句柄,也就继承了锁。父进程退出后,只要子进程还活着,锁就不会释放。

三、外部锁:Redis 与 MySQL

多机部署时,锁必须放在所有机器都能访问的地方。

Redis 方案(需要 phpredis 扩展或 Predis 库)。核心是SET key value NX EX seconds这一条原子命令:只有 key 不存在时才设置成功,同时带上过期时间,避免进程被强杀后死锁。

<?php declare(strict_types=1); final class RedisLock { public function __construct( private Redis $redis, private string $key, private int $ttl = 300 ) {} public function acquire(): ?string { // token 用于释放时校验持有者,避免误删别人的锁 $token = bin2hex(random_bytes(16)); $ok = $this->redis->set($this->key, $token, ['nx', 'ex' => $this->ttl]); return $ok ? $token : null; } /** 续期:任务可能比预期跑得久,在关键节点调一次 */ public function renew(string $token): bool { $script = <<<'LUA' if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) end return 0 LUA; return (bool) $this->redis->eval($script, [$this->key, $token, $this->ttl], 1); } public function release(string $token): void { // 必须"比较再删除",且要原子,所以用 Lua $script = <<<'LUA' if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0 LUA; $this->redis->eval($script, [$this->key, $token], 1); } }

MySQL 方案(不需要额外组件,只需一个数据库连接)。GET_LOCK(str, timeout)返回 1 表示拿到锁、0 表示超时、NULL表示出错;RELEASE_LOCK(str)释放。

SELECT GET_LOCK('cron:sync_orders', 0) AS got; -- 拿到锁(got = 1)才执行任务 SELECT RELEASE_LOCK('cron:sync_orders');

MySQL 的命名锁是连接级的:必须在同一个连接上GET_LOCK和RELEASE_LOCK,连接断开时锁自动释放。所以不能"开一个连接加锁、另一个连接释放"。它的优势是无需引入 Redis,天然支持多机;代价是占用一个数据库连接,且锁名是全局的,要起一个不易冲突的前缀。

四、配套措施:幂等与超时

锁解决的是"同时跑",不解决"跑了两遍"。真正健壮的定时任务还需要两件事:

幂等(idempotency):让任务重复执行也不产生副作用。常见手法是用唯一键约束兜底,例如同步订单时用INSERT ... ON DUPLICATE KEY UPDATE,或者记一张sync_cursor表记录"处理到哪个时间点/哪个 ID",每次从游标继续,而不是"处理最近一小时的全部数据"。

超时控制:脚本跑飞了要有上限。

<?php declare(strict_types=1); set_time_limit(0); // CLI 下默认为 0(不限制),显式写出来避免依赖环境 ignore_user_abort(true); // 命令行下通常无意义,但显式声明意图 $start = microtime(true); const MAX_RUNTIME = 240; // 最长 4 分钟,留出余量给下一轮 // 在循环里检查 // while (...) { // if (microtime(true) - $start > MAX_RUNTIME) { // error_log('sync_orders 达到最长运行时间,主动退出'); // break; // } // }

一个经验性的配置策略:把 cron 的间隔设成大于任务的正常耗时上限。如果任务正常需要 90 秒,就写成*/2 * * * *而不是每分钟一次;锁是兜底,不是常态。

实战:完整可运行示例

下面这个脚本用flock()实现防重入,并通过pcntl_fork()模拟"两个进程同时启动",能直观看到第二个进程被拒绝。需要 PHP 8.0 及以上(用到了构造函数属性提升等 8.0 语法),CLI 下运行,且需要pcntl扩展。

<?php declare(strict_types=1); final class FileLock { /** @var resource|null */ private $handle = null; public function __construct(private string $path) {} public function acquire(): bool { $h = @fopen($this->path, 'c'); if ($h === false) { return false; } if (!flock($h, LOCK_EX | LOCK_NB)) { fclose($h); return false; } ftruncate($h, 0); rewind($h); fwrite($h, (string) getmypid()); fflush($h); $this->handle = $h; return true; } public function release(): void { if ($this->handle === null) { return; } flock($this->handle, LOCK_UN); fclose($this->handle); $this->handle = null; } } /** * 真正的业务:这里必须写成幂等的 */ function doSync(int $workerId, int $seconds): void { printf("[worker %d] 开始同步,预计耗时 %d 秒\n", $workerId, $seconds); for ($i = 1; $i <= $seconds; $i++) { sleep(1); printf("[worker %d] 处理中... %d/%d\n", $workerId, $i, $seconds); } printf("[worker %d] 完成\n", $workerId); } function runTask(string $lockPath, int $workerId, int $seconds): void { $lock = new FileLock($lockPath); if (!$lock->acquire()) { // 拿不到锁就是"上一轮还没跑完",这是正常退出,不是错误。 // 锁文件里存着持锁进程的 pid,直接读出来便于排查是谁在跑。 $holder = (int) @file_get_contents($lockPath); printf("[worker %d] 已有任务在运行(pid=%d),本次跳过\n", $workerId, $holder); exit(0); } try { doSync($workerId, $seconds); } finally { // 必须在 finally 里释放,异常路径也不能漏 $lock->release(); } } $lockPath = sys_get_temp_dir() . '/cron_sync_orders.lock'; $seconds = 3; if (function_exists('pcntl_fork')) { // 模拟 cron 在上一轮还没结束时又拉起一个进程 $pid = pcntl_fork(); if ($pid === 0) { usleep(500_000); // 子进程晚半秒启动,模拟"下一分钟" runTask($lockPath, 2, $seconds); exit(0); } runTask($lockPath, 1, $seconds); pcntl_wait($status); echo "主进程结束\n"; } else { // 没有 pcntl 时退化成串行演示 runTask($lockPath, 1, $seconds); // 再跑一次:此时锁已释放,会正常执行 runTask($lockPath, 2, 1); }

预期的输出形态:

[worker 1] 开始同步,预计耗时 3 秒 [worker 1] 处理中... 1/3 [worker 2] 已有任务在运行(pid=12345),本次跳过 [worker 1] 处理中... 2/3 [worker 1] 处理中... 3/3 [worker 1] 完成 主进程结束

worker 2那一行就是防重入生效的证据:它照常被 cron 拉起、照常执行了脚本、照常退出了(退出码 0,不会让 cron 发告警邮件),但没有碰任何业务数据。

在真实项目里,runTask()的开头再加一行日志和多机场景下的 Redis 锁即可:

$lock = new RedisLock($redis, 'cron:sync_orders', 600); $token = $lock->acquire(); if ($token === null) { error_log('sync_orders 已在其他节点运行,跳过'); exit(0); } try { doSync(1, 60); } finally { $lock->release($token); }

常见坑点


  1. ❌ 用file_exists()或自己写的"标记文件"判重:判断与写入之间不是原子的,两个进程会同时通过检查。


✅用flock()、RedisSET NX或 MySQLGET_LOCK()这类原子操作,判断和占用一步完成。


  1. ❌ 任务结束时unlink()锁文件:删文件不释放锁,反而让下一个进程在新 inode 上立刻加锁成功,出现"看起来有锁却完全没效果"的现象;更糟的是持锁进程还在跑。


✅锁文件保留不动,靠flock()的句柄释放锁。


  1. ❌ 只在脚本开头加锁,忘了异常路径:任务中途抛异常,fclose()没执行到(在部分场景下句柄仍被 PHP 释放,但如果用了外部锁就真的会泄漏),下一次任务被自己挡住。


✅用try/finally包住业务逻辑,finally里释放;外部锁还要设置合理的 TTL 作为兜底。


  1. ❌ Redis 锁不设过期时间:进程被kill -9后 key 永远留在 Redis 里,任务从此再也不会执行,而且没有任何报错——最阴的一种故障。


✅SET key value NX EX 300,TTL 取任务最长耗时的 1.5 至 2 倍,并在长任务里续期。


  1. ❌ 释放 Redis 锁时直接DEL:任务 A 超时释放了锁,任务 B 拿到了锁,A 随后才真正结束并执行DEL,把 B 的锁删掉了。


✅释放时比较持有者 token,用 Lua 脚本做原子的"比较再删除"。


  1. ❌ MySQLGET_LOCK和RELEASE_LOCK用了两个连接:命名锁是连接级的,另一个连接上释放的是"不存在的锁",实际锁还在,任务被永久挡住。


✅加锁、执行业务、释放锁全程使用同一个连接对象。


  1. ❌ 在 NFS 或容器共享卷上用flock():锁语义在网络文件系统上可能不生效,本机测试正常,一上多机集群就重复执行。


✅多机部署改用 Redis / MySQL 的外部锁。


  1. ❌ 只靠锁不做幂等:锁只能防"同时跑",防不住"跑了两遍"——例如上一轮中途被杀、这一轮从头再处理同一批数据。


✅用唯一键约束、状态字段或游标表把业务逻辑写成幂等的,锁只是第一道防线。

总结

方案依赖适用规模关键点
flock()文件锁无单机`LOCK_EX \
RedisSET NX EXRedis 扩展/客户端多机必须设 TTL;释放要比较 token(Lua)
MySQLGET_LOCK()一个数据库连接多机连接级锁,加锁与释放必须同一连接
常驻进程 + 调度器常驻运行时大规模取消 cron 拉起,改为进程内按计划触发
业务幂等无所有规模唯一键、状态机、游标续跑


"PHP 8.0 定时任务重复执行"这个说法里,PHP 8.0 并不是问题的成因,也不提供解法——重复执行的机制永远是"cron 只管拉起、不管结束",而这个机制从 cron 诞生起就没变过。真正的解法是给任务加一把原子的、能自动释放的锁,再把业务本身写成幂等的。装上锁只需要十几行代码,但顺序上建议先做幂等:锁能挡住并发,只有幂等能挡住"重跑"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询