免费

让 Claude 找出 Rails 里的 CDN 缓存中毒——三种症状,一个根因

三个 PR 一个根因:HTML body 随登录态、UA、flash 变化,但 CDN 的 cache key 不知道,导致内容跨用户串号。让 Claude 顺着 PR 链一路读出三种可复用模式:lazy personalize frame、CSS 客户端隐藏、缓存边界守卫。


用户进我的站,每个公开页面底部都浮着一行 "Content missing"。最早的几条报告全来自 iOS——一开始我以为是 Hotwire Native 客户端的某个怪行为,等翻 Cloudflare 日志才意识到不对:这跟客户端无关,是 CDN 缓存被污染了,三端(Web、iOS、Android)都会中招,只是 iOS 最先把它暴露出来

第一反应是 Hotwire Native 的 bug,第二反应是 Cloudflare 的某个怪边缘行为。真相都不是。把这一类型的 bug 看作"CDN 问题"也是错的——CDN 在做你告诉它的事。

让 Claude 顺着 PR 链一路读下去,连续三个 PR 的根因是同一个:HTML body 会随登录态、UA、flash 这种请求属性变化,但 CDN 的 cache key 不知道这件事。下面是三种不同症状,一个根因。

陷阱 1:tab-badge turbo-frame 跨用户串了

//topics/square/coins/searches/app 这几个页面用 public_expires_in 走 CDN 缓存。布局里有这么一段:

<% if mobile_hotwire? && user_signed_in? %>
  <%= render "shared/tab_badge" %>
<% end %>

shared/tab_badge 渲染的是一个 turbo-frame:

<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>

意图很清楚:登录的 Hotwire Native 用户在底部 tab bar 上看未读消息红点。Web 端不需要(有自己的渲染分支),未登录的也不需要。

问题mobile_hotwire? && user_signed_in? 这个分支让同一个 URL 的 HTML 长成两种。但 CDN 的 cache key 只看 URL 和 Accept 头之类的东西——它不知道你登录没登录。

时间线:

  1. 一个登录的 Hotwire Native 用户访问 /,CDN 第一次回源,缓存了带有 tab-badge frame 的 HTML
  2. 一个未登录的访客(Web / iOS / Android 都行)访问 /,CDN 命中同一条缓存,把这份 HTML 给了他
  3. 浏览器执行到 turbo-frame,按 src 去请求 /notifications?badge_only=true
  4. NotificationsControllerbefore_action :authenticate_user!,看到没登录就 302 到 /users/sign_in
  5. Turbo 在 sign_in 页面里找不到 id 为 tab_badge 的 frame,吐出 "Content missing"

三端(Web / iOS / Android)公开访客都可能踩到——只要那条 cache slot 被某个登录用户先填过。报告集中在 iOS,是因为 iOS 用户登录率最高、缓存最容易被登录态先污染,所以未登录的 iOS 访客碰中污染条目的概率最大。Web 和 Android 不是"没事",是触发概率低 + 报告少——一个本质上是全站灾难的 bug,被早期数据伪装成了"iOS 专属"。

修法两步走,目的都是让缓存不再按登录态分裂:

第一步:布局里那个 && user_signed_in? 必须去掉。否则你永远在和 cache key 玩猜猜看:

<%# 渲染给所有 mobile_hotwire? 请求,不管登录与否——
    带 user_signed_in? 分支会把同一个 URL 的 HTML 切成两个变体,
    CDN 缓存命中谁就把谁的版本发给下一个人。 %>
<% if mobile_hotwire? %>
  <%= render "shared/tab_badge" %>
<% end %>

第二步/notifications?badge_only=true 这个 frame 端点必须对未登录请求返回长得一模一样的 200 frame,不能 302。同时端点本身要 private, no-store,免得 CDN 共享单用户的未读数。

class NotificationsController < ApplicationController
  before_action :authenticate_user!, except: [:index]

  def index
    if params[:badge_only] || params[:count_only]
      response.headers["Cache-Control"] = "private, no-store"
      redirect_to notifications_path and return unless turbo_frame_request?
      @unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
      if params[:badge_only]
        render(partial: "notifications/tab_badge")
      else
        render(partial: "notifications/bell")
      end
      return
    end

    authenticate_user!
    # ... 正常 index 逻辑
  end
end

注意 authenticate_user! 只对 index 排除——而且只对 frame 子请求排除,非 frame 请求依然走 redirect。

同一个 PR 里还有一个一模一样形状的 bug:未登录的 Hotwire Native 客户端用户也看到了 compose FAB 浮动按钮——同样是被登录态污染的缓存漏给了未登录访客。原本的写法是布局里直接根据 user_signed_in? 决定渲不渲染那个 stimulus controller div。修法:把 FAB 也改成 lazy frame,由 /personalize/compose_fab 这个 private, no-store 端点来渲染。

<%# 布局里一律渲染空 frame %>
<% if mobile_hotwire? %>
  <turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# /personalize/compose_fab 模板 %>
<%= turbo_frame_tag "compose-fab" do %>
  <% if user_signed_in? %>
    <div data-controller="bridge--compose-fab" class="hidden"></div>
  <% end %>
<% end %>

布局每次都吐出一个空 frame,frame 里加载的端点根据登录态决定要不要塞 controller div。主页面里完全没有任何"按用户分支"的 markup,CDN 怎么缓存都不会出问题。

陷阱 2:flash 提示串到了别的用户身上

修完 #117 几天,又一个用户报告:他没登录,进首页,页面顶上有一行"成功登录"的 flash 提示,他人都懵了。

这是同一类病,新一种症状。

布局里有:

<%= render "shared/_notice" %>

这一行渲染 flash[:notice] / flash[:alert]。任何 redirect 之后第一次请求都会带着 flash,比如:

  • 登录成功后 redirect_to root_path
  • 登出后 redirect_to root_path
  • Pundit redirect_back(fallback_location: ...)

如果这次 redirect 落在的 URL 又是 public_expires_in 路径,那 flash 就被烤进缓存的 HTML 里了。下一个匿名用户访问同一个 URL,CDN 把这份 HTML 给他——他就看到了别人的"成功登录"。

修法的诱惑是去重构每一个可缓存的 view,把 flash 渲染移到 frame 里。但更稳的办法是在缓存边界拦一刀:只要这次响应带 flash,就不能 public 缓存

def public_expires_in(duration)
  return unless Rails.env.live?
  # flash 在 layout 里直接渲染,缓存这次响应就会把上一个用户的提示
  # 漏给下一个匿名访客
  if flash.any?
    response.headers["Cache-Control"] = "private, no-store"
  else
    expires_in duration, public: true
  end
end

这一招好处:

  1. 不用改 view——所有走 public_expires_in 的页面自动受益
  2. 不浪费缓存空间——下一次没有 flash 的请求来时,CDN 重新回源拿到干净的 HTML,缓存正常工作
  3. 没有 flash 的常态访问完全不受影响——99% 的请求该缓存还是缓存

顺手发现 coins#show 自己写了 expires_in 5.minutes, public: true,绕开了 helper。一并改成走 public_expires_in,否则同样的 flash 串号 bug 会从那里冒出来。

部署完之后必须跑一次 cloudflare:purge_personalized_pages——已经被污染过的缓存条目不会自动过期,最长会留到对应 URL 的 TTL 用完(/topics 是 1 周)。

陷阱 3:登录用户在 /topics 看不到"+ 新主题"按钮

又过几个小时,第三个症状:登录用户进 /topics,"+ 新主题"按钮不见了。刷新也不行。但同一个用户用别的页面跳到 /topics?r=1 加随机参数(绕开缓存),按钮就回来了。

/topicspublic_expires_in 1.week,缓存得最狠。原本的 view 里:

<% unless mobile_hotwire? %>
  <a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>

<% if mobile_hotwire? %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

两个守卫:UA 守卫(web vs native)、auth 守卫(登录与否)。CDN 不知道这两个,第一次回源是哪种用户、缓存就长哪样。第一个匿名 web 访客把"无 native bridge button、有 web button"那份缓存了;第一个登录的 native 访客把"有 bridge button、无 web button"那份缓存了——下一个的命运看 cache slot 对不对得上。

修法和 #117 同一套思路:

Web 那个按钮——不需要任何分支,永远渲染:

<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>

.web-only 用 CSS 在 native UA 下隐藏。CDN 缓存的 HTML 永远长得一样,按钮永远在,UA 决定显示不显示——CSS 是 client-side 决策,cache 不参与。

Native bridge 按钮——挪到 lazy personalize frame:

<% if mobile_hotwire? %>
  <turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# /personalize/topic_new_button 模板 %>
<%= turbo_frame_tag "topic-new-button" do %>
  <% if user_signed_in? %>
    <button data-controller="bridge--new-topic">+</button>
  <% end %>
<% end %>

主页面 HTML 里只有 <turbo-frame> 容器和 web 按钮——这两块都不会按用户分支。所有"按用户决定要不要渲染"的 markup 都搬到了 personalize 端点里,那个端点是 private, no-store,永远不会进 CDN。

真正的教训:CDN cache poisoning 不是 CDN 的 bug

每一个症状当时看都像是不同的 bug。"Content missing" 看像是 Hotwire Native 的问题,flash 串号看像是 cookies/session 配置问题,按钮失踪看像是 view template 的逻辑分支错了。

把三个 PR 放在一起看,根因只有一个:HTML body 的内容由 cache key 之外的请求属性决定。CDN 的工作就是按 cache key 把 HTML 缓存起来——它不可能知道这一份 HTML 其实只对登录用户、或者只对 Hotwire Native、或者只对带 flash 的请求是正确的。

要根治这一类 bug,三个可复用的模式:

  1. 把变化推出 cached body——任何"按用户分支"的 markup 都改成 lazy turbo-frame,由 private, no-store 的 personalize 端点渲染。主页面 HTML 永远不变,CDN 怎么缓存都对。

  2. 渲染通用版本,前端 client-side 隐藏——比如 web 按钮永远在 HTML 里,CSS 用 .web-only 在 native 下藏起来。这是把分支推到了 CSS/JS 层,cache key 之内的 HTML 完全统一。

  3. 缓存边界守卫——已经有状态漏进响应(比如 flash)的请求,在缓存边界把 Cache-Control 降级成 private, no-store。常态请求完全不受影响。

这三个模式的共同点:让"被缓存的 HTML"和"按用户分支的 markup"永远不重叠

部署修复完之后还要做一件事:CDN 已经污染的条目不会自动过期,TTL 最长到 1 周。写一个一次性 rake task cloudflare:purge_personalized_pages 把所有可疑路径主动 purge 掉,否则 bug 还会在缓存自然过期前一直冒出来。

顺带提一下:lazy frame 不止有缓存问题

PR #122 是同一段时间发现的,形状一样、bug 类型不一样——值得单独说一下,因为它和 cache poisoning 共享同一个隐藏机制。

/following 页面用 lazy turbo-frame 加载下一页(infinite scroll 的常规做法)。frame 的 src 由一个叫 set_load_more_path 的 helper 算出来——它根据当前 controller/action 决定下一页的 URL。

def set_load_more_path(page:, anchor_id: nil)
    if controller_name == "coins" && action_name == "show"
        path = coin_symbol_path(...)
    elsif controller_name == "posts" && action_name == "hot"
        path = hot_path(...)
    # ...一堆分支...
    elsif controller_name == "users" && action_name == "index"
        path = users_path(page: page, ...)
    # ...
    else
        path = posts_path(page: page, ...)  # ← fallback
    end
end

posts#following 这个 action 不在分支列表里,于是落到了最后的 posts_path fallback——也就是 /posts,那是 explore feed。

效果:/following 页面第一页是对的(因为 controller action 自己 set 了 @posts = following_posts),第二页之后 lazy frame 偷偷去拉 /posts?page=2,加载的是 explore feed 的内容——你不关注的人的帖子也飘进来了。

修法很短:

elsif controller_name == "posts" && action_name == "following"
    path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)

这跟前面三个 cache poisoning bug 不是同一类——set_load_more_path 的分支错和 CDN 没关系。但它和它们共享同一个让人看不到的机制:lazy turbo-frame 加载的内容你不会主动 review

117 的 frame 在出错时静默 302,#119/#121 的内容缓存里写错了你看不出来,#122 的 frame 路径错了你只看第一页就发现不了。一旦把"动态内容"塞进 lazy frame,就要主动 review frame 加载之后的状态——Claude 在 PR review 阶段帮我抓的就是这种"frame 加载完之后会发生什么"的检查项。

几个值得自查的清单

如果你的 Rails 应用前面挂了 CDN(Cloudflare、Fastly、随便哪家),下面这几个组合八成至少踩了一个:

  • 任何 public_expires_in / expires_in ..., public: true 的 view 模板里,存不存在 if user_signed_in? 这种分支?
  • flash 在 layout 里直接渲染吗?public_expires_in 路径在 flash 存在时会不会被缓存?
  • Hotwire Native 应用是否有 mobile_hotwire? 守卫的 markup 出现在可缓存路径上?
  • 任何 turbo-frame 的 src 端点,它的 controller 用 before_action :authenticate_user! 吗?未登录请求会不会 302?
  • lazy turbo-frame 的 src 路径是从一个 helper 算出来的吗?这个 helper 有没有兜底分支?兜底是不是错的 URL?

让 Claude 把这五个清单跑一遍——如果某条 hit 了,那就是下一个 PR 的素材。这种 bug 不会主动浮出水面,因为 lazy frame 静默失败、CDN 静默命中、CSS 静默隐藏。三个机制叠在一起,bug 可以在生产藏好几个月。