mcpField
S
spree-performance

spree-performance

github.com/spree開発10226 回インストール4 週間前に更新
リポジトリ

spree-performance について

spree-performanceは、Spree 6の開発者向けに性能の調査と改善を案内するスキルです。Store APIの主要処理、商品読み込み、キャッシュ、検索、画像、バックグラウンドジョブを扱い、Spreeのワークフローに沿って説明します。

主な機能

  1. 01ワークフローと外部呼び出しの追跡
  2. 02カート再計算の負荷軽減
  3. 03商品取得時のN+1クエリ防止
  4. 04Store APIキャッシュの設定
  5. 05検索・画像・ジョブキューの調整

活用シーン

  1. 01カートやチェックアウトの遅い処理の特定
  2. 02独自の商品エンドポイントの最適化
  3. 03ゲスト向けカタログ応答のキャッシュ
  4. 04一括ジョブとチェックアウト処理のキュー分離

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/spree/agent-skills --skill spree-performance

インストール後、Claude Code で/spree-performanceを入力して読み込まれたか確認します。

詳細

Spree のパフォーマンス

Spree 6 は API ファーストです。負荷が集中するのは、Store API リクエスト(カタログの読み取り、カートへの書き込み、チェックアウト)とバックグラウンドジョブです。効果の大半は、一般的な Rails のチューニングではなく、よく知られた少数のボトルネックを解消することで得られます。

まず計測する

  1. トレースします。 gem 'spree_opentelemetry' を追加し、OTEL_EXPORTER_OTLP_ENDPOINT をコレクターに設定します(ローカルの Jaeger の場合:docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:latest)。すべての Spree::Workflow の実行で、各ステップ、フックのディスパッチ、イベントサブスクライバーのディスパッチ、Webhook の配信、ゲートウェイの呼び出しに、SQL および HTTP のスパンと並んで個別のスパンが記録されます。例:carts.add_item、carts.complete process_payments。これにより、推測に頼らず、どのステップが遅いかがわかります。カスタムワークフローは自動的にトレースされます。
  2. コンソールまたはリクエストスペックでクエリ数を確認します:ActiveRecord::Base.logger = Logger.new($stdout) を設定し、ワークフローを直接実行します。
  3. 本番環境の APM(Skylight、Scout、New Relic、Datadog)はすべて動作します。Datadog、Honeycomb、New Relic は OTel トレースの取り込みにも対応しています。

カートへの書き込み:再計算パイプライン

カートの変更(Spree::Carts::AddItem、UpsertItems、明細項目の更新や削除、その他のカート編集)では、Spree.cart_recalculate_workflow(Spree::Carts::Recalculate)が実行され、次の処理を行います。

  1. Spree.cart_recalculate_totals_workflow(Spree::Carts::RecalculateTotals)を使って合計を更新します。
  2. 配送提案を再構築します(cart.ensure_updated_fulfillments — ここで配送レートプロバイダーが実行されます)。
  3. プロモーションを有効化します(set_promotion_context フックの後、Spree::PromotionHandler::Cart を実行します)。
  4. 割引と税を再構築するため、RecalculateTotals を再度実行します。
  5. after_recalculate フックハンドラーを実行します。

RecalculateTotals は型付きの行(Spree::Discount、Spree::Fee、Spree::TaxLine)を再生成し、未確定のカートで実行されるたびに cart.tax_provider.estimate(...) を呼び出します。その結果:

  • 外部の税プロバイダーは、カートへの書き込みごとに少なくとも 2 回呼び出されます。 プロバイダー内で見積もりをキャッシュしてください(明細項目、数量、金額、配送先住所、免税の有無をキーにします)。タイムアウトは短く設定してください。確定済みの注文は金額が固定され、再生成はスキップされます。
  • 外部の配送レートプロバイダーは、提案を再構築するたびに実行されます。(住所、荷物の内容)ごとに見積もりをキャッシュしてください。
  • フックハンドラー(carts.recalculate.after_recalculate、carts.recalculate.set_promotion_context、carts.recalculate_totals.set_tax_line_context)は、書き込みのたびに実行されます。クエリを軽量に保ち、必須でない処理は非同期で実行されるイベントサブスクライバー(cart.updated)に移してください。
  • データベーストランザクション内では、絶対にネットワーク I/O を行わないでください。独自のワークフローでは、ゲートウェイやネットワークの呼び出しに external_step を使用してください。ワークフローのトランザクション内で実行するとエラーになり、スパンはクライアント呼び出しとして記録されます。
  • Spree::LineItem/Spree::Cart に、再計算を行う after_save コールバックを追加しないでください。ワークフローですでに再計算されるため、コールバックを追加すると処理が重複します。

カタログの読み取り:N+1 問題

Store API の ResourceController#collection は、すでに .includes(collection_includes).preload_associations_lazily(ar_lazy_preload)をチェーンし、Pagy でページネーションしています(デフォルトの limit は 25、最大 100)。products コントローラーでは、遅延プリロードで取得できない関連を明示的に読み込んでいます。

# Spree::Api::V3::Store::ProductsController#scope_includes (abridged)
[:seller, {
  primary_media: [attachment_attachment: :blob, poster_attachment: :blob],
  default_variant: [:prices, stock_levels: [:stock_location, :active_stock_reservations]],
  variants: [:prices, :seller, stock_levels: [:stock_location, :active_stock_reservations]]
}]

カスタムエンドポイントやシリアライザーを作成するときは、次の形にしてください。

  • マスターバリアントはありません。リストでは product.default_variant を読み取るため、:prices とともにプリロードしてください。
  • variant.price_in(currency) は、prices 関連が読み込まれていればそれを使い、そうでなければバリアントごとに1 件のクエリを実行します。反復処理の前に、必ず prices をプリロードしてください。
  • Spree::Media には attachment と poster の blob を含むデフォルトスコープがあります。リストでは primary_media をプリロードしてください。
  • シリアライザーにフィールドを追加したら、新しい関連がプリロードされているか確認してください。コントローラーでは .includes をインラインで呼び出すのではなく、scope_includes/collection_includes をオーバーライドしてください。
  • クライアントでは、画面に必要な項目に限って expand= を使用し、疎なペイロードには fields= を使用してください。展開するたびに、読み込みとシリアライズの処理が増えます。
products = current_store.products.active
  .includes(primary_media: [], default_variant: [:prices])
  .preload_associations_lazily

Store API の HTTP キャッシュ

Store カタログコントローラー(products、categories、collections、markets、currencies、locales、policies、sellers)には、Spree::Api::V3::HttpCaching が含まれています。

  • ゲストには、ETag とともに Cache-Control: public(5 分、一覧では stale-while-revalidate)が返されます。レスポンスは Accept, x-spree-currency, x-spree-locale, x-spree-channel によって Vary されます。一覧の ETag には、最大の updated_at、件数、expand、fields、q、ページ、制限数、通貨、ロケール、チャネルが反映されます。
  • 認証済みの顧客には、private, no-store が返されます。

そのため、API の前段に CDN を配置すると、ゲストのカタログトラフィックをキャッシュできます。CDN が Vary/これらのヘッダーを尊重するように設定し、ストアフロントではクエリ文字列のバリエーションではなく、引き続き x-spree-* ヘッダーを送信するようにしてください。独自の Store API コントローラーでは、index/show アクションで cache_collection(collection)/cache_resource(resource) を呼び出してください(どちらも 304 を送信した後は false を返します)。カート、チェックアウト、アカウントのエンドポイントはキャッシュしないでください。

デフォルトでは、Rails.cache は Solid Cache(データベースベース)です。メモ化した検索には問題ありません。キャッシュトラフィックが大きくなった場合は、Redis/Valkey(redis_cache_store)に切り替えてください。顧客ごとのデータを共有キーでキャッシュしないでください。

検索

  • データベースプロバイダー(Spree::SearchProvider::Database、デフォルト):テキスト検索では、商品名/SKU に対する先頭ワイルドカード付きの LIKE(検索可能として定義にマークされたカスタムフィールドも対象)を実行し、その後 Ransack フィルターを適用します。小規模なカタログには適していますが、商品数が約 10K を超えると性能が低下します。PostgreSQL ではコアのマイグレーションによって pg_trgm が有効になります。トライグラムインデックスを追加する場合は、まず生成された SQL を EXPLAIN し、インデックスが実際の式と一致することを確認してください。
  • Meilisearch(spree_meilisearch gem、Spree.search_provider = 'SpreeMeilisearch::SearchProvider'):中規模/大規模のカタログやファセット検索に適しています。
    • 商品の作成/更新がコミットされるたびに、ストアごとに Spree::SearchProvider::IndexJob がキュー(Spree.queues.search)に登録されます(バックオフ付きで再試行)。一括インポートではジョブが大量に発生するため、search を優先度の低い専用キューに割り当てるか、インポート後に一度だけ再インデックスしてください:bin/rails spree:search:reindex(spree task search:reindex)。
    • インデックス設定/プレゼンターフィールドを変更した後は、再インデックスしてください。

画像

Spree::Media は、Spree::Config.product_image_variant_sizes で指定された名前付き WebP バリアント(デフォルトは mini、small、medium、large、xlarge 2000×2000、og_image 1200×630)を preprocessed: true で定義します。Active Storage はアップロード時に変換をキューに登録するため、アップロードごとにサイズごとの変換が 1 回必要です(動画の場合はポスターごとの変換も必要です)。リッチテキストの :embed バリアントは初回使用時に生成されます。

  • 使用しないサイズはイニシャライザーで削除してください(モデルの読み込み前に設定する必要があります)。サイズが少ないほど、アップロードごとの CPU 負荷が低くなります。
  • 変換処理をチェックアウトの重要な処理から切り離し(config.active_storage.queues.transform)、/rails/active_storage/representations/ の前段に CDN を配置してください(spree-deployment、CDN_HOST を参照)。
  • サブスクライバーからバリアントを事前生成しないでください。名前付きバリアントはすでに事前生成されており、アドホックなバリアントは異なるダイジェストを持ちます。

バックグラウンドジョブ

Spree のジョブは Spree.queues からキューを選択します(デフォルトではすべて :default)。キー:default、events、exports、images、imports、products、variants、categories、collections、stock_location_stock_levels、coupon_codes、themes、addresses、gift_cards、webhooks、payment_webhooks、api_keys、search、stock_reservations、tax_identifiers、data_requests、payouts。インストール済みの spree_core にある Spree.queues(lib/spree/core.rb)で確認してください。

# server/config/initializers/spree.rb
Spree.queues.payment_webhooks   = :spree_payment_webhooks
Spree.queues.events             = :spree_events
Spree.queues.webhooks           = :spree_webhooks
Spree.queues.stock_reservations = :spree_stock_reservations
Spree.queues.imports            = :spree_imports
Spree.queues.images             = :spree_images
Spree.queues.search             = :spree_search
Spree.queues.categories         = :spree_categories
Spree.queues.collections        = :spree_collections
Spree.queues.payouts            = :spree_payouts

現在のキー名を使用してください。Spree.queues.taxons= と stock_location_stock_items= は非推奨です。また、Spree が読み取らないキー(例:reports)を割り当てても、何も通知されず無視されます。

Solid Queue のチューニング
  • config/queue.yml は、キューを記載順にポーリングします。チェックアウト/決済/在庫予約の処理を先に、カタログ/インポート/画像の一括処理を後に配置し、末尾の "*" はそのまま残してください。
  • 統合モードでは、ジョブスレッドが Web リクエストと Puma プロセスおよび GVL を共有します。そのため、JOB_THREADS を増やすと API のレイテンシと引き換えにスループットが向上します。ジョブが重要になったら、bin/jobs ワーカーを分離し(Web 側では SOLID_QUEUE_IN_PUMA=false)、JOB_THREADS/JOB_CONCURRENCY/レプリカでスケールしてください。
  • 分離するには、queue.yml に複数のワーカーを定義します。たとえば、[spree_payment_webhooks, spree_events, spree_webhooks, mailers, default] 用のプールと、[spree_imports, spree_images, active_storage_transform, spree_search] 用のプールを用意します。
  • CSV インポートは SPREE_IMPORT_JOB_CONCURRENCY(デフォルトは JOB_THREADS の 75%)によって同時実行数を制限するため、大規模なインポートがすべてのスレッドを占有することはありません。
  • DB プールは RAILS_MAX_THREADS + JOB_THREADS 以上(さらに余裕分を加算)にする必要があります。
  • /jobs の Mission Control でバックログを監視してください。
  • Sidekiq は非常に大量の処理に対応する選択肢です。sidekiq.yml にすべてのキューを重み付きで列挙してください(キャッチオールは指定しません)。spree-deployment を参照してください。
イベントサブスクライバー

サブスクライバーはデフォルトで非同期です(Spree.queues.events 上の Spree::Events::SubscriberJob)。リクエスト内で実行する必要があり、かつ軽量な処理に限り、subscribes_to 'order.placed', async: false を使用してください。負荷の高い処理やネットワークに依存する副作用は、非同期サブスクライバーで実行してください。

ダッシュボード(React 管理画面)

ダッシュボードでは TanStack Query を使用します(デフォルトの staleTime は 60s、retry は 1、フォーカス時の再フェッチはありません)。プラグインでは、次の点に留意してください。

  • adminClient 経由で取得し、ビューで表示するデータに限って expand: [...] を渡してください。すべてを読み込むのではなく、リストの limit と Ransack フィルターを使用してください。
  • クエリーキーを再利用して、コンポーネント間でキャッシュデータを共有してください。変更頻度の低い参照データには、より長い staleTime を設定してください。
  • ダッシュボードのページが遅い場合、その原因はほぼ常に Admin API エンドポイントの遅さです。サーバー側で調査してください。

よくある間違い

  • バリアントを反復処理し、prices を事前読み込みせずに price_in を呼び出す。
  • 外部の税金/為替レート API をキャッシュせずに呼び出す。カートの書き込みごとに複数回実行されます。
  • トランザクション内や同期フックハンドラー内で HTTP 呼び出しを行う。
  • 負荷の高いストアで、すべての Spree.queues を :default のままにする。インポートや画像変換によって、決済 Webhook や在庫予約の期限切れ処理が遅延します。
  • Vary を尊重しない CDN 経由でゲスト向けカタログトラフィックを配信する。または CDN で x-spree-* ヘッダーを削除する。
  • カスタムエンドポイントで Spree::Product.all を使用する。current_store でスコープを指定し、ページネーションしてください。

さらに詳しく読む

  • node_modules/@spree/docs/dist/developer/providers/observability.md — スパン、サンプリング、スパンメトリクス
  • node_modules/@spree/docs/dist/developer/deployment/background_jobs.md、caching.md、cdn.md
  • node_modules/@spree/docs/dist/developer/core-concepts/search-filtering.md
  • ソース:Spree::Carts::Recalculate/RecalculateTotals(spree_core/app/workflows/spree/carts/)、Spree::Api::V3::HttpCaching、Spree::Api::V3::ResourceController
  • 関連スキル:spree-workflows、spree-order-totals、spree-taxes、spree-deployment、spree-events-webhooks

コメント