🚀

B2B SaaSのユーザーインポートを数十倍高速化した話

に公開

はじめに

こんにちは。ourlyでバックエンドエンジニアをしているjakeです。
今回はB2B SaaSにおける重要機能であるユーザーインポート機能を数倍〜数十倍以上高速化した取り組みを紹介します。
N+1クエリを解消したら終わりと思いきや、伏兵が潜んでいました。

TL;DR

  • SQLクエリの最適化だけでは終わらず、bcryptハッシュ生成を並列化してさらに速度向上
  • クエリ数削減により DB負荷も大幅減、システムの安定性向上

背景と課題

ourlyでは 2021年のリリース当初からCSVによるユーザーインポート機能を提供してきました。
リリースからしばらくは100〜1000ユーザー程度の規模が主流だったものの、非常にありがたいことに大きな規模の企業様にも導入いただく事例が増えました。
最近では1万ユーザーを超える企業様もあり、ユーザーインポートにかかる時間の長大化によるユーザー体験の低下が顕著になってきました。

結果として、問題の原因を整理すると次の2点に集約されました。

  1. 無駄なSQLが多い - 特に古い実装に残っていたN+1クエリ
  2. CPUバウンドな処理がボトルネック - bcryptハッシュ生成

施策① N+1クエリの排除

バルクアップサートの徹底

既に一部のテーブルに対してはバルクアップサートを行っていましたが、対策しきれていないクエリが相当量ありました。
リリース当初からの古いコードや、機能追加を重ねるうちにActiveModelの after_update などのフックでユーザー個別のアップデート処理を行っている場合もありました。
そこで共通アップサートメソッドを実装し、対象となるクエリを洗い出して泥臭く移行を行いました。

インテグレーションテストの効力

インテグレーションテストが整備されていたため、大きめのリファクタでも安心して横展開できました。
(これは次のbcryptの最適化にもいえます)

結果としてN+1クエリをほぼ解消し、テナント規模とクエリ総数の比例関係は飛躍的に薄まりました。
(大変にお恥ずかしい話ですが、従来は1万人規模の場合で数十万クエリ走っていたところを数百にまで削減しました)
またトランザクションを張る時間が減ったことにより、DBのロック待ちの減少で他処理への波及も軽減しました。

施策② bcryptハッシュ生成の並列化

ボトルネック分析

SQL最適化後も、大規模なテナントにおいては予想より時間がかかる例が残りました。プロファイラなどを使用して調査すると、bcrypt (bcrypt-ruby) による処理が非常に高い割合を占めており、明確なボトルネックとなっていることが判明しました。

さて皆さんは、次のコードの実行にはどれくらいの時間がかかると予想されるでしょうか。

require 'bcrypt'
require 'benchmark'

passwords = Array.new(100) { 'password123' }

elapsed = Benchmark.measure do
  passwords.each do |password|
    bcrypt::Password.create(password)
  end
end

puts "Elapsed time: #{elapsed.real} seconds"

私のローカル環境(MacBook Pro M2 Pro)ではこの処理に約22秒かかりました。皆さんの予想より長かったでしょうか、予想どおりだったでしょうか。

bcryptのハッシュ生成は、セキュリティ向上のためストレッチ回数(cost factor)を増やしている関係上どうしても時間がかかります。

これをユーザーインポート処理の中で単純にユーザー数ぶん直列で実行しているのは、恥ずかしながら実装当時の知識不足ゆえです。

ちなみにこのストレッチ回数を減らしてしまえば処理時間は減少しますが、もちろんセキュリティ的には望ましいことではなく別の方法を検討しました。

concurrent-ruby / Promises を採用

bcryptによるハッシュ生成処理はGVL(Global VM Lock)を開放する[1][2]ことがわかったため、
並列処理による高速化が望めると判断しました。
使用するGemの選定については、既にourlyでは concurrent-ruby を依存に持っており、APIの使いやすさと実績面で信頼できるこのGemに決定しました。

プロダクションコードではありませんが、概ね次のように実装しました。
実行していただくと↑のコードに比べて、CPU数に応じて処理時間が減少していることがわかるかと思います。

require 'bcrypt'
require 'benchmark'

passwords = Array.new(100) { 'password123' }

elapsed = Benchmark.measure do
  num_threads = (Etc.nprocessors / 2).floor
  pool = Concurrent::FixedThreadPool.new(num_threads)
  futures = passwords.map do |password|
    Concurrent::Promises.future_on(pool) do
      bcrypt::Password.create(password)
    end
  end
  futures.map(&:value!)
end

puts "Elapsed time: #{elapsed.real} seconds"

私のローカル環境での実行結果は次のようになりました。

並列数 ハッシュ生成数 実行時間(秒)
1(並列なし) 100 22.04
1(並列なし) 1000 217.44
2 100 10.7
2 1000 109.19
4 100 5.71
4 1000 55.84

順当にハッシュ生成数に応じて実行時間が伸び、並列数に応じて減少しているのがわかります。

ポイント

  • Concurrent::Promises の使用により、タスクを非同期・並列に処理しつつ value! メソッドで同期的に値を取得
  • スレッド数は (Etc.nprocessors / 2).floor とし、全てのリソースを食い潰さないよう調整
  • 今後CPUを増強すればするほど高速化する

結果として、さらに数倍の速度向上を果たしました。


得られた知見

  1. 思い込みを捨てる - N+1クエリ退治後もボトルネックは他に潜んでいた。メトリクスとプロファイルを信じることが大事。
  2. bcryptは遅い - bcryptの性質上遅くなるのは仕方ない。適切なストレッチ回数によってセキュリティを担保しつつ、並列処理によってユーザー体験を損なわずに済む。
  3. テストコードの安心感 - 大胆なリファクタでもインテグレーションテストがあるおかげで攻められる。

資料

脚注
  1. https://github.com/bcrypt-ruby/bcrypt-ruby/pull/124 ↩︎

  2. https://github.com/bcrypt-ruby/bcrypt-ruby/pull/260 ↩︎

ourly tech blog

Discussion