《TypeScript编程实战》8.1 缓存层次与键设计

本节讲清缓存分层的工程取舍与缓存键的设计规范。先给出浏览器、CDN、进程内、Redis、数据库五层缓存各自的职责表与失效半径对比,再深入缓存键的三要素——命名空间、版本号与粒度,并用 TypeScript 写出类型安全、可版本化的键构造器与稳定序列化函数,最后列出大小写、浮点精度、对象键顺序等常见键冲突坑与排查手段,读完能独立设计一套不互相污染的键空间。

本节目标:把「加缓存」从一句口号变成一套可复盘的工程决策。读完本节,你能说清浏览器、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 兜底。

每层的决策清单

在给一个接口加缓存前,先回答四个问题:

  1. 数据能不能容忍过期? 容忍度决定它能放到哪一层。库存数、余额这类强一致数据,只能放 L3 且必须配合主动失效。
  2. 命中率能到多少? 命中率低于 60% 的缓存,收益往往抵不过运维复杂度与一致性风险。
  3. 键的基数有多大? 基数大而访问分散(例如按用户 ID 查订单)时,缓存更像「加速器」;基数小但访问集中(例如首页榜单)时,缓存更像「承压层」。
  4. 失效由谁触发? 是写路径主动 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 类型安全封装 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes