TS 缓存策略与类型安全:层次、失效、防护与一致性取舍

系统讲解 TypeScript 缓存的工程实践:内存/CDN/Redis 的缓存层次、键设计与命名空间、TTL 与失效策略、缓存穿透击穿与雪崩防护、类型安全的缓存封装、序列化与版本兼容、一致性取舍,以及命中率观测与生产实践。

引言

缓存是提升性能最直接的手段,也是最容易引入 bug 的地方。缓存本质上是**「用一致性换性能」**:一旦引入缓存,数据就有了两份,就有了「谁更新、谁失效、多久过期」的一整套问题。很多线上事故的根因不是缓存没生效,而是缓存生效了但返回了过期或错误的数据。

在 TypeScript 里,缓存的另一层难点是类型:缓存里存的是序列化后的字符串或字节,取出来时是 unknown,如果不加约束就断言,类型安全会在缓存边界处彻底失效。让缓存层在编译期与运行时都受控,是把它用好的前提。

本文聚焦 TypeScript 缓存的工程落地:从缓存层次讲起,覆盖键设计、TTL 与失效、穿透击穿雪崩防护、类型安全封装、序列化与版本、一致性取舍、分布式缓存与 Redis,最后给出命中率观测与生产实践。

前置:Node 后端、运行时校验、数据访问。


目录


1. 缓存的层次与定位

1.1 三层缓存

浏览器/CDN 缓存离用户最近,成本最低、命中收益最大;进程内内存缓存(Map、LRU)延迟纳秒级但各实例不共享;分布式缓存(Redis、Memcached)跨实例共享,但多一次网络往返。

1.2 层次对比

层次延迟共享范围失效难度
浏览器/CDN最低全局难(需版本化 URL)
进程内内存纳秒单实例中(各实例独立)
分布式缓存毫秒全集群易(统一删除)

1.3 何时该缓存

缓存适合读多写少、计算昂贵、能容忍短暂过期的数据。反过来,强一致、写多读少、或每次结果都不同的数据不该缓存——缓存不是越多越好,缓存的数据越多,失效的复杂度越高。

1.4 缓存的成本

引入缓存意味着多一套需要运维的组件、多一层排查路径(「是缓存还是数据源的问题」)、以及一致性风险。先确认瓶颈真的在数据读取,再引入缓存;否则可能只是把问题复杂化。

一句话总结:缓存按「离用户的远近」分层,越近越快也越难失效——它只适合读多写少、可容忍短暂过期的数据,引入前先确认瓶颈。


2. 键设计与命名空间

2.1 键的构成

const key = `v1:user:${userId}:profile`

键应包含版本前缀、业务域、实体、标识、可选参数。版本前缀让格式变更时可以整体切换;业务域避免不同模块的键相互碰撞。

2.2 命名空间与多租户

const scoped = (tenant: string) => (suffix: string) => `v1:${tenant}:${suffix}`

多租户系统必须在键里带上租户 ID,否则一个租户的数据可能被另一个租户命中——这是最严重的数据泄漏路径之一。

2.3 键的可读性与长度

键要能一眼看出含义(便于排查),但也不要过长——Redis 的键本身占内存,长键在高基数下会显著增加占用。用短但有结构的键,如 v1:u:123:p。

2.4 键的坑

用对象直接 JSON.stringify 做键会因属性顺序不同产生不同键;把查询参数无序列化拼接可能碰撞(a=1&b=2 与 a=1&b=2&);键里带用户输入而不转义可能注入特殊字符;忘记版本前缀导致格式变更后旧数据被当作新格式解析。

2.5 键的规范化

对含对象的键,先按键名排序再序列化,保证同一逻辑参数生成同一个键:

const normalize = (o: Record<string, unknown>) =>
  JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))

一句话总结:键 = 版本 + 业务域 + 实体 + 标识 + 参数,多租户必须带租户 ID——对象做键要先规范化排序,键要短而有结构。


3. TTL 与失效策略

3.1 为什么必须有 TTL

所有缓存都应有过期时间。没有 TTL 的缓存会在数据源变更后永远返回旧值,且内存只增不减。TTL 是兜底:即使主动失效全部失败,数据也会在一段时间后自愈。

3.2 TTL 的选择

await redis.set(key, value, "EX", 300)   // 5 分钟

TTL 短则一致性好但命中率低,长则命中率高但陈旧窗口大。选择依据是业务能容忍的陈旧时间:用户昵称可容忍几分钟,账户余额可能一秒都不能容忍。

3.3 主动失效

async function updateProfile(userId: string, patch: Profile) {
  await db.user.update({ where: { id: userId }, data: patch })
  await redis.del(`v1:user:${userId}:profile`)   // 写后删缓存
}

写后删除而非写后更新:删除让下次读自然回源,避免并发写导致的乱序覆盖。这就是常说的 Cache-Aside 模式。

3.4 失效的坑

只更新数据库不删缓存(脏数据直到 TTL 到期);先删缓存再写库(并发读会把旧值写回);批量失效用 keys 命令扫描(阻塞 Redis,应改用 scan 或维护索引集合);忘记失效关联键(改用户信息却漏了它的列表页缓存)。

一句话总结:所有缓存都要有 TTL 兜底,写操作后删除缓存而非更新缓存——用 Cache-Aside,批量失效避免 keys,关联键的失效要一并考虑。


4. 穿透击穿与雪崩防护

4.1 三个经典问题

穿透:查询不存在的数据,缓存永远不命中,请求全部打到数据库;击穿:热点键过期瞬间,大量并发同时回源;雪崩:大量键在同一时刻集中过期,或缓存整体不可用,请求同时涌向数据库。

4.2 穿透防护

// 空值也缓存,TTL 设短
const row = await db.find(id)
await redis.set(key, row ? JSON.stringify(row) : "__NULL__", "EX", 60)

对不存在的键缓存一个短 TTL 的空标记;配合参数校验挡掉明显非法的请求;必要时用布隆过滤器在缓存前拦截。

4.3 击穿防护

// 用分布式锁保证只有一个请求回源
const lock = await redis.set(lockKey, "1", "EX", 10, "NX")
if (lock) {
  try { return await loadAndCache() } finally { await redis.del(lockKey) }
}
await sleep(50)
return getFromCache()   // 等锁释放后重试读缓存

热点键可以不设过期(逻辑过期),由后台任务异步刷新,彻底避免击穿。

4.4 雪崩防护

给 TTL 加随机抖动(base + random(0, jitter))避免集中过期;缓存层做多级(本地 + 分布式)互为兜底;对数据源加限流与熔断,即使缓存全失效也不至于把数据库打垮。

4.5 防护的代价

空值缓存占用内存且需与真实数据区分;分布式锁增加一次往返并可能成为瓶颈;TTL 抖动降低命中率的稳定性。防护不是免费的,应按业务风险选择性启用。

一句话总结:穿透缓存空值、击穿用锁或逻辑过期、雪崩给 TTL 加抖动——三层防护的共同目标是「缓存失效时不要让请求同时压垮数据源」。


5. 类型安全的缓存封装

5.1 朴素写法的问题

const raw = await redis.get(key)
return JSON.parse(raw!) as User   // 断言,类型安全在此处崩塌

as User 让编译器闭嘴,但缓存里可能是旧版本的数据、被截断的字符串、或别人写入的其他结构。缓存是外部输入,必须校验。

5.2 用 schema 校验

import { z } from "zod"

async function cached<T>(key: string, schema: z.ZodType<T>, load: () => Promise<T>): Promise<T> {
  const raw = await redis.get(key)
  if (raw !== null) {
    const parsed = schema.safeParse(JSON.parse(raw))
    if (parsed.success) return parsed.data
    await redis.del(key)   // 校验失败,视为未命中
  }
  const value = await load()
  await redis.set(key, JSON.stringify(value), "EX", 300)
  return value
}

schema 既是运行时校验,也是类型来源——校验通过即类型收窄,无需断言。

5.3 类型化的键与值

interface CacheMap {
  "v1:user:profile": { id: string; name: string }
  "v1:post:detail": { id: string; title: string }
}
type CacheKey = keyof CacheMap

用映射类型把「键 ↔ 值」绑定,cached("v1:user:profile", ...) 的返回值类型自动确定,键名拼错即编译报错。

5.4 封装的分层

底层是纯粹的 get/set/del;中层是「读缓存 → 回源 → 写缓存」的模板方法;上层是各业务的缓存函数。把校验与回源逻辑收敛到中层,业务层只声明键与 schema,避免每个调用点各写一遍。

一句话总结:缓存是外部输入,取出时必须用 schema 校验而非 as 断言——把「读缓存 → 校验 → 回源 → 回写」收敛到统一的封装里,键值用映射类型绑定。


6. 序列化与版本兼容

6.1 序列化格式

JSON 可读、跨语言,但体积大、不支持二进制、数值精度有限(大整数会丢精度);MessagePack 更紧凑;Protocol Buffers 更省空间但需要 schema 文件。大多数场景 JSON 够用,热点大对象才考虑更紧凑的格式。

6.2 版本与结构演进

const CACHE_VERSION = 3
const key = `v${CACHE_VERSION}:user:${userId}`

结构变更时递增版本号,旧数据自然失效,避免「新代码解析旧结构」导致的反序列化错误。这比写迁移逻辑简单可靠得多。

6.3 日期与特殊类型

JSON.parse('{"at":"2026-10-03T10:00:00Z"}')   // at 是 string,不是 Date

JSON 无法表达 Date、Map、Set、BigInt。存日期要么存时间戳、要么在 schema 里显式转换;不要假设取出来还是原来的类型。

6.4 兼容性检查清单

新增可选字段:安全;删除字段:旧缓存里有、新代码忽略,安全;改字段类型:必须升版本;改嵌套结构:必须升版本;改数值语义(如从分改成元):必须升版本。

6.5 大对象与压缩

对体积大的缓存值,可先 gzip 再存,用 CPU 换内存与带宽:

const put = async (key: string, v: unknown) =>
  redis.set(key, gzipSync(JSON.stringify(v)), "EX", 600)

一句话总结:结构变更靠递增键版本号而非写迁移逻辑——JSON 无法表达 Date 与 BigInt,schema 里要显式转换,改字段类型或语义时必须升版本。


7. 一致性取舍

7.1 缓存一致性没有银弹

「缓存与数据库强一致」在分布式环境下无法廉价实现。工程上的目标是**「最终一致 + 陈旧窗口可控」**:明确业务能容忍多久的陈旧,据此选 TTL 与失效策略。

7.2 常见模式对比

模式一致性复杂度适用
Cache-Aside最终一致低通用
Write-Through较强中写少读多
Write-Behind弱高高写入吞吐
读时刷新最终一致中热点数据

7.3 并发写导致的乱序

两个请求先后更新同一数据,删除缓存的操作可能乱序执行,导致旧值被重新写入缓存。缓解方式是删除缓存而非更新、给键加短 TTL 兜底、或用消息队列串行化失效操作。

7.4 什么时候不该用缓存

账户余额、库存扣减、权限判定这类强一致要求的数据,缓存带来的风险大于收益;此时应直接查库,或用「本地缓存 + 版本号校验」这类可验证的方案。

7.5 与消息队列配合

把失效操作投递到队列异步执行,可避免数据库写入路径变长,也便于失败重试;代价是失效有延迟,只适合能容忍更长陈旧窗口的场景。

一句话总结:缓存一致性追求的是「最终一致 + 陈旧窗口可控」——Cache-Aside 是通用起点,强一致场景(余额、库存、权限)宁可不缓存。


8. 分布式缓存与 Redis

8.1 连接与客户端

import Redis from "ioredis"
export const redis = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: 2,
  enableReadyCheck: true,
})

复用单例连接而非每次新建;设置合理的重试与超时,避免缓存故障时把请求挂死——缓存不可用时应快速失败并回源,而不是拖垮整个请求。

8.2 缓存故障的降级

async function safeGet(key: string) {
  try { return await redis.get(key) }
  catch (err) { logger.warn({ err, key }, "cache unavailable"); return null }   // 降级为回源
}

缓存层故障必须降级为回源,而不是让请求失败。同时要给回源加限流,否则缓存挂了会立刻压垮数据库。

8.3 集群与键分布

Redis Cluster 按 key 的哈希槽分片,同一事务或 Lua 脚本涉及的所有键必须在同一槽,否则会报错。需要多键操作时用哈希标签 {user:123}:profile 强制同槽。

8.4 内存与淘汰

配置 maxmemory 与淘汰策略(allkeys-lru 等);淘汰策略意味着键可能被提前驱逐,因此代码不能假设「写进去的就一定还在」——这与 TTL 的假设是一致的。

一句话总结:复用连接、故障降级回源、集群注意同槽、内存设置淘汰策略——缓存层永远是可失效的,代码必须能承受「取不到」这一常态。


9. 命中率观测

9.1 关键指标

指标含义健康信号
命中率命中/总请求越高越好,突降要查
回源 QPS未命中打库量应远低于总 QPS
平均延迟缓存与回源耗时缓存应显著更低
内存占用已用/上限接近上限需扩容
淘汰数量被驱逐的键突增说明内存不足

9.2 命中率为何会突降

键前缀变更导致全部未命中;TTL 设得过短;缓存被清空或重启;序列化格式变更使旧数据校验失败;查询参数抖动(如每次都带不同的时间戳)导致键不稳定。

9.3 分键统计

整体命中率会掩盖问题,应按业务域分别统计——某个域的命中率骤降,往往比整体命中率下降更早暴露问题。埋点时给每个键前缀打标签即可。

9.4 观测的纪律

命中率、回源量与淘汰数要同时看:命中率下降但回源量不变,可能只是请求总量下降了;淘汰数突增则说明内存不足,此时扩容比调 TTL 更有效。

9.5 埋点示例

const raw = await redis.get(key)
metrics.incr("cache_total", { prefix: keyPrefix(key), result: raw ? "hit" : "miss" })

一句话总结:命中率、回源 QPS、淘汰数量要一起看——按业务域分别统计才能定位问题,命中率突降优先查「键是否变了」。


10. 生产实践与踩坑清单

10.1 落地顺序

先确认瓶颈在读路径;再从最简单的 Cache-Aside 加 TTL 起步;然后补主动失效与空值缓存;接着加穿透击穿雪崩防护;最后接入命中率观测与降级策略。

10.2 踩坑清单

没有 TTL 导致永久脏数据;先删缓存再写库导致旧值被写回;用 keys 批量删除阻塞 Redis;键里漏了租户 ID 导致跨租户命中;as 断言绕过缓存校验;结构变更不升版本导致解析错乱;缓存故障未降级反而拖垮请求;淘汰策略下假设「写进去就一定在」。

10.3 与事务和锁的关系

缓存失效与数据库事务不是原子的:事务提交后才删缓存,否则事务回滚会留下已删缓存与旧库数据的不一致;分布式锁的 key 要设过期时间,避免持锁进程崩溃导致死锁。

10.4 上线纪律

所有缓存必须有 TTL;所有取值必须经过 schema 校验;所有失效必须有主动删除;缓存不可用必须降级回源;命中率、回源量、淘汰数三项指标必须可见。

一句话总结:缓存的生产化 = TTL 兜底 + 写后删除 + schema 校验 + 降级回源 + 命中率观测——缓存永远可能取不到,代码必须能在「没有缓存」的情况下依然正确。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. TS GraphQL 服务端类型安全:codegen、Resolver 与 DataLoader 实践
  2. TS 边缘框架 Hono:Web 标准、端到端类型安全与多运行时部署
  3. TS 流式 I/O:Node Streams 类型体系、背压与大文件处理