entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

赤いラクダは3倍早い!ピーク時毎分1400件を捌くための決済処理のチューニング紹介

こんにちは、4月からBOOTH部になったorekyuuです。 この記事では、転属後の一番大きな成果である、BOOTHで発生する大量の注文(ピーク毎分約1400件)を整合性を取りつつ高速にさばく改善について解説します。

BOOTHが抱えていた課題

まずはBOOTHが抱えていた課題について説明します。 BOOTHでは販売開始時刻が事前に予告されていた場合などの理由で瞬間的に決済が集中し、サーバーが大量の注文に耐えきれないケースが度々ありました。 その原因は在庫の処理にありました。擬似コードですが、注文の処理は以下のようになっていました。

def checkout!
  ActiveRecord::Base.transaction do
    商品の悲観的ロック # 在庫数を同時に編集しないようにロックを取る
    商品の在庫の減算処理
    注文を確定済みにする
    決済の請求APIを叩く
  end
end

上記のコードであれば、請求APIの呼び出しに失敗したときにロールバックされるため比較的容易に整合性を保つことができますが、決済の請求APIは2秒〜4秒ほどの長い時間のかかる処理です。そのため、商品がロックされる時間がそれだけ長くなってしまいます。 同じ商品に注文が集中すれば、商品のロック待ちによって同じ商品を買おうとしている他のユーザーの決済が完了するまで次の処理に進めません。さらに多くのユーザーが待っている場合、DBの Lock wait timeout exceeded エラーが出てカートの画面まで戻されてしまい、購入体験が良くないものになります。

長時間の悲観的ロックを回避するための選択肢

ロック用に在庫ごとにレコードを作る

真っ先に思いつくのは、在庫1つに対して1レコードを事前に用意しておき、必要な数だけロックを取る方法です。MySQL 8系であればSKIP LOCKED*1を使うことによって、ロックが取られていないレコードだけロックを取る事ができます。 しかし、BOOTHにある在庫の総数は億レコードを軽く超えてしまうため、あまり現実的ではありません。

トランザクションの分割

在庫減算処理と請求を同時に行うと長時間のロック待ちが避けられません。そこで、早く処理が終わる在庫確保トランザクションと、時間は掛かるがロックを取らない請求・注文確定トランザクションに分けることにしました。 処理の流れとしては以下のようになります。

def checkout!
  reserve_transaction
  payment_transaction
end

# 在庫を確保する、悲観的ロックを取るが高速に終わるトランザクション
def reserve_transaction
  ActiveRecord::Base.transaction do
    商品の悲観的ロック
    在庫減算処理
    在庫確保レコードをinsert
  end
end

# 決済処理を行う、ロックを取らず時間のかかるトランザクション
def payment_transaction
  ActiveRecord::Base.transaction do
    在庫確保レコードをdelete
    注文を確定済みにする
    決済の請求APIを叩く
  end
end

このようにトランザクションを分けることで、同じ商品に注文が殺到してもロックで待たされるのはreserve_transactionの時間ぶんだけになりました。一方でトランザクションを分けたことで、途中で失敗したときの整合性の取り方について考える必要が出てきました。

トランザクションを分割したときの整合性

困るケースとして考えられるのは、payment_transactionが失敗したケースです。 ここでエラーが発生すると在庫が減ったまま決済が行われないので、reserve_transactionと逆の操作を行う必要があります。そこで、payment_transactionでエラーが発生した場合は「在庫確保レコードの削除と在庫の加算処理」を行ってからエラーをraiseし直します。

def payment_transaction
  ActiveRecord::Base.transaction do
    在庫確保レコードをdelete
    注文を確定済みにする
    決済の請求APIを叩く
  end
rescue => e
  rollback
end

def rollback
  ActiveRecord::Base.transaction do
    在庫数を戻す商品の悲観的ロック
    在庫数を加算して戻す
    在庫確保レコードをdelete
  end
end

請求APIのエラーが出た場合などの殆どの場合はこれだけで対処ができますが、ネットワークの不調やDBが落ちた場合など「在庫確保レコードの削除と在庫の加算処理」も失敗してしまうケースも考えられます。 このようなケースを考慮して、定期的なバッチ処理により一定時間以上残っている在庫確保レコードを見つけて、rollbackと同じように在庫を戻す処理を走らせています。これにより、一時的なネットワークの不調があっても在庫の整合性をとれるようにしました。

改善結果

今回の変更により、それまでの分間決済数の最大値は約400件程度だったものが、現在では約1400件の注文を障害無く捌くことができるようになりました。また、1決済にかかる時間の悪化も見られませんでした。

f:id:pxvpxv:20200706114124p:plain

今回の改善によって、大量のユーザーによる集中アクセスにサーバーが耐えきれないケースも見えてきたので、今後も快適にBOOTHを使っていただけるようにパフォーマンス改善を続けていきます。

まとめ

今回得られた教訓としては、長時間かかるトランザクションで悲観的ロックを取ってはいけないということです。しかし、トランザクションを分割すると複雑度が上がってしまうため障害リスクを把握した上で天秤にかけるのが良いでしょう。

さいごに

BOOTHでは購入体験を自分自身の力でより良くしていきたいエンジニアを募集しています。

www.wantedly.com

20191219020934
orekyuu
pixiv PAYチームの2017年入社のサーバーサイドエンジニア。Javaが好き。なぜか入社してからずっとRailsを書いています。