Upgrade to Pro — share decks privately, control downloads, hide ads and more …

データベースだけじゃないN+1とその対策

 データベースだけじゃないN+1とその対策

大阪Ruby会議04 freeeスポンサーセッション

Shogo Kawahara

August 23, 2024
Tweet

More Decks by Shogo Kawahara

Other Decks in Technology

Transcript

  1.   3 • 2018.08〜: ECやら⼈材やらいろいろでシステム開発 • 2022.08〜: freee株式会社 ◦ プレイングマネージャーっぽいことをやったりしている

    • 趣味は放送⼤学でなんか学ぶこと • freee 関⻄ボードゲーム部部⻑ (ノリで遊びに来てね) • Ruby歴は9年くらい? 川原 翔吾 freee販売‧freee⼯数管理エンジニア Shogo Kawahara
  2. 5 freee販売 / freee⼯数管理はメイドイン関⻄ • 関⻄では freee販売 / freee⼯数管理といったフロント業務領域のサービスを中⼼に開発しています ・案件管理表に

    取引先情報登 録 ・案件管理表に商 談内容記入 ・契約書作成 ・見積書作成 ・案件管理表に受注 内容記入 ・案件管理表に 納品状況記入 ・請求書作成 ・入金確認 ・売上計上 「販売管理」領域 問い合わせ 商談 受注 見積 制作/納品 請求
  3. 6 そして Ruby on Rails で作られています • 技術書展15 で頒布された『freee技術の本』の第3章〜4章で Ruby

    on Rails で販売管理ソフトウェアを作るアーキテクチャの話を扱っ ています • 型を意識した Ruby on Rails 上のモデル - freee Developers Hub • 今⽇は、どちらかというと具体的な課題の話をします
  4. 7 • freee販売は社内の様々な他の製品とつながっています • データをどう「統合」するか? 統合のやりかたを間違えると⼤惨事に • 課題のひとつが N+1問題 いろんなシステムをつなげる悩み

    販売管理 システム ⼯数管理 システム 会計 システム 請求書 システム .. 各種 マスタ 販売でデータができたら 取引 (仕訳) が作られる 取引状態を供給する 表⽰に必要な 情報を供給する ⼯数から⼈件費を割り出し 原価として情報を渡す データから帳票を ⽣成する 発⾏状態を供給する
  5. 8 そもそもN+1問題ってなんだっけ? 売上ID 案件ID 金額 1 A 3,000 2 B

    4,000 3 C 6,000 案件ID 案件名称 A A案件 B B案件 C C案件 sales = Sales.all # SELECT * FROM sales sales.each do |s| s.business # SELECT * FROM business WHERE id = ? が sales数分発生 end —- N+1 が起きちゃうケース • 1 Request で 1クエリが発⾏され、その結果の関連情報のためN回分クエリが発⽣する • データの規模が少なければよいが、Nが多いとパフォーマンスが悪化する • ActiveRecord であれば includes などを利⽤することで対処ができる # SELECT * FROM sales # SELECT * FROM businesses WHERE id IN (“A”, “B”, “C”) sales = Sales.includes(:business).all sales.each do |s| s.business # メモリから読み込むぞ end —- N+1 を防いだケース
  6. 9 • bullet gem という gem は N+1 が発⽣した場合にログで警告を出す •

    spec 上で検知することも可能 • 本質的には設計で解決するべきだが、予備的にいれておくと助かるよ ActiveRecord には bullet という友がいる GET / USE eager loading detected Sale => [:business] Add to your query: .includes([:business]) Call stack /Users/kawahara-shogo/dev/demo/sales_management/app/controllers/home_controller.rb:5:in `block in index' /Users/kawahara-shogo/dev/demo/sales_management/app/controllers/home_controller.rb:4:in `index' —- 問題のあるコードが実行されると log/bullet.log に記録が残る
  7. 10 しかし、N+1 は RDB へのアクセスにとどまらず 販売管理 システム ⼯数管理 システム 会計

    システム 取引先 マスタ 無数の micro services 請求書 システム WebAPI など
  8. 11 • 他のサービスへの通信 (←今⽇はWebAPIへの通信にフォーカスします) • RDB以外へのストレージへのアクセス • 重い処理の実⾏ • ..

    考えないとN+1問題が平気で起きる (特に⼀覧画⾯で) 売上ID 顧客ID 金額 1 1 3,000 2 1 4,000 3 2 6,000 取引先ID 名前 1 うどん太郎 2 お好み焼き次郎 3 たこ焼き三郎 売上ID 顧客ID 顧客名称 金額 1 1 うどん太郎 3,000 2 1 うどん太郎 4,000 3 2 お好み焼き次郎 6,000 販売管理システム (RDBから) 取引先マスタ(WebAPIから) 導き出したいもの N+1 はRDBだけにあらず!
  9. 15 • そもそもN+1 は偶有的複雑さ ◦ ドメインの実装時にはあまり意識したくない • 前提として ◦ 呼び出し先APIは

    /partners/1 のような単⼀問い合わせだけでなく、/partners?ids[]=1&ids[]=2 のような複 数ID問い合わせに対応できるようにする ◦ これを実現するコミュニケーションや標準も⼤事 攻め: 設計で解決しよう # Repository から集約たちをとってくる sales = sales_repository.search(query) # DTO作る sales_list = sales.map { |s| SalesListDto.new(sales: sales) } # 別のシステムからやってきた顧客情報を扱えるぞ sales_list.first.customer&.name — イメージとしては実装時はこれくらい簡単に書けつつ、顧客情報の問い合わせは最⼩限に抑えたい
  10. 16 販売管理システムの境界 • データを読み取る時にAPIコールの結果をキャッシュしておく • ぱっと思いつきやすい対策で、実装もかんたん • だが、キャッシュがない状態では、 N+1 についてはなにも解決していない

    • 要件次第でキャッシュ期間などを考える必要がある 読み込み時Cache 販売管理 システム 取引先 マスタ Cache class SalesListDto # 省略 def customer partner_repository.load(customer.id) end end — class PartnerRepository def load(id) Rails.cache.fetch(“#{cache_key_with_version}/partner”, expires_in: 1.hour) do PartnerClient.fetch(id) end end end – 注: 参考コード, 実際にはDTOからrepositoryへの参照を行わないように実装するなどする
  11. 17 • 実装時は1件ずつ取ってくるように⾒えて、データを利⽤するタイミングで必要なものを⼀括で取得するようにする • キャッシュと組み合わせるとより効果的 • ruby であれば BatchLoader gem

    が助けになる Lazy Load 販売管理 システム 取引先 マスタ 1コールに なるように実装 class SalesListDto # 省略 def customer # 取得時はあまり意識しない partner_repository.load(customer.id) end end — class PartnerRepository def load(id) BatchLoader.for(id).batch do |ids, loader| # [{id => Hash}, {id => Hash}] のような形式で返されるものとする PartnerClient.fetch(ids).each {|k, v| loader.call(k, v) } end end end – 注: 参考コード, 実際にはDTOからrepositoryへの参照を行わないように実装するなどする
  12. 18 販売管理システムの境界 • そもそも外部システムの情報もこっちに持ってきてしまい、利⽤時はそれを使う • 外部システムのデータを⾃システムの検索エンジンに載せられるので検索要件がある場合はこれ • イベントソーシングありきなところもあるし、外部システムとのイベントの扱いの取り決めが必要になる • 取得のためのAPIコールは外部システムの更新量によって決まる

    • 運⽤上、結果整合性の許容と検索エンジンとの同期がズレたときどうするかをよく考えておく 事前にデータを⽤意 販売管理 システム 取引先 マスタ MQ 検索エンジン など (1) 更新通知 (2) 購読 (4)取得時はこちらを読む (3) 販売管理側で マスタの情報とmix # 検索用の実装 sales = query.execute(query) # そもそも検索結果には取引先情報が乗っているのでWebAPIをコールしない sales.first.customer&.name
  13. 22 • rspec で WebMock を使っているという前提だが、WebMockに少し⼿を加えるだけで同⼀のエンドポイントがコールさ れていないかという制限を与えることができる spec による対策 RSpec.shared_context

    ‘過剰なAPIコールを制限する’ do let(:max_api_calls) { 1 } after do api_calls = Hash.new(0) WebMock::RequestRegistry.instance.requested_signatures.hash.each do |signature, num| path_without_parameters = signature.uri.omit(:query).to_s.gsub(/\/(\d+|[0-9A-Z]{26})(?=\/|$)/, '') method = signature.method.to_s expect(api_calls[method + pathwithout_parameters] += num).to be <= max_api_calls end end end RSpec.configure do |config| # api: :mock config.include_context ‘過剰なAPIコールを制限する’, api: :mock end —- 参考コード (これを rails_helper.rb や、spec/support/ などに入れておく) 実際には特定のパスを除外する方法などを提供している
  14. 23 • Datadog の APM などにより、1リクエストで発⽣している、データベースへのクエリや、HTTP Request のコール数などを監視することができる ◦ (Datadogの場合)

    ⾃アプリケーションから、外部へのWebAPIリクエストについては、 faraday を利⽤するようにして、faraday インテグレーションを有効にすると Flame Graph などで通信の状態を確認することができる ◦ 1リクエストに対して、Downstream に⼤量のリクエストが発⽣していたら要注意 モニタリング
  15. 25 • flyerhzm/bullet • Railsのキャッシュ機構 - Railsガイド • BatchLoader gem

    • GraphQL BatchLoader | GitLab ◦ GraphQL での実装だが、説明含めて読み応えあり • WebMock::RequestRegistory (WebMock API) • N+1 API Calls (Sentry) ◦ Sentry では検知⽅法がある ◦ それに加えて、ドキュメントには解決案がいくつか⽰されている • Ruby アプリケーションのトレース (Datadog) 参考資料