防止查询彼此覆盖

时间:2018-11-07 08:42:33

标签: mysql sql ruby-on-rails ruby

我有一个接收汽车预订请求的应用程序,并且cars表中的汽车预订状态应设置为in_use

通常会执行以下操作:

def reserve_car(user_id)
  car = Car.find_by(status: 'available')
  car.update_columns(user_id: user_id, status: 'in_use')

  car
end

但是此解决方案在选择可用汽车和更新其状态之间造成了差距,然后当应用程序必须处理相对大量的请求时出现了难题,因此在同一时间内很少有请求尝试保留同一辆汽车。

为减少这种风险,我在同一SQL查询中找到并更新了可用的汽车。我也将可用的汽车列表随机化,以进一步减少它。为了使结果随机化,我不使用ORDER BY RAND() LIMIT 1,因为AFAIK会为每条记录生成随机id,仅在将结果限制为指定数字-1时才对其进行排序。将来的记录数量(超过100k)。

所以我想出了这个解决方案:

def reserve_car(user_id)
  sql = <<-SQL
    UPDATE
      cars AS r0,
      (
        SELECT
          r1.id
        FROM
          cars AS r1
          JOIN (
            SELECT
              (
                RAND() * (
                  SELECT
                    MAX(id)
                  FROM
                    cars
                )
              ) AS id
          ) AS r2
        WHERE
          r1.status = 'available'
          AND r1.id >= r2.id
        LIMIT
          1
      ) AS r3
    SET
      r0.status = 'in_use',
      r0.user_id = #{ActiveRecord::Base.connection.quote(user_id)}
    WHERE
      r0.id = r3.id
  SQL

  updates = ActiveRecord::Base.connection.exec_update(sql)

  car = Car.find_by(user_id: user_id, status: 'in_use')

  if car.present?
    car
  else
    raise "Failed to reserve car. Updates: #{updates}"
  end
end

但是我经常收到异常“无法预订汽车。更新0”,尽管事实上我知道有很多可用的汽车。

可能是什么问题?也许有人可以提出更好的解决方案?

谢谢

2 个答案:

答案 0 :(得分:1)

您不会说正在使用什么数据库,但是如今,大多数主要的DB都可以执行更新并从已更新的行中返回数据

例如,在oracle中:

update car
set in_use = 1
where in_use = 0 and id = (select min(id) from car where in_use = 0)
returning id into car_id_that_was_set_in_use 

参数car_id_that_was_set_in_use将包含已预订汽车的ID

作为一个建立锁并且长时间不使事务保持打开状态的单个操作,它不会引起任何争用

MySQL似乎是一个明显的例外-我没有发现任何迹象表明MySQL支持UPDATE..RETURNING之类的东西,但是还有其他解决方法,例如innodb支持SELECT..FOR UPDATE以允许您锁定您想要更新的记录,以及涉及可能类似于以下内容的变量的黑客攻击:

 UPDATE car SET
     in_use = 1, id = @affectedid := id
 WHERE in_use = 0 AND id=(SELECT MIN(id) FROM car WHERE in_use = 0);
 SELECT @affectedid;

尽管进行测试;我从来没有使用过它,而是从SO答案中改编了它


尽管效率较低,但您也可以对前端应用程序进行编码以使其轮循。这是伪代码,因为我不做红宝石:

int rowsupdated = 0
int potentialId = -1
while(rowsupdated = 0 and potentialId is not null) {
  potentialId = sql_scalar("SELECT MIN(id) FROM car WHERE in_use = 0")
  rowsupdated = sql_nonquery("UPDATE car SET in_use = 1 WHERE in_use = 0 and id = " + potentialId)
}
if(potentialId is null)
  //there was no car to book, we tried them all - potentialId would only be null if there were no more cars
else
  //potentialId now contains the id of the car we booked

while循环将继续进行,直到预订了汽车。它既幼稚又效率低下,但提出了一个重要的观点,也适用于先前的查询

更新查询必须引用我们仍期望的in_use值

您不能选择一个ID,只能继续设置in_use = 1,而不考虑在我们空闲时是否有人设置了in_use = 1。这称为开放式并发-您希望没有其他人更改要编辑的行上的数据,但是您包含了有关该行的所有数据,因此,如果其他人DID更改了该行,则更新将失败并返回0记录已更新。如果其他人在空闲时将in_use设置为1,则更新失败,并且我们将更新条件设为in_use仍为0,以使更新成功。 如果更新返回0,则可以假定其他人在此之前更改了行。然后,知道我们没有得到那一行,我们尝试另一行(或做出覆盖/合并/接受对方更改的决定)

答案 1 :(得分:1)

如果您正在使用带有活动记录的Rails,则一旦找到该行车记录,就应该能够将其锁定,以防止其他请求检索到该行车记录。 像这样:

def reserve_car(user_id)
  car = Car.find_by(status: 'available')
  car.with_lock do
    car.update_columns(user_id: user_id, status: 'in_use')
  end

  car
end

您可能需要根据ActiveRecord locking docs

进行调整