本文目录导读:

PHP项目高并发下 Laravel 队列任务唯一性锁的优雅实现与避坑指南
目录导读
- 为什么需要队列任务唯一性锁?(现实场景中的“重复消费”痛点)
- Laravel 队列的原生机制与缺陷(
onQueue与unique的局限性) - 三大主流方案深度对比(Redis锁 / 数据库唯一索引 / 原子计数器)
- 手写一个可复用的
ShouldBeUnique接口实现(含代码详解) - 常见坑位与性能调优(锁过期时间、并发断裂、死锁预防)
- 问答环节(解决你最后的疑虑)
为什么需要队列任务唯一性锁?
在电商秒杀、订单状态同步、定时任务补偿等PHP项目中,同一任务被重复推入队列是高频事故,用户连点两次“提交订单”,或Webhook回调重试,都会导致队列中出现两条相同业务逻辑的任务,若不加以控制,将引发库存超卖、重复发短信、脏数据覆盖等致命问题。唯一性锁是确保任务幂等性的第一道防线。
Laravel 队列的原生机制与缺陷
Laravel 自带 ShouldBeUnique 接口(版本≥7.x),但它的实现基于 缓存驱动(默认file/database),存在两个痛点:
- 非原子性:检查与设置缓存值不是原子操作,高并发下仍会击穿。
- 无法跨进程:若项目使用
horizon多进程,缓存锁可能失效。
而 onQueue 只是指定队列名,并不能去重,我们需要 自研一个基于 Redis 的原子锁,确保“检查-锁定-执行-释放”是一气呵成的。
三大主流方案深度对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis SETNX 锁 | 利用 SET key value NX EX 原子命令 |
性能最高,支持分布式 | 需手动处理锁过期和释放 |
| 数据库唯一索引 | 在任务表中增加 unique_key 字段 |
实现简单,持久化 | 大流量下DB压力大,锁粒度粗 |
| 原子计数器(Lua脚本) | INCR + 过期时间 |
可控制最大重试次数 | 逻辑复杂,调试成本高 |
推荐方案:采用 Redis 锁 + Lua 脚本,因为Laravel自带的 Cache::lock() 底层正是基于 Predis 的 SET 命令,但我们需要对它做一层业务封装。
手写一个可复用的 ShouldBeUnique 接口实现
我们将创建一个自定义 Job 基类,并利用 Cache::lock() 作为核心锁,关键代码示例如下:
namespace App\Jobs\Concerns;
use Illuminate\Support\Facades\Cache;
trait InteractsWithUniqueLock
{
// 唯一键前缀,order:123
public function uniqueId()
{
return $this->order_id ?? uniqid();
}
// 获取锁实例,默认锁10秒
public function getLock()
{
$lockKey = 'job_lock:' . static::class . ':' . $this->uniqueId();
return Cache::lock($lockKey, 10);
}
public function handleWithLock()
{
$lock = $this->getLock();
if (!$lock->get()) {
// 拿不到锁,说明已有相同任务在执行,直接丢弃或延后
$this->release(5); // 5秒后重试
return;
}
try {
$this->handle(); // 你的核心业务逻辑
} finally {
$lock->release();
}
}
}
在Job里使用:
class ProcessOrder implements ShouldQueue
{
use InteractsWithUniqueLock;
public $order_id;
public function __construct($orderId)
{
$this->order_id = $orderId;
}
public function handle()
{
// 实际业务代码
}
}
关键点:handleWithLock 需在 dispatch 时被调用,你可以重写 displayName() 或直接监听 Queue::before 事件来统一处理锁逻辑。
常见坑位与性能调优
- 锁过期时间太小:若业务处理超过10秒,锁自动释放,导致新任务进来。解决:动态计算过期时间,或利用
lock->get(function () { ... })闭包自动续期。 - 任务失败未释放锁:务必使用
try...finally保证释放。 - 水平扩展时的 Redis 单点故障:使用
Redis Sentinel或集群,且锁要设置合理TTL。 - 不要
dispatchNow:同步模式下锁根本来不及生效,必须走异步队列。
问答环节
Q1:为什么不用 Laravel 内置的 ShouldBeUnique?
答:内置实现依赖
Cache的add()方法,它在并发下并非原子操作,且无法完美适配Horizon多进程场景,我们手工封装的Cache::lock()底层使用SET NX,原子性更强。
Q2:如果业务中途抛异常,锁会释放吗?
答:不会自动释放,必须用
try...finally,我们在handleWithLock中已做了处理,异常也会执行release()。
Q3:队列任务被 release(5) 后,会不会造成死循环?
答:不会。
release是让任务重新回到队列,但重试次数默认有上限($tries),建议设置public $tries = 3,超过次数后会进入失败表。
Q4:如何为不同业务定制不同的锁超时?
答:在子类中重写
lockExpireTime()方法,返回秒数即可。
通过以上方案,你的 Laravel 项目将具备高并发下的任务唯一性保障,无论是秒杀系统还是订单同步,都能避免重复执行的灾难点,如果你正在维护一个大型PHP项目,强烈建议将此模式封装为基础设施,这会是技术债务中最低成本的一笔投资。
切记:锁是为了最终一致性,而不是完美的并发控制,配合数据库唯一约束双保险,才能立于不败之地。