PHP项目Laravel队列任务唯一性锁

wen PHP项目 3

本文目录导读:

PHP项目Laravel队列任务唯一性锁

  1. 文章标题:PHP项目高并发下 Laravel 队列任务唯一性锁的优雅实现与避坑指南
  2. 目录导读

PHP项目高并发下 Laravel 队列任务唯一性锁的优雅实现与避坑指南


目录导读

  1. 为什么需要队列任务唯一性锁?(现实场景中的“重复消费”痛点)
  2. Laravel 队列的原生机制与缺陷onQueueunique 的局限性)
  3. 三大主流方案深度对比(Redis锁 / 数据库唯一索引 / 原子计数器)
  4. 手写一个可复用的 ShouldBeUnique 接口实现(含代码详解)
  5. 常见坑位与性能调优(锁过期时间、并发断裂、死锁预防)
  6. 问答环节(解决你最后的疑虑)

为什么需要队列任务唯一性锁?

在电商秒杀、订单状态同步、定时任务补偿等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() 底层正是基于 PredisSET 命令,但我们需要对它做一层业务封装。

手写一个可复用的 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

答:内置实现依赖 Cacheadd() 方法,它在并发下并非原子操作,且无法完美适配 Horizon 多进程场景,我们手工封装的 Cache::lock() 底层使用 SET NX,原子性更强。

Q2:如果业务中途抛异常,锁会释放吗?

答:不会自动释放,必须用 try...finally,我们在 handleWithLock 中已做了处理,异常也会执行 release()

Q3:队列任务被 release(5) 后,会不会造成死循环?

答:不会。release 是让任务重新回到队列,但重试次数默认有上限($tries),建议设置 public $tries = 3,超过次数后会进入失败表。

Q4:如何为不同业务定制不同的锁超时?

答:在子类中重写 lockExpireTime() 方法,返回秒数即可。


通过以上方案,你的 Laravel 项目将具备高并发下的任务唯一性保障,无论是秒杀系统还是订单同步,都能避免重复执行的灾难点,如果你正在维护一个大型PHP项目,强烈建议将此模式封装为基础设施,这会是技术债务中最低成本的一笔投资。

切记:锁是为了最终一致性,而不是完美的并发控制,配合数据库唯一约束双保险,才能立于不败之地。

抱歉,评论功能暂时关闭!