# HTTP的缓存代理
30 秒速记
- 缓存不只在浏览器,链路中间的代理也能存,请求「就近」拿到结果
- 代理命中后请求根本到不了源站,应用、数据库都不用动
- 最适合读多写少、瞬间高并发的内容:热点新闻、爆款详情页
- 哪怕只缓存几秒,源站
RPS也能降几个数量级 Redis、Memcache这类应用内缓存是另一回事:请求照样要进应用
缓存代理就是把响应存在源站前面的那一层,后来的请求在半路就被打发了。 比如爆款商品页一秒一万次请求,源站生成一次要 80ms,代理缓存 5 秒,那这 5 秒里只有第一个请求回源,剩下全在代理层返回,源站基本没压力。它和 Redis 的区别在于拦截的位置:Redis 命中了,请求还是要进 Node / Java 进程走一遍逻辑;代理命中,请求连应用的门都没进。两者可以叠着用,但指标要分开看。
之前谈到缓存时,主要讲了客户端(浏览器)上的缓存控制,它能够减少响应时间、节约带宽,提升客户端的用户体验。
但 HTTP 传输链路上,不只是客户端有缓存,服务器上的缓存也是非常有价值的,可以让请求不必走完整个后续处理流程,“就近”获得响应结果。
特别是对于那些“读多写少”的数据,例如突发热点新闻、爆款商品的详情页,一秒钟内可能有成千上万次的请求。即使仅仅缓存数秒钟,也能够把巨大的访问流量挡在外面,让 RPS(request per second)降低好几个数量级,减轻应用服务器的并发压力,对性能的改善是非常显著的。
HTTP 的服务器缓存功能主要由代理服务器来实现(即缓存代理),而源服务器系统内部虽然也经常有各种缓存(如 Memcache、Redis、Varnish 等),但与 HTTP 没有太多关系,所以这里暂且不说
原理拆解: 缓存代理位于客户端与源站之间。首次请求未命中时,代理向源站取回响应,根据请求方法、状态码和缓存控制字段判断是否保存;后续请求若缓存键相同且响应仍新鲜,代理直接返回副本,应用、数据库等源站链路都不会被触发。假设源站生成商品页需要 80ms,代理缓存 5s,这段时间内到达的数千次请求便可能只产生一次回源。
最小验证: 输入是两个连续的同资源请求,验证第二次响应可由共享代理复用,并通过 Age 观察缓存对象已经存活的时间。
GET /products/42 HTTP/1.1
Host: shop.example
Accept: application/json
HTTP/1.1 200 OK
Cache-Control: public, max-age=5
Content-Type: application/json
Content-Length: 25
{"id":42,"stock":"many"}
GET /products/42 HTTP/1.1
Host: shop.example
Accept: application/json
HTTP/1.1 200 OK
Cache-Control: public, max-age=5
Age: 2
Content-Type: application/json
Content-Length: 25
{"id":42,"stock":"many"}
第二个响应中的 Age: 2 表示共享缓存持有该响应约两秒;是否命中还可结合代理访问日志中的 HIT 标记确认。压测时应同时比较代理入口 RPS 与源站 RPS,命中生效后前者可保持高位,而后者明显下降。
边界与排查: 缓存键通常至少包含请求目标,并受 Vary 指定的请求头影响;不同编码、语言或鉴权上下文不能错误复用。写操作、强个性化页面和实时库存不宜仅靠固定时长缓存。Redis 缓存仍需应用接收请求并执行取值逻辑,而 HTTP 代理命中会在源站之前终止链路,两者可以叠加,但监控指标和失效策略不能混为一谈。
面试官追问
追问 1接口已经有 Redis 缓存了,再加一层代理缓存是不是重复?
不重复。Redis 省的是数据库,代理缓存省的是整个应用层,连接数、CPU、序列化都省了。热点流量真打上来时,应用实例先扛不住的往往是请求数本身。
追问 2怎么确认代理缓存真的生效了,不是自己感觉变快?
看第二次响应的 Age 头有没有值,以及代理日志里的 HIT / MISS。最硬的证据是对比代理入口 RPS 和源站 RPS,命中生效时前者高、后者明显降下来。
追问 3新闻页缓存 10 秒,编辑改了错别字要求立刻生效,怎么办?
两条路:缩短缓存时间,或者发布时调代理的 PURGE 接口主动删缓存。真要求零延迟就得接受更多回源,固定时长缓存天生会有一段旧数据窗口。
追问 4接口按 Accept-Language 返回不同语言,代理只按 URL 缓存会怎样?
中文用户可能拿到英文页面。源站要返回 Vary: Accept-Language,让代理把语言也算进缓存键。代价是同一个 URL 拆成多份,命中率会下降,语言多的话可以先把头归一化成几种。
# 缓存代理服务
30 秒速记
- 没缓存的代理只转发;有缓存的代理拿到响应后做两件事:转给客户端 + 存一份
- 新鲜期内命中:代理直接回
200+ 完整内容,不找源站 - 过期了:代理带
If-None-Match去问源站,源站回304,代理再用本地副本给客户端拼一个200 - 命中 ≠
304:304只在请求方自己带了条件头时才会回给它 - 代理既是客户端又是服务器,所以两边的
Cache-Control都管它,另外还有几条专门约束共享缓存的指令
缓存代理就是给便利店配了个冰柜,进一次货能卖很多次。 第一次请求没命中,代理去源站拿,拿到后一边转给用户一边存起来;之后同样的请求,副本还新鲜就直接回 200。过期了也不用重下,代理自己拿 ETag 去源站做条件请求,源站说 304 没变,代理刷新一下有效期,再用存着的内容给用户回完整的 200。很多人以为缓存命中就是 304,其实用户看到 304 只有一种情况:浏览器自己也发了条件请求。
我还是沿用“生鲜速递 + 便利店”的比喻,看看缓存代理是怎么回事。
便利店作为超市的代理,生意非常红火,顾客和超市双方都对现状非常满意。但时间一长,超市发现还有进一步提升的空间,因为每次便利店接到顾客的请求后都要专车跑一趟超市,还是挺麻烦的。
干脆这样吧,给便利店配发一个大冰柜。水果海鲜什么的都可以放在冰柜里,只要产品在保鲜期内,就允许顾客直接从冰柜提货。这样便利店就可以一次进货多次出货,省去了超市之间的运输成本。

通过这个比喻,你可以看到:在没有缓存的时候,代理服务器每次都是直接转发客户端和服务器的报文,中间不会存储任何数据,只有最简单的中转功能。
加入了缓存后就不一样了。
代理服务收到源服务器发来的响应数据后需要做两件事。第一个当然是把报文转发给客户端,而第二个就是把报文存入自己的 Cache 里。
下一次再有相同的请求,代理服务器就可以直接发送 304 或者缓存数据,不必再从源服务器那里获取。这样就降低了客户端的等待时间,同时节约了源服务器的网络带宽。
在 HTTP 的缓存体系中,缓存代理的身份十分特殊,它“既是客户端,又是服务器”,同时也“既不是客户端,又不是服务器”。
说它“即是客户端又是服务器”,是因为它面向源服务器时是客户端,在面向客户端时又是服务器,所以它即可以用客户端的缓存控制策略也可以用服务器端的缓存控制策略,也就是说它可以同时使用第 20 讲的各种“Cache-Control”属性。
但缓存代理也“即不是客户端又不是服务器”,因为它只是一个数据的“中转站”,并不是真正的数据消费者和生产者,所以还需要有一些新的“Cache-Control”属性来对它做特别的约束
原理拆解: 普通代理对每次请求都建立上游交互;缓存代理则在转发首次响应时保存响应头、响应体及存储时间。再次收到等价请求后,它先计算当前年龄和新鲜度:仍新鲜便直接返回 200 与缓存实体;已经过期且存在验证器时,代理向源站发送条件请求,源站返回 304 后,代理更新元数据并把可用实体返回给客户端。
最小验证: 输入是代理对过期对象进行再验证的完整报文,验证 304 发生在代理与源站之间,而客户端最终仍可收到带响应体的 200。
GET /news/hot HTTP/1.1
Host: origin.example
If-None-Match: "news-v7"
HTTP/1.1 304 Not Modified
Date: Mon, 17 Aug 2026 02:00:10 GMT
ETag: "news-v7"
Cache-Control: public, max-age=10
HTTP/1.1 200 OK
Date: Mon, 17 Aug 2026 02:00:10 GMT
Age: 0
ETag: "news-v7"
Cache-Control: public, max-age=10
Content-Type: application/json
Content-Length: 24
{"title":"hot","rev":7}
前两段是代理作为客户端向源站验证及获得 304;最后一段是代理作为服务器,用本地实体组装给下游的响应。若下游自己发送了条件请求且验证条件成立,代理也可能直接向下游返回不含实体的 304,但不能把“缓存命中”一概等同于 304。
边界与排查: POST、带认证信息的请求或含 no-store 的响应通常不能按普通公开资源复用;Vary 还会把指定请求头纳入匹配条件。工程验证应记录 HIT、MISS、STALE、回源次数和
