ThinkPHP5集成Workerman定时器:实现到点执行任务的完整方案 做业务系统的人大概都遇到过这种需求用户下单后 24 小时未支付自动关闭订单活动在 12 月 31 日 23:59:59 开奖会员到期后三天发送提醒。这类任务的共同点是执行时间不是一个固定周期而是具体的某个时间点甚至这个时间点是运行期间动态产生的。我最近在维护一个基于 ThinkPHP5 的老项目正好要把几十个这种“某个时间点执行一次”的任务统一管理起来最后是用 Workerman 自带的定时器解决的。这里整理出来的方案适合已经用过 TP5、对常驻进程有一点概念但还没有把定时任务真正落地的读者。1. 先搞清楚普通定时方案为什么满足不了“某一时间执行”1.1 业务场景里的“某一天某一刻”到底长什么样只要是做交易、会员、活动这类系统就躲不开时间触发型任务。最常见的一类是“延迟到点执行”比如订单支付超时关闭用户拍下订单后如果 30 分钟内没有付款系统要在“下单时间 30分钟”那一刻把订单状态改成已关闭。这个时间点是动态算出来的每张订单都不一样。另一类是“固定时刻单次执行”比如运营在后台配置了一场秒杀活动活动结束时间是 2024-01-15 20:00:00系统必须在这个时间点把活动状态改成结束并且统计中奖名单。还有一类是“周期中的特殊时刻”每周五 18:00 生成周报每月 1 号凌晨跑对账。这类表面上像周期任务但真正执行时往往还依赖于业务数据状态不能简单用系统级 crontab 一条命令搞定。这些任务的共同点在于执行时间是一个明确的时间点或时间差而不是“每五分钟执行一次”。如果只从“定时器”三个字理解很容易被带偏以为起一个循环脚本不断 sleep 就行。实际上我们需要的是一个调度器接收任务、记录任务、到了时间就触发、执行完还要回写状态。1.2 crontab 和消息队列延时任务的两个软肋先说 crontab。crontab 本身是一个非常好的系统级调度工具但它擅长的是“固定周期”不是“固定时间点”。想让 crontab 在 14:32:00 执行一个任务最粗暴的办法是每分钟扫描一次任务表发现当前时间匹配就跑。这样做的问题有三个第一精度受限于 crontab 的最小粒度。标准 crontab 最小只能精确到分钟秒级控制需要额外写一层包装。第二任务和业务的耦合很别扭。crontab 每次执行都是拉起一个全新的 PHP 进程进程里需要重新加载框架、连接数据库。如果任务是处理一批数据倒还好但如果是“动态创建的任务”还要每次都去轮询数据库判断哪些该跑代码会越写越绕。第三任务状态难以管理。crontab 本身不关心你这个任务跑没跑成功、重复跑了几次、上一次运行报了什么错。这些逻辑全部要自己写而且散落在 shell 脚本和业务控制器里。再说消息队列的延迟队列。像基于 Redis 延迟队列、RabbitMQ 延迟消息这类的方案确实能做“某个时间点执行”它们的好处是消息可靠、削峰填谷能力强。但问题是引入额外组件后运维成本上来了尤其是对于已经跑了好几年的 ThinkPHP5 老项目很多服务器环境连 Redis 扩展都没装全。为一个“定时任务管理”需求把整个技术栈升级一遍性价比太低。1.3 为什么最终选了 Workerman 定时器Workerman 是一个 PHP 常驻内存的进程框架它自带一个基于事件循环的定时器组件。所谓事件循环可以理解成程序内部有一个“时间轮”每秒检查哪些回调到时间了到点就去执行。它不像 crontab 那样依赖操作系统调度也不像sleep那样阻塞整个进程。同一个进程里可以挂几百甚至上千个定时器互不阻塞适合作为 TP5 项目里的任务调度底座。最吸引我的一点是Workerman 是纯 PHP 写的和 ThinkPHP5 天然兼容。任务逻辑可以直接写在 TP5 的模型里、用 TP5 的数据库查询构造器不需要在 shell 脚本和 PHP 之间来回切换。进程常驻之后数据库连接、缓存连接、配置都可以复用启动开销只有一次。这套方案适合的场景也很明确项目本来就是 TP5 技术栈定时任务数量在几千个以内允许秒级误差不想引入额外的消息队列或独立的调度系统。如果你的需求是每日几百万条延迟消息那还是老老实实上成熟的消息队列本文方案解决不了那么大规模的问题。2. 环境准备把 Workerman 装进 ThinkPHP5 项目2.1 依赖安装与最简目录结构我这里的示例以 ThinkPHP 5.0 为例PHP 版本是 7.0 左右Workerman 用的是 3.5 系列。如果你的 PHP 版本在 7.2 以上用 Workerman 4.x 也一样定时器 API 没变化。老项目安装依赖时最怕版本冲突所以建议先确认 PHP 版本再执行安装。composer require workerman/workerman:~3.5安装完成后项目根目录下会出现 vendor/workerman 目录。我建议把入口文件放在项目根目录名字就叫 worker.php和 think、public 这些入口文件平级。目录结构大致是这样项目根目录 ├── application ├── thinkphp ├── vendor ├── public ├── think └── worker.phpworker.php 就是定时任务进程的启动入口启动后它会常驻内存不退出。2.2 入口脚本的骨架先写一个最小可用的骨架把所有定时任务的初始化放在onWorkerStart回调里。这个回调在 Workerman 进程启动后只会执行一次适合做加载任务、检查数据库这些前置操作。?php use Workerman\Worker; use Workerman\Timer; require __DIR__ . /vendor/autoload.php; $worker new Worker(); $worker-name TaskScheduler; $worker-count 1; $worker-onWorkerStart function ($worker) { // 这里放定时任务的初始化逻辑 }; Worker::runAll();注意new Worker()的时候没有传监听地址。这是因为我们不需要它对外提供网络服务只利用它的常驻进程和定时器能力。Worker::runAll()是一个阻塞调用它会一直运行直到收到停止信号。2.3 让 Workerman 进程能使用 TP5 的 Db上面的骨架还不够因为在定时任务回调里我肯定要操作数据库。Workerman 进程是命令行环境不会走 TP5 的 Web 入口所以需要手动初始化 TP5 框架。最稳妥的做法是先定义APP_PATH然后加载 thinkphp 的 base.php再调用\think\App::initCommon()完成配置加载。?php use Workerman\Worker; use Workerman\Timer; define(APP_PATH, __DIR__ . /application/); require __DIR__ . /thinkphp/base.php; \think\App::initCommon(); $worker new Worker(); $worker-name TaskScheduler; $worker-count 1; $worker-onWorkerStart function ($worker) { // 后续在这里注册定时器 }; Worker::runAll();这里有几个细节要提醒\think\App::initCommon()是 TP5 命令行模式里的常用初始化方式它会把数据库配置、应用配置都加载好之后在回调里就能直接\think\Db::name(cron_task)查数据。如果你用的是 TP5.1静态代理类变成了think\facade\Db但初始化思路一样。不要在onWorkerStart里做太重的操作比如一次性查几十万条任务加载到内存否则进程启动会很慢。3. 定时器实现“到点执行”的核心逻辑3.1 Workerman Timer 的两种用法add 与 delayWorkerman 的定时器组件入口是Workerman\Timer。它最常用的方法是Timer::add签名是Timer::add(float $time_interval, callable $callback, array $args [], bool $persistent true);$time_interval定时器执行的间隔时间单位是秒支持小数。$callback到点后要执行的函数。$args传给回调函数的参数数组。$persistent是否持久执行。如果为true表示每隔$time_interval秒都会执行如果为false表示只执行一次。Timer::add执行后会返回一个非零的整数定时器 ID我们可以用Timer::del($timerId)把它删除。这里最容易忽略的就是第四个参数$persistent。很多人写Timer::add(3600, fn)以为它是“一小时后执行一次”实际上它默认是“每隔一小时执行一次”。只有在$persistent false时才是延迟一次。3.2 延迟秒数计算目标时间减当前时间既然要“在某个时间点执行”最直接的想法就是算出现在到目标时间还剩多少秒然后用Timer::add($delay, callback, [], false)注册一个一次性定时器。$runTime strtotime($task[run_time]); // 目标执行时间的 Unix 时间戳 $now time(); $delay $runTime - $now;如果$delay 0说明还没到点可以正常注册。如果$delay 0说明任务时间已经过去了这时候要根据业务策略决定是立即执行、忽略、还是记录下来人工处理。我一般会加一个expire_policy字段来控制。这里有个性能问题和内存问题需要提前想清楚每次Timer::add都会在内存里保留一个定时器节点。如果同时有几千个未来任务一次性注册几千个定时器是没问题的Workerman 底层是事件循环不是线程不会因为你定时器多就创建一堆进程。但如果任务数量到了几十万内存占用会明显增加而且进程重启后重新注册也需要时间这时就应该换成分页轮询方案。3.3 方案一一次性 Timer 注册未来任务把上面的计算过程封装成一个注册函数针对每个未来任务创建一个一次性定时器。function registerTask($task) { $delay strtotime($task[run_time]) - time(); if ($delay 0) { return; } $timerId Timer::add($delay, function () use ($task) { executeTask($task); }, [], false); // 将 timerId 和任务ID关联后续需要取消任务时可以用 $GLOBALS[taskTimers][$task[id]] $timerId; }executeTask负责真正执行业务逻辑。这里我强烈建议不要直接在闭包里写一堆业务代码而是把业务处理单独抽成一个函数或者 TP5 的模型方法这样定时器回调只做“调度”这一件事。function executeTask($task) { try { // 先把任务状态置为执行中避免重复执行 $updated \think\Db::name(cron_task) -where(id, $task[id]) -where(status, 0) -update([status 1]); if (!$updated) { return; } // 这里写具体业务逻辑比如关闭订单、发送通知 doSomethingWithTask($task); // 执行成功回写状态 \think\Db::name(cron_task) -where(id, $task[id]) -update([ status 2, execute_time date(Y-m-d H:i:s), ]); } catch (\Exception $e) { \think\Db::name(cron_task) -where(id, $task[id]) -update([ status 3, error $e-getMessage(), ]); } }你可能会问都已经用一次性定时器了为什么 executeTask 里还要用where(status, 0)做更新这是因为进程可能收到 reload 信号重启重启瞬间之前注册的一次性定时器就会丢失但数据库任务状态还是待执行。等进程重启后重新注册时如果任务已经到点并且被其他方式执行过了这个判断条件能拦住重复执行。3.4 方案二每秒轮询任务表兜底动态任务一次性 Timer 适合“启动时任务已经知道”的情况但业务上经常会有“运行中新增任务”的需求。举个例子管理员在后台点了一个按钮生成一个 30 分钟后的临时任务。如果 Worker 进程里没有提前扫描到这个任务就不会为它创建定时器。解决方案有两种一种是后台每新增一条任务就通知 Worker 进程重新加载任务列表实现起来绕另一种更简单粗暴用 Workerman 的持久化定时器每隔 1 秒扫描一次数据库中的待执行任务发现到点就执行。Timer::add(1, function () { $now date(Y-m-d H:i:s); $tasks \think\Db::name(cron_task) -where(status, 0) -where(run_time, , $now) -limit(50) -select(); foreach ($tasks as $task) { executeTask($task); } }, [], true);这段逻辑很直白定时器间隔 1 秒属于持久执行。每次查询status 0且run_time now的任务。limit(50)防止一次查出太多导致单次轮询超时剩下的下一秒继续处理。执行失败的任务会被executeTask改成 status 3不会反复进入查询结果。这个方案的优点是完全动态新增任务后最多 1 秒就能被发现不需要重新启动进程。缺点是每秒查一次数据库不过如果你在cron_task表上建了idx_status_run_time (status, run_time)查询成本很低几千个任务的小系统完全没压力。实际项目中我的做法是“方案一 方案二”结合进程启动时用方案一给所有未来任务挂上精确的一次性定时器同时再用方案二作为兜底处理运行期间新增或修改的任务。只要executeTask里有状态抢占逻辑两个方案不会导致重复执行。4. 任务持久化设计任务表 状态流转4.1 任务表字段设计如果只把定时任务放在内存里进程一重启就全部丢失了。所以任务必须持久化到数据库。我设计了一张cron_task表字段如下CREATE TABLE cron_task ( id int(11) unsigned NOT NULL AUTO_INCREMENT, task_name varchar(100) NOT NULL DEFAULT , run_time datetime NOT NULL, status tinyint(4) NOT NULL DEFAULT 0, expire_policy tinyint(4) NOT NULL DEFAULT 0, execute_time datetime DEFAULT NULL, error text, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_status_run_time (status, run_time) ) ENGINEInnoDB DEFAULT CHARSETutf8;字段含义task_name任务说明方便后台查看。run_time任务应该执行的时间点。status0 待执行1 执行中2 执行成功3 执行失败。expire_policy任务时间已过期时的处理策略。0 表示跳过1 表示立即执行。这个字段很重要因为进程重启或者服务器停机恢复后很多任务已经过了执行时间不能一刀切都补跑。execute_time实际执行完成的时间。error失败时记录异常信息。4.2 进程启动时加载未完成任务在onWorkerStart中我先查询所有状态为待执行、执行时间在未来的任务给它们注册一次性定时器。同时启动一个 1 秒轮询定时器处理动态新增任务。$worker-onWorkerStart function ($worker) { // 1. 注册未来任务的一次性定时器 $futureTasks \think\Db::name(cron_task) -where(status, 0) -where(run_time, , date(Y-m-d H:i:s)) -select(); foreach ($futureTasks as $task) { registerTask($task); } // 2. 每秒扫描到期任务作为动态兜底 Timer::add(1, function () { checkExpiredTasks(); }, [], true); };这里有一个取舍如果未来任务非常多一次性注册全部定时器会导致进程启动慢、内存占用高。我遇到的项目任务量在千级所以没问题。如果你的场景是十万级建议只保留每秒轮询方案不要做一次性注册或者把未来的任务按时间分桶只注册最近一小时的任务到点前再重新扫描补充。4.3 任务执行成功的回写机制任务执行完成后要把状态从“执行中”改成“执行成功”。因为 Workerman 进程是常驻的不能像 Web 请求那样结束就释放资源所以每次执行任务都要尽量把状态写干净。执行成功后回写$data [ status 2, execute_time date(Y-m-d H:i:s), ]; \think\Db::name(cron_task)-where(id, $task[id])-update($data);执行失败时除了状态改成 3还要记录错误信息方便排查$data [ status 3, error $e-getMessage(), ]; \think\Db::name(cron_task)-where(id, $task[id])-update($data);如果任务失败需要重试可以在轮询逻辑里加一个retry_count字段或者把失败任务单独扔到另一个“失败重试表”里。千万不要让失败任务一直保持 status0否则每秒轮询都会把它捞出来再执行一次看起来像是在重试实际上可能把下游系统打垮。5. 实战问题排查与避坑指南5.1 时区问题导致任务提前或延后执行这是我第一次接入时踩得最深的坑。Linux 服务器默认时区如果是 UTC而 PHP 配置和数据库连接时区各自为政就会出现strtotime($task[run_time])算出来的时间戳比实际慢了 8 小时。解决方法是统一时区date_default_timezone_set(Asia/Shanghai);这行代码要放在 worker.php 的最前面在调用strtotime之前执行。同时确认数据库连接配置里的charset和时区也一致。另一个细节是不要直接在 SQL 里依赖NOW()因为 MySQL 的NOW()返回的是数据库会话时区PHP 里的time()返回的是 PHP 时区的 Unix 时间戳。两者如果不一致查询run_time NOW()就可能漏掉刚过期的任务。我一般统一在 PHP 里用date(Y-m-d H:i:s)生成时间字符串再传给数据库查询。5.2 常驻进程里数据库连接断开普通 Web 请求生命周期短数据库连接用完就释放断线问题不明显。Workerman 是常驻进程一个数据库连接会保持很久MySQL 的wait_timeout默认 8 小时如果空闲时间超过这个值连接会被服务端关闭。这时候再执行查询ThinkPHP 就会报“服务器进程退出”或“MySQL server has gone away”。解决办法是给 TP5 的数据库配置加上断线重连参数。在你的database.php配置数组里加上break_reconnect true,如果 TP5 版本较老不支持这个参数可以在执行任务前先发送一个检测查询try { \think\Db::query(SELECT 1); } catch (\Exception $e) { // 重新连接后继续执行 }但更省事的做法是让 ThinkPHP 在每次查询时自动 ping。TP5 的break_reconnect就是干这个的建议优先使用。5.3 任务重复执行的幂等设计重复执行是定时任务里最危险的问题。进程收到 reload 信号、多进程并行、数据库事务回滚都可能导致同一个任务被执行两次。我建议三个措施配合使用第一Workerman 的$worker-count设置为 1保证进程内只有一个调度进程。第二执行任务前用“状态抢占”的方式更新数据库。也就是在把任务从待执行改成执行中的时候加一个status 0作为更新条件如果影响行数为 0说明已经被其他进程抢走了直接跳过。第三业务逻辑本身也要幂等。比如关闭订单时加判断order_status 待支付发通知时通过task_id去重。调度的幂等只是第一道防线业务层的幂等才是最终防线。5.4 定时任务进程的守护与监控Workerman 自身提供了 start/stop/reload 机制。在 CLI 环境下运行php worker.php start可以启动加-d参数可以进入 daemon 后台运行模式。php worker.php start -d php worker.php stop php worker.php reload这里要强调reload只会重启业务进程不会退出主进程。重启后onWorkerStart会重新执行之前注册的一次性定时器全部重新注册。所以如果你手动 reload 了 Worker不用担心定时器丢失它会自动从数据库里把未完成任务捞回来。如果你部署的服务器上 PHP 禁用了pcntl扩展Workerman 的信号处理功能会失效。安装 PHP 时必须开启pcntl和posix扩展否则 Worker 无法常驻运行。5.5 常见问题速查表现象可能原因解决办法任务到点没执行进程没启动 / 定时器没注册 / 时区偏差检查php worker.php start -d是否正常运行用ps -ef查看进程核对服务器时区和 PHP 时区任务执行了多次进程有多个 / 状态更新条件没写 / 业务逻辑不幂等count设为 1更新任务状态时加status0条件业务层增加去重逻辑后台新增任务不执行只用了启动时一次性注册没有轮询兜底引入每秒扫描任务表的 Timer或新增任务后调用注册函数长时间运行后数据库报连接断开MySQL 空闲超时 / 连接被服务端回收database.php加break_reconnect true进程一重启所有任务都没了任务没有持久化把任务写入cron_task表进程启动时重新加载任务失败后一直被重复执行失败状态没有标记捕获异常后把 status 置为 3并记录 error 信息6. 对这套方案的最终评价与个人建议我把这套定时器方案在几个老项目里用了快两年整体感受是轻量、可控、没有多余的依赖。Workerman 定时器加一张任务表就能覆盖绝大多数“到点执行”的业务需求而且因为它常驻内存任务回调里可以直接用 TP5 的模型和队列服务开发效率比写 crontab 脚本高很多。最后分享一个我后来才养成的习惯把executeTask里真正的业务代码做成一个可以单独调用的服务层方法然后在命令行下也写一个模拟入口。这样调试定时任务逻辑的时候不需要跑到 Worker 进程里看日志直接命令行执行对应方法就能复现问题。另外任务表里记得给run_time加上索引每秒轮询一次不会造成明显的数据库压力但索引一定不能省。这套东西不难难点全在边界情况的处理上希望这篇整理能帮你少走我当时那些弯路。