关于 spree-performance
spree-performance 是面向 Spree 6 开发者的性能诊断与优化技能,聚焦 Store API 热点、商品读取、缓存、搜索、图片和后台任务,并结合 Spree 工作流提供建议。
主要功能
- 01追踪工作流与外部调用
- 02减少购物车重复计算
- 03避免商品读取的 N+1 查询
- 04配置 Store 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 span 一起生成各自的 span — 例如carts.add_item、carts.complete process_payments。这样无需猜测,就能知道哪个步骤耗时较长。自定义工作流会自动追踪。 - 在控制台或请求 spec 中统计查询次数:设置
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(...)。由此会产生以下影响:
- 每次购物车写入至少会调用两次外部税务提供方。 在提供方内部缓存估算结果(以行项目、数量、金额、收货地址、免税项为键),并将超时时间设得较短。已下单的订单金额会冻结,不会重新生成。
- 外部配送费率提供方会在每次重建方案时运行 — 按(地址、包裹内容)缓存报价。
- 钩子处理程序(
carts.recalculate.after_recalculate、carts.recalculate.set_promotion_context、carts.recalculate_totals.set_tax_line_context)会在每次写入时运行。应尽量减少其中的查询;将非必要操作移至异步运行的事件订阅者(cart.updated)。 - 绝不要在数据库事务中执行网络 I/O。在自己的工作流中,使用
external_step进行网关/网络调用 — 如果在工作流事务中运行,它会抛出异常,并将该 span 标记为客户端调用。 - 不要在
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关联已加载时会使用它,否则会对每个变体执行一次查询。迭代前务必预加载prices。Spree::Media有一个默认作用域,会包含attachment和posterblob;商品列表应预加载primary_media。- 向序列化器添加字段时 → 检查是否已预加载新增关联。在控制器中,重载
scope_includes/collection_includes,不要直接调用.includes。 - 客户端应仅对屏幕所需的内容使用
expand=,并使用fields=获取精简载荷 — 每次展开都会增加加载和序列化开销。
products = current_store.products.active
.includes(primary_media: [], default_variant: [:prices])
.preload_associations_lazily
Store API 上的 HTTP 缓存
Store 目录控制器(产品、分类、集合、市场、货币、区域设置、政策、卖家)包含 Spree::Api::V3::HttpCaching:
- 访客会获得
Cache-Control: public(5 分钟,列表支持stale-while-revalidate)以及一个 ETag;响应会根据Accept, x-spree-currency, x-spree-locale, x-spree-channel进行Vary。列表 ETag 会纳入最大updated_at、数量、expand、fields、q、页码、限制、货币、区域设置和渠道。 - 已认证客户会获得
private, no-store。
因此,API 前面的 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)。 - 更改索引设置/presenter 字段后重新索引。
- 每次产品创建/更新提交都会按店铺将
图片
Spree::Media 根据 Spree::Config.product_image_variant_sizes 定义命名的 webp 变体(默认包括 mini、small、medium、large、xlarge 2000×2000、og_image 1200×630),并启用 preprocessed: true——Active Storage 会在上传时将转换任务加入队列,因此每次上传的每种尺寸都要进行一次转换(视频还需为每个封面图转换一次)。富文本的 :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%),以免大型导入占满所有线程。 - 数据库连接池必须 ≥
RAILS_MAX_THREADS + JOB_THREADS(并留有余量)。 - 在 Mission Control 的
/jobs中查看积压情况。 - 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 端点变慢——请在服务端追踪。
常见错误
- 遍历变体并调用
price_in,却未预加载prices。 - 调用外部税费/汇率 API 时不做缓存——每次购物车写入都会触发多次调用。
- 在事务或同步钩子处理程序中执行 HTTP 调用。
- 繁忙的商店中让所有
Spree.queues都使用:default——导入和图像转换会延误支付 Webhook 和库存预留过期处理。 - 提供访客目录流量时,CDN 未遵循
Vary,或在 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