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

ActiveRecords#update_countersを使用して競合状態を防止する

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

シリーズのこの記事では、ActiveRecordのupdate_countersを見ていきます。 方法。このプロセスでは、マルチスレッドプログラムでの「競合状態」の一般的なトラップと、この方法でそれらを防ぐ方法について説明します。

スレッド

プログラミングの際には、プロセス、スレッド、そして最近では(Rubyでは)ファイバーやリアクターなど、コードを並行して実行する方法がいくつかあります。この記事では、Rails開発者が遭遇する最も一般的な形式であるため、スレッドについてのみ説明します。たとえば、Pumaはマルチスレッドサーバーであり、Sidekiqはマルチスレッドバックグラウンドジョブプロセッサです。

ここでは、スレッドとスレッドセーフについて詳しくは説明しません。知っておくべき主なことは、2つのスレッドが同じデータを操作している場合、データが簡単に同期しなくなる可能性があるということです。これは「競合状態」として知られているものです。

競合状態

競合状態は、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を検索するメソッドが1つだけあります (BigDecimalbalanceを備えた標準のRailsモデル 属性)。テストの再実行を簡単にするために、バランスをゼロにリセットしました。

内側のループは、物事がもう少し面白くなる場所です。アカウントの現在の残高を取得し、それに100を追加し(たとえば、$ 100のデポジット)、すぐに100を差し引く(たとえば、$ 100の引き出し)4つのスレッドを作成しています。 reloadも使用しています どちらの場合も追加 最新の残高があることを確認してください。

残りの行はほんの少し片付けています。 Thread.join つまり、すべてのスレッドが終了するのを待ってから続行し、メソッドの最後に最終的な残高を返します。

これを単一のスレッドで実行した場合(ループを1.times doに変更することにより) )、100万回実行して、最終的な口座残高が常にゼロになるようにすることができます。ただし、2つ(またはそれ以上)のスレッドに変更すると、状況はそれほど確実ではなくなります。

コンソールでテストを1回実行すると、おそらく正しい答えが得られます:

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.0 および300.0 したがって、エントリは、競合状態にあることの証拠です。

注釈付きの例

ここで問題のコードを拡大して、何が起こっているかを見てみましょう。 balanceへの変更を分離します さらに明確にするために。

threads << Thread.new do
  # Thread could be switching here
  balance = account.reload.balance
  # or here...
  balance += 100
  # or here...
  account.update!(balance: balance)
  # or here...

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

コメントにあるように、このコードのほぼすべての時点でスレッドがスワップしている可能性があります。スレッド1が残高を読み取ると、コンピューターはスレッド2の実行を開始するため、update!を呼び出すまでにデータが古くなる可能性があります。 。言い換えると、スレッド1、スレッド2、およびデータベースにはすべてデータが含まれていますが、それらは互いに同期していません。

ここでの例は、分析しやすいように意図的に些細なものです。ただし、現実の世界では、特に競合状態を確実に再現できないため、競合状態の診断が難しくなる可能性があります。

ソリューション

競合状態を防ぐためのいくつかのオプションがありますが、それらのほとんどすべては、常に1つのエンティティのみがデータを変更していることを確認するという単一のアイデアを中心に展開しています。

オプション1:ミューテックス

最も単純なオプションは、一般にミューテックスとして知られている「相互排他ロック」です。ミューテックスは、キーが1つしかないロックと考えることができます。 1つのスレッドがキーを保持している場合、ミューテックスにあるものは何でも実行できます。他のすべてのスレッドは、キーを保持できるようになるまで待機する必要があります。

サンプルコードにミューテックスを適用するには、次のようにします。

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

# equivalent to:
value = value + 10

# Or even more verbose:
temp_value = value + 10
value = temp_value

スレッドがvalue + 10を計算する間に突然「スリープ」した場合 であり、結果をvalueに書き戻します 、それからそれは競合状態の可能性を開きます。ただし、Rubyがこの操作中にスレッドのスリープを許可しなかったと想像してみましょう。この操作中にスレッドがスリープしない(たとえば、コンピューターが実行を別のスレッドに切り替えることはない)と確実に言える場合は、「不可分」操作と見なすことができます。

一部の言語には、まさにこの種のスレッドセーフのためのアトミックバージョンのプリミティブ値があります(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

ミューテックスもロックもありません。2行のRubyだけです。 update_counters 最初の引数としてレコードIDを取り、次に変更する列を指定します(balance: )とそれを変更する量(100 または-100 )。これが機能する理由は、読み取り-更新-書き込みサイクルがデータベースで1回のSQL呼び出しで発生するためです。これは、Rubyスレッドが操作を中断できないことを意味します。スリープ状態であっても、データベースが実際の計算を行っているので問題ありません。

生成される実際のSQLは次のようになります(少なくとも私のマシンのpostgresでは):

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に変更はありません。 タイムスタンプ)。

結論

競合状態は、Heisenbugのポスターの子である可能性が非常に高いです。それらは簡単に取り入れることができ、再現することはしばしば不可能であり、予測することは困難です。少なくとも、RubyとRailsは、これらの問題を見つけたら、それらを潰すのに役立つツールをいくつか提供してくれます。

一般的なRubyコードの場合、Mutex これは良い選択肢であり、おそらくほとんどの開発者が「スレッドセーフ」という用語を聞いたときに最初に考えることです。

Railsの場合、ほとんどの場合、データはActiveRecordから取得されます。このような場合、lock! (またはwith_lock )は簡単に使用でき、データベース内の関連する行のみをロックするため、ミューテックスよりもスループットが高くなります。

ここで正直に言うと、update_countersに連絡できるかどうかわかりません 現実の世界ではたくさんあります。他の開発者がそれがどのように動作するかをよく知らないかもしれないことは十分に珍しいことであり、それはコードの意図を特に明確にしません。スレッドセーフの懸念に直面した場合、ActiveRecordのロック(lock!のいずれか) またはwith_lock )はより一般的であり、コーダーの意図をより明確に伝えます。

ただし、バックアップする単純な「加算または減算」ジョブが多数あり、ペダルから金属までの生の速度が必要な場合は、update_counters バックポケットに便利なツールになります。


  1. Rubyでのラムダの使用

    ブロックはRubyの非常に重要な部分であり、ブロックなしで言語を想像するのは難しいです。しかし、ラムダ?ラムダが好きなのは誰ですか?あなたはそれを使わずに何年も行くことができます。まるで過ぎ去った時代の遺物のようです。 ...しかし、それは完全に真実ではありません。ラムダを少し調べてみると、ラムダにはいくつかの興味深いトリックがあります。 この記事では、ラムダの使用法の基本から始めて、さらに興味深い高度な使用法に移ります。したがって、ラムダを毎日使用していて、それらについてすべて知っている場合は、下にスクロールするだけです。 Lambdasについて覚えておくべき主なことは、それらが関数の

  2. Rails5でのAngularの使用

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