ThinkPHP6定时任务需用Redis锁防并发重复执行,必须原子性加锁(set nx ex)和校验值解锁(Lua脚本),推荐通过中间件统一管理。

ThinkPHP6 定时任务在多实例或进程并发场景下极易重复执行,比如同步订单、发放优惠券、清理缓存等任务被多个服务器同时触发,造成数据错乱或资源浪费。Redis 锁是解决该问题最直接有效的方式——关键不是“加锁”,而是“正确加锁+安全解锁”。
为什么定时任务必须加 Redis 锁
TP6 的 think:timer 本质是基于 PHP 进程的单次调度,不带分布式协调能力。一旦部署多个 Web 实例(如 Nginx + 多个 PHP-FPM 进程)或用 Linux cron 并行调用,相同任务就会在不同进程里同时启动。例如:
- 两个服务器都运行
php think command:SyncOrder,同一秒内读取未处理订单并重复推送; - 多个定时脚本同时扣减库存,导致超卖;
- 短信/邮件服务被触发多次,用户收到重复通知。
这些都不是逻辑错误,而是缺乏跨进程互斥控制。Redis 锁利用其单点写入和原子命令特性,天然适合做这个“裁判”。
加锁必须用原子 set + nx + ex/pa
TP6 中不能分两步写 setnx 再 expire,否则可能加锁成功但过期没设上,锁永久残留。必须使用 Redis 原生原子指令:
立即学习“PHP免费学习笔记(深入)”;
- 推荐写法(TP6 Redis 驱动支持):
$redis->set('lock:sync_order_20261029', 'node-a-'.uniqid(), ['nx', 'ex' => 300]) - 返回
true表示抢锁成功,可继续执行业务;
返回false或null说明锁已被占用,应直接退出,不重试也不报错。 - key 要含业务标识 + 时间维度(如
sync_order_20261029),避免跨天任务互相阻塞。
解锁必须校验值再删,不能直接 DEL
如果 A 加锁后执行慢,锁自动过期;B 成功加锁;此时 A 执行完直接 DEL lock:key,就把 B 的锁删了——引发并发事故。安全解锁需 Lua 脚本保障原子性:
- 封装一个解锁方法:
$script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";<br>$redis->eval($script, ['lock:sync_order_20261029'], [$token]); - 只在加锁时记录的
$token(如uniqid())与当前 key 值完全一致,才允许删除。 - 业务执行完毕后必须调用此解锁逻辑,建议放在
try...finally块中确保执行。
集成到定时命令中的两种方式
方式一:在命令类 handle() 开头手动加锁(适合快速验证):
新建 app/command/SyncOrder.php,开头插入:
$redis = \think\facade\Cache::store('redis')->getRedis();$token = uniqid('sync_', true);if (!$redis->set('lock:sync_order_'.date('Ymd'), $token, ['nx', 'ex' => 600])) { $this->output->writeln('⚠️ 已有节点在执行,跳过'); return; }- 末尾加
finally { $redis->eval($unlockScript, [...], [$token]); }
方式二:抽成中间件(推荐长期维护):
创建 app/middleware/LockMiddleware.php,在 process() 中统一加锁/解锁,然后在命令类声明:protected $middleware = [LockMiddleware::class];
这样新增定时任务无需重复写锁逻辑,复用性高且不易遗漏。



















