ActiveRecord操作PostgreSQL遇竞态条件:锁机制失效求助
解决PostgreSQL并发任务重复执行的锁机制问题
这个并发抢占任务的坑我之前踩过,核心问题就是你现在的代码把「查询未启动任务」和「更新状态」分成了两步操作,中间有时间间隙,多个脚本很容易在这间隙里拿到同一个任务。下面给你几个针对性的解决方案,从推荐到备选排序:
1. 行锁+事务(阻塞式,保证任务不丢失)
这是最稳妥的方案,利用PostgreSQL的行级排他锁,在事务内原子完成查询和更新,确保同一时间只有一个脚本能拿到目标任务:
def find_unstarted_job Jobs.transaction do # 找到第一个未启动任务并立即加行锁,其他事务会阻塞到当前事务提交 job = Jobs.where(status: :unstarted).lock('FOR UPDATE').first if job job.started! job end end end
为什么这能生效?
transaction包裹确保所有操作要么一起成功要么一起回滚,避免中间状态暴露给其他脚本lock('FOR UPDATE')会给查询到的行加上排他锁,其他事务要读取或修改这行时会被阻塞,直到当前事务提交(也就是started!执行完成后),从根本上杜绝了竞态条件
2. 跳过锁定行(非阻塞高并发场景)
如果你的任务量很大,不想让脚本因为等待锁而阻塞,可以用FOR UPDATE SKIP LOCKED,直接跳过已经被其他事务锁定的任务,拿下一个可用的:
def find_unstarted_job Jobs.transaction do job = Jobs.where(status: :unstarted).lock('FOR UPDATE SKIP LOCKED').first if job job.started! job end end end
适用场景:
- 任务数量足够多,允许跳过暂时被锁定的任务
- 追求高并发吞吐量,不想让脚本长时间等待锁
3. 表锁方案(不推荐但满足你的需求)
如果你确实需要锁定整张表(不推荐,会严重影响并发性能),可以用UPDATE ... RETURNING *原子完成更新和查询,同时在事务内锁表:
def find_unstarted_job Jobs.transaction do # 先给整张表加排他锁(注意:这会阻塞所有其他对jobs表的操作) ActiveRecord::Base.connection.execute("LOCK TABLE jobs IN ACCESS EXCLUSIVE MODE;") # 原子更新并返回结果,避免两次操作的间隙 job = Jobs.find_by_sql([ "UPDATE jobs SET status = $1 WHERE status = $2 LIMIT 1 RETURNING *;", Jobs.statuses[:started], Jobs.statuses[:unstarted] ]).first job end end
注意事项:
ACCESS EXCLUSIVE是PostgreSQL最严格的表锁,会阻塞所有读、写操作,只有当前事务能操作表,除非业务必须,否则别用UPDATE ... RETURNING *是原子操作,直接把查询和更新合并成一步,完美解决你之前用表锁时无法返回结果的问题
为什么你之前的尝试没生效?
你提到的.lock、.transaction没起作用,大概率是因为:
- 没有把查询和更新逻辑放在同一个事务块里,导致锁的作用范围失效
- 单纯用
.lock(true)而没有指定FOR UPDATE,默认的锁级别不足以阻止并发读写 - 先查询再更新的两步操作之间存在间隙,给了其他脚本抢占的机会
内容的提问来源于stack exchange,提问作者Joe
相关产品推荐
相关产品推荐

