Rails におけるレースコンディションの例とその回避方法

最近立て続けにレースコンディション問題に遭遇したのでメモ。
レースコンディション(競合状態)とは、複数のプロセスやスレッドが共有リソースに対して何らかの操作をする際に、処理のタイミングによって結果が異なってしまう状態のこと。よくトランザクションの解説の際に銀行口座の例として紹介されるおなじみのやつです。


今回は、アプリケーションの書き方によって発生するレースコンディションと、MySQL のテーブル定義時の制約不足で発生するレースコンディションについてそれぞれ紹介したいと思います。
どちらの場合も共有リソースはDB で、条件を満たすと意図しない形でデータが保存されてしまいます。

サンプルアプリケーション

サンプルアプリケーションとして、簡単なアクセス解析機能付きの短縮URL ツールを考えます。
アクセス解析機能として、以下のような機能を持つとしましょう。

  1. URL毎 にクリック数を計測できる
  2. 最小単位として、日毎のクリック数を閲覧できる
テーブル定義
class CreatePages < ActiveRecord::Migration
  def self.up
    create_table :pages do |t|
      t.string :url, :null => false
      t.index :url, :unique => true
    end

    create_table :counts do |t|
      t.integer :click, :null => false, :default => 0
      t.date :count_date, :null => false

      t.references :page
    end
  end
  
  def self.down
    drop_table :pages
    drop_table :counts
  end
end
アプリケーションコード
class Page < ActiveRecord::Base
  has_many :counts

  def click
    Count.click(self.id)
  end
end

class Count < ActiveRecord::Base
  belongs_to :page

  def self.click(page_id)
    count = self.find_or_create_by_page_id_and_count_date(page_id, Date.today)
    count.click += 1
    count.save
  end
end

class RelayController < ApplicationController
  def index
    page = Page.find(params[:id])
    page.click
    redirect_to page.url
  end
end

サンプルアプリケーションの問題

サンプルアプリケーションの「Count.click」メソッドにはレースコンディションが発生する可能性がある部分が二カ所存在します。

1. クリック数のインクリメントにおけるレースコンディション

Count オブジェクトが既に存在する場合、「Count.click」メソッドは次のコードと同じになります。

def self.click(page_id)
  count = self.find_by_page_id(page_id)
  count.click += 1
  count.save
end


今、Rails アプリケーションのプロセスが2つ起動しているとします。そして、「RelayController#index」アクションに同時にアクセスがあった場合、次のようなことが起きる可能性があります。

  • プロセスA がcounts レコード(現在のclick の値は1)を取得
    • プロセスB がcounts レコード(現在のclick の値は1)を取得
  • プロセスA が「click += 1」を実行し、click の値は2になる
    • プロセスB が「click += 1」を実行し、click の値は2になる
  • プロセスA が更新内容をDB に書き出す
    • プロセスB が更新内容をDB に書き出す


この結果、本来「3」になるはずのクリック数が「2」となってしまいます。


このようなことが発生してしまう原因は、アプリケーション側で値のインクリメントを行い、その値で現在のレコードの値を更新しているからです。
この問題に対応するにはDB 側でインクリメント処理を行う必要がありますつまり、アプリケーションはDB に対して「インクリメントして!」という命令のみを発行します。
Rails にはまさにこの処理を行うメソッド「ActiveRecord::Base.increment_counter」が用意されています(´∀`) このメソッドは指定された値をインクリメントするSQL を発行し、MySQL の場合は次のようなクエリが発行されます。

UPDATE counts SET click = click + 1 WHERE id = 1


increment_counter を使った「Count.click」は次のようになります。

def self.click(page_id)
  count = self.find_or_create_by_page_id_and_count_date(page_id, Date.today)
  self.increment_counter(:click, count.id)
end


これならば、先程のケースの問題を回避できます。DB には値そのものを書き込むSQL ではなく、インクリメントを指示するSQL が2回発行されるからです。これでも結果が正しくならない場合、それはDB 側のトランザクションの問題やコネクションが切れた等アプリケーションの外の問題になります。

2. counts レコードの生成におけるレースコンディション

レコードの生成は次のように行っています。

count = self.find_or_create_by_page_id_and_count_date(page_id, Date.today)


指定したページと日付の組合せが既に存在すればそのレコードを使います。存在しなければレコードを作成し、作成されたレコードを使います。これによって、同じ日付のクリック数を記録するレコードが複数作成されることを防止しています。


さて、この状態で先のように同時にアクセスがあると…。予想通り1つの「pages」レコードに対して同じ日付を持つ「counts」レコードが複数生成される可能性があります。これは、「find_or_create_by」メソッドが、最初に「find」を実行し、対応するレコードがない場合に「create」を実行するからです。最初にアクセスしたプロセスが新しい「count」レコードを生成する前に、次のプロセスが「find」を実行してしまうと、どちらのプロセスも新しいレコードを生成してしまいます。


この問題に対応するにはどうしたら良いでしょうか?「validates_uniqueness_of」を使えば良いでしょうか?
答はノーです。「validates_uniqueness_of」も結局SELECT を実行してレコードが取得できなければユニークであると判定してしまうため、レースコンディションを発生させます。この「validates_uniqueness_of」の問題に関しては3 日坊主日記 - validates_uniqueness_of は信頼できない , sqlite3 - Getting Startedが詳しいです。
この問題に対応する方法の1つは「counts」テーブルにユニーク制約を追加することです。ユニーク制約を追加すれば、レースコンディションが発生した際に2番目にレコードを生成しようとしたプロセス側でユニーク制約違反が発生します。書き込み時にブロックするので、同じレコードが複数生成されるのを防止することができます。


追加するユニーク制約は次のようになります。

create_table :counts do |t|
  t.integer :click, :null => false, :default => 0
  t.date :count_date, :null => false

  t.references :page
  t.index [:page_id, :count_date], :unique => true
end

まとめ

今回は自分がはまったレースコンディションについて2つ書きました。オープンソースのコードの中にはこの問題に対応していないものもあります。rails-ad-serverの「rads_url」アクションとか。アクセス解析系ツールは同時アクセスが非常に発生しやすいアプリケーションだと思うのでこれは危険なにおいがします。
かくいう自分も、最初に「はまった」と言った通り、サンプルアプリケーションと似たようなコードを書いてしまいました。この問題を発見できたのは、テスト時に同時アクセスのベンチマークをとったためです。やはり実際に試してみると気づくことが多いなと、また1つ勉強になりました。
「counts レコードの生成におけるレースコンディション」のようにアプリケーション(フレームワーク)レイヤーのレベルでは対応できないものもありました。ORM は裏でどんな処理が走っているかを考えなくても使えてしまう便利なものですが、その処理内容と、内容によっては起こる問題をきちんと理解しておくことが大切だなと思います。

追記

id:lizy さんからブコメでツッコミをいただきました。

これはISOLATION LEVELの問題じゃないですか?1個目はREPEATABLE READがあれば大丈夫か。2個目はSERIALIZABLEが必要かな


これについて考えてみました。説明を分かりやすくするため「2. counts レコードの生成におけるレースコンディション」について先に考えます。

2. counts レコードの生成におけるレースコンディションの場合

この場合はご指摘の通りISOLATION LEVEL がSERIALIZABLE ならば防ぐことができそうです。

このレベルは REPEATABLE READ と似ていますが、InnoDB は暗黙的に全ての単純な SELECT ステートメントを SELECT ... LOCK IN SHARE MODE にコミットします。

MySQL :: MySQL 5.1 リファレンスマニュアル :: 13.5.10.3 InnoDB と TRANSACTION ISOLATION LEVEL


LOCK IN SHARE MODE で実行すると何が起こるかは、以下のマニュアルが参考になります。

共有モードで読み取りを行うというのは、まず最新の有効なデータを読み取り、そして読んだ行に共有モードを設定するという意味です。共有モード ロックは、読み取った行が別の人によって更新されたり、削除されたりする事を防ぎます。また、もし最新データが別のクライアント接続のコミットされていないトランザクションに属していたら、そのトランザクションがコミットされるまで待ちます。

MySQL :: MySQL 5.1 リファレンスマニュアル :: 13.5.10.5 SELECT ... FOR UPDATE と SELECT ... LOCK IN SHARE MODE ロック読み取り


これらをふまえて、「2. counts レコードの生成におけるレースコンディション」についてレースコンディションが発生しそうな3パターンと、それに対するISOLATION LEVEL 毎の挙動をまとめます。

  1. プロセスA が新しいレコードをINSERT する前にプロセスB がSELECT を実行した
    • REPEATABLE READ の場合、プロセスB のSELECT は空の結果を返すので、プロセスB もINSERT を実行します。結果重複レコードが生成されてしまいます
    • SERIALIZABLE の場合、プロセスB のSELECT は空の結果を返すので、プロセスB もINSERT を実行します。しかし、プロセスA, B 共に共有ロックを取得した状態なので、先にINSERT を実行した方はまずブロックされ、他方がINSERT を実行した時点で、先にINSERT を実行した方は成功し、後に実行した方はデッドロックによるエラーになります。エラーしたプロセス側でもう一度SELECT を発行した場合、他方のプロセスがトランザクションをCOMMIT していれば最新のレコードを取得でき、COMMIT されていなくてもSELECT がブロックされるので、いずれにせよレコードの取得に成功します。結果、重複レコードが生成されることはありません
  2. プロセスA が新しいレコードをINSERT したが、プロセスA がトランザクションをCOMMIT する前にプロセスB がSELECT を実行した
    • REPEATABLE READ の場合、COMMIT していない内容を他のトランザクションから見ることは防止されているので、プロセスB のSELECT は空の結果を返します。よって、プロセスB もINSERT を実行します。結果重複レコードが生成されてしまいます
    • SERIALIZABLE の場合、リファレンスにある通り、最新データが別のCOMMIT されていないトランザクション(この場合はプロセスA のトランザクション)にある場合、SELECT をブロックします。そして、COMMIT された時点も最新データが取得されるので、プロセスB のSELECT はレコードの取得に成功します。結果、重複レコードが生成されることはありません
  3. プロセスA がトランザクションをCOMMIT する前にプロセスB のトランザクションが開始していて、プロセスA のトランザクションがCOMMIT された後でプロセスB のSELECT が実行された
    • REPEATABLE READ の場合、トランザクション中のSELECT はトランザクション中で最初に実行されたSELECT と同じスナップショットを読みとるのであって、トランザクションが開始された時点でのスナップショットではありません。よって、この場合プロセスB のSELECT はレコードの取得に成功します。結果、重複レコードが生成されることはありません
    • SERIALIZABLE の場合、SELECT は最新データを読もうとし、最新データが他のCOMMIT されていないトランザクションに属する場合はSELECT をブロックします。よって、この場合プロセスB のSELECT はレコードの取得に成功します。結果、重複レコードが生成されることはありません


結論として「2. counts レコードの生成におけるレースコンディション」についてはISOLATION LEVEL がSERIALIZABLE なら防止可能でした。

1. クリック数のインクリメントにおけるレースコンディションの場合

この問題の根本は「プロセスA, B がそれぞれが同じクリック数取得してしまう」ことです。本来ならプロセスB はプロセスA がインクリメントした結果の値を取得しなければなりません。
これについても、SERIALIZABLE ならば対応可能な問題ですが、REPEATABLE READ だと問題が発生する場合があります。例を書きます。

  • プロセスA がクリック数をインクリメントし、トランザクションをCOMMIT する前にプロセスB が現在のクリック数を取得した
    • REPEATABLE READ の場合、他トランザクションのCOMMIT されていない変更を見ることができません。よって、プロセスB が取得するクリック数はプロセスA がインクリメントする前の値になります。結果、プロセスA, B 共に同じ値を新しいクリック数として書き込み、実質インクリメントは1回か行われません
    • SERIALIZABLE の場合、プロセスA がクリック数を更新しているので、プロセスB のSELECT はプロセスA のトランザクションがCOMMIT するまで待たされます。よって、プロセスB が取得できるクリック数はプロセスA によってインクリメントされた後の値となります。結果、インクリメントは正常に2回行われます


結論として「1. クリック数のインクリメントにおけるレースコンディション」についてもSERIALIZABLE なら防止可能でした。
ちなみに、この例に対してはより適切な方法があります。

もし2ユーザがカウンタを同時に読むと、少なくても1ユーザはカウンタを更新しようとする時にデッドロックになってしまう為、 LOCK IN SHARE MODE はこの場合良い解決法とはいえません。

この場合、カウンタの読み取りとインクリメントを実装する為の良い方法が2つあります:(1) カウンタを1でインクリメントする事で更新し、その後にだけ読み取る、または、 (2) ロックモード FOR UPDATE を利用してまずカウンタを読み取り、その後にインクリメントする。後者の方法は、次のように実装できます:


SELECT counter_field FROM child_codes FOR UPDATE;
UPDATE child_codes SET counter_field = counter_field + 1;


A SELECT ... FOR UPDATE は、読み取る各行上に専用ロックを設定し、最新の有効データを読み取ります。従って、それは SQL UPDATE が行上に設定する物と同じロックを設定します。

MySQL :: MySQL 5.1 リファレンスマニュアル :: 13.5.10.5 SELECT ... FOR UPDATE と SELECT ... LOCK IN SHARE MODE ロック読み取り


ここにある通り、SERIALIZABLE で対応する場合には、後にUPDATE を実行したトランザクションデッドロックによるエラーが発生します。よって、正常な動作をさせるためには、もう一度SELECT によって値を取得し直さなければなりません。
そもそもこの問題の根本は「プロセスA, B がそれぞれが同じクリック数を読みとってしまう」ことでした。よって、プロセスB のSELECT 自体をプロセスA のトランザクションがCOMMIT されるまでブロックできれば良い訳です。そして、これを実現するのが「SELECT 〜 FOR UPDATE」です。このクエリを実行したプロセス(MySQL レベルではセッション)はSELECT 対象となった行の排他ロックを取得し、トランザクション中はロックを保持します。なので、後発のSELECT は排他ロックを獲得できるまで待たされ、ブロックされたSELECT の実行結果には先にロックを保持していたトランザクションの実行結果が反映されるという寸法です。


さらにちなむと、Rails のfind メソッドは「:lock => true」というオプションを渡してやると「FOR UPDATE」付きSELECT を発行します。但し、「find_or_create_by」系のメソッドではこのオプションが無効なので使いどころは微妙かもしれません。


今回の問題について、ISOLATION LEVEL のことは考えていませんでした。ツッコミいただき感謝です!勉強になりました。