基础定时任务
Laravel 自己不是常驻进程
Laravel 的 Scheduler(调度器) 本质是:每分钟被系统 cron 唤醒一次,在这一次里判断「哪些任务该执行」,再执行它们。
也就是说:没有一个 Laravel 内置的「一直跑着的定时守护进程」;靠的是 操作系统 cron + artisan schedule:run。
典型服务器 crontab(每分钟):
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
schedule:run 做了什么
- 加载应用(bootstrap)。
- 读取 app/Console/Kernel.php(或 Laravel 11+ 的 routes/console.php 等注册方式)里通过 Schedule:: 注册的所有任务。
- 对每个已注册任务,用 Cron 表达式(或闭包里自定义逻辑)判断 当前这一分钟是否命中。
- 命中的任务按配置执行:可能是 Artisan 命令、闭包、队列 Job、shell 命令等。
- 未命中的直接跳过。
所以:「定时」的数学含义在 Laravel 里 = Cron 表达式匹配 + 每分钟一次的轮询。
任务类型与执行方式
- 同步:在当前
php artisan schedule:run进程里直接跑完(会阻塞该次 cron,直到该任务结束或超时)。 - 后台 / 不重叠(runInBackground、withoutOverlapping 等):通过子进程、锁文件等方式避免重复执行或把执行放到子进程(具体行为依版本与配置略有差异)。
- 队列:若任务 dispatch 到队列,则
schedule:run往往只是把 Job 推进队列;真正消费要靠queue:work/ Horizon 等 worker。
秒级定时任务
不是「系统每秒调一次 PHP」
- 系统 cron 仍然通常是:每分钟执行一次
php artisan schedule:run。 - 一旦调度里存在 可重复(repeat) 的任务(repeatSeconds 非空),本次
schedule:run不会立刻退出,而是 在本分钟内进入一个循环,用短睡眠轮询,直到「这一分钟结束」。
所以:秒级任务 = 每分钟被 cron 唤醒一次 + 这一次进程在 0~59 秒内自己多跑几次,而不是 cron 每秒跑一次。
everySecond() 到底改了什么?
ManagesFrequencies 里(PendingEventAttributes 通过 trait 提供链式 API):
- everySecond() → repeatEvery(1)。
- repeatEvery($seconds) 会做两件关键事:
- 校验:60 % $seconds === 0,否则抛 InvalidArgumentException。
也就是说:只能选能整除 60 的秒数(1、2、3、4、5、6、10、12、15、20、30…),没有「每 7 秒」这种。 - 设置 $this->repeatSeconds = $seconds,并 everyMinute():Cron 表达式在「分钟」维度上仍是 每分钟命中(* 在分钟字段)。
- 校验:60 % $seconds === 0,否则抛 InvalidArgumentException。
含义:Cron 只负责「这一分钟要不要跑」;同一分钟内跑几次、隔几秒,由 repeatSeconds + 后面的 repeatEvents 循环决定。
ScheduleRunCommand 的两段式
核心在 Illuminate\Console\Scheduling\ScheduleRunCommand::handle()(逻辑概括):
第一段——和普通任务一样:
- dueEvents():按 Cron 筛「当前这一分钟是否 due」。
- 对每个 due 且 filter 通过的事件:执行 runEvent()(第一次执行)。
第二段——只有存在「可重复」事件才触发: - if ($events->contains->isRepeatable()) 时调用 repeatEvents(...)。
- Event::isRepeatable() 的实现就是:repeatSeconds 不为 null。
repeatEvents() 的结构:while (当前时间 <= startedAt 这一分钟的最后一秒) { foreach (所有 repeatable 事件) { if (应中断) return; if (! shouldRepeatNow) continue; if (维护模式且不允许) continue; if (! filtersPass) continue; onOneServer 则走单服务器互斥,否则 runEvent } usleep(100000); // 睡眠 0.1 秒,再进入下一轮 }要点:
- 外层 while 绑在「启动 schedule:run 的那一整个自然分钟」上(startedAt->endOfMinute()),所以进程可能 最长驻留约 60 秒(从本次启动时刻到该分钟结束——若启动偏晚,实际不足 60 秒,见下文「对齐问题」)。
- 轮询粒度约 100ms(Sleep::usleep(100000)),不是内核高精度定时器;不是硬实时。
shouldRepeatNow() 与 lastChecked
Event 里(概念):
- shouldRepeatNow():在 isRepeatable() 为真时,要求
lastChecked 到「现在」的秒差 ≥ repeatSeconds(diffInSeconds 语义以 Carbon 为准)。 - filtersPass() 里会把 lastChecked 更新为当前时间。
因此:重复触发依赖「上次检查/通过 filter 的时间」与 间隔秒数 的比较,而不是你自己写的 sleep(1)。
若某次执行 同步阻塞很久,会 推迟 后续「应重复」的判断——这正是文档建议 亚分钟任务尽量 dispatch 队列或后台进程 的原因。
为什么能保证「整分钟对齐」?
ScheduleWorkCommand 的实现很直白:
- 死循环里 usleep(100 * 1000)(每 100ms 醒一次)。
- 仅当
Carbon::now()->second === 0且 新的一分钟(与上次启动的「分钟起点」不同)时,起一个子进程跑schedule:run。
也就是说:本地开发下,框架尽量让schedule:run在 每分钟的第 0 秒 启动,这样 repeatEvents 的整段循环能覆盖 完整的 0~59 秒。
生产环境 cron 的「对齐」风险
若生产环境 不是 用 schedule:work,而是 crontab:
- 若 cron 在 10:00:03 才执行
schedule:run,则 repeatEvents 的 while 只跑到 10:00:59,本分钟前 3 秒的亚分钟触发不会发生。 - 官方文档强调:应尽量在每分钟开始处 调用
schedule:run,原因就在这里。
这不是 bug,是 「每分钟只拉一次」+「进程只活到该分钟结束」 的必然结果。
部署与中断:illuminate:schedule:interrupt
当存在 repeatable 事件时,handle() 开头会 清除 cache 里的中断标记;repeatEvents 里若 shouldInterrupt() 为真(读 cache:illuminate:schedule:interrupt),提前结束循环。
用于:发版时避免旧的 schedule:run 长时间占着旧代码跑到该分钟结束(文档有说明)。深度排查运维脚本时可以盯这个 key。

