学完这篇,你能搭出一套「crontab 只负责敲门、队列真正干活、分布式锁保证同任务只有一台机器在跑」的 PHP 定时任务体系,多机部署也不会重复执行。
第一步:把 crontab 当心跳,不当工人
crontab 的职责只有一个——按点启动入口脚本。真正的业务逻辑写在脚本里,方便本地调试,也方便换调度器。
* * * * * /www/server/php/74/bin/php /www/wwwroot/site/cron.php >> /www/logs/cron.log 2>&1
注意:cron 的环境变量极简,`php` 必须写绝对路径(宝塔一般在 `/www/server/php/版本号/bin/php`),日志重定向不要省,否则出问题你连报错都看不到。
第二步:用队列把任务拆开
别在 cron 脚本里直接跑耗时五分钟的同步逻辑,否则下一分钟又来了一个进程,任务开始叠加。正确做法是把「要做什么」写进队列表,由 worker 消费。
一张够用的表:
CREATE TABLE jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(64) NOT NULL,
payload TEXT,
run_at INT NOT NULL,
status TINYINT DEFAULT 0, -- 0待跑 1运行中 2成功 3失败
attempts TINYINT DEFAULT 0,
locked_until INT DEFAULT 0,
KEY idx_pick (status, run_at)
);
每次拉取用「抢占式更新」,天然避免两个 worker 抢同一条:
$db->exec("UPDATE jobs SET status=1, locked_until=UNIX_TIMESTAMP()+300, attempts=attempts+1
WHERE status=0 AND run_at<=UNIX_TIMESTAMP() ORDER BY id LIMIT 20");
$rows = $db->query("SELECT * FROM jobs WHERE status=1 AND locked_until>UNIX_TIMESTAMP()-300")->fetchAll();
注意:MySQL 8.0 直接写 `FOR UPDATE SKIP LOCKED` 更省事;5.7 没有这个语法,就用上面的「先标状态再拉取」。
第三步:先搞清楚为什么文件锁不够
单机时 `flock()` 就够:
$fp = fopen('/tmp/cron.lock', 'c');
if (!flock($fp, LOCK_EX | LOCK_NB)) exit("已有实例在跑\n");
但两台机器上 `/tmp` 是各自的,锁等于没加——这就是必须上分布式锁的场景。
第四步:分布式锁的两种可靠实现
Redis(推荐),关键是「设置」和「过期」必须原子,释放时必须校验自己的 token,不能直接 `DEL`:
class RedisLock {
public function __construct(private Redis $r) {}
public function acquire(string $key, string $token, int $ttl): bool {
return (bool)$this->r->set($key, $token, ['nx', 'px' => $ttl * 1000]);
}
public function release(string $key, string $token): void {
$script = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0";
$this->r->eval($script, [$key, $token], 1);
}
}
MySQL,如果没有 Redis,用唯一键插入也行:
INSERT INTO cron_locks(name, expire_at) VALUES('dispatch', UNIX_TIMESTAMP()+60)
ON DUPLICATE KEY UPDATE expire_at = IF(expire_at < UNIX_TIMESTAMP(), VALUES(expire_at), expire_at);
然后看 `affected_rows`:为 1(插入)或 2(抢到了过期锁)才算拿到锁。
组装起来就是:
$token = bin2hex(random_bytes(16));
if (!$lock->acquire('cron:dispatch', $token, 60)) exit(0);
try { dispatchJobs(); } finally { $lock->release('cron:dispatch', $token); }
注意:锁 TTL 必须大于任务最长耗时。任务可能跑超过 TTL 时,要在任务里做「看门狗续期」(每 TTL/3 续一次),否则会出现两个进程同时在跑。宁可把 TTL 设大一点,配合下面的超时回收,也别设小。
第五步:兜底三件事,缺一不可
- 超时回收:worker 崩了,`status=1` 的任务会永远卡住。加一条清理:`locked_until < now` 且 status=1 的,回退成 status=0,`attempts >= 3` 的直接标失败。
- 幂等:重试必定发生,业务侧要有唯一业务键(订单号、用户+日期),配合数据库唯一索引兜底,避免重复发币、重复发通知。
- 退避重试:失败后不要立刻重试,`run_at = now + 60 * 2^attempts`,防止把下游打挂。
顺带说一句,如果站点本身没有服务器 crontab 权限,也可以用「懒触发」思路:Clara BBS 的 `Cron::register` 就是把定时任务挂在正常请求上顺带检查执行,零配置,代价是精度取决于有没有人访问。对时效要求不高的任务完全够用。
小结
- crontab 只做心跳,用绝对路径 + 日志重定向;
- 耗时逻辑进队列,用「状态抢占」避免 worker 互抢;
- 单机 `flock` 够用,多机必须上 Redis `SET NX PX` 或 MySQL 唯一键,释放时校验 token;
- 锁 TTL 要大于任务耗时,超长任务加心跳续期;
- 超时回收、幂等、指数退避重试是三个必做兜底,少一个都会在生产环境咬你一口。