読者です 読者をやめる 読者になる 読者になる

inFablic

Fablic開発者ブログ

Railsにおけるレコード作成時のレースコンディションについて

エンジニアリング

こんにちは。サーバサイドエンジニアの @yamy です。

フリルでは商品情報など様々なレコードが作成されています。 今回は、レコード作成時にレースコンディションが発生した件についてお話しします。

レースコンディションとは

「レースコンディション(競合状態、Race Condition)」とは、並列で走る複数の処理(プロセスやスレッド)が、共有のリソースへほぼ同時にアクセスしたとき、処理のタイミングによって想定外の結果をもたらすことをレースコンディションと呼びます。

一般的なレースコンディションの例を以下に記載します。

  • スレッドAが17を取得
  • スレッドBもほぼ同時に17を取得
  • スレッドAが18にインクリメントして保存
  • スレッドBも18にインクリメントして保存(オーバーライト)
  • 期待した19ではなく18として保存されてしまう

f:id:infablic:20170315130020p:plain

フリルでのケース

あるトランザクション内において、レコード作成処理と関連リソースの更新処理があるとします。

def create
  ActiveRecord::Base.transaction do
    return unless creatable?

    # レコード作成処理
    Product.create!(
      foo: 'bar',
    )

    # 更新処理
    ...
  end
end

creatable? は、Productレコードの存在をチェックし、以降の処理をすべきかの判定をするものとします。 ここで、複数プロセスA,Bが同時に処理された場合、creatable?のブロック条件に該当するはずのところ、レースコンディションによりすり抜けてしまい、以下のようなエラーになる可能性があります。ユニーク制約をかけているので Duplicate entry となっています。

A> BEGIN
B> BEGIN
A> INSERT INTO `products` (`foo`) VALUES ('bar')
B> INSERT INTO `products` (`foo`) VALUES ('bar') <- Duplicate entry 'bar' for key 'index_products_on_foo'

Productの重複登録は避けれていますが、プロセスBにはプロセスAの処理を待ってから処理してほしいところです。

楽観的ロック

レースコンディションへの対応として、Railsでは「楽観的ロック」「悲観的ロック」と呼ばれる排他制御が提供されています。

「楽観的ロック」とは、「競合は多分起きないだろう」という前提で、データの更新時に競合をチェックする方法です。ActiveRecordでは、lock_versionというバージョン管理向けのカラムを追加するだけで楽観的ロックを利用できます。

レコードの更新時に1づつインクリメントし、更新時にデータ取得時のロックバージョンと異なっている場合、競合が発生したと判断し ActiveRecord::StaleObjectError を発生させます。

カラム追加の必要があるため、フリルでは利用を見送りましたが、以下に簡単な利用方法を記載します。Productsモデルを例にしています。

マイグレーションファイルの追加

rails g migration add_lock_version_to_products lock_version:integer
class AddLockVersionToProducts < ActiveRecord::Migration
  def change
    add_column :products, :lock_version, :integer, default: 0, null: false
  end
end

マイグレーションの実行

rake db:migrate

これで、Productsモデルで自動的に楽観的ロックが行われるようになります。

悲観的ロック

「悲観的ロック」とは、「競合が発生する可能性が十分ある」という状況に向いた排他的なロック手法です。SELECT … FOR UPDATE によりレコード取得時にロックを行い、ロックされたレコードを更新できないようにする仕組みです。

def create
  ActiveRecord::Base.transaction do
    item.lock!
    return unless creatable?

    # レコード作成処理
    Product.create!(
      foo: 'bar',
    )

    # 更新処理
    item.update!(
      status: 2,
    )
    ...
  end
end

ここで、共有リソースのitemという更新対象のオブジェクトがあるとします。トランザクションの冒頭で共有リソースに対してitem.lock!を実行しています。モデルオブジェクトのインスタンスメソッドのlock!は、そのオブジェクトのテーブルレコードに対しての悲観的ロックを取得します。

あるプロセスAとBがほぼ同時に処理を開始し、プロセスAが一瞬早く呼び出された場合、プロセスBは共有リソースであるitem.lock!のところで待たされるため、プロセスAのトランザクションが完了するまで先へは進めません。 これにより、レースコンディションを防ぐことが可能となります。

なお、トランザクション内から別メソッド呼び出しでlock!を行なっている場合、想定したロックが掛からないケースがあるので、テストケースなど十分な検証が必要です。

まとめ

レコード作成時にレースコンディションが発生した件についてお話ししました。同時アクセスなどによる非常に検出しづらい問題ではありますが、Railsのメソッドとしてロック機構が提供されているので、そこまでの手間を掛けずに対応することが出来ました。

参考