spree-performance について
spree-performanceは、Spree 6の開発者向けに性能の調査と改善を案内するスキルです。Store APIの主要処理、商品読み込み、キャッシュ、検索、画像、バックグラウンドジョブを扱い、Spreeのワークフローに沿って説明します。
主な機能
- 01ワークフローと外部呼び出しの追跡
- 02カート再計算の負荷軽減
- 03商品取得時のN+1クエリ防止
- 04Store APIキャッシュの設定
- 05検索・画像・ジョブキューの調整
活用シーン
- 01カートやチェックアウトの遅い処理の特定
- 02独自の商品エンドポイントの最適化
- 03ゲスト向けカタログ応答のキャッシュ
- 04一括ジョブとチェックアウト処理のキュー分離
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-performanceインストール後、Claude Code で/spree-performanceを入力して読み込まれたか確認します。
詳細
Spree のパフォーマンス
Spree 6 は API ファーストです。負荷が集中するのは、Store API リクエスト(カタログの読み取り、カートへの書き込み、チェックアウト)とバックグラウンドジョブです。効果の大半は、一般的な Rails のチューニングではなく、よく知られた少数のボトルネックを解消することで得られます。
まず計測する
- トレースします。
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。これにより、推測に頼らず、どのステップが遅いかがわかります。カスタムワークフローは自動的にトレースされます。 - コンソールまたはリクエストスペックでクエリ数を確認します:
ActiveRecord::Base.logger = Logger.new($stdout)を設定し、ワークフローを直接実行します。 - 本番環境の APM(Skylight、Scout、New Relic、Datadog)はすべて動作します。Datadog、Honeycomb、New Relic は OTel トレースの取り込みにも対応しています。
カートへの書き込み:再計算パイプライン
カートの変更(Spree::Carts::AddItem、UpsertItems、明細項目の更新や削除、その他のカート編集)では、Spree.cart_recalculate_workflow(Spree::Carts::Recalculate)が実行され、次の処理を行います。
Spree.cart_recalculate_totals_workflow(Spree::Carts::RecalculateTotals)を使って合計を更新します。- 配送提案を再構築します(
cart.ensure_updated_fulfillments— ここで配送レートプロバイダーが実行されます)。 - プロモーションを有効化します(
set_promotion_contextフックの後、Spree::PromotionHandler::Cartを実行します)。 - 割引と税を再構築するため、
RecalculateTotalsを再度実行します。 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_meilisearchgem、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.mdnode_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