Rails并发场景下如何仅放行单请求执行操作拦截其余并发请求
问题根因
你的行锁方案不生效,核心原因是加锁时机晚于状态校验逻辑,属于典型的Check-Then-Act竞态问题:
- 现有逻辑先无锁查询
request和order记录,无锁判断二者是否为拒绝状态 - 完成校验后才对记录加行锁
多个并发请求会同时走完第一步的无锁查询和校验,全部判定状态为非拒绝,之后再排队等待拿锁。第一个拿到锁的请求完成状态更新、提交事务释放锁后,后续排队拿到锁的请求不会再重新校验状态,会直接执行后续的更新、创建评论逻辑,最终生成重复记录。
另外两个会放大问题的细节:
- 控制器中在事务外提前查询了
@request和@order,拿到的是事务提交前的旧快照数据,感知不到其他事务对状态的更新 lock!是对已经查询出的内存对象加锁,查询动作本身没有加锁,无法阻止其他并发事务在查询阶段读到旧数据。
修复方案
1. 锁前置:查询阶段就加排他锁,校验逻辑放在加锁之后
行锁必须在事务内的查询阶段就生效,Rails中调用lock方法会生成SELECT ... FOR UPDATE语句,在当前事务提交前,其他事务对同一条记录的加锁查询会被阻塞,等锁释放后读到的一定是最新的持久化数据。
修改后的模型代码:
class Request < ApplicationRecord def self.reject_request(key) ActiveRecord::Base.transaction do # 查询时直接加排他锁,禁止先查后锁 request = Request.lock.find_by(reject_key: key) order = request&.order raise ActiveRecord::Rollback if request.blank? || order.blank? # 关联的order记录同样带锁查询 order = Order.lock.find(order.id) # 加锁完成后再做状态校验,此时读取的是最新数据 raise ActiveRecord::Rollback if request.rejected? || order.rejected? request.update!(status: 'rejected') order.update!(status: 'rejected') return true end false end end
2. 移除控制器事务外的提前查询
不要在事务开启前查询关联对象,避免拿到脏快照,所有数据读取操作都放在事务内执行:
class OrdersController < ApplicationController def reject rejected = false ActiveRecord::Base.transaction do rejected = Request.reject_request(params[:reject_key]) if rejected # 事务内读取最新的request记录,不用事务外的旧对象 @request = Request.find_by(reject_key: params[:reject_key]) @order = @request.purchase_order @order.comments.create(comment: "Order rejected by approver: #{@request.approver.name}") end end if rejected redirect_to root_path, notice: 'Rejected' else redirect_to root_path, notice: 'Try again!' end end end
3. 数据库层加唯一索引兜底
代码层逻辑不能完全规避极端场景的异常,需要在数据库层加约束做最后一道防线,从根源上阻止重复记录生成:
# 数据库迁移示例 class AddRejectUniqueConstraintToRequests < ActiveRecord::Migration[5.0] def change # 保证每个reject_key只会存在一条已拒绝的记录 add_index :requests, :reject_key, unique: true, where: "status = 'rejected'" end end
方案说明
- 带
FOR UPDATE的排他锁会阻塞其他事务对同一条记录的加锁查询,第一个拿到锁的请求执行完成前,其他并发请求都会卡在查询步骤,不会提前进入状态校验逻辑 - 锁释放后,阻塞的请求查到的是已经更新为拒绝状态的记录,会直接触发回滚,不会执行后续的评论创建逻辑
- 数据库部分唯一索引不影响非拒绝状态的正常操作,仅对已拒绝的记录做唯一性校验,就算代码逻辑出现疏漏,数据库也会直接抛出重复插入异常,不会产生脏数据。
内容的提问来源于stack exchange,提问作者siv rj
相关产品推荐
相关产品推荐

