PHP多Worker并发执行时重复获取Postgres任务的原因排查
这事儿核心问题出在数据库操作的原子性缺失上。你现在的代码是两步走:先执行Job::first()查询出第一条任务,然后再调用delete()删除它。在并发场景下,多个Worker会同时走到first()这一步——因为Postgres在执行普通SELECT的时候不会加锁,所以多个Worker会同时拿到同一条任务记录,接着各自尝试删除,这时候就会出现重复执行的情况了。
举个直观的例子:Worker A和Worker B几乎同时执行Job::first(),都拿到了ID=1的任务,然后A先执行delete()成功,B紧接着执行delete()(这时候这条记录其实已经没了,但你的代码里判断$job->delete()==true可能因为ORM的缓存或者判断逻辑问题,误以为删除成功,进而执行后续的任务处理逻辑)。
核心思路是把**“获取任务”和“锁定/删除任务”变成一个原子操作**,让数据库保证同一时间只有一个Worker能拿到某条任务。Postgres正好提供了完美的解决方案:SELECT ... FOR UPDATE SKIP LOCKED。
方案1:用Postgres的行级锁+跳过已锁定记录(推荐)
这个方法会让Worker查询第一条未被锁定的任务,同时立刻给这条记录加排他锁,其他Worker再查询时会自动跳过被锁定的记录,从下一条未被锁定的任务开始取。
如果用Laravel的话,可以这么写:
DB::beginTransaction(); try { // 查询并锁定第一条未被锁定的任务,跳过已被其他Worker锁定的 $job = Job::lockForUpdate()->skipLocked()->first(); if ($job) { // 这里可以先标记任务为"处理中",或者直接删除 $job->delete(); DB::commit(); // 执行任务处理逻辑 // do something } else { DB::commit(); // 没有任务可处理,直接退出或者等待 } } catch (\Exception $e) { DB::rollBack(); // 处理异常 }
方案2:用原生SQL实现原子删除
另一种思路是直接在一个SQL语句里完成“查询+删除”,利用Postgres的RETURNING子句,把删除的记录返回回来,这样整个操作是原子的,不会出现并发冲突:
$job = DB::selectOne("DELETE FROM jobs WHERE id = (SELECT id FROM jobs ORDER BY id LIMIT 1) RETURNING *"); if ($job) { // 执行任务处理逻辑 // do something }
这个方法的好处是不需要手动管理事务,因为单个SQL语句本身就是原子的。
- 不要依赖ORM的缓存:比如有些ORM会把查询到的对象缓存起来,即使数据库里记录已经被删除,对象的属性还在,导致后续判断出错。
- 优先标记任务状态而非直接删除:如果任务处理过程中Worker崩溃,直接删除会导致任务丢失。更好的做法是先把任务标记为
processing,处理完成后再删除或者标记为completed,这样即使Worker挂了,后续可以重新处理状态为processing且超时的任务。
内容的提问来源于stack exchange,提问作者Gediminas Šukys

