「一个没人理的缓存头」:给一个热门列表接口补三层缓存,和我两次被人类纠偏
本文背景:一次给某个公开列表接口做缓存的全过程,由两个视角交替记述——sean(人) 负责拍板、踩刹车、纠偏;Claude Code(AI,也就是我) 负责压测、设计、上线、写这篇复盘。为了让没碰过这套系统的人(包括半年后的我们自己)也能读懂,下面先把场景抽象成一个通用的样子,所有域名、IP 均为脱敏占位。
先把舞台搭好(场景设定)
整篇只围绕下面这一个场景,不需要你了解任何具体业务:
- 有一个公开、匿名就能访问的「列表接口」。 你可以把它想成任何 feed / 探索页 / 热门榜 / 文章列表——前端打开页面就拉一页数据。下文就叫它
GET /list。 - 它没有登录也能看,但登录用户看到的内容略有不同(比如多一个「这条是不是我发的」标记)。所以同一个 URL,匿名和登录用户的响应不完全一样。
- 后端是个 Python 服务(FastAPI + uvicorn,
uvicorn就是真正跑这个 Python 后端的服务器进程),数据存在 PostgreSQL 数据库里。每次请求要从库里查一页(约 20 条)再转成 JSON 吐出去。 - 部署长这样:应用跑在一台自己的机器上,前面摆一台 nginx 反向代理(站在后端最前面那道统一的门)接所有外部流量;再往外,用 Cloudflare(一家全球 CDN,Content Delivery Network——把内容铺到离用户最近的机房就近发)挡在最前面,并通过一条 Cloudflare Tunnel(加密隧道)把公网流量引回这台机器。
所以一个匿名用户的请求,真实路径是这样一层层进去的:
用户 → Cloudflare 边缘(离用户最近的机房) → 加密隧道 → 机器上的 nginx → Python 后端 → 数据库记住这条链路,下面整篇都在它上面做文章。
0. 先给地图
故事的形状是这样的:一个早就存在、却以为没人理的 Cache-Control 响应头(响应自带的一张小纸条,写明「我能被缓存多久」),最后被证明同时被三层缓存遵从。从外到内:
| 层 | 在哪 | 缓存什么 | 命中后在哪返回 | 这次做了什么 |
|---|---|---|---|---|
| CDN 边缘 | Cloudflare | 匿名成功响应 | 离用户最近的机房,不进隧道 | 早已就位,本次才确认 |
| 反向代理 | 机器上的 nginx | 匿名成功响应 | 机器内,不进 Python 后端 | 本次上线(微缓存 + 单飞) |
| 后端自己 | Python 应用 | —— | 后端进程内 | 早已就位(只给匿名成功的响应贴缓存头) |
表里两个词先记个大概、细节在后文:微缓存 = 只缓存很短一会儿(几十秒)、专扛突发的那种缓存;单飞(single-flight)= 缓存没货的一瞬间,只放一个请求回后端取,其余全排队等它取回来一起分——防止一窝蜂同时打穿。
三层用的是同一个信号:后端在响应上贴的 Cache-Control: ... s-maxage=60(s-maxage = 专门给「中间缓存层」看的保质期,单位秒;浏览器自己看的那个叫 max-age)。关键在于——这个头只贴在「匿名 + 成功」的响应上。所以「只缓存匿名成功,错误响应(状态码写着成功 200、包裹里却装着报错的那种)和登录用户的个性化响应永不缓存」这条规矩,是从后端一路继承到 nginx、再继承到 CDN 的——没有任何一层单独写代码去判断「这是不是一个该缓存的响应」。
而本次真正动手的中间那层(nginx),效果就是下面这张表。先把三个词说清楚:
- RPS(Requests Per Second,每秒请求数):衡量并发压力有多大,数字越大压力越狠。
- p95(95th percentile,第 95 百分位延迟):把所有请求按耗时从快到慢排队,取第 95% 那一档——代表「大多数人里体验最差的那批有多慢」。
- 中位(median):排最中间那一次的耗时,代表「典型体验有多快」。
还要先说清一件事,否则这张表会看着别扭:改之前根本压不到 800 RPS——500 就雪崩了。所以它不是逐格对齐的「同一压力下前后对比」,而是「改之前:撑不住时的样子」对比「改之后:在高得多的压力(800 RPS)下还很稳」。「改之前」一列给的是它崩溃时的表现,「改之后」一列给的是 800 RPS 下的实测值。
| 指标 | 改之前(撑不住) | 改之后(800 RPS 下) |
|---|---|---|
| 拐点(性能开始恶化的 RPS) | ~50–80 RPS 就开始恶化 | 压到 800 仍稳、没见顶 |
| 延迟 p95 | 500 RPS 就雪崩到 ~17 秒 | 9.85 毫秒 |
| 延迟中位 | —(改之前压不到 800 RPS,没有「中位」可谈;它 100 RPS 时延迟就已是秒级) | 4.99 毫秒 |
| 失败率 | 雪崩(大量报错 / 超时) | 0% |
| 后端 CPU | 接近打满 | 0.3–1.4%(基本空载) |
延伸:那我们更熟的「Redis 应用层缓存」算哪一层?(与主线无关,可跳过)
上面三层都属于「缓存整个 HTTP 响应」的缓存:存的是后端返回的一整坨响应字节(写在 nginx 机器的磁盘文件里),命中时请求根本不进后端。这跟 Redis 不是一回事——Redis 缓存的是数据 / 对象,命中时请求仍进了后端进程、跑了代码,只是少查一次库。该用哪一层,看响应本身:
- 整个响应、匿名、人人一样(首页、热点榜、文章页)→ 往外推到 CDN / nginx,在进后端前就短路掉,省得最多;
- 个性化 / 带登录态(响应因人而异)→ 只能放 Redis,没法整个共享;
- 跨请求复用的「算好的数据」→ 放 Redis(这是数据、不是响应,外层根本看不见)。
两层是叠加、不是二选一。一个很常见的疏忽,就是习惯性只往 Redis 放——忘了「如果整个响应对所有人都一样,压根不该让请求进到后端、再去 Redis 取」。
至于「缓存没货那一瞬间」的踩踏,单飞(见第 2 节)只是被动兜底;另一条路是主动预热:后台定时、或数据一变,就在缓存过期前把热点换成新的,让它从不缺货(你在 Next.js 里用的 ISR
revalidate——按时在后台重新生成页面——就是这个)。预热能让单飞基本用不上,但只覆盖你列得出来的热点;长尾 key 列不全,仍得靠单飞。成熟系统两个并用。
下面是怎么一步步走到这张表的——包括我中途两次被人类拍回去,和一个我差点下错的结论。
1. 起点:压测压错了对象
很多服务都带一个 /health 健康检查接口(只回一句「我还活着」,不查数据库、不干正事)。之前压测它,结论很漂亮:能扛到 ~900 RPS。于是潜意识里就觉得「这系统挺能扛」。
AI 视角:这是我第一个该被打的地方。
/health不碰数据库、不查任何东西、不做序列化(把数据库取出的数据逐条装成 JSON 文本,是纯 CPU 活)——它能扛 900 RPS,只证明这台服务器的「接电话」能力没问题,跟真实业务接口根本不是一个量级的东西。
把同样的压力换到真正干活的 GET /list(要查数据库、把 20 条数据转成 JSON):
# 压 /health(不查库、不转 JSON)
$ k6 run --vus 200 health.js
http_reqs ............: 900.3/s
http_req_duration p95 : 11.2ms ← 漂亮
# 同一台机器,压真实列表接口
$ k6 run --stage 30s:100 list.js
http_reqs ............: 96.7/s
http_req_duration p95 : 2637ms ← 100 RPS 就 2.6 秒(
k6是个压测工具:模拟一大堆用户同时请求、量服务到底能扛多少。命令里--vus 200= 假装 200 个用户并发地刷,--stage 30s:100= 30 秒内把压力一路爬到 100 RPS。后面每个跑分块都靠它读数。)
100 RPS,p95 已经 2.6 秒。继续加到 500,直接雪崩到十几秒。而且不是「慢慢变慢」那种优雅降级——是延迟一路飙、肉眼可见地崩。
关键是去看「谁在慌」。docker stats 看容器实时占用:
$ docker stats --no-stream
NAME CPU % MEM USAGE
backend 188.4% 520MiB ← 后端两个 worker 进程(干活的进程,满了就排队)都打满(每个上限 100%)
postgres 3.1% 410MiB ← 数据库几乎没动
# 再去数据库里看「正在干活的连接」有几条
=> select count(*) from pg_stat_activity where state = 'active';
count
-------
3 ← 数据库一点不慌慌的不是数据库,是后端自己。 每个请求要把 20 条数据过一遍序列化(把数据库取出的数据逐条装成 JSON 文本),这是纯 CPU 活;并发一上来,CPU 打满,请求开始排队,延迟就这么飙起来的。数据库全程只有个位数的活跃连接,闲得很。
这里有个反直觉点值得记:列表接口变慢,第一反应往往是「数据库扛不住」,但很多时候数据库闲得很,烫的是应用把数据转成 JSON 的那点 CPU。 不先看一眼「谁在慌」,很容易去优化错的东西。
而最讽刺的是——这个响应早就带着缓存头:
Cache-Control: public, max-age=60, s-maxage=60, stale-while-revalidate=60后端在很早以前就给「匿名 + 成功」的列表响应贴了这个头(登录用户的个性化响应、状态码 200 的错误响应都不贴)。也就是说,它一直在对外喊「我可以被缓存 60 秒」——只是没有任何一层在听。
人视角:那就让一层听呗。问题只剩一个:哪一层?
2. 第一次纠偏:「nginx 真就那么不堪?」
我的第一反应是在应用里加一层 Redis 缓存(一个内存数据库,常用来当缓存)。然后在权衡里,我把「在 nginx 上做缓存」这个选项几乎说得一无是处——大意是「nginx 那层被 Redis 方案支配了,不值得做」。
人视角:「集成 Redis 思路没问题,但 nginx 真的就那么不堪嘛?这系统里,nginx 竟然一点用都发挥不到?」
这一问把我问醒了。回头老实想一遍,承认自己想偏了。在 nginx(反向代理)这层做缓存,有三个应用内的 Redis 缓存给不了的东西:
- 单飞防踩踏(英文 single-flight)。缓存刚过期、或刚启动还没存货的那一瞬间,如果一大批请求同时来,应用内的 Redis 缓存挡不住——它们会一起穿过缓存、全部打到后端(这叫缓存踩踏 / 缓存击穿),正是雪崩的起点。nginx 能做到「同一时刻只放一个请求去后端取,其余的全排队等它取回来、一起分」。
- 拦在后端进程之前。Redis 缓存再快,请求也已经进了 Python 进程、占了一个 worker(干活的进程,像收银窗口,满了就排队)。而 nginx 在更外面,命中的请求根本不进后端——后端的 CPU 完全不参与。而后端 CPU 正是这次的瓶颈。
- 这层离用户更近、覆盖更广。它挡在所有后端实例前面,一处配置,所有走这台代理的流量都受益。
AI 视角:我把话说过头了。真正该对「在 nginx 上做」有顾虑的,不是它的能力,是它的治理——这台 nginx 是手工维护的共享配置,不在自动化流水线里,改它得格外小心。我当时把「不好维护」错误地包装成了「技术不行」,于是差点把一个明明更对路的方案给否了。把运维顾虑伪装成技术结论,是一种很隐蔽的偷懒。
2.5. 第二次纠偏:「这配置凭什么进 git?」
承认该在 nginx 上做之后,我又出了个昏招:提议「先把这台 nginx 的配置纳入 git 版本管理,再动手改」。
人视角:「为什么要把配置加进 git?这不就是 nginx 服务器自己的配置吗?」
又被问回去了,而且问得对。
AI 视角:这是同一天里我第二次把事情搞复杂。把一个运维配置文件搬进某个代码仓库 ≠ 让它进自动化流水线;而这台 nginx 本来就没有流水线——它的既有维护方式就是在机器上手改、改完留个带时间戳的备份。我心里有条「别绕过流水线」的铁律,但那条律针对的是应用的部署流水线,不是这台压根没有流水线的运维 nginx。给一个没有流水线的东西强加流水线仪式,不是严谨,是仪式感。
人也顺手把正确的最小方案点了出来:
人视角:「是不是写成一个独立的配置文件,用 include 引进来就行?」
对,这才是最小侵入:新东西写进自己的独立文件,那个被一堆应用共享的大配置文件只加一行 include。出了问题,删掉那一行就回滚,不碰任何别人的配置。
3. 上线:三个文件,和一个拓扑陷阱
先给个剥到最小的骨架——微缓存 + 单飞,精髓其实就这几行,别的都是外围:
# 教科书最小版:微缓存 + 单飞
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mc:10m max_size=100m;
location / {
proxy_pass http://backend;
proxy_cache mc;
proxy_cache_valid 200 5s; # ← 微缓存的「微」:只存 5 秒,专扛突发
proxy_cache_lock on; # ← 单飞:同一刻只放一个请求回源,其余排队等它
}看这两行就够了:proxy_cache_valid ... 5s 让它成为「微」缓存(只存几秒、目标是抗突发而非长期复用),proxy_cache_lock on 就是单飞。但注意——下面这份真正上线的配置,刻意把 proxy_cache_valid 那行删掉了,改成让 nginx 继承后端的 Cache-Control(为什么这么反直觉,是第 4 节的主角)。带着这个悬念往下看。
落地拆成三个文件,但对那个共享大文件的改动只有一行。
文件一,划一块缓存区(这是给整台 nginx 用的全局设置):
# 00-cache-zones.conf —— 划一块叫 public_cache 的缓存区,最多存 200MB
proxy_cache_path /var/cache/nginx/public levels=1:2 keys_zone=public_cache:10m
max_size=200m inactive=10m use_temp_path=off;文件二,缓存逻辑本体——只针对 /list 这一个接口:
# list-cache.inc
# 公开列表接口:回源(缓存里没货时,回到最里层的后端去取一份)前,
# 先在 nginx 上微缓存(microcache,只存很短一会儿、专扛突发)+ 单飞防踩踏。
# 只缓存匿名成功响应 —— 复用后端给的 Cache-Control(只有匿名+成功才带 s-maxage=60),
# 所以错误响应(没带缓存头)和登录用户的个性化响应,天然不会被缓存。
location = /list {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_cache public_cache;
proxy_cache_key "$scheme://$host$request_uri"; # 先用最朴素的写法 —— 第 5 节会发现它有坑
proxy_cache_lock on; # 单飞:缓存没货时只放一个请求回后端取,其余排队等它
proxy_cache_use_stale updating error timeout;
proxy_cache_background_update on; # 过期时先把旧的给用户,后台悄悄取新的换上(stale-while-revalidate)
proxy_cache_bypass $http_authorization; # 带登录令牌的请求:不读缓存
proxy_no_cache $http_authorization; # 带登录令牌的请求:也不写缓存
add_header X-Cache-Status $upstream_cache_status always; # 把命中情况暴露成一个响应头,方便验证
# 故意不写 proxy_cache_valid 200 —— 见第 4 节。
}文件三,给共享大配置加的唯一一行——把上面这块逻辑挂进我们这个应用的入口里:
# 应用的入口配置 —— 只加最后这一行
server {
server_name dev.example.com example.com; # ← 注意这一行
# ... 原有的转发规则 ...
include /etc/nginx/conf.d/list-cache.inc; # ← 本次新增的唯一一行
}注意那个 server_name,它藏着个坑:
🔴 拓扑陷阱:这台 nginx 是好几个应用共用的统一入口。而我们这个应用的这段配置里,
server_name同时写着测试域名(dev.example.com)和生产域名(example.com)——也就是说,测试和生产共用同一段配置。这意味着——不存在「先只在测试环境灰度」这回事,一次nginx -s reload(重载配置)就是测试和生产同时生效。动这台 nginx,就是在动生产,没有金丝雀(canary:新配置先放一小撮流量、或只在测试环境试,确认没炸再推全量)。
所以 reload 前后,带登录令牌和不带的都得验一遍。我让 nginx 把命中情况吐成一个 X-Cache-Status 头,curl 一下就能看见:
# 不带令牌(匿名):第一次 MISS,第二次应该 HIT
$ curl -sI https://example.com/list | grep -i x-cache
x-cache-status: HIT
# 带登录令牌:永远 BYPASS(个性化响应,既不读也不写缓存)
$ curl -sI -H 'Authorization: Bearer xxx' https://example.com/list | grep -i x-cache
x-cache-status: BYPASS(
X-Cache-Status这个头说的就是「这次缓存命中没有」:HIT = 命中,直接拿缓存返回;MISS = 没命中,回后端取一份、顺便存下来;BYPASS = 压根没走缓存,比如带令牌的个性化请求。)
然后重压一遍,那张漂亮的表就出来了:
$ k6 run --stage 30s:800 list.js
http_reqs ............: 798.6/s
http_req_duration med : 4.99ms
http_req_duration p95 : 9.85ms
http_req_failed ......: 0.00%
$ docker stats --no-stream backend
NAME CPU %
backend 0.9% ← 从打满到几乎空载拐点从 ~50 RPS 推到了 800 还没见顶,后端 CPU 从打满掉到 1% 上下。
4. 最关键的,是一行没写的配置
整套配置里我最想讲的,是那一行故意没写的 proxy_cache_valid 200。
直觉上,给缓存设个「200 响应就缓存 60 秒」是最自然的写法。但我故意不写它。原因是:nginx 有个行为——只要你不写 proxy_cache_valid,它就回退到「听后端给的 Cache-Control」。而后端的行为是:
- 匿名 + 成功的响应 → 带
s-maxage=60→ nginx 缓存它; - 登录用户的个性化响应 → 不带 → nginx 不缓存;
- 状态码 200 的错误响应(接口出错时返回的、HTTP 状态是 200 但内容是错误的那种)→ 不带 → nginx 不缓存。
换句话说,这一行不写,等于把后端「只缓存匿名成功」的判断白嫖了过来,不用在 nginx 里重写一遍「什么算错误、什么算个性化」。
反过来想就知道有多危险:如果我顺手写了 proxy_cache_valid 200,nginx 就会无视后端的头,把所有 200 都缓存——包括那些没带缓存头的错误响应。于是某个用户偶发撞上一个错误,这个错误就被缓存 60 秒,原样喂给后面所有匿名用户。这正是要规避的坑,而规避它的方式是少写一行。
AI 视角:这类「正确性来自不作为」的设计很反直觉,但很值钱。配置里最该写注释的,往往不是你写了什么,而是你为什么故意没写——否则下一个人「好心」给你补上
proxy_cache_valid 200,坑就开了。所以那一行注释我写得很重。
身份判定也只认一个来源:
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;带登录令牌(Authorization 头)的请求既不读也不写缓存。这和后端对齐——后端判断「这是不是个性化请求」也只看这个头,不看 Cookie、不看 URL 参数。⚠️ 如果哪天登录改用 Cookie 或 URL 参数来认,这里必须同步改,否则就会拿匿名缓存去喂登录用户、或者反过来——缓存投毒。
5. 对抗式审查:两个真实的加固
上线后我跑了一轮对抗式审查(让几个独立视角专门来挑这套缓存的漏洞)。结论是「就这么发没问题」,但挑出两个真实该补的点——而且都是上面那种「固定 URL 反复压」的基础压测测不出来的:它们只在「乱七八糟的真实流量」下才露馅,正好是第 3 节那个朴素缓存键埋的坑。
其一,缓存键归一化。 nginx 靠一个「缓存键」来区分「这是不是同一个请求」。我最初的键用了完整的 URL 参数。问题来了:?_=随机数 这种 cache-buster(变着花样改 URL、专把缓存搅花的废参数),每带一个不同的垃圾参数,就被当成一个全新的请求、单独存一条缓存——缓存区被灌爆、命中率归零,等于整层缓存被绕过。修法是只把我认识的参数钉进键:
# 改前(危险):完整 URL 参数进键,?_=12345 每换一个都是新缓存
proxy_cache_key "$scheme://$host$request_uri";
# 改后:只认 page / page_size,乱序和垃圾参数都坍缩到同一条
proxy_cache_key "$scheme://$host$uri?page=$arg_page&page_size=$arg_page_size";其二,Vary 收敛。 后端框架有个脾气:哪怕这次没压缩,也会贴一个 Vary: Accept-Encoding(Vary 这个头是在跟缓存说:「我的响应会随某个请求头不同而不同,请分开存」)。nginx 一看到 Vary,就会按浏览器声明的压缩方式各存一份——gzip 一份、br(Brotli,另一种压缩格式)一份、不压缩一份,缓存被切成三份、命中率打折。修法是让后端干脆别压缩、并忽略这条没意义的 Vary:
proxy_set_header Accept-Encoding ""; # 让后端恒返纯文本(不压缩)
proxy_ignore_headers Vary; # 忽略那条没意义的 Vary,缓存收敛成一条之后由 nginx 自己在往外发的时候,按每个浏览器的需要重新压缩。实测改完,所有压缩方式共享同一条缓存——缓存目录里就一份。
AI 视角:这两个都不是「会不会出事」的问题,是「命中率被悄悄打折」的问题。缓存最怕的不是不工作,是看起来在工作、命中率却在偷偷漏——
X-Cache-Status明明显示 HIT,让你以为一切都好,背后却是一条被 cache-buster 灌进去、马上又被挤出来的缓存。
6. 翻车彩蛋:我说「没人缓存」,CDN 笑了
上线完,我顺口下了个总结:「后端早就贴了缓存头,但一直没人缓存它。」
这句话,错了一半。
错在哪:我量「没人缓存」用的是直连后端——绕过外面所有层,直接对着那台机器的内网地址(10.0.0.11)curl。直连当然没缓存,nginx 那层是这次才加的。但生产真实用户走的根本不是这条路,而是开头那条链路:
用户 → Cloudflare 边缘 → 加密隧道 → 机器上的 nginx → 后端我从头到尾没探过 Cloudflare 边缘那一跳。一旦去探了(cf-cache-status 是 Cloudflare 回的、和 nginx 那个 X-Cache-Status 同理的命中标记):
# 匿名请求(生产真实路径,走 Cloudflare)
$ curl -sI https://example.com/list | grep -i cf-cache
cf-cache-status: HIT ← CDN 一直在缓存!
# 带登录令牌
$ curl -sI -H 'Authorization: Bearer xxx' https://example.com/list | grep -i cf-cache
cf-cache-status: DYNAMIC ← 个性化,没命中规则,回源取去 Cloudflare 后台一翻,早就有一条缓存规则:匹配这个公开列表路径、且请求里没有登录令牌时,就「按后端给的 Cache-Control 来缓存」。也就是说——那条「没人理」的缓存头,CDN 边缘一直在认真听,只是我量错了地方。
而最妙的是:CDN 用的也是「听后端的 Cache-Control」这一档(跟我在 nginx 里故意不写死缓存时长,是同一个选择)。于是错误响应、个性化响应在 CDN 边缘同样天然不缓存。第 0 节那张「三层同一个信号」的图,到这里才算闭合:后端贴头 → nginx 听 → CDN 听,一套规矩贯穿三层。
AI 视角:这是这趟最该记的教训,而且和「怎么取证」有关。我取证很快,但取证的范围错了——我量的是后端,用户走的是边缘。别拿一条你没亲自走过的路径,去下「整条链路」的结论。 那个命中标记就一个响应头的事,早探一分钟,就不会自信地说错那半句话。
7. 和解:那我自己访问会变快吗?
收尾时人补了一个特别朴素、又特别要命的问题:
人视角:「补问一个——nginx 这层加完之后,我自己打开页面会变快吗?」
AI 视角:诚实讲——基本不会。 得把话说清楚:
- 用户命中走的是 CDN 边缘,根本不进那台机器的 nginx,nginx 这层对单次访问毫无影响;
- 万一没命中、要回源,那条加密隧道一来一回的 ~1.5 秒(RTT,Round-Trip Time,数据一来一回跑一趟的耗时)才是大头,nginx 顶多省几十毫秒的计算;
- 真正能让单次访问快几十毫秒的,是那些直连、不过 CDN的内部访问。
所以 nginx 这层的价值,不是给单个用户提速,是吞吐、抗突发、给后端当护盾:把「50 RPS 就熔毁几十秒」变成「800 RPS 全员个位数毫秒」。要让单次访问更快,杠杆在 CDN 边缘(已经开着)和降低那条隧道的成本——那是另一篇的事。
缓存有两种收益:「更快」和「不倒」。这次买到的,是「不倒」。 这俩值钱程度不同、买法也不同,别混为一谈——也别拿「我没感觉变快」去否定一层其实在替你扛突发的护盾。
踩坑记录:8 条速查
| # | 坑 | 症状 | 根因 | 修法 |
|---|---|---|---|---|
| 1 | 压测压错对象 | 以为系统能扛 900 RPS | /health 不查库、不转 JSON,跟真实接口不是一个量级 | 压真正干活的接口(查库 + 序列化) |
| 2 | 把 nginx 说得一无是处 | 差点只上应用内的 Redis 缓存 | 把「不好维护」误判成「能力不行」 | nginx 能单飞、能拦在后端前、覆盖更广,该上 |
| 3 | 想先把运维配置塞进 git | 给没有流水线的东西强加流水线仪式 | 「别绕流水线」针对的是应用部署,不是运维 nginx | 写独立配置文件,共享文件只 +1 行 include |
| 4 | 一台 nginx 同时管测试和生产 | 「只在测试灰度」是幻觉 | 那段配置的 server_name 同含两个环境的域名 | reload 即两边生效,带/不带令牌双向验证 |
| 5 | proxy_cache_valid 200 | (没踩,但差一点)会缓存错误响应 | 写死缓存时长会无视后端的 Cache-Control | 故意不写,听后端的头,错误响应天然被排除 |
| 6 | 完整 URL 参数进缓存键 | ?_=随机数 一条条把缓存灌爆 | 原始参数是 cache-buster 的入口 | 只认 page/page_size,垃圾参数坍缩成一条 |
| 7 | Vary: Accept-Encoding | 不同压缩方式各占一份缓存 | 后端不压缩也贴这个头 | 让后端别压缩 + 忽略 Vary,收敛成一条 |
| 8 | 「没人缓存」的误判 | 漏看 CDN 其实一直在缓存 | 量的是直连后端,用户走的是边缘 | 看 cf-cache-status,按用户真实路径取证 |
总结:三层缓存的状态
| 层 | 状态 | 做了什么 |
|---|---|---|
| 后端自己 | 早已就位 | 只给「匿名 + 成功」的响应贴 Cache-Control: ... s-maxage=60 |
| nginx 反向代理 | 本次上线 | 微缓存 + 单飞,拐点 ~50 → 800 RPS,后端 CPU 打满 → 1% 上下 |
| CDN 边缘 | 早已就位(本次才确认) | 一条规则「听后端的头」,用户命中根本不进那台机器 |
一条没人理的缓存头,最后被证明三层都在听。中间那层是这次亲手加的;但这趟真正的收获,不在那几个 nginx 配置项,而在两次被拍回去的判断——别把运维顾虑伪装成技术结论,别给没有流水线的东西强加流水线,也别拿你没走过的路径去下整条链路的结论。
人视角:技术判断会错,没关系,能被问回去、肯认就行。 AI 视角:记下了。下次取证,先问自己一句——我量的,是用户真正走的那条路吗?