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
バックポケットに便利なツールになります。
-
Rubyでのラムダの使用
ブロックはRubyの非常に重要な部分であり、ブロックなしで言語を想像するのは難しいです。しかし、ラムダ?ラムダが好きなのは誰ですか?あなたはそれを使わずに何年も行くことができます。まるで過ぎ去った時代の遺物のようです。 ...しかし、それは完全に真実ではありません。ラムダを少し調べてみると、ラムダにはいくつかの興味深いトリックがあります。 この記事では、ラムダの使用法の基本から始めて、さらに興味深い高度な使用法に移ります。したがって、ラムダを毎日使用していて、それらについてすべて知っている場合は、下にスクロールするだけです。 Lambdasについて覚えておくべき主なことは、それらが関数の
-
Rails5でのAngularの使用
あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた