ホーム DoRuby ActiveRecordとデータベースについて知っておきたいこと
ActiveRecordとデータベースについて知っておきたいこと
 

ActiveRecordとデータベースについて知っておきたいこと

この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。

Ruby on RailsのActiveRecordをぼーっと使っていると、メモリにあるデータやdbにあるデータの整合性が取れなくなってバグを生んでしまう事があるという話です。

ActiveRecordが好きなのでたびたびActiveRecordの話をしますが、今回はうっかりやってしまいがちなDB上のデータとメモリ上のデータの不整合との話です。知らないと失敗しがちなので

railsは5.1.1を使っていますが、あまりバージョンには関係なく、rails固有というわけでもなさそうな話です。

うっかりしてしまった!

プレイヤーが複数持つデッキの中から、一つだけメインデッキを選べるような実装がしたいとします。

class Player < ApplicationRecord
  has_many :decks
  has_one :selected_deck, -> { where(selected_flg: true) }, class_name: "Deck"

  def select_main_deck(deck)
    transaction do
      self.decks.each{ |deck| deck.update!(selected_flg: false) }
      deck.update!(selected_flg: true)
    end
  end
end

素直に読むと、一度すべてを非選択状態にし、選択したいデッキを選択しているようです。
色々問題はありますが、この select_main_deck が同時に使われることがないと仮定しても、
今メインデッキとして選択されているデッキをまた選択した場合、全てのデッキが非選択状態になってしまうのです。

※ ふつうeachではなくupdate_allを使うところですが、update_allにはまた別の落とし穴があり、本筋からそれるここではeachを使っています。

どうしてこうなってしまうのか

一瞬何が悪かったのかわかりにくいですが、

ひとつめ: DBで変更があったとしてもメモリ上のデータは更新されない

下の例では first_player が指すものと another_first_player が指すものはメモリ上では別の場所におかれており
ActiveRecodr::Baseは勝手に変更を取ってきてはくれません。

first_player = Player.first # #<Player id: 1, name: "あああああ">
another_first_player = Player.first
another_first_player.update!(name: "いいいいい")
p first_player 
=>  #<Player id: 1, name: "あああああ"> # DB上のデータとは違う

another_first_player からupdateされたものはanother_first_playerには反映されますが、たとえ同じデータを参照していても、オブジェクトとして別のものである first_player にはその変更は反映されません。

これは絶対知っておいたほうがいいことです。
読み込んで表示するだけならば問題ないですが、今のデータをもとに更新を行う場合は、DBからデータを読み込んでから更新するまでの間に他で変更されないよう、きちんと排他制御をしましょう(後述します)。

ふたつめ: 変更がなければUPDATEは走らない

ActiveRecord:::Base は DBからデータをひいてきてメモリ上のオブジェクトが作られたあと、updateなりsaveなりを使ったとしても、変更がなければDB上のデータを更新しようとしません

player = Player.first # #<Player id: 1, name: "あああああ">
player.update!(name: "あああああ")
# UPDATEは走らない

これで何が起こっているかはっきりしてきたかと思います。

def select_main_deck(deck)
  transaction do
    # 全てのデッキのselected_flgがfalseになる
    self.decks.each{ |deck| deck.update!(selected_flg: false) }
    # DB上ではselected_flgがfalseだが、メモリ上ではtrueのまま変わらないのでUPDATEされない
    deck.update!(selected_flg: true)
  end
end

こういったバグ、特に他人や昔の自分がかいたコードを読んだ場合かなり見つけにくいですね。

上に挙げた

  • 同じデータを違うオブジェクトから参照する
  • データの取得と更新を行う

の2つはバグの温床になりがちなので気をつけたいところです。

さて、この悲しい事態を避けるにはどうすればよかったのか考えてみましょう。

どうすればよかったのか

そもそも構造を変える

この場合なら、playersテーブルに selected_deck_id というカラムを追加して、そこで選択中のデッキを持っておいたほうが筋がいい気がします。
選択中のデッキを切り替えるときに複数のレコードを更新しなくて済み、データ不整合を起こすことがなくなります。

class Player < ApplicationRecord
  has_many :decks
  belongs_to :selected_deck, class_name: "Deck"

  def select_main_deck(deck)
    self.update!(selected_deck: deck)
  end
end

この場合に限らず、何らかの操作をしたとき、更新する部分があまり多くならないよう考えておくとバグが起こりにくいですね。

ロックをとる

どうしても上に挙げた方法が使えないときは、きちんと不整合を防ぐ仕組みが必要です。
例えばロックというDBからデータを読み込んでから更新するまでの間にデータが更新されないようにするDBの仕組みがあります。

たとえば所持金を増やしたいとき、知らないうちにDBのデータが更新されてしまうと困ったりしますよね。

class Player
  def add_money(val)
    # 今の所持金を200とすると
    added_money = self.money + 100
    # ここで、DB上でのplayerの所持金が300になる
    self.update!(money: added_money)
    # 本当は400なのに300に...
  end
end

「所持金を増やす」間に他のところでデータが書き換えられないようにしないといけません。
たとえば rails では、以下のように with_lock メソッドを使って書くと、with_lockに与えたブロックの中の処理が終わるまで別の場所でDB上のデータが更新できなくなります(こういった、一つのレコードに対しての更新を制御するものは行ロックと呼ばれています)。

class Player
  def add_money(val)
    with_lock do
      added_money = self.money + 100
      self.update!(money: added_money)
    end
  end
end

さらに気をつけたいのは、複数のレコードに対してロックを取る場合です。
一度ロックを取ってしまうと処理が終わるまでは他ではデータの更新ができないため、順番次第ではお互い更新が終わるまで待ち状態になる、いわゆ
るデッドロックという状態になってしまうことがあります。「食事する哲学者の問題」がわかりやすい例ですね。

デッドロックに陥らないためには、たとえば必ずidが大きい方からロックを取るなど、一意な順番でロックを取っていく必要があります。

class Player < ApplicationRecord
  has_many :decks
  belongs_to :selected_deck, class_name: "Deck"

  def pay_for(target_player, val)
    if self.id < target_player.id
      self.with_lock do
        target_player.with_lock do
           target_player.update!(money: target_player.money + val)
           self.update!(money: self - val)
         end
       end
     elsif self.id > target_player.id
      target_player.with_lock do
        self.with_lock do
           target_player.update!(money: target_player.money + val)
           self.update!(money: self - val)
        end
      end
    end
  end
end

まとめ

基本的にこういったデータの不整合が起きやすいのは、 同じDBのレコードを指すオブジェクトが複数できるとき です。

まずそういう状況に気づくこと、気づいたら意図しない場所での更新が起きないようにすること、そもそも広い範囲での更新が起きないよう考え直すことを意識していきたいですね。

記事を共有

最近人気な記事