Rails: データベースのロック機能をSKIP LOCKEDで改善して高速化する(翻訳)
Ruby on Railsアプリケーションに在庫管理用のモジュールを実装するときは、在庫レベルに矛盾が発生しないようにすることが重要です。複数のユーザーが同一の商品に対して同時に購入操作を実行すると、コンカレンシーの問題が発生して、在庫にない商品を売ってしまう(=過剰販売)可能性があります。
本記事では、在庫管理を処理するための2通りの方法について解説します。
- 競合状態を防ぐために、カウンタとデータベースロックを組み合わせるシンプルな方法
- 在庫を効果的に割り当てるために
SKIP LOCKED
でパフォーマンスを改善する方法
🔗 行ロックによる基本的な在庫管理
在庫管理の最も基本的な方法は、データベースロックによって同時更新を防ぐというものです。以下はシンプルな実装例です。
OutOfStock = Class.new(StandardError)
class Inventory < ActiveRecord::Base
def reserve!(quantity)
with_lock do
raise OutOfStock if self.available < quantity
self.available -= quantity
self.save!
end
end
end
上のwith_lock
は行レベルロック(内部ではSELECT ... FOR UPDATE
を実行)によって、在庫を変更できるトランザクションが1度に1つだけになるようにしています。これによって過剰販売は防げますが、高コンカレンシーな状況でロック行同士の競合が発生するとパフォーマンスのボトルネックになる可能性もあります。
🔗 パフォーマンスの問題
同一レコードに対して複数のトランザクションが更新をかけると、ロックが解放されるまで待機する必要があります。競合が増えすぎるとデッドロックやパフォーマンス低下につながる可能性があります。この方法は、大量のトランザクションを処理する場合にスケーラビリティに問題が生じる可能性もあります。また、高負荷時に信頼性の問題が発生する可能性もありえます。
SNSで膨大なフォロワーを擁しているインフルエンサーがあなたのシステムを宣伝してくれたおかげで、数量限定の同一商品に全フォロワーが一斉に押しかけたときの状況を想像してみてください。まさに私たちのプロジェクトでこれが起きたのです☺️。
🔗 SKIP LOCKED
で最適化する
パフォーマンスを改善するにはSKIP LOCKED
が使えます。これは、他のクエリをロック解放待ちでブロックすることなく、購入可能な在庫をSELECT
できます。この手法は、キューのような処理でアイテムを効果的に割り当てたい場合に有用です。
🔗 改善後の実装
OutOfStock = Class.new(StandardError)
class Inventory < ActiveRecord::Base
has_many :items, class_name: 'InventoryItem', foreign_key: :inventory_id
def reserve!(quantity)
items_to_take = self.items.where(status: 'free')
.lock('FOR UPDATE SKIP LOCKED')
.limit(quantity)
raise OutOfStock if items_to_take.length < quantity
items_to_take.update_all(status: 'reserved')
end
end
class InventoryItem < ActiveRecord::Base
validates :status, inclusion: {in: %w[free reserved]}
end
🔗 この方法がよい理由
- ロックの競合が回避される
SKIP LOCKED
は、ロックされた行をブロックせずに、単に次の利用可能な行に進みます。 -
スループットが改善される
トランザクションがロックの解放を待つ必要がなくなります。 -
スケーラビリティが向上する
数千件のトランザクションが同時に処理される高コンカレンシー環境で有用です。
🔗 パフォーマンスを比較する
with_lock
を使うと、トランザクションがロックの解放を待つ間滞ってしまい、高トラフィックシステムで競合の増加やタイムアウトが発生する可能性があります。
Starting LockingInventory: 1000 times trying to reserve product 928
Before LockingInventory: For 928, available: 100, reserved: 0
Done 1000 requests using 100 workers, with ~10 requests per worker
After LockingInventory: For 928, available: 0, reserved: 100
{ActiveRecord::LockWaitTimeout => 880, OutOfStock => 20}
Starting NonLockingInventory: 1000 times trying to reserve product 2935
Before NonLockingInventory: For 2935, available: 100, reserved: 0
Done 1000 requests using 100 workers, with ~10 requests per worker
After NonLockingInventory: For 2935, available: 0, reserved: 100
{OutOfStock => 900}
user system total real
Using LockingInventory 2.422339 0.339037 2.761376 ( 13.163827)
Using NonLockingInventory 0.362705 0.091984 0.454689 ( 1.375855)
このテストは、販売可能な製品が100個であるところに、1000件のリクエストが同時に同一製品の予約を試みている状況をシミュレートします(ワーカーは100個で、それぞれが10件のリクエストを処理します)。個別のワーカー(スレッド)は、コネクションプールから独自のコネクションを利用します。
結果は以下のとおりです。
- どちらの実装も過剰販売は起きなかった
-
「ロックしない在庫管理実装(NonLockingInventory)」は著しく高速化された(ロックを待つ必要がなくなったため)
-
「ロックする在庫管理実装(LockingInventory)」は予約の在庫が一部切れていることを報告している状態でロックが開始されると、即座にロックタイムアウトエラーがraiseされる
サンプルコードは以下のGistでご覧いただけます。
参考: Sample code for "Handling Concurrency with Database Locks and SKIP LOCKED" blog post
🔗 SKIP LOCKED
の場合の欠点
SKIP LOCKED
はコンカレンシーとスループットを改善しますが、いくつかの課題も生じます。
まず、在庫管理が複雑になります。
在庫レベルを1個のカウンタで管理するのではなく、個別のアイテムレベルで管理しなければならなくなるため、在庫の数だけレコードを個別に作成する必要があります。
在庫状況を単一のカウンタで把握できないことが問題になる可能性もあります。
在庫が複数行に分散することになるため、在庫総数を効果的にクエリしようとすると複雑さが増します。
この問題は、別途サマリー用のカウンタを非同期更新する形で軽減可能です。たとえば、バックグラウンドジョブで在庫レベルを定期的に集計してサマリーカウンタを更新することで在庫状況を正確に把握できるようにする方法が考えられます。
🔗 まとめ
with_lock
を利用することで在庫を安全に管理できるようになりますが、高負荷時にパフォーマンスの問題が発生します。SKIP LOCKED
を活用することでコンカレンシーやシステムの効率を大きく改善して、スムーズな在庫管理を実現できるようになります。
Eコマースプラットフォームなど、トラフィックの多いシステムを構築するときは、SKIP LOCKED
を利用することで、在庫管理の整合性を確保しながらパフォーマンスを維持できるようになります。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。