Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

ActiveRecordのupdate_countersで競合状態を防ぐ

Railsは巨大なフレームワークであり、特定の状況で役立つ便利なツールが多数組み込まれています。このシリーズでは、Railsの巨大なコードベースに隠された、あまり知られていないツールのいくつかを紹介していきます。

今回は、ActiveRecordのupdate_countersメソッドを取り上げます。その過程で、マルチスレッドプログラミングにおけるよくある落とし穴である「競合状態(レースコンディション)」と、このメソッドがいかにしてそれを防止できるかを見ていきます。

スレッド

プログラミングにおいて、コードを並列実行する方法には、プロセス、スレッド、そして(Rubyでは比較的最近)ファイバーとリアクターがあります。ここでは、Rails開発者が最もよく遭遇する形式であるスレッドに焦点を当てます。例えば、Pumaはマルチスレッドサーバーであり、Sidekiqはマルチスレッドのバックグラウンドジョブプロセッサーです。

ここではスレッドとスレッドセーフティについて深くは掘り下げません。知っておくべき主なことは、2つのスレッドが同じデータを操作すると、データが簡単に Out-of-sync 状態(整合性が取れない状態)になるということです。これがいわゆる「競合状態」です。

競合状態(レースコンディション)

競合状態とは、2つ(以上)のスレッドが同時に同じデータを操作することで、あるスレッドが古いデータを使ってしまう状況を指します。「レースコンディション」と呼ばれるのは、2つのスレッドが競争しているようなものであり、どちらのスレッドが「競争に勝ったか」によってデータの最終状態が変わってしまうためです。何より厄介なのは、スレッドが特定の順序で、特定のコードポイントにおいて「ターンを交代」したときにのみ発生するため、再現が非常に困難だという点です。

具体例

競合状態を説明するよくあるシナリオは、銀行口座の残高更新です。シンプルなRailsアプリケーション内にテスト用クラスを作成し、実際に何が起きるか確認してみましょう:

class UnsafeTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        balance = account.reload.balance
        account.update!(balance: balance + 100)

        balance = account.reload.balance
        account.update!(balance: balance - 100)
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

UnsafeTransactionは非常にシンプルです。Account(BigDecimal型のbalance属性を持つ標準的なRailsモデル)を検索し、再実行しやすいよう残高をゼロにリセットしています。

内側のループが興味深い部分です。4つのスレッドを作成し、それぞれが口座の現在の残高を取得し、100を加算(例:100ドルの入金)し、すぐに100を減算(例:100ドルの出金)します。より確実に最新の残高を得るため、両方のタイミングでreloadを呼んでいます。

残りの行は後処理です。Thread.joinですべてのスレッドの終了を待ち、最後に最終残高を返します。

これをシングルスレッドで実行(ループを1.times doに変更)すれば、100万回実行しても最終残高は常にゼロです。しかし、2つ以上のスレッドにすると、話は別です。

コンソールで一度実行すると、おそらく正しい答えが返ってきます:

UnsafeTransaction.run
=> 0.0

しかし、これを繰り返し実行したらどうでしょう。10回実行してみます:

(1..10).map { UnsafeTransaction.run }.map(&:to_f)
=> [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0]

構文が慣れない場合のために補足すると、(1..10).map {}はブロック内のコードを10回実行し、各実行の結果を配列に格納します。末尾の.map(&:to_f)は、BigDecimalの値が指数表記(0.1e3など)で表示されるのを防ぎ、人間が読みやすい形にするためのものです。

覚えておいてほしいのは、このコードは現在の残高を取り、100を足し、すぐに100を引くため、最終結果は常に0.0であるべきだということです。100.0300.0が出現していることは、競合状態が発生している証拠です。

注釈付きの例

問題のコードを拡大し、balanceの変更をさらに分解して何が起きているか見てみましょう。

threads << Thread.new do
  # ここでスレッドが切り替わる可能性がある
  balance = account.reload.balance
  # あるいはここ...
  balance += 100
  # あるいはここ...
  account.update!(balance: balance)
  # あるいはここ...

  balance = account.reload.balance
  # あるいはここ...
  balance -= 100
  # あるいはここ...
  account.update!(balance: balance)
  # あるいはここ...
end

コメントにある通り、スレッドはこのコードのほぼあらゆるポイントで切り替わる可能性があります。スレッド1が残高を読み取った直後にコンピューターがスレッド2の実行を開始すると、スレッド1がupdate!を呼ぶ頃にはデータが古くなっている可能性があります。言い換えれば、スレッド1、スレッド2、データベースのすべてがデータを保持していますが、それらが互いに Out-of-sync 状態になってしまうのです。

この例はわざと些細なものにしており、解剖しやすくしています。しかし現実世界では、競合状態の診断はより困難になります。特に、確実に再現できないことが多いためです。

解決策

競合状態を防ぐにはいくつかの選択肢がありますが、ほぼすべてが単一のアイデアに集約されます:「ある瞬間にデータを変更しているのは1つの主体だけであることを保証する」ということです。

選択肢1:Mutex(ミューテックス)

最もシンプルなのは「相互排他ロック」、通称Mutexです。Mutexは鍵が1つしかないロックのようなものと考えられます。あるスレッドが鍵を持っていれば、Mutex内のコードを実行できます。他のすべてのスレッドは、鍵を持てるまで待たなければなりません。

この例にMutexを適用すると以下のようになります:

class MutexTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    mutex = Mutex.new

    threads = []
    4.times do
      threads << Thread.new do
        mutex.lock
        balance = account.reload.balance
        account.update!(balance: balance + 100)
        mutex.unlock

        mutex.lock
        balance = account.reload.balance
        account.update!(balance: balance - 100)
        mutex.unlock
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

ここで、accountの読み書きのたびにまずmutex.lockを呼び、完了したらmutex.unlockを呼んで他のスレッドにターンを譲っています。ブロックの最初でmutex.lock、最後でmutex.unlockを呼ぶこともできますが、そうするとスレッドが並行実行されなくなり、スレッドを使うメリットが薄れます。パフォーマンスのためには、mutex内のコードを可能な限り小さく保ち、スレッドができるだけ多くのコードを並列実行できるようにするのがベストです。

ここでは.lock.unlockを明示的に使いましたが、RubyのMutexクラスにはブロックを受け取ってこれを自動で行う便利なsynchronizeメソッドがあります。以下のように書けます:

mutex.synchronize do
  balance = ...
  ...
end

RubyのMutexで必要なことはできますが、Railsアプリケーションでは特定のデータベース行をロックしたい場面がよくあります。ActiveRecordにはこのシナリオに対応する機能が用意されています。

選択肢2:ActiveRecordのロック

ActiveRecordはいくつかの異なるロック機構を提供していますが、ここではすべてを深掘りしません。今回の目的では、更新したい行をlock!でロックできます:

class LockedTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        Account.transaction do
          account = account.reload
          account.lock!
          account.update!(balance: account.balance + 100)
        end

        Account.transaction do
          account = account.reload
          account.lock!
          account.update!(balance: account.balance - 100)
        end
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

Mutexが「特定のスレッドのためにコードのセクションをロックする」のに対し、lock!は「特定のデータベース行をロックします」。これにより、同じコードを複数の口座(例:複数のバックグラウンドジョブ)に対して並列実行できます。同じレコードにアクセスする必要があるスレッドのみが待機すればよくなります。 ActiveRecordには、トランザクションとロックを一度に行える便利な#with_lockメソッドもあります。上記の更新は以下のように簡潔に書けます:

account = account.reload
account.with_lock do
  account.update!(account.balance + 100)
end
...

解決策3:アトミック(原子的)なメソッド

「アトミック(原子的)」なメソッド(または関数)とは、実行の途中で停止されないものを指します。例えば、Rubyでよく使われる+=演算は、単一の操作に見えますがアトミックではありません

value += 10

# 以下と等価:
value = value + 10

# さらに冗長に書くと:
temp_value = value + 10
value = temp_value

value + 10の計算と、結果をvalueに書き戻す間にスレッドが突然「スリープ」(別のスレッドに実行権が移る)すると、競合状態の可能性が生じます。しかし、この操作中にスレッドがスリープしないことが保証されていれば(コンピューターが別のスレッドに切り替えない)、それは「アトミック」な操作と見なせます。

一部の言語には、まさにこの種のスレッドセーフティのために、プリミティブ値のアトミック版が用意されています(例:AtomicInteger、AtomicFloat)。しかし、Rails開発者である私たちにも、いくつかの「アトミック」な操作が利用可能です。その一例がActiveRecordのupdate_countersメソッドです。

このメソッドは主にカウンターキャッシュを最新に保つためのものですが、私たちのアプリケーションで使用することを妨げるものは何もありません。カウンターキャッシュの詳細については、以前のキャッシュに関する記事を参照してください。

使い方は非常にシンプルです:

class CounterTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        Account.update_counters(account.id, balance: 100)

        Account.update_counters(account.id, balance: -100)
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

Mutexもロックも不要、わずか2行のRubyコードです。update_countersは第1引数にレコードIDを取り、どのカラムを変更するか(balance:)と、どれだけ変更するか(100または-100)を指定します。これが機能する理由は、読み取り・更新・書き込みのサイクルがデータベース内で単一のSQL呼び出しとして実行されるからです。つまり、Rubyのスレッドが操作を中断できません。スリープしても問題ありません。実際の計算はデータベースが行うためです。

実際に発行されるSQLは以下のようになります(少なくとも私の環境のPostgreSQLでは):

Account Update All (1.7ms)  UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2  [["balance", "100.0"], ["id", 1]]

この方法はパフォーマンスも大幅に向上します。計算が完全にデータベース内で完結するため、最新の値を得るためにreloadする必要がないからです。しかし、この速度には代償があります。生のSQLを実行するため、Railsのモデルをバイパスし、バリデーションやコールバックが実行されません(つまり、updated_atタイムスタンプも更新されません)。

結論

競合状態は、まさに「ハイゼンバグ(観測すると消えるバグ)」のポスターチャイルドと言えるでしょう。入り込みやすく、再現はしばしば不可能で、予見するのは困難です。しかし、RubyとRailsには、これらの問題を発見したときに叩き潰すための便利なツールが用意されています。

一般的なRubyコードであれば、Mutexが良い選択肢であり、「スレッドセーフティ」という言葉を聞いて多くの開発者が最初に思い浮べるものでしょう。

Railsを使っていて、データがActiveRecordから来ているなら、lock!(またはwith_lock)は簡単に使え、Mutexよりもスループットが向上します。関連するデータベース行のみをロックするためです。

正直なところ、実務でupdate_countersを積極的に選ぶことはないでしょう。あまり一般的ではないため、他の開発者がその振る舞いに精通していない可能性があり、コードの意図が特に明確ではないからです。スレッドセーフティが懸念される場面では、ActiveRecordのロック(lock!またはwith_lock)の方が一般的で、コーダーの意図をより明確に伝えられます。

しかし、単純な「加算・減算」のジョブが大量に溜まっており、徹底的な速度が必要な場合、update_countersは懐の深い有用なツールになり得ます。

  1. Rubyのラムダ徹底解説――基本から実践的な応用テクニックまで

    Rubyにおいてブロックは非常に重要な存在であり、ブロックなしにこの言語を語ることはできません。では、ラムダはどうでしょうか?ラムダを愛している人はいるのでしょうか?実際のところ、何年もラムダを使わずに過ごすことだって可能かもしれません。まるで昔の時代の遺物のように見えることもあるでしょう。 しかし、それは完全な真実ではありません。少し掘り下げてみると、ラムダには興味深い技が数多く隠されているのです。 この記事では、まずラムダの基本的な使い方から始め、その後、より高度で面白い応用へと進んでいきます。すでに毎日ラムダを使いこなしている方は、ぜひ後半の応用編までスクロールしてください。 ラムダにつ

  2. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた