9.3 缓存键设计
一句话
缓存键回答 “这份内容归谁”:键里多放一个维度,命中率掉一截;少放一个维度,就把 A 的内容发给了 B。所以键设计不是 “配置项”,是 “正确性与成本之间的那条线”。
键由什么组成
| 维度 | 默认是否入键 | 该不该入键 | 说明 |
|---|---|---|---|
| scheme + host | 是 | 是 | 同 IP 多域名必须区分(SNI/Host) |
| path | 是 | 是 | 核心维度 |
| query | 是(全量) | 要规范化 | 排序、去追踪参数、忽略大小写 |
| 请求头 | 否 | 按需(Vary) | Accept-Encoding 必须进;UA/语言按业务 |
| Cookie | 否 | 默认不进 | 全量入键 = 命中率归零;只放业务需要的标识 |
| 设备/地域 | 否 | 按需 | 移动端/PC 内容不同时才加 |
规范化:三个动作,命中率能翻倍
| 动作 | 例子 | 收益 |
|---|---|---|
| query 排序 + 去空值 | ?b=2&a=1 与 ?a=1&b=2 视为同一份 | 消除同内容多键 |
| 去追踪参数 | 忽略 utm_*、spm、from、_t | 营销链接不再各占一份缓存 |
| 统一大小写/尾斜杠 | /Img/A.png 与 /img/a.png | 需要源站本身大小写不敏感,否则会取错文件 |
⚠️
_t=时间戳是最常见的 “命中率杀手”:前端为了 “防缓存” 给每个请求加随机参数,效果是每个请求都是新键,CDN 命中率直接归零。要防缓存应改用Cache-Control: no-store,而不是随机化 URL。
两类事故,方向相反
| 事故 | 原因 | 症状 | 修法 |
|---|---|---|---|
| 键过多 | 随机参数、全量 Cookie、UA 全量入键 | 命中率 < 30%,回源打满 | 规范化 + 白名单 Cookie |
| 键过少 | 忽略了 Vary: Accept-Encoding | 把 brotli 压缩体发给不支持它的客户端 → 乱码 | 让 Accept-Encoding 入键,或统一在边缘转码 |
| 键过少(更严重) | 个性化内容被公共缓存 | 用户 A 看到用户 B 的订单 | 个性化响应加 Cache-Control: private,或键里带用户维度 |
第三类是安全事件级的事故:它的根因不是 CDN,而是 “把私有内容当公共内容缓存”。判据只有一条:响应是否因人而异?是,就不许进公共缓存。
变体与多租户:加法要克制
| 场景 | 建议 | 代价 |
|---|---|---|
| 多语言 | 键加 lang,或不同路径 /en/、/zh/ | 路径方案更直观,键更少 |
| 移动/PC | 优先用不同路径或不同域名 | UA 全量入键会碎片化(UA 有上千种) |
| 多租户 SaaS | 键加 tenant,但要有上限 | 租户数 × 资源数 = 键空间爆炸 |
| 灰度/版本 | 键加 version,或用不同文件名 | 版本用完要回收,否则键永久膨胀 |
经验规则:键里每增加一个维度,键空间按该维度的取值数乘上去。三个各 10 取值的维度 = 1000 倍键空间,命中率会被打散到不可用。
怎么验证键配得对不对
# 看边缘返回的缓存状态(不同厂商头名不同)
curl -sI https://cdn.example.com/static/app.js | grep -iE "x-cache|x-cache-key|age|via"
# X-Cache: HIT / MISS;Age 表示已缓存多久
| 检查项 | 期望 |
|---|---|
| 同一 URL 连续两次 | 第二次 X-Cache: HIT、Age 增大 |
带 ?utm_source=x 与不带 | 命中同一个键(若已配置忽略) |
| 带不同 Cookie | 公共资源仍 HIT(Cookie 未入键) |
带 Accept-Encoding: gzip vs br | 两个键各自 HIT(Vary 生效) |
反模式
| 反模式 | 后果 |
|---|---|
| 用随机 query 参数 “防缓存” | 命中率归零,回源率接近 100% |
| 把全部 Cookie 放进键 | 每个登录用户一份缓存,存储与命中率双输 |
忽略 Vary: Accept-Encoding | 压缩体发给不支持的客户端,出现乱码 |
| 个性化接口被公共缓存 | 越权泄露(最严重) |
| 键里放时间戳/请求 ID | 等于禁用缓存 |
| 多租户键无上限 | 键空间爆炸,边缘存储被小租户挤占 |
三条铁律
🔑 响应因人而异,就不许进公共缓存;键设计的第一问是 “这份内容是不是公共的”。
🔑
Accept-Encoding必须入键(或由边缘统一转码);否则压缩会变成乱码。
🔑 键空间按维度相乘;每加一个维度,先算命中率会掉多少。
自检
Q1 一个静态站点上了 CDN,命中率只有 12%,X-Cache 几乎全是 MISS。最可能的原因?
▸ 答案:URL 被随机化(?_t=1699...),或者全量 Cookie 入了键。两者都让 “同一份内容” 变成无数个键。诊断:取两次同一个资源的完整 URL 对比;若带随机参数,改前端(用 Cache-Control 控制不缓存),而不是改 CDN。
Q2 为什么 Vary: Accept-Encoding 对正确性至关重要?
▸ 答案:因为同一 URL 可能返回 gzip/br/未压缩三种字节流。若键里不含编码维度,边缘会把 br 压缩体发给只支持 gzip 的客户端,浏览器解析失败(乱码或白屏)。正规做法是让 Accept-Encoding 入键、各存一份;或者让边缘按客户端能力实时转码(此时键可以不含它,但要有转换成本)。
Q3 一个接口返回 “当前用户的订单列表”,被 CDN 缓存后发生了什么?怎么防?
▸ 答案:越权泄露:任何用户请求同一 URL 都会拿到别人的订单。防线有三层:① 应用显式返回 Cache-Control: private, no-store;② CDN 侧对 “带登录态/Authorization 的请求” 默认不缓存;③ 键里带用户维度只应作为最后手段(会让命中率归零,且仍有错配风险)。第一层最可靠。
Q4 多语言站点,用路径区分还是用键区分?
▸ 答案:优先路径(/en/...、/zh/...)。原因:路径方案天然键少、可被搜索引擎索引、便于按目录做不同缓存策略;键方案会让同一路径衍生多份缓存,且调试时看不出差异。只有当同一 URL 必须返回不同语言(如按 Accept-Language 协商)时,才把语言放进键。
Q5 灰度发布时把 version=v2 放进 query 作为缓存键,有什么隐患?
▸ 答案:一是键永久膨胀(旧版本 URL 仍在被请求与缓存);二是用户可能在版本间跳变(链接带不同 version);三是回滚要同时处理缓存。更稳的做法是换文件名/路径(app.v2.js),让版本成为路径的一部分,天然隔离,且可以给旧版本设短 TTL 自动淘汰。
跨篇相关:命中率与容量的数学见 9.1 边缘缓存原理;键修好后回源率的变化见 9.2 回源策略与回源率;发布时的刷新与预热见 9.4 预热、刷新与失效;CDN 的整体组成见 9.0 CDN 全景:它到底是什么;Vary 与响应头的语义见 8.5 HTTP/1.1、HTTP/2、HTTP/3 (QUIC);越权与信息泄露见 38.6 常见攻击。