皆さん「在庫管理」ってどうしてます?itemsテーブルに、stockカラム作ってdecrementしてますか?

まぁ正直、それでも良い感じしますよね。楽だし何やってるかわかりやすい。

しかし! 超人気商品に超アクセスが集中して購入処理がめちゃくちゃ行われたら...!?

大抵の人はトランザクション処理を挟み込むはずなので、itemsの対象商品レコードのロックが多発 してコネクション待ちが多発。

からのサーバーダウンが考えられますよね。辛い。対策せねば。

今回はそんな 超人気商品 ができちゃった or 生み出したいという場合に、システムが詰まって死なない実装を考えたので、その実装をまとめてみた。

そんな記事になります。

↑の実態をもう少し詳しく説明

この記事では

  • MySQL
  • Ruby on Rails 4系

を使ったアプリケーションの、商品の在庫管理の一例を提示するものです。

MySQLの構成として

  • items
    • 商品データを保存するテーブル
    • stock という在庫数を保存するカラムを持つ
  • purchases
    • 商品の購入データを保存するテーブル
    • itemsの外部キー item_id カラムを持つ

があるとします。

商品の購入が発生した際にpurchasesのレコードを作成し、購入があったitemsのレコードのstockを購入数分decrementする。というもの。

無論、itemsレコードをロックして必ず購入数分だけ在庫を減らさないといけない

そうしないと同時に商品を購入した場合に在庫の数がこんがらがってしまう可能性があります!

詳しくはこちら: 排他制御(楽観ロック・悲観ロック)の基礎

なので、レコードをロックする必要があります。しかし、安易にロックしてしまうと、前述の通り大量にアクセスがある前提なので、ロックの解除待ちが発生してしまいます。

そうなると読み込みすらロックしてしまい、商品の詳細ページの閲覧すら遅延します。

そして迎えるコネクションプールの限界。からのサーバーダウン....これを回避したいのです。

回避策とは!?

今回、改修を入れようとしているサービスの要件的に

  • 大量アクセスは来る予定
  • 他の商品に影響は出したくない
  • 在庫数をこえて注文を受けてはいけない

というのを想定していました。

3人よれば文殊の知恵。ということでチームの3人であーだこーだと考えた結果....

itemsテーブルと1対多な、 stocksデーブル を作成し、 在庫の数だけレコードを作成し、商品が売れればその数だけ削除する という設計を考えました!

実装の仕組み

実装としてはそんなに難しいことはないんです。図にすると...

こんな感じです。

在庫数を増やす時はその数だけstocksレコードをcreateする。

そして商品が売れたとき。すなわちpurchasesがcreateされるタイミングで 売れた数だけレコードをdeleteする。

これだけです。削除のロジックは下記の通り。

class Purchase < ActiveRecord::Base
  before_save :decrement_stock, if: -> { new_record? }

  def decrement_stock
    # 売れた数だけレコードを取得してレコードロック
    stocks = Stock.lock.where(item_id: item_id).limit buy_count

    # 在庫データが購入数取得できなければ、在庫切れとして例外を投げる
    if stocks.count < buy_count
      errors.add(:base, 'out of stock')
      raise ActiveRecord::RecordInvalid.new(self), 'out of stock'
    end

    # 足りていればその在庫分削除
    stocks.destroy_all
  end
end

メリット

  • itemレコードがロックされないので、読み込みを邪魔しない
  • 在庫を増やすときにもレコードを増やすだけなので 購入処理のレコードロックの影響を受けない
  • 在庫がない時はレコードがないときなので、判断しやすい

数値で在庫数を守っていると一番困るのが在庫数を増やすときですね。

レコードロックされている可能性もあるし、増加させる処理もしっかり書かないといけない。その分、createするかdeleteするかなので、レコードによる在庫管理は非常に楽ちんです。

デメリット

  • 1商品1万個の在庫があると、1万件のレコードが生成される
  • ↑の関係上、在庫があまりに多いとプライマリーキーが上限行くかもしれない(bigintで回避?)
  • 結局レコードロックしている時間は生まれる

before_saveにして極力ロックの時間は短くしてますが、削除する際に同じ在庫レコードをみる関係上どうしたって少し待機時間が生まれてしまいますね...

良い感じの実装が思いつかなくてやりませんでしたが、 在庫レコードをランダムに取得することができればロックを回避できそうな気がします。

大量にレコードがある時はロックするレコード自体をランダムに出来るのでロックにかかる時間を削減できそうです。いい方法無いかなぁ。

ちなみに、Rails5だと便利なメソッドがあるぞ

#increment!#decrement! というメソッドがRails 5系から追加されたようです。

同時実行を意識したSQLが発行されるので、変に凝ったこと自前で実装しなくていい!楽ですね!

Rails5 で #increment! と #decrement! が同時実行を意識した処理になった!

ただ、この場合でもstocksテーブルは別で作ったほうが良いかもしれないですね。

itemsに在庫情報を持ったままだと、(処理にもよりますが)トランザクションに巻き込んでしまい、結局ロックしてしまい読み込み待ちになるのは怖いので。

総括

ということで、在庫をデータとして抱える方法の一つの解でした。

この設計を取り入れたサービスは想定していたよりずっと安定可動しています。数値で在庫を管理するより速度的に良いとかそういうことはないですが、目立った問題もなく動いているのは嬉しいですね。

在庫のデータを持つのが数字じゃない方法が望まれている!!という状況に遭遇したら是非、参考にしてください!

それでは、また。