本节目标:把「加缓存」从一句口号变成一套可复盘的工程决策。读完本节,你能说清浏览器、CDN、进程内、Redis、数据库这五层各自该放什么、失效半径有多大;能写出类型安全、可版本化、不会互相覆盖的缓存键构造器;并知道哪些看似无害的键写法会在线上悄悄制造脏数据。
缓存不是「加一层 Redis」。真正的性能收益来自分层:让请求在尽可能靠前、尽可能便宜的一层被回答掉。真正的线上事故则大多来自键设计:两个语义不同的查询算出了同一个键,于是 A 接口读到了 B 接口的数据。本节先讲层次,再讲键。
8.1 缓存层次与键设计
为什么单层缓存不够
假设一个商品详情接口 QPS 是 8000,数据库单次查询 6ms。只加一层 Redis(命中率 90%)后数据库压力降到 800 QPS,看起来够用。但如果:
- 这 800 QPS 全部落在同一个热点商品上,就退化成热键问题;
- Redis 集群抖动 30 秒,全部流量直落数据库,数据库瞬间被打穿;
- 缓存里存的是未压缩的 2KB JSON,Redis 出口带宽先于数据库成为瓶颈。
单层缓存的根本问题是它只有一个失效半径:要么全命中,要么全打到数据库,中间没有缓冲带。分层缓存的价值,就在于每一层提供不同的「成本 / 命中率 / 失效半径」组合。
五层缓存模型
| 层次 | 典型载体 | 命中率量级 | 失效半径 | 单次读成本 | 适合放什么 |
|---|---|---|---|---|---|
| L0 浏览器 | HTTP 缓存、Service Worker | 视场景 | 单个用户 | 接近 0 | 静态资源、个人资料 |
| L1 CDN / 边缘 | CDN、边缘函数 | 高(静态资源) | 地域 | 极低 | 图片、JS/CSS、公开列表页 |
| L2 进程内 | LRU Map、lru-cache | 中高 | 单个实例 | 纳秒级 | 配置、字典、热点小对象 |
| L3 分布式 | Redis、Memcached | 高 | 整个集群 | 亚毫秒 | 会话、实体快照、聚合结果 |
| L4 数据库 | 物化视图、查询缓存 | — | 全局 | 毫秒级 | 真值来源(source of truth) |
一条容易被忽略的规律:越靠前的层,失效半径越小、主动更新越难。L0 的缓存你几乎无法主动失效(数据在用户机器上),所以只能放「过期即可、不需要立刻一致」的内容;L3 可以精确 DEL 某个键,所以放业务实体最合适。
延伸阅读可参考既有专题中的 缓存策略与模式 ,那里对 cache-aside、write-through、write-behind 三种写路径有更细的对比。
L2 进程内缓存:最小的失效半径
进程内缓存常被低估。它没有网络往返,读一次通常在百纳秒量级,对「读极多、写极少、允许短暂陈旧」的数据极其划算。典型场景是配置项、字典表、功能开关。
import { LRUCache } from "lru-cache";
type Dict = Map<string, string>;
// 单实例内缓存,最多 500 个键,TTL 60 秒兜底
const dictCache = new LRUCache<string, Dict>({
max: 500,
ttl: 60_000,
// 允许过期后返回旧值,同时后台刷新(stale-while-revalidate)
allowStale: true,
updateAgeOnGet: false,
});
export async function getDict(name: string): Promise<Dict> {
const cached = dictCache.get(name);
if (cached) return cached; // 命中,0 次网络往返
const fresh = await loadDictFromDb(name);
dictCache.set(name, fresh);
return fresh;
}
它有三个必须记住的约束:
- 每个实例各有一份,多实例部署下会出现「A 实例已更新、B 实例还是旧值」的窗口,因此只适合容忍短暂不一致的数据;
- 内存计入进程堆,大对象会把 Node 的 GC 压力推高,必须设
max; - 重启即丢,冷启动后有一段全量回源期,流量大的服务要给 L3 兜底。
每层的决策清单
在给一个接口加缓存前,先回答四个问题:
- 数据能不能容忍过期? 容忍度决定它能放到哪一层。库存数、余额这类强一致数据,只能放 L3 且必须配合主动失效。
- 命中率能到多少? 命中率低于 60% 的缓存,收益往往抵不过运维复杂度与一致性风险。
- 键的基数有多大? 基数大而访问分散(例如按用户 ID 查订单)时,缓存更像「加速器」;基数小但访问集中(例如首页榜单)时,缓存更像「承压层」。
- 失效由谁触发? 是写路径主动
DEL,还是纯靠 TTL?纯 TTL 的缓存天然有一段时间的脏读窗口,业务方必须知情。
把这四个答案写进接口的注释里,是成本最低的缓存设计评审方式。
写路径:缓存与数据库谁先写
分层之后,写操作会同时触碰 L3 与 L4,顺序决定了不一致窗口的形状:
| 策略 | 写序 | 一致性窗口 | 适用 |
|---|---|---|---|
| Cache-Aside | 先写库,再删缓存 | 极小(删失败时最长到 TTL) | 绝大多数读多写少场景 |
| Write-Through | 先写库,再写缓存 | 无(但写延迟翻倍) | 写后立刻会被读到的数据 |
| Write-Behind | 先写缓存,异步落库 | 明显(宕机可能丢数据) | 计数、埋点等可容忍丢失的写入 |
工程上最常用的组合是 Cache-Aside + 删缓存而不是更新缓存。原因是「更新缓存」会引入并发写覆盖:
// 反例:先写库再 set 缓存
await db.update({ id, value: v }); // t1
await redis.set(key, v); // t2 —— 若另一请求在 t1/t2 之间也写了库并先 set,
// 这里会用更旧的值覆盖更新的值
而「先写库、后删缓存」虽然仍有极小的脏读窗口,但最坏情况只是读到一个即将过期的旧值,且下一次读会重建,不会长期错下去。若业务完全不能容忍,就要引入延迟双删或订阅 binlog 做失效。
TTL 该设多久
TTL 是「一致性」与「命中率」的旋钮,经验值:
- 热点实体(商品、用户资料):5–30 分钟,配合写路径主动失效;
- 聚合结果(榜单、统计):1–5 分钟,宁可短一些;
- 字典/配置:10–60 分钟,靠进程内缓存再叠一层;
- 负结果(空值):30 秒–5 分钟,必须设,否则会放大穿透(详见 8.3)。
绝对不要给业务实体设「永久」TTL——一旦写路径失效漏了一个入口,脏数据就再也回不来了。
缓存键的三要素
键设计不是「拼个字符串」,它有三个必须显式表达的部分:
| 要素 | 作用 | 缺失后果 |
|---|---|---|
| 命名空间 | 隔离不同业务/服务/环境 | 测试环境写脏生产数据;两个服务互相覆盖 |
| 版本号 | 支持结构变更时平滑切换 | 改了数据结构后旧缓存反序列化失败或读到错误字段 |
| 粒度 | 决定键的基数与失效精度 | 粒度太粗导致频繁失效,太细导致命中率低 |
一个合格的键长这样:
// 命名空间 : 版本 : 实体 : 主键 : 可选限定
// app:v2:product:10086
// app:v2:product:10086:locale=zh-CN
// app:v2:order:user:42:page:1
用冒号分隔是 Redis 社区事实标准,好处是 SCAN / 监控工具能按前缀聚合,也能用 app:v2:product:* 做批量清理(生产环境慎用 KEYS,见后文)。
用类型系统守住键的形状
键最容易出错的地方是「拼字符串时少写一段」。我们可以用模板字面量类型把键的形状固定下来:
type Namespace = "app" | "session" | "rate";
type Version = `v${number}`;
/** 实体键:ns:vN:<entity>:<id> */
type EntityKey<E extends string> = `${Namespace}:${Version}:${E}:${string}`;
function entityKey<E extends string>(entity: E, id: string | number): EntityKey<E> {
// 运行时仍要防注入:id 里出现 ":" 会破坏键结构
if (String(id).includes(":")) {
throw new Error(`cache key id must not contain ':' (got ${id})`);
}
return `app:v1:${entity}:${id}` as EntityKey<E>;
}
const k1 = entityKey("product", 10086);
// ^? const k1: "app:v1:product:${string}"
const k2 = entityKey("product", "100:86");
// ^? 运行时报错:cache key id must not contain ':' (got 100:86)
注意最后一行:类型只能约束形状,运行时校验仍不可省。历史上有真实事故就是订单号里带了分隔符,把 app:v1:order:100:86 解析成了两级。
复杂查询参数的稳定序列化
当键由多个查询参数决定时,直接 JSON.stringify(params) 是危险的,因为对象键顺序不稳定:
const a = JSON.stringify({ page: 1, size: 20 });
const b = JSON.stringify({ size: 20, page: 1 });
console.log(a === b); // false —— 同样的查询,两个不同的键
正确做法是先归一化再序列化:
type Primitive = string | number | boolean | null;
function stableKey(input: Record<string, Primitive | undefined>): string {
return Object.keys(input)
.filter((k) => input[k] !== undefined) // 剔除 undefined,避免 null 与缺省混淆
.sort()
.map((k) => {
const v = input[k];
// 浮点必须先规范化精度,否则 0.1+0.2 会算出不同键
const normalized =
typeof v === "number" ? Number(v.toFixed(6)) : v;
return `${k}=${String(normalized)}`;
})
.join("&");
}
console.log(stableKey({ page: 1, size: 20 }));
// "page=1&size=20"
console.log(stableKey({ size: 20, page: 1 }));
// "page=1&size=20" —— 与上一行完全一致
console.log(stableKey({ q: "TypeScript 教程", page: 1 }));
// "page=1&q=TypeScript 教程"
中文、空格、& 这些字符进入键本身没有问题(Redis 键是二进制安全的),但如果键会出现在 URL、日志或监控指标标签里,就应该统一做一次 URL 编码,避免日志里出现断行和解析歧义。
键空间登记表
大型项目里,键的散乱是维护噩梦。推荐把键集中登记,让「所有键长什么样」一眼可见:
export const CacheKeys = {
product: {
detail: (id: number) => `app:v3:product:${id}` as const,
list: (q: { page: number; size: number; sort: string }) =>
`app:v3:product:list:${stableKey(q)}` as const,
},
user: {
profile: (id: number) => `app:v3:user:${id}:profile` as const,
perms: (id: number) => `app:v3:user:${id}:perms` as const,
},
} satisfies Record<string, Record<string, (...args: never[]) => string>>;
CacheKeys.product.detail(10086); // "app:v3:product:10086"
这样做的收益有三点:改版本号只改一处;grep 就能找到谁在读写某个键;新同学加缓存时有现成模板可抄。
失效半径与批量清理
登记表还让「批量失效」变得可枚举。例如商品更新后,需要失效详情与所有列表页:
async function invalidateProduct(redis: Redis, id: number) {
// 详情键可以精确删除
await redis.del(CacheKeys.product.detail(id));
// 列表键基数未知,用「版本号自增」代替遍历删除:
// 把列表键里的 v3 换成动态版本,即可一次性让旧列表全部失效
await redis.incr("app:version:product:list");
}
用「版本号当失效开关」是一个非常重要的技巧:当一批键的基数未知时,不要去删它们,而是让它们整体作废。这也解释了为什么版本号应该放在键的靠前位置——它天然是一个失效开关。
具体的键命名约定、前缀规范与集群下的哈希槽注意事项,可以对照既有专题的 Redis 键设计约定 一起读。
常见坑与错误信息
| 现象 | 根因 | 修法 |
|---|---|---|
| 两个接口互相读到对方数据 | 键缺命名空间或粒度不一致 | 键前缀加服务名,参数全部进键 |
| 改了字段名后旧缓存报错 | 键没有版本号 | 版本号入键,升级即换新键空间 |
| 相同查询算出两个键 | 参数未排序 / 浮点未归一 | 用 stableKey 统一序列化 |
| 日志里键被截断 | 键含空格或换行 | 统一 URL 编码,禁用裸 JSON |
| 内存只涨不降 | 键基数随参数爆炸且无 TTL | 限制可选参数个数,全部设 TTL |
Node 侧最典型的反序列化错误长这样,它几乎总意味着键版本没管好:
SyntaxError: Unexpected token 'o', "old-shape" is not valid JSON
at JSON.parse (<anonymous>)
at RedisCodec.decode (/app/src/cache/codec.ts:24:18)
看到这个错误时,正确的处理不是加 try/catch 吞掉,而是让新旧键空间并存一段时间,等旧键自然过期。
从键到类型边界
键解决的是「取哪个值」,还没解决「取出来的值是什么类型」。目前 redis.get(key) 返回的永远是 string | null,业务代码要自己 JSON.parse 再强转——这正是下一节要处理的问题。在 8.2 Redis 类型安全封装
中,我们会把键登记表、codec 与泛型读写组合成一个类型安全的封装层;而键本身的健壮性,是后面 8.3 穿透·击穿·雪崩防护
讨论所有防护手段的前提。
小结
本节建立了两件事:
- 缓存分层:五层缓存各自的成本、命中率与失效半径不同,越靠前越难主动失效。设计前先回答「能否容忍过期 / 命中率多少 / 键基数多大 / 谁触发失效」四个问题。
- 缓存键设计:键必须显式包含命名空间、版本号与粒度;复杂参数要用稳定序列化归一化;把键集中登记成
CacheKeys,既方便批量失效,也方便版本切换。
一句话记住:缓存的第一性原理不是「存得快」,而是「失效得准」。键设计决定了你能不能失效得准。
下一节我们进入 8.2 Redis 类型安全封装 ,把本节这些字符串键真正变成编译器能检查的类型边界。
阅读导航:上一节:7.3 迁移、事务与连接池 · 下一节:8.2 Redis 类型安全封装 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。