Activerecord事务并发竞争条件问题

时间:2013-04-20 18:25:28

标签: ruby-on-rails ruby postgresql activerecord transactions

我目前正在对我正在为Android制作的游戏进行实时测试。这些服务是用rails 3.1编写的,我使用的是Postgresql。我的一些技术精湛的测试人员能够通过将他们的请求记录到服务器并以高并发性重放它们来操纵游戏。我将尝试简要描述下面的场景,而不会陷入代码中。

  • 用户可以购买多个项目,每个项目在数据库中都有自己的记录。
  • 请求转到控制器操作,该操作会创建购买模型以记录有关交易的信息。
  • 交易模型有一种设置购买物品的方法。它基本上做了几个逻辑步骤,看看他们是否可以购买该项目。最重要的是,他们在任何给定时间都限制每个用户100个项目。如果所有条件都通过,则使用一个简单的循环来创建他们请求的项目数。

所以,他们正在做的是,通过代理记录1个有效请求购买。然后用高并发性重放它,这实际上每次都允许一些额外的漏洞。因此,如果他们将其设置为购买100个数量,他们可以将其提升至300-400,或者如果他们的数量达到15个,他们可以将其提升至120个。

上述购买方法包含在交易中。但是,即使它被包装,它也不会在请求几乎同时执行的某些情况下停止它。我猜这可能需要一些DB锁定。需要知道的另一个变化是,在任何给定时间,rake任务都在针对用户表的cron作业中运行,以更新玩家的健康和能量属性。所以,这也无法阻止。

任何帮助都会非常棒。这是我的小爱好项目,我想确保游戏对每个人都公平有趣。

非常感谢!

控制器操作:

  def hire
    worker_asset_type_id = (params[:worker_asset_type_id])
    quantity = (params[:quantity])

    trade = Trade.new()

    trade_response = trade.buy_worker_asset(current_user, worker_asset_type_id, quantity)

    user = User.find(current_user.id, select: 'money')

    respond_to do |format|
      format.json {
        render json: {
            trade: trade,
            user: user,
            messages: {
                messages: [trade_response.to_s]
            }
        }
      }
    end
  end

交易模型方法:

def buy_worker_asset(user, worker_asset_type_id, quantity)
    ActiveRecord::Base.transaction do

      if worker_asset_type_id.nil?
        raise ArgumentError.new("You did not specify the type of worker asset.")
      end

      if quantity.nil?
        raise ArgumentError.new("You did not specify the amount of worker assets you want to buy.")
      end

      if quantity <= 0
        raise ArgumentError.new("Please enter a quantity above 0.")
      end

      quantity = quantity.to_i
      worker_asset_type = WorkerAssetType.where(id: worker_asset_type_id).first

      if worker_asset_type.nil?
        raise ArgumentError.new("There is no worker asset of that type.")
      end

      trade_cost = worker_asset_type.min_cost * quantity

      if (user.money < trade_cost)
        raise ArgumentError.new("You don't have enough money to make that purchase.")
      end

      # Get the users first geo asset, this will eventually have to be dynamic
      potential_total = WorkerAsset.where(user_id: user.id).length + quantity

      # Catch all for most people
      if potential_total > 100
        raise ArgumentError.new("You cannot have more than 100 dealers at the current time.")
      end

      quantity.times do
        new_worker_asset = WorkerAsset.new()
        new_worker_asset.worker_asset_type_id = worker_asset_type_id
        new_worker_asset.geo_asset_id = user.geo_assets.first.id
        new_worker_asset.user_id = user.id
        new_worker_asset.clocked_in = DateTime.now

        new_worker_asset.save!
      end

      self.buyer_id = user.id
      self.money = trade_cost
      self.worker_asset_type_id = worker_asset_type_id
      self.trade_type_id = TradeType.where(name: "market").first.id
      self.quantity = quantity

      # save trade
      self.save!

      # is this safe?
      user.money = user.money - trade_cost

      user.save!
    end
  end

2 个答案:

答案 0 :(得分:4)

听起来您需要幂等请求,因此请求重播无效。在可能的情况下实施操作,以便重复它们无效。如果不可能,请为每个请求提供唯一的请求标识符,并记录请求是否已满足。您可以将请求ID信息保存在PostgreSQL中的UNLOGGED表中或redis / memcached中,因为您不需要它是持久的。这将阻止整类攻击。

要解决这个问题,请在用户项目表上创建AFTER INSERT OR DELETE ... FOR EACH ROW EXECUTE PROCEDURE触发器。有这个触发器:

BEGIN
    -- Lock the user so only one tx can be inserting/deleting items for this user
    -- at the same time
    SELECT 1 FROM user WHERE user_id = <the-user-id> FOR UPDATE;

    IF TG_OP = 'INSERT' THEN
        IF (SELECT count(user_item_id) FROM user_item WHERE user_item.user_id = <the-user-id>) > 100 THEN
            RAISE EXCEPTION 'Too many items already owned, adding this item would exceed the limit of 100 items';
        END IF;
    ELIF TG_OP = 'DELETE' THEN
       -- No action required, all we needed to do is take the lock
       -- so a concurrent INSERT won't run until this tx finishes
    ELSE 
        RAISE EXCEPTION 'Unhandled trigger case %',TG_OP;
    END IF;
    RETURN NULL;
END;

或者,您可以在添加或删除任何项目所有权记录之前对客户ID进行行级锁定,从而在Rails应用程序中实现相同的功能。我更喜欢在触发器中做这种事情,你不能忘记在某个地方应用它,但我意识到你可能更喜欢在应用程序级别执行它。请参阅Pessimistic locking

乐观锁定不适合此应用程序。您可以在添加/删除项目之前通过递增用户的锁定计数器来使用它,但它会导致用户表上的行流失,并且当您的交易无论如何都很短时,这实际上是不必要的。

答案 1 :(得分:0)

除非您向我们展示您的相关架构和查询,否则我们无能为力。我想你做的事情如下:

$ start transaction;
$ select amount from itemtable where userid=? and itemid=?;
15
$ update itemtable set amount=14 where userid=? and itemid=?;
commit;

你应该做的事情如下:

$ start transaction;
$ update itemtable set amount=amount-1 returning amount where userid=? and itemid=?;
14
$ commit;