mcpField
C
caching

caching

github.com/thinkoodle开发7187 次安装8 周前更新
代码仓库

关于 caching

面向 Rails 开发者和编码 Agent 的缓存指南,用于实现或排查应用缓存。内容涵盖策略选择、失效机制、HTTP 条件请求、缓存存储和测试,并优先保证缓存正确性。

主要功能

  1. 01选择 HTTP、片段、集合或低级缓存
  2. 02设计缓存键和失效链
  3. 03配置 Rails 缓存存储
  4. 04使用 ETag 和条件 GET
  5. 05测试缓存行为并排查过期数据

适用场景

  1. 01缓存重复渲染的局部视图
  2. 02缓存耗时计算或 API 响应
  3. 03记录更新后使嵌套片段缓存失效
  4. 04资源未变更时返回 304 响应

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill caching

安装后在 Claude Code 中输入/caching确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
cache-stores.md
conditional-get.md
fragment-caching.md
low-level-caching.md
performance.md
russian-doll.md

详细介绍

Rails 缓存专家

在 Rails 应用中实现快速、正确的缓存。缓存正确性优先于缓存覆盖率——过期数据比慢数据更糟糕。

理念

核心原则:

  1. 缓存正确性优先 — 缓存未命中会变慢;过期数据则是缺陷
  2. 失效处理才是难点 — 每添加一项缓存,就必须处理其失效
  3. 先衡量再缓存 — 不要缓存不慢的内容
  4. Solid Cache 是 Rails 8 的默认选项 — 除非有明确理由,否则使用它
  5. 片段缓存是你的主力 — 从这里开始,而不是从底层缓存入手

缓存层级(优先选择上层):

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

底层缓存规则:

  1. 对 AR 记录使用 cache_key_with_version——记录更新时会自动失效
  2. 设置 expires_in——无限期缓存会积累难以调试的过期数据
  3. 缓存 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_keyproducts/1稳定的键(无版本)
cache_key_with_versionproducts/1-20260101120000键 + 时间戳版本
cache_version20260101120000仅版本时间戳
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 缓存调用、预热、防止缓存击穿、调试

评论

支持 Markdown