TP6定时任务重复执行的根本原因是多实例部署下未加锁导致并发执行;必须用Redis的SET原子操作加锁,key含时间维度,value用uniqid(),解锁须用Lua脚本校验value,并通过中间件统一管理,且Redis连接类型需匹配集群或哨兵配置。

TP6 定时任务重复执行,根本原因是没加锁
多实例部署下,同一 cron 触发的定时命令(如 php think sync:order)会在每台服务器上独立运行。若不加互斥控制,几个节点同时写数据库、发短信、扣款,立刻出事。这不是“偶尔重”,而是“必然并发”——只要 config/timer.php 里 'multi_process' => true 或部署了多个 PHP-FPM 实例或容器,就默认触发该问题。
加锁必须用 set 原子操作,别碰 setnx + expire 分步
TP6 的 Redis 驱动不支持原生 SETNX 单独调用后再设过期,手动分两步极易失败:第一步成功、第二步因崩溃/超时没执行,锁就永远卡住。正确写法只有一条:
$result = $redis->set('lock:sync_order_' . date('YmdH'), 'tp6-' . uniqid(), ['nx', 'ex' => 300]);
注意三点:
-
['nx', 'ex' => 300]是 TP6 Redis 驱动唯一认可的原子参数组合,px不支持 - key 必须含时间维度(如
YmdH),避免跨小时任务被前一个锁拦死 - value 用
uniqid()或md5(microtime(true) . rand()),为后续校验身份留依据
解锁必须用 Lua 脚本校验 value,不能直接 del
业务执行慢导致锁自动过期后,另一个进程已拿到锁;此时原进程若还执行 $redis->del('lock:key'),就把别人的锁删了。正确解法是封装 Lua:
立即学习“PHP免费学习笔记(深入)”;
$script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";$redis->eval($script, ['lock:sync_order_2026081713'], ['tp6-abc123']);
这个脚本必须在 finally 块里执行,哪怕 handle() 抛异常、数据库挂了、网络超时,都得确保跑一次。
中间件方式比硬编码更可靠,也更容易复用
每个命令类开头手写锁逻辑,容易漏、难维护、升级时易出错。推荐抽成中间件:
- 新建
app/middleware/LockMiddleware.php,process()方法里做acquire()和release() - 在命令类定义
protected $middleware = [LockMiddleware::class]; - 中间件构造时接收配置项,比如
lock_key_prefix、expire,不同任务可差异化设置
这样新增一个定时任务,只需声明中间件,不用再翻文档查怎么加锁——但要注意,中间件里的 $redis 实例必须和命令类用同一个连接池,否则可能连错库或哨兵节点。
最常被忽略的是 Redis 连接类型:集群或哨兵环境下,config/cache.php 中 'type' 必须设为 'redis_cluster' 或对应驱动,否则 set(..., ['nx', 'ex' => ...]) 会静默失败,锁形同虚设。



















