在 Web 应用的缓存设计中,有一个经常被忽略的中间地带:既不想每次都重新生成响应,又不允许缓存随意使用可能过期的数据。HTTP 规范用 must-revalidate 指令描述这个需求,而 Hanami 把它封装成 Hanami::Action::Cache::MustRevalidate。启用后,响应会携带 Cache-Control: must-revalidate,中间缓存和浏览器在副本超过新鲜度窗口后,必须先向源服务器发起条件请求,获得 304 Not Modified 才能继续复用。下面结合它的工作原理、使用方法和组合策略展开。

must-revalidate 与 no-cache 的核心差异
不少开发者会把 must-revalidate 和 no-cache 混为一谈,因为它们都涉及“验证”这个动作。实际上两者的约束范围完全不同。no-cache 表示每次使用缓存副本之前都必须向源站验证,即使副本仍然处于 max-age 规定的新鲜期内也不能直接复用。也就是说,只要响应带 no-cache,缓存基本退化为一个协商缓存的存储容器。
must-revalidate 则只对已经过期的副本起作用。它允许缓存副本在新鲜期内被直接使用,但一旦超过 max-age 或 Expires 给出的时间边界,缓存就必须回到源站重新验证。如果源站返回 304 Not Modified,则可以继续用旧副本;如果源站返回新的 200 OK,就必须更新。这种设计特别适合那些允许短暂陈旧、但不能跨过关键时间点的数据。
举个例子,某商品价格接口设置 Cache-Control: max-age=60, must-revalidate。在这 60 秒内,任何缓存都可以直接返回价格;60 秒之后,下一次请求必须携带 If-None-Match 或 If-Modified-Since 回到源站。如果价格没变,源站返回 304,缓存继续用旧数据;如果价格变了,则返回新响应。这样既减少了源站压力,又避免了用户在价格调整后长时间看到旧价格。
在 Hanami Action 中启用 MustRevalidate
在 Hanami 中启用强制重新验证并不复杂。框架已经提供了与缓存策略相关的模块,其中 Hanami::Action::Cache::MustRevalidate 的职责是给响应追加或合并 Cache-Control 头。你只需要在具体的 action 类里引入这个模块即可。
# apps/web/controllers/price/show.rb
module Web
module Controllers
module Price
class Show
include Hanami::Action
include Hanami::Action::Cache::MustRevalidate
def call(params)
self.body = fetch_price(params[:id])
end
end
end
end
end
这段代码执行后,来自该 action 的 HTTP 响应会自动带上 Cache-Control: must-revalidate。需要注意的是,模块的引入顺序可能影响最终头部内容,因为不同缓存模块会向同一个 Cache-Control 头写入不同指令。如果后续代码手动设置了 Cache-Control,最好检查一下最终输出,避免指令互相覆盖。
如果不想通过 include 模块的方式,也可以直接在 action 内部设置响应头。这种手动写法更直观,适合已经有一套统一响应处理逻辑的项目。
class Price
include Hanami::Action
def call(params)
headers['Cache-Control'] = 'must-revalidate'
self.body = fetch_price(params[:id])
end
end
手动设置的优势是明确且不易受模块加载顺序影响,缺点是不够语义化,也难以统一维护。使用 Hanami::Action::Cache::MustRevalidate 则能让缓存策略在类声明层面可见,团队协作时更容易理解每个接口的缓存意图。
与 max-age、private 等指令组合
单独使用 must-revalidate 时,它本身并不会设置新鲜期。如果没有 max-age 或 Expires,那么很多缓存会把这个响应视为已经过期,从而每次都需要重新验证。这跟直接使用 no-cache 的效果有些接近。为了让缓存真正发挥价值,通常需要把 must-revalidate 与 max-age 配合起来。
class StockLevel
include Hanami::Action
include Hanami::Action::Cache::MustRevalidate
def call(params)
headers['Cache-Control'] = 'public, max-age=30, must-revalidate'
self.body = fetch_stock(params[:sku])
end
end
上面的响应头表示:共享缓存和浏览器都可以缓存该响应,最多缓存 30 秒;30 秒内直接使用,30 秒后必须重新验证。这里的 public 允许 CDN 等共享缓存参与存储,而如果响应与用户身份相关,则应该改为 private,例如 Cache-Control: private, max-age=30, must-revalidate。
组合指令时还要注意优先级与冲突。比如 no-store 与 must-revalidate 同时出现时,no-store 的优先级更高,缓存根本不会存储响应,must-revalidate 自然也就没有作用了。又比如同时设置 max-age 和 Expires,max-age 会被优先采用。理解这些关系有助于避免写出自相矛盾的缓存头。
测试验证与 CDN 注意事项
要确认 Hanami::Action::Cache::MustRevalidate 真的生效,最直接的方法是写一个 RSpec 请求测试,检查响应头里是否包含预期指令。Hanami 的测试工具通常能模拟完整请求生命周期,因此可以准确看到 Cache-Control 的最终值。
RSpec.describe 'Price endpoint', type: :request do
it 'adds must-revalidate cache control header' do
get '/price/1'
expect(last_response.status).to eq(200)
expect(last_response.headers['Cache-Control']).to include('must-revalidate')
end
end
不过,通过单元测试只能说明应用层逻辑正确,真正部署到生产环境时,还需要关注反向代理和 CDN 的行为。有些 CDN 会把源站的 Cache-Control 作为参考,但可能允许运营人员配置覆盖规则;有些旧版本的代理对 must-revalidate 支持不完整,甚至会忽略该指令。因此,除了代码层面的保证,还应该在预发布环境利用抓包工具观察实际响应头。
另外,并非所有客户端都严格遵循 must-revalidate。例如某些浏览器的前进后退缓存(bfcache)在特定情况下会复用内存中的页面,而不发起网络请求。对于强一致性的业务场景,更不能只依赖这个指令,还应该在服务端做好数据版本控制,例如使用 ETag 和 Last-Modified 响应头作为校验依据。
总结来说,Hanami::Action::Cache::MustRevalidate 是处理“允许缓存但过期必须验证”这一需求的有力工具。把它与 max-age、private 合理搭配,能够显著降低源站负载,同时保证关键数据不会长时间陈旧。理解它与 no-cache、no-store 的边界,是设计稳健 HTTP 缓存策略的前提。