トランザクション・ロックについて

データの整合性を保つために、トランザクションはたびたび使われる。

銀行の入金処理がよく例に挙げられるが、A口座からB口座に対して5000円を振込む際には、

  1. 対象となる口座残高を読み込む

  2. A口座残高を5000円減らす

  3. B口座残高を5000円増やす

これらの処理が一連している必要がある。トランザクションなしで3の処理中にエラーが発生した場合、

A口座の5000円が消失してしまうことになる。

トランザクションを使うことで、失敗した場合にはトランザクション前の状態までロールバックする。

ただ、上記のトランザクション処理を行っている途中でC口座からB口座への振込が行われた場合には処理が重複してしまうため、

2つのトランザクション終了時点でB口座の残高が合計5000円しか増えないことがある。

処理の重複によるをデータの矛盾を防ぐために、適切にロックを実装する必要がある。

Rails でのロックについて

ActiveRecord にはいくつかロックのメソッドが用意されている。

ActiveRecord::Locking::Pessimistic

lock , lock! はインスタンスに使うことでそのレコードをロックする。クエリに FOR UPDATE が追加されるので、ロックがかかったレコードの読み込みは可能だが書き込みは待機となる。

  # select * from accounts where name = 'shugo' limit 1 for update nowait
  shugo = Account.lock("FOR UPDATE NOWAIT").find_by(name: "shugo")

このように引数にクエリ句を渡せる。上記だと排他ロックとなり、読み込みで待機となる。

# File activerecord/lib/active_record/locking/pessimistic.rb, line 67
def lock!(lock = true)
  if persisted?
    if has_changes_to_save?
      raise(<<-MSG.squish)
        Locking a record with unpersisted changes is not supported. Use
        `save` to persist the changes, or `reload` to discard them
        explicitly.
      MSG
    end

    reload(lock: lock)
  end
  self
end

lock! はレコードがDBに保存されておらず、DBに保存されていない変更が該当するインスタンスにある場合には例外を発生させる。そうでない場合にレコードにロックをかける。

with_lock はブロックを渡すことができ、トランザクションとロックを同時にかけることができる。

# File activerecord/lib/active_record/locking/pessimistic.rb, line 85
def with_lock(lock = true)
  transaction do
    lock!(lock)
    yield
  end
end

メソッドの中ではトランザクション内で lock! を実行し、メソッドに渡したブロックを実行している。

MySQL5.7 の GET_LOCK, RELEASE_LOCK

MySQL では排他ロックの機能として GET_LOCK(str, timeout) , RELEASE_LOCK(str) がある。

前者は任意の文字列に対してロックをかけ、第2引数ではロックの取得までの待機時間を指定することができる。第2引数を渡さない場合には待機せず0を返す。後者は任意の文字列に対するロックを解放する。

ActiveRecord には MySQL 用のクラスがあり、用意されているメソッドを使うことでもロックを実装できる。

rails/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb:L140-L146

def with_lock(lock_str)
  if ActiveRecord::Base.connection.get_advisory_lock(lock_str)
    begin
      yield
    rescue => e
      raise e
    end
  else
    raise DuplicateError
  end
ensure
  ActiveRecord::Base.connection.release_advisory_lock(lock_str)
end

参考

データを保護するロックの仕組み

MySQL 5.7 Reference 12.15 Locking Functions

MySQL でシンプルな排他制御を GET_LOCK で実現する!