引言
缓存是提升性能最直接的手段,也是最容易引入 bug 的地方。缓存本质上是**「用一致性换性能」**:一旦引入缓存,数据就有了两份,就有了「谁更新、谁失效、多久过期」的一整套问题。很多线上事故的根因不是缓存没生效,而是缓存生效了但返回了过期或错误的数据。
在 TypeScript 里,缓存的另一层难点是类型:缓存里存的是序列化后的字符串或字节,取出来时是 unknown,如果不加约束就断言,类型安全会在缓存边界处彻底失效。让缓存层在编译期与运行时都受控,是把它用好的前提。
本文聚焦 TypeScript 缓存的工程落地:从缓存层次讲起,覆盖键设计、TTL 与失效、穿透击穿雪崩防护、类型安全封装、序列化与版本、一致性取舍、分布式缓存与 Redis,最后给出命中率观测与生产实践。
目录
- 1. 缓存的层次与定位
- 2. 键设计与命名空间
- 3. TTL 与失效策略
- 4. 穿透击穿与雪崩防护
- 5. 类型安全的缓存封装
- 6. 序列化与版本兼容
- 7. 一致性取舍
- 8. 分布式缓存与 Redis
- 9. 命中率观测
- 10. 生产实践与踩坑清单
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 校验 + 降级回源 + 命中率观测——缓存永远可能取不到,代码必须能在「没有缓存」的情况下依然正确。
延伸阅读
- Node 后端的服务化实践
- 缓存数据的运行时校验
- 数据访问与查询优化
- 指标与追踪接入
- 并发控制与锁
- TypeScript 专题 — TypeScript 专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。