proxy_cache 上线三天就串号:缓存键、Set-Cookie 与回源风暴

三行配置就让回源掉了九成,三天后有客户看到了别人的库存和会员价。缓存键里少了身份维度、上游还在发 Set-Cookie,修完又撞上热点 key 过期的回源尖峰。

商品详情接口是我们站上最重的读接口,一个页面要串行请求三个接口,其中 /api/product/detail 占了七成响应时间。它的数据半小时才变一次,但每天被请求两百多万次,全部打到应用服务器上,数据库的 QPS 里有一半是同一个商品被反复查。

很自然的想法是在 Nginx 上做一层缓存。proxy_cache 是内置模块,不用改一行业务代码,配置三行就能生效。我们上线那天下午回源流量掉了九成,RT 从 78ms 掉到 4ms,值班群里一片欢呼。

三天后出事了。有客户反馈:打开自己店铺的商品页,看到的「库存」「会员价」是别人的。第一反应是后端串了数据,查了两小时应用日志,发现请求根本没到后端——那些响应是 Nginx 直接从缓存里拿的。

一、第一版配置:三行就生效了

当时的配置大概是这样的,从一篇教程里抄的:

proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=product:100m
                 max_size=10g inactive=30m use_temp_path=off;

server {
    listen 443 ssl;
    server_name ispt.top;

    location /api/product/ {
        proxy_cache product;
        proxy_cache_valid 200 30m;
        proxy_pass http://app_backend;
    }
}

三个参数各是什么,我当时只知道个大概:keys_zone=product:100m 是内存里存 key 索引的空间,100m 大概能存八十万个 key;max_size=10g 是磁盘上限;inactive=30m 是「三十分钟没人访问就删」。

问题在于 proxy_cache_key 我一个字都没写。它有个默认值:

$scheme$proxy_host$request_uri

也就是「协议 + 上游主机 + 完整 URI(含查询串)」。看起来挺合理——不同的 URL 有不同的缓存,同一个 URL 共用一份。但这个 key 完全不知道「谁在请求」。

二、串号事故:缓存键里少了什么

我们的商品详情接口有两种调用方式。游客访问走 GET /api/product/detail?id=1001,返回公开价格;登录用户带 Authorization: Bearer xxx,返回会员价和该用户所在门店的库存。

在 $scheme$proxy_host$request_uri 这个 key 下,这两种请求的 key 完全一样——因为 Authorization 是请求头,不在 URI 里。于是第一个访问的游客把公开版本写进了缓存,后面所有登录用户拿到的都是它;反过来更糟,某个带会员身份的人先访问,缓存里存的就是他的会员价,别人直接拿走。

这已经不是性能问题,是数据泄露。我当时的第一反应是「把带 Authorization 的请求全部 bypass 掉」:

# 带身份凭证的请求不进缓存,也不写缓存
proxy_cache_bypass $http_authorization;
proxy_no_cache     $http_authorization;

proxy_cache_bypass 和 proxy_no_cache 经常被混为一谈,其实分工很清楚:前者决定「这次请求要不要绕过缓存直接回源」,后者决定「回源拿到的响应要不要写进缓存」。两个都要配,只配一个会出现「绕过读取但仍然写入了别人的响应」这种更隐蔽的问题。

改完之后串号没了,但命中率从 92% 掉到 31%——因为我们的前端对登录用户也带 token,而站上八成流量是登录用户。这样缓存基本白做。

三、正确的做法:把身份放进 key,粒度要选对

真正要解决的问题不是「要不要缓存登录用户」,而是「登录用户的响应能不能共用」。答案取决于响应里到底有什么维度。

我们梳理了接口返回的字段,发现会员价只跟「会员等级」有关,门店库存只跟「门店 ID」有关,跟具体是哪个人无关。于是改成按维度组 key:

# 把身份维度收敛成两个可枚举的值:等级 + 门店
map $http_authorization $cache_level {
    default          "anon";
    "~.+"            "auth";
}

map $arg_store_id $cache_store {
    default          "0";
    "~^\d+$"         $arg_store_id;
}

proxy_cache_key "$scheme$request_method$host$uri$is_args$args|lv=$cache_level|st=$cache_store";

这里有个关键取舍:$cache_level 只有 anon 和 auth 两个值,意味着所有登录用户共用一份缓存。这只有在上游响应真的跟用户无关时才成立。我们为此做了一件事——让后端在响应里明确声明维度:

# 后端在响应头里声明「这份响应依赖哪些维度」
X-Cache-Dims: level,store

然后 Nginx 侧根据这个头决定用哪套 key,没声明的一律不进缓存。这套约定有点重,但它把「能不能缓存」的判断权交给了最清楚业务的人。

顺带说一个坑:$request_uri 包含原始未解码的路径和完整查询串,参数顺序不同会被当成两个 key,比如 ?a=1&b=2 和 ?b=2&a=1。前端如果参数顺序不稳定,缓存会被切成很多份,命中率虚低。我最后用 $uri$is_args$args 显式拼,并在应用侧约定参数顺序固定。

配置项 作用 我们踩的坑
proxy_cache_key 决定「什么算同一个资源」 默认不含请求头,登录态被混进同一份缓存
proxy_cache_bypass 本次请求绕过缓存读 只配它,回源响应仍会被写入
proxy_no_cache 本次响应不写缓存 与上面成对使用,缺一个就出事
proxy_cache_valid 按状态码设 TTL 只写 200,5xx 仍会被缓存默认时长
proxy_ignore_headers 忽略上游的缓存控制头 上游发了 Set-Cookie 会阻止缓存

四、Set-Cookie 和 Vary:两个隐形的刹车

修好 key 之后命中率还是上不去,X-Cache-Status 大量返回 BYPASS 和 MISS。打开 debug 日志才看到原因:上游响应里有 Set-Cookie。

Nginx 的规则是,如果响应带 Set-Cookie,默认不缓存(除非配 proxy_ignore_headers Set-Cookie)。我们的框架给每个响应都塞了一个 SESSION cookie,哪怕这个接口根本不需要会话。

我的处理不是无脑忽略这个头——那样会把会话状态也缓存出去,比串号更严重。做法是让后端在纯读取接口上不再下发 Set-Cookie,只保留在登录、下单这类接口上。这一改动比 Nginx 侧任何配置都有效。

第二个刹车是 Vary。上游返回 Vary: Accept-Encoding, User-Agent 时,Nginx 会把这些请求头也纳入缓存区分维度,等于把 key 空间乘上几十倍。Accept-Encoding 我们确实需要(gzip 和非 gzip 是两份内容),User-Agent 完全不需要。最后在上游去掉了 User-Agent,只保留压缩维度。

五、回源风暴:缓存过期那一刻发生了什么

命中率稳定在 88% 之后,新的问题出现了:每天早上八点半,也就是 TTL 到期的时刻,应用服务器会收到一波尖峰,QPS 是平常回源的六倍。

原因是同一个热点商品被几千个用户同时请求,缓存刚过期的瞬间,这几千个请求全部判定为 MISS,一起回源。这是典型的缓存击穿,Nginx 侧有现成的解法:

proxy_cache_lock on;              # 同一个 key 只放一个请求回源
proxy_cache_lock_timeout 5s;      # 其他请求最多等 5 秒
proxy_cache_lock_age 10s;         # 回源超过 10 秒还没回,放第二个请求进去

# 上游挂了的时候,用过期内容顶着,别把错误放给用户
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # 用旧内容响应,同时后台异步刷新

proxy_cache_lock 是这次最值钱的一行。它把「几千个请求同时回源」变成「一个请求回源、其余等待」,代价是等待的请求会多占一点连接。我们的上游 RT 在 80ms 左右,5 秒的超时足够了。

proxy_cache_background_update 配 use_stale updating 是另一组搭配:缓存过期后,第一个请求立刻拿到旧内容(用户无感),同时后台发一个子请求去刷新。适合「内容可以稍微旧一点,但不能慢」的场景。

六、怎么确认它真的在工作

配置改完必须能看见效果,否则下次出问题还是靠猜。三件事:

第一,把缓存状态打进响应头和日志:

add_header X-Cache-Status $upstream_cache_status always;
log_format cache '$remote_addr "$request" $status '
                 'cs=$upstream_cache_status rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/product-cache.log cache;

第二,用日志算命中率,别信感觉:

awk -F'cs=' '{split($2,a," "); print a[1]}' /var/log/nginx/product-cache.log \
  | sort | uniq -c | sort -rn
#  812344 HIT
#   96211 MISS
#   41028 BYPASS
#    2103 STALE

第三,主动清理。改了商品价格但缓存还没过期时,需要按 key 删。开源版 Nginx 没有 proxy_cache_purge,那是商业版和第三方模块的功能。我们用了一个折中办法:把 cache 目录按 URL 哈希做了软链索引,清理时直接删文件,再让 Nginx 自然回源。更彻底的做法是换 OpenResty,用 Lua 精确 purge。

我最后悔的是没有在第一版就加上 X-Cache-Status。前三天所有判断都靠「感觉快了」,串号事故也是靠客户投诉才发现的。如果一开始就在响应头里能看到 HIT,那么「游客和登录用户拿到同一份缓存」这件事,抓第一个请求就能看出来。

小结

  • proxy_cache_key 的默认值不含任何请求头,凡是响应内容跟身份、门店、语言相关的接口,都必须显式把维度拼进 key,否则就是串号。
  • proxy_cache_bypass 管「读」,proxy_no_cache 管「写」,两者必须成对配置,只配一个会留下更隐蔽的污染路径。
  • 上游响应带 Set-Cookie 会导致默认不缓存,正确做法是让纯读接口不下发这个头,而不是在 Nginx 侧忽略它。
  • proxy_cache_lock 是防击穿的关键,热点 key 过期瞬间只放一个请求回源,配合 use_stale 和后台更新可以做到用户无感。
  • $request_uri 对参数顺序敏感,参数顺序不稳定的接口会把缓存切碎,命中率虚低。
  • 上线第一天就要加 X-Cache-Status 和独立的 cache 日志,命中率、BYPASS 比例必须可观测,否则问题只能等投诉暴露。
查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

proxy_cache 上线三天就串号:缓存键、Set-Cookie 与回源风暴 - 起航社区