【问题标题】:Prevent queries overwriting each other防止查询相互覆盖
【发布时间】:2018-11-07 08:42:32
【问题描述】:

我有接收汽车预订请求的应用程序,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”,尽管事实上我知道有很多可用的汽车。

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

谢谢你????

【问题讨论】:

    标签: mysql sql ruby-on-rails ruby


    【解决方案1】:

    你没有说正在使用什么数据库,但现在大多数主要的数据库都有一种方法来执行更新并从更新的行返回数据

    例如在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 答案中改编它


    您也可以编写前端应用程序以循环使用它,但效率较低。这是伪代码,因为我不做 ruby​​:

    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,我们可以假设其他人在我们之前更改了行。然后,知道我们没有得到那一行,我们尝试另一行(或决定覆盖/合并/接受其他人的更改)

    【讨论】:

    • 用 mysql 标签更新了我的问题。没有考虑循环解决方案,虽然它效率较低但简单得多,并且认为它足够可靠。我将实施它,看看它的去向。谢谢你的回答!
    【解决方案2】:

    如果您使用带有活动记录的 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 进行调整

    【讨论】:

    • 有兴趣,会研究一下,谢谢!我目前所做的是:Car.where(id: potential_car_id, status: 'available').limit(1).update_all(status: 'in_use')。我从状态可用的 Car 中获取随机的 potential_car_id,然后尝试更新包含什么 ID 和状态“可用”的所有记录,在这种情况下,如果另一个请求保留了具有 potential_car_id 的汽车,它将不会被覆盖,因为条件状态 = 'available' 不会满足.就像在回答中建议的那样。
    猜你喜欢
    • 2023-02-03
    • 2020-03-13
    • 2016-02-25
    • 1970-01-01
    • 2017-05-27
    • 2014-06-02
    • 2011-01-19
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多