定时任务几乎是每个PHP项目都绕不开的东西:凌晨跑数据汇总、每五分钟同步一次缓存、定时发送提醒消息。业务小的时候,往服务器的Crontab里加一行就完事了。可一旦任务数量涨到几百上千个,或者单个任务要处理几十万条数据,这套原始方案就会暴露出各种问题。这篇文章把高并发场景下定时任务管理的完整思路梳理一遍,从调度拆分到执行进程管理,再到防重复与失败兜底,给出可以直接落地的方案。

为什么原生Crontab撑不住大量定时任务
先说结论:Crontab本身是个调度器,不是任务管理系统。它的设计目标是按时间点触发命令,仅此而已。当任务规模上来之后,至少有四个硬伤会陆续出现。
第一是执行重叠问题。假设某个任务设定每分钟执行一次,但实际跑一次要90秒,Crontab并不会等上一次结束,到点就再起一个进程,两个进程同时操作同一批数据,轻则重复计算浪费资源,重则数据错乱。你当然可以在脚本里加文件锁来规避,但每个脚本都要自己写一遍锁逻辑,维护成本会越来越高。
第二是单机资源瓶颈。所有任务挤在一台机器上,某个重任务把CPU打满,其他任务全部被拖慢。而且Crontab没有队列概念,同一分钟内注册了二十个任务,它们会在零秒附近并发启动,瞬时负载尖峰非常明显。
第三是可观测性几乎为零。任务执行成功还是失败,只有靠重定向输出到日志文件才能知道,没有统一的执行记录、耗时统计、失败告警。出问题的时候只能一台台机器去翻日志。
第四是任务散落在各台机器的crontab配置里,没有版本管理,改配置靠手工登录服务器,一旦机器故障迁移任务非常麻烦。
调度与执行分离:基于Redis队列的消费模型
解决思路的核心只有一句话:把“什么时候执行”和“怎么执行”拆开。调度层只负责按时把任务投递到队列,执行层用一组常驻的Worker进程去消费队列。这样调度本身变得极轻,执行能力可以通过加Worker数量横向扩展。
调度器可以是一个单独的PHP脚本,由Crontab每分钟触发一次。它读取任务配置表,判断当前时间点哪些任务该执行,然后往Redis的List里推送任务消息:
<?php
// scheduler.php 由crontab每分钟执行
// * * * * * /usr/bin/php /app/scheduler.php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$pdo = new PDO('mysql:host=127.0.0.1;dbname=task', 'root', 'pass');
$tasks = $pdo->query('SELECT * FROM task WHERE status = 1')->fetchAll();
$now = time();
foreach ($tasks as $task) {
// 简化的时间匹配:判断该任务是否命中当前分钟
if (shouldRun($task['cron_expr'], $now)) {
$redis->lPush('task:queue', json_encode([
'id' => $task['id'],
'cmd' => $task['command'],
'time' => $now,
]));
// 记录投递日志,便于追踪
$pdo->prepare('INSERT INTO task_log(task_id, status) VALUES(?, ?)')
->execute([$task['id'], 'dispatched']);
}
}Worker端则通过brPop阻塞式取任务,拿不到任务时挂起等待,几乎不消耗CPU。多个Worker进程同时消费同一个队列,天然实现了负载均衡——谁空闲谁抢任务,忙的机器不会接到新任务:
<?php
// worker.php 常驻运行
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
while (true) {
// 阻塞等待,最多30秒返回一次,便于处理信号
$data = $redis->brPop('task:queue', 30);
if (!$data) {
continue;
}
$job = json_decode($data[1], true);
try {
$start = microtime(true);
exec('/usr/bin/php /app/jobs/' . escapeshellcmd($job['cmd']), $out, $code);
$cost = round((microtime(true) - $start) * 1000);
// 写回执行结果
logResult($job['id'], $code === 0 ? 'success' : 'fail', $cost);
} catch (Throwable $e) {
// 失败任务进入重试队列,带延迟
$redis->zAdd('task:retry', time() + 60, json_encode($job));
}
}这套模型的好处是显而易见的:任务配置全部收敛到数据库,可以做成后台管理界面;执行能力不够就加Worker进程或者加机器;重任务不会阻塞轻任务,因为它们分布在不同Worker上。代价是引入了Redis依赖,并需要自己维护Worker进程的存活,这就轮到下面要讲的进程管理工具出场了。
用Supervisor托管Worker进程
Worker是常驻进程,直接在命令行后台跑肯定不靠谱:机器重启后没人拉起,进程崩了没人管,日志输出也没法统一。Supervisor就是专门解决这类问题的进程管理器,用配置文件声明要管理的进程,它会自动重启异常退出的进程。
[program:task-worker] command=/usr/bin/php /app/worker.php process_name=%(program_name)s_%(process_num)02d numprocs=8 autostart=true autorestart=true startretries=3 user=www stdout_logfile=/var/log/task/worker.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10
配置里的numprocs=8表示启动8个相同Worker进程,调整并发能力就是改一个数字然后执行supervisorctl reload。所有Worker的标准输出统一写入日志文件并自动轮转,配合supervisorctl status可以随时查看每个进程的运行状态。
如果项目已经上了Swoole或Workerman,也可以不依赖Supervisor,直接用框架自带的多进程模型实现Worker。以Swoole为例,用进程池组件同样能实现队列消费,而且性能更好,因为省去了每次任务都要exec拉起一个PHP进程的开销。对于执行频率高、单次任务逻辑轻的场景,常驻内存的方案优势明显;而对于任务逻辑复杂、怕内存泄漏的场景,每次任务独立起进程的隔离性反而更安全。两种方式各有适用面,按任务特点选即可。
高并发下的三个必须处理的细节
分布式锁防止重复执行
多Worker甚至多机部署时,有些任务全局只能跑一个实例,比如全量数据汇总。这时需要分布式锁,Redis的SET key value NX EX是最常用的实现。要注意锁必须设置过期时间,避免持有锁的进程崩溃后死锁;释放锁时要校验值,防止误删别人的锁,稳妥的做法是用Lua脚本保证判断和删除的原子性。
<?php
function acquireLock(Redis $redis, string $name, int $ttl = 300): ?string
{
$token = bin2hex(random_bytes(16));
$ok = $redis->set('lock:' . $name, $token, ['nx', 'ex' => $ttl]);
return $ok ? $token : null;
}
function releaseLock(Redis $redis, string $name, string $token): bool
{
$lua = <<<'LUA'
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
LUA;
return (bool)$redis->eval($lua, ['lock:' . $name, $token], 1);
}任务幂等性设计
重试机制必然导致任务可能被执行多次,所以任务逻辑必须幂等。常见做法有:任务开始前按任务ID加业务锁,已完成的直接返回成功;写库时用唯一索引兜底,重复插入自然失败;数据处理按批次记录水位点,重跑时从上次位置继续而不是从头再来。设计任务时不妨问自己一句:这段逻辑连续执行两次,结果会不会变坏?如果会,就得先改造。
失败重试与监控告警
前面代码里失败任务放进了task:retry这个有序集合,分数是下次执行的时间戳,调度器每分钟扫描一次这个集合,把到期的任务重新投回主队列,并限制最大重试次数。超过次数还失败的任务标记为死信,触发告警。告警渠道建议直接对接企业微信或钉钉的机器人接口,推送内容包含任务名、失败原因和最近一次日志摘要,让值班人员一眼定位问题。
架构选型的几点建议
如果任务量在百个以内、单任务执行时间可控,继续用Crontab加脚本内文件锁完全可以,不必过度设计。任务超过几百个或者有明显的高峰低谷,就值得上队列消费模型,Redis加Supervisor的组合部署简单,两天内可以落地。如果团队已经有消息队列中间件如RabbitMQ或Kafka,直接复用现有设施比新引入Redis队列更好管理。
再往上一层,当任务之间出现依赖关系,比如B任务必须等A任务跑完才能开始,手工编排会越来越痛苦,这时可以考虑定时任务框架层面的方案,例如部署Laravel框架的项目可以直接用自带的队列加任务调度器,其任务事件、失败钩子、频率限流都是开箱即用的;或者选择XXL-JOB这类独立调度平台,PHP任务以HTTP方式接入,调度、日志、告警、手动触发全部可视化管理。
最后强调一点:不管选哪套方案,任务执行记录一定要落库,包括开始时间、耗时、返回码、输出摘要。没有这些数据,后续的容量评估、任务优化、故障排查都无从谈起。定时任务系统一旦建立,生命周期往往比业务代码还长,前期把可观测性和幂等性做好,后面能省下大量的救火时间。