尧图精选

PHP8.0定时任务重复执行怎么解决

🕒 发布时间:2026/10/2 4:05:09 📁 来源:尧图网络
前言* * * * * 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_types1); 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_types1); 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_types1); 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_types1); 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] 已有任务在运行pid12345本次跳过 [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); }常见坑点❌ 用file_exists()或自己写的标记文件判重判断与写入之间不是原子的两个进程会同时通过检查。✅用flock()、RedisSET NX或 MySQLGET_LOCK()这类原子操作判断和占用一步完成。❌ 任务结束时unlink()锁文件删文件不释放锁反而让下一个进程在新 inode 上立刻加锁成功出现看起来有锁却完全没效果的现象更糟的是持锁进程还在跑。✅锁文件保留不动靠flock()的句柄释放锁。❌ 只在脚本开头加锁忘了异常路径任务中途抛异常fclose()没执行到在部分场景下句柄仍被 PHP 释放但如果用了外部锁就真的会泄漏下一次任务被自己挡住。✅用try/finally包住业务逻辑finally里释放外部锁还要设置合理的 TTL 作为兜底。❌ Redis 锁不设过期时间进程被kill -9后 key 永远留在 Redis 里任务从此再也不会执行而且没有任何报错——最阴的一种故障。✅SET key value NX EX 300TTL 取任务最长耗时的 1.5 至 2 倍并在长任务里续期。❌ 释放 Redis 锁时直接DEL任务 A 超时释放了锁任务 B 拿到了锁A 随后才真正结束并执行DEL把 B 的锁删掉了。✅释放时比较持有者 token用 Lua 脚本做原子的比较再删除。❌ MySQLGET_LOCK和RELEASE_LOCK用了两个连接命名锁是连接级的另一个连接上释放的是不存在的锁实际锁还在任务被永久挡住。✅加锁、执行业务、释放锁全程使用同一个连接对象。❌ 在 NFS 或容器共享卷上用flock()锁语义在网络文件系统上可能不生效本机测试正常一上多机集群就重复执行。✅多机部署改用 Redis / MySQL 的外部锁。❌ 只靠锁不做幂等锁只能防同时跑防不住跑了两遍——例如上一轮中途被杀、这一轮从头再处理同一批数据。✅用唯一键约束、状态字段或游标表把业务逻辑写成幂等的锁只是第一道防线。总结方案依赖适用规模关键点flock()文件锁无单机LOCK_EX \RedisSET NX EXRedis 扩展/客户端多机必须设 TTL释放要比较 tokenLuaMySQLGET_LOCK()一个数据库连接多机连接级锁加锁与释放必须同一连接常驻进程 调度器常驻运行时大规模取消 cron 拉起改为进程内按计划触发业务幂等无所有规模唯一键、状态机、游标续跑PHP 8.0 定时任务重复执行这个说法里PHP 8.0 并不是问题的成因也不提供解法——重复执行的机制永远是cron 只管拉起、不管结束而这个机制从 cron 诞生起就没变过。真正的解法是给任务加一把原子的、能自动释放的锁再把业务本身写成幂等的。装上锁只需要十几行代码但顺序上建议先做幂等锁能挡住并发只有幂等能挡住重跑。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →