关于 caching
面向 Rails 开发者和编码 Agent 的缓存指南,用于实现或排查应用缓存。内容涵盖策略选择、失效机制、HTTP 条件请求、缓存存储和测试,并优先保证缓存正确性。
主要功能
- 01选择 HTTP、片段、集合或低级缓存
- 02设计缓存键和失效链
- 03配置 Rails 缓存存储
- 04使用 ETag 和条件 GET
- 05测试缓存行为并排查过期数据
适用场景
- 01缓存重复渲染的局部视图
- 02缓存耗时计算或 API 响应
- 03记录更新后使嵌套片段缓存失效
- 04资源未变更时返回 304 响应
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill caching安装后在 Claude Code 中输入/caching确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/cache-stores.mdconditional-get.mdfragment-caching.mdlow-level-caching.mdperformance.mdrussian-doll.md详细介绍
Rails 缓存专家
在 Rails 应用中实现快速、正确的缓存。缓存正确性优先于缓存覆盖率——过期数据比慢数据更糟糕。
理念
核心原则:
- 缓存正确性优先 — 缓存未命中会变慢;过期数据则是缺陷
- 失效处理才是难点 — 每添加一项缓存,就必须处理其失效
- 先衡量再缓存 — 不要缓存不慢的内容
- Solid Cache 是 Rails 8 的默认选项 — 除非有明确理由,否则使用它
- 片段缓存是你的主力 — 从这里开始,而不是从底层缓存入手
缓存层级(优先选择上层):
HTTP Caching (stale?/fresh_when) ← Fastest: never even hits your app
Fragment Caching (cache helper) ← Fast: skips view rendering
Collection Caching (cached: true) ← Fast: batch cache reads
Low-Level Caching (Rails.cache) ← Flexible: cache any computation
SQL Caching (automatic per-request) ← Free: Rails does this for you
何时使用此技能
- 为视图、局部模板或开销较大的计算添加缓存
- 设置缓存存储(Solid Cache、Redis、Memcached)
- 实现带有正确失效处理的俄罗斯套娃缓存
- 添加 HTTP 条件 GET 支持(ETag、Last-Modified)
- 排查过期缓存或缓存失效问题
- 使用缓存策略优化性能
说明
步骤 1:确定缓存内容
编写任何缓存代码之前,先找出性能瓶颈:
# Check logs for slow queries or renders
grep "Completed" log/development.log | sort -t= -k2 -rn | head -20
# Use rack-mini-profiler or Rails server timing
# Look for: slow partials, N+1 queries, expensive computations
适合缓存的内容:
- 在循环中渲染的局部模板(商品卡片、评论列表)
- 开销较大的计算(报告、聚合、API 调用)
- 很少变化的内容(导航、页脚、设置)
- 外部 API 响应
不适合缓存的内容:
- 每次请求都会变化的内容(CSRF 令牌、Flash 消息)
- 未使用用户作用域键的用户专属内容
- 未正确限定键作用域的认证后内容
- 任何会写入数据库的操作
步骤 2:选择合适的缓存策略
| 策略 | 适用场景 | 缓存键依据 |
|---|---|---|
| 片段缓存 | 缓存一段视图 HTML | 记录 + 模板摘要 |
| 集合缓存 | 渲染由多个局部模板组成的集合 | 每条记录单独缓存 |
| 俄罗斯套娃缓存 | 使用共享失效处理的嵌套片段 | 带有 touch 的记录层级 |
| 底层缓存 | 缓存计算结果、API 调用 | 由你定义的自定义键 |
| 条件 GET | 整个响应未发生变化 | ETag 或 Last-Modified |
步骤 3:实现片段缓存
基本片段缓存:
<% cache product do %>
<%= render product %>
<% end %>
Rails 生成的键类似于:views/products/show:abc123/products/1-20260101120000000000
该键包含:
- 模板树摘要(模板变化时更新)
- 记录的
cache_key_with_version(记录更新时变化)
条件缓存:
<% cache_if !current_user&.admin?, product do %>
<%= render product %>
<% end %>
用于用户专属或特定区域设置内容的多部分缓存键:
<% cache [current_user, I18n.locale, product] do %>
<%= render product %>
<% end %>
如果内容因用户而异,请将用户纳入缓存键。 否则,你会缓存“欢迎你,Ryan”,并将其提供给每位访客——这是最常见的缓存错误。
步骤 4:为列表使用集合缓存
不要在循环中逐项缓存,而应使用集合缓存:
<%# SLOW — one cache read per item %>
<% @products.each do |product| %>
<% cache product do %>
<%= render partial: "products/product", locals: { product: product } %>
<% end %>
<% end %>
<%# FAST — batch cache read (multi-fetch) %>
<%= render partial: "products/product", collection: @products, cached: true %>
集合缓存只需一次往返即可读取所有缓存条目。对于大型集合,这会快得多。
集合的自定义缓存键:
<%= render partial: "products/product",
collection: @products,
cached: ->(product) { [I18n.locale, product] } %>
步骤 5:实现俄罗斯套娃缓存
俄罗斯套娃缓存 = 嵌套的缓存片段,内部缓存失效会通过 touch 向外传播。
视图:
<% cache @category do %>
<h2><%= @category.name %></h2>
<% @category.products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
<% end %>
模型 — touch: true 会沿链向上传播缓存失效:
class Category < ApplicationRecord
has_many :products
end
class Product < ApplicationRecord
belongs_to :category, touch: true
end
商品更新时 → 其 updated_at 会变化 → touch: true 会更新类别的 updated_at → 两个缓存片段都会过期。
没有 touch: true 时,外层缓存会提供过期数据 — 更新商品不会改变类别的 updated_at,因此类别片段永远不会过期。俄罗斯套娃链中的每个 belongs_to 都需要 touch: true。
多层 touch 链:
class Store < ApplicationRecord
has_many :categories
end
class Category < ApplicationRecord
belongs_to :store, touch: true
has_many :products
end
class Product < ApplicationRecord
belongs_to :category, touch: true
# Updating a product touches category, which touches store
end
第 6 步:使用 Rails.cache 进行底层缓存
使用 fetch——它会在一次调用中处理读取和写入:
class Product < ApplicationRecord
def competing_price
Rails.cache.fetch("#{cache_key_with_version}/competing_price", expires_in: 12.hours) do
Competitor::API.find_price(id)
end
end
end
底层缓存规则:
- 对 AR 记录使用
cache_key_with_version——记录更新时会自动失效 - 设置
expires_in——无限期缓存会积累难以调试的过期数据 - 缓存 ID 或基本类型,而不是 Active Record 对象——缓存的 AR 对象会过期,在代码重新加载时也会出错
# Avoid — cached AR objects go stale and break on code reloads
Rails.cache.fetch("super_admins", expires_in: 12.hours) do
User.super_admins.to_a
end
# Better — cache IDs, re-query fresh
ids = Rails.cache.fetch("super_admin_ids", expires_in: 12.hours) do
User.super_admins.pluck(:id)
end
User.where(id: ids)
其他缓存操作:
Rails.cache.read("key") # Returns nil on miss
Rails.cache.write("key", value, expires_in: 1.hour)
Rails.cache.delete("key")
Rails.cache.exist?("key")
Rails.cache.increment("counter")
Rails.cache.decrement("counter")
# Delete by pattern (not supported by all stores)
Rails.cache.delete_matched("products/*")
# fetch_multi for batch operations
Rails.cache.fetch_multi("key1", "key2", "key3", expires_in: 1.hour) do |key|
expensive_computation(key)
end
第 7 步:HTTP 条件 GET(stale? / fresh_when)
这是影响最大的缓存方式——它可以完全避免生成响应。
有自定义响应逻辑时使用 stale?:
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
if stale?(@product)
respond_to do |format|
format.html
format.json { render json: @product }
end
end
# If fresh, Rails auto-sends 304 Not Modified
end
end
简单场景(默认模板渲染)使用 fresh_when:
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
fresh_when @product
# That's it — renders template if stale, 304 if fresh
end
def index
@products = Product.all
fresh_when @products
end
end
使用显式选项:
fresh_when last_modified: @product.updated_at.utc, etag: @product.cache_key_with_version
对于永不更改的内容:
def show
http_cache_forever(public: true) do
render
end
end
适合使用条件 GET 的场景:
- 单条记录的展示页面
- 更新模式可预测的索引页面
- 客户端会缓存响应的 API 端点
- 类静态页面(关于、条款等)
第 8 步:配置缓存存储
Solid Cache(Rails 8 默认值——推荐使用)
使用 SSD 的数据库支持型缓存。无需额外基础设施。
# config/environments/production.rb
config.cache_store = :solid_cache_store
# config/database.yml
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
# config/cache.yml
default: &default
store_options:
max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
选择 Solid Cache 的原因:
- 零基础设施——只需使用数据库
- 存储空间比基于 RAM 的存储更大(SSD 很便宜)
- 按 FIFO 策略淘汰,并可配置 max_age
- 支持加密敏感缓存数据
- 支持分片以实现水平扩展
**其他存储:**Redis(:redis_cache_store)、Memory(:memory_store)、Null(:null_store)。有关 Redis 生产环境配置(含错误处理)以及完整的存储对比,请参阅 references/cache-stores.md。
第 9 步:在开发环境中启用缓存
bin/rails dev:cache # Toggle caching on/off
这会创建或删除 tmp/caching-dev.txt,并切换 perform_caching。
在开发环境中使用 Solid Cache:
# config/environments/development.rb
config.cache_store = :solid_cache_store
并在 config/database.yml 的 development 部分添加缓存数据库。
第 10 步:测试缓存行为
测试缓存是否正常工作,而不是测试 Rails 缓存是否正常工作:
class ProductTest < ActiveSupport::TestCase
test "competing_price is cached" do
product = products(:widget)
# First call hits the API
assert_equal 29.99, product.competing_price
# Verify cache was written
assert Rails.cache.exist?("#{product.cache_key_with_version}/competing_price")
end
test "touch propagation invalidates parent cache" do
category = categories(:electronics)
product = products(:widget)
old_updated_at = category.updated_at
product.update!(name: "New Name")
assert_operator category.reload.updated_at, :>, old_updated_at
end
end
在请求测试中,验证条件 GET:
class ProductsControllerTest < ActionDispatch::IntegrationTest
test "returns 304 when product unchanged" do
product = products(:widget)
get product_path(product)
assert_response :success
etag = response.headers["ETag"]
get product_path(product), headers: { "HTTP_IF_NONE_MATCH" => etag }
assert_response :not_modified
end
end
常见错误
1. 缓存用户专属内容时未包含用户键
<%# Wrong: all users see the same cached content %>
<% cache @dashboard do %>
Welcome, <%= current_user.name %>!
<%= render @dashboard.widgets %>
<% end %>
<%# Fixed: include user in cache key %>
<% cache [current_user, @dashboard] do %>
Welcome, <%= current_user.name %>!
<%= render @dashboard.widgets %>
<% end %>
2. 关联上缺少 touch
# Problem: updating a comment doesn't expire the post cache
class Comment < ApplicationRecord
belongs_to :post
end
# Fixed: touch propagates invalidation to parent
class Comment < ApplicationRecord
belongs_to :post, touch: true
end
3. 缓存 Active Record 对象
# Problem: cached AR objects become stale and break on code reload
Rails.cache.fetch("featured") { Product.featured.to_a }
# Fixed: cache IDs, re-query fresh
ids = Rails.cache.fetch("featured_ids", expires_in: 1.hour) { Product.featured.pluck(:id) }
Product.where(id: ids)
4. 底层缓存未设置过期时间
# Problem: cache lives forever, exchange rates go stale silently
Rails.cache.fetch("exchange_rates") { ExchangeRateAPI.current }
# Fixed: set expires_in so data refreshes
Rails.cache.fetch("exchange_rates", expires_in: 1.hour) { ExchangeRateAPI.current }
有关其他常见错误(包括缓存键依赖问题和 helper 摘要陷阱),请参阅 references/fragment-caching.md。
快速参考:缓存键方法
| 方法 | 返回值 | 用途 |
|---|---|---|
cache_key | products/1 | 稳定的键(无版本) |
cache_key_with_version | products/1-20260101120000 | 键 + 时间戳版本 |
cache_version | 20260101120000 | 仅版本时间戳 |
to_param | "1" | 非 AR 对象的回退方式 |
参考资料
有关详细模式、边界情况和高级配置,请参阅 references/ 目录:
references/fragment-caching.md— 缓存键、复合键、集合缓存、依赖项references/russian-doll.md— 嵌套缓存、touch 传播、计数器缓存references/low-level-caching.md— Rails.cache.fetch、fetch_multi、原子操作references/conditional-get.md— stale?、fresh_when、ETags、HTTP 缓存references/cache-stores.md— 存储对比、Solid Cache、Redis、Memcached 配置references/performance.md— N+1 缓存调用、预热、防止缓存击穿、调试