缓存拿新鲜度和复杂度,换来速度和承载量。如果没人说得清键、归属、生命周期和失效规则,那你没有缓存设计,你只有一堆延迟很低的旧数据。
🎙️ 发布并录制于: ·
数据的真值归属在源头。缓存存的是一份读起来更便宜的副本。一份有用的缓存约定要回答四个问题:哪个具体请求映射到哪一个键,这份副本什么时候变得不可接受,谁负责删除或替换它,以及缓存不可用时会发生什么。速度是最容易的那部分,约定才是工程。
request → key → cached value
↘ miss → source of truth → fill cache → response
Useful latency sketch:
in-process memory microseconds
same-region Redis often under a few milliseconds
database query depends on indexes, load, and network
remote API usually the slowest and least predictable
别因为一个函数看着很贵就给它加缓存。先量重复读的次数、源头延迟、值的大小、更新频率,还有数据过期要付的代价。一个五毫秒的走索引查询,每分钟被调两次,用不上 Redis。加一层网络缓存反而可能更慢,还更难推理。
浏览器缓存省掉一趟公网往返。CDN 省掉回源。反向代理省掉应用层的工作。像 Redis 这样的共享缓存,替所有应用实例省掉数据库或 API 的工作。进程内缓存连 Redis 那一次调用都省了,但每个进程各拿着一份不同的副本。
browser → CDN → reverse proxy → application memory → Redis → database
Question at each layer:
What work is skipped?
Who shares this copy?
How is it invalidated?
Can private data leak between users?
What is the failure behavior?
用能消掉这份昂贵工作的最低那一层,同时别制造出你没法失效的一堆副本。商品目录的元数据也许适合放共享缓存。解析好的配置对象适合放进程内存。属于单个用户的 HTML 不该进公共 CDN 缓存。把每一层缓存都堆上去不叫成熟,那只是造出好几个地方,让昨天的值继续活着。
Cache-Control: public, max-age=3600,所以浏览器和 CDN 各留了自己的副本。清 Redis 是清错了层。删任何东西之前,先看 Age、Via 和缓存状态响应头。两个请求只有在所有能改变答案的输入都体现在键里时,才可以共用一份缓存值。租户、用户权限、语言、货币、功能开关、查询过滤条件和数据结构版本,是最常被漏掉的输入。把等价的输入归一化,别让查询参数换个顺序就多出一份副本。
# Safer, inspectable key
product:v3:tenant_42:sku_9001:locale_en-GB:currency_GBP
# Dangerous: tenant and permission scope are absent
product:sku_9001
# Version the serialized shape during deployment
user-card:v7:{tenantId}:{userId}
对格式变更来说,键上带版本号是最干净的失效方式。上线读得懂第七版的代码,写第七版,让第六版自己过期。别把旧字节反序列化成新对象结构,然后指望可选字段能兜住。在基数或键长逼你做哈希之前,让键保持可读。
生存时间限定的是一份副本在没有新决定之前能保留多久。它不保证到期那一刻会刷新。过期通常意味着值被删掉或被忽略,下一个请求要为一次 miss 付钱。生命周期该由「能容忍多旧」和「更新多频繁」决定,不是由「一小时」这种整齐数字决定。
hard TTL: 10 minutes # value cannot be served after this
soft TTL: 8 minutes # refresh in background after this
negative TTL: 15 seconds # brief cache for “not found”
stored value = {
data,
refreshedAt,
softExpiresAt,
hardExpiresAt
}
负缓存能在爬虫大量请求不存在的 ID 时保护数据库,但生命周期要短。刚创建出来的对象,绝不能因为一条缓存下来的「未找到」而一直看不见。给 TTL 加上随机抖动,别让一次批处理写进去的几百万条条目,在同一秒集体过期。
cache-aside 是实用的默认方案。读的时候先查缓存,miss 就回源加载并回填。写的时候先提交数据库,然后删掉相关的键。删通常比更新安全,因为下一次读会从权威状态重建。危险的顺序是在数据库提交之前就删。
# Read path
value = cache.get(key)
if value is None:
value = database.load(id)
cache.set(key, serialize(value), ttl=600)
return value
# Write path
database.transaction(() => updateProduct(product))
cache.delete(productKey(product.id)) # only after commit
竞态是这样的:请求 A 删掉缓存,请求 B miss 后读到旧的数据库行,请求 B 把旧值回填,然后请求 A 才提交新行。这份过期副本从此能活满一整个 TTL。先提交,再删除。想要更强的保证,就在同一个数据库事务里写一条 outbox 事件,让 worker 根据这条持久化事件去做失效。
KEYS product:* 在扫描整个 keyspace 时,能把一台大 Redis 卡住。做大范围切换用带版本的命名空间,或者维护明确的依赖集合,或者在请求链路之外用 SCAN 迭代。全局 FLUSHALL 不是失效策略。一个热门键过期时,很多 worker 可能同时 miss,然后一起重建同一个值。这就是缓存击穿风暴。它常常表现为数据库在缓存本该保护它的那一刻出现负载尖峰。用 single-flight 加锁把并发工作收敛成一次,在硬过期之前提前刷新热门值,并且在键之间加抖动。
value = cache.get(key)
if value is fresh: return value
if lock.tryAcquire("refresh:" + key, ttl=10s):
try:
value = source.load()
cache.set(key, value, ttl=random(540s, 660s))
return value
finally:
lock.release()
return staleValueIfSafe() # or wait briefly, with a deadline
锁必须有过期时间,否则崩掉的那个刷新者会永久堵死这个键。回源调用的超时要短于锁的生命周期。释放锁时要校验持有者,一般用一个随机 token 配一段原子脚本。对可以容忍旧内容的场景,stale-while-revalidate 能让用户立刻拿到旧答案,同时由一个 worker 在后台刷新。
FATAL: remaining connection slots are reserved for non-replication superuser connections,先去调高数据库连接数上限解决不了问题。第一步:找出同时过期的那批键和 miss 洪峰。第二步:给回填并发设上限。第三步:用带抖动的 TTL 恢复。第四步:加上 single-flight 刷新。第五步:在改数据库容量之前,先对冷缓存路径做压测。在自己发明客户端存储之前,先用 HTTP 缓存。新鲜度指令告诉浏览器和共享缓存,它们能不能复用一个响应。校验器让过期的缓存可以去问一句,自己这份副本是否还一致。ETag 标识的是一个表示形态;Last-Modified 用时间,精度更差。
# Public fingerprinted asset
Cache-Control: public, max-age=31536000, immutable
# Private account page; browser may store, CDN may not
Cache-Control: private, max-age=60
ETag: "account-42-v19"
Vary: Accept-Encoding
# Revalidation
If-None-Match: "account-42-v19"
HTTP/1.1 304 Not Modified
别给 app.js 挂一年的生命周期,除非它的字节变了 URL 也跟着变。用内容指纹,比如 app.a81c9f.js。绝对不能留存的响应用 no-store。no-cache 不是「不要存」,它的意思是复用之前先去校验。
curl -I,对比 Age、ETag、Cache-Control、Vary 和服务商的缓存状态响应头。登录态和匿名态各测一遍。如果内容会随 cookie 或 authorization 变化,就要证明缓存键也跟着安全地变化了。Redis 支持字符串、哈希、集合、有序集合、流等类型。命令必须和存进去的类型对得上。给缓存条目设过期时间,明确设定内存上限,选一个匹配业务负载的淘汰策略,并且量一量序列化之后的体积。一个没有内存预算的缓存,等于一场由流量增长安排好日期的故障。
# Inspect before changing
TYPE user:42
TTL user:42
MEMORY USAGE user:42
# Atomic write with expiry
SET product:42 '{"name":"Mug"}' EX 600
# Typical raw failure
WRONGTYPE Operation against a key holding the wrong kind of value
TYPE key。第二步:看命令本身,GET 要的是字符串,HGET 要的是哈希。第三步:查出是哪次上线写进了冲突的类型。第四步:上线一个带版本的键名,比如 user:v2:42。第五步:确认没有旧读者之后,让旧键自己过期或者删掉它。直接把键删了,很可能被旧的写入方重新造出同样的冲突。除非产品真的要求,永远不要把 Redis 可用性当成普通读操作的前置条件。设一个短的缓存超时,用有上限的并发回源兜底,避免重试风暴。如果兜底会把数据库压垮,那就拒绝或降级一部分请求,别假装缓存是可选的。
单看命中率会骗人。一千万次廉价对象的命中,能盖住一个昂贵键的反复 miss。要量请求量、命中、miss、加载延迟、错误、淘汰量、内存、值大小,以及返回时数据的年龄。只按有限的维度拆分,比如缓存名和结果,绝对不要按原始键或用户 ID 拆。
cache_requests_total{cache="product",result="hit"}
cache_requests_total{cache="product",result="miss"}
cache_load_duration_seconds{cache="product"}
cache_value_age_seconds{cache="product"}
cache_evictions_total{cache="shared_redis"}
# Failure drill
1. Add latency to cache calls
2. Make cache unavailable
3. Start with an empty cache
4. Expire the hottest keys together
5. Confirm source limits and degraded behavior
主动去测冷启动和缓存故障。一条从没经历过生产级流量的兜底路径,只是一个理论。给连接和命令都设明确的超时。如果一次短暂的缓存故障会让每个应用实例重启、把事故放大,那就别把缓存健康度放进应用的基础就绪检查里。
把这些信息写在创建缓存的代码旁边,不要写进一张没人更新的架构图里。凌晨两点排查数据过期的那个人,需要的是确切的键和归属规则。
DESIGN
Source of truth: database table / API / computed result
Skipped work: exact query or computation
Key: every input that changes the answer
Value: schema version and maximum size
Freshness: soft TTL, hard TTL, allowed stale age
Invalidation: actor, event, ordering, retry behavior
Failure mode: bypass, stale serve, degrade, or reject
Capacity: memory limit and eviction policy
DEBUG STALE DATA
1. Capture request identity, response, and timestamp
2. Identify browser, CDN, proxy, process, and shared caches
3. Inspect key inputs, stored value, TTL, and value age
4. Compare directly with the source of truth
5. Trace the write commit and invalidation event
6. Purge only the proven layer and key
7. Fix key, ordering, or freshness contract
DEBUG LOAD SPIKE
1. Graph hits, misses, evictions, and load latency
2. Find hot keys and synchronized expiry
3. Bound fill concurrency and source connections
4. Add single-flight refresh and TTL jitter
5. Exercise empty-cache recovery before the next incident
值得记住的立场是这一条:数据过期必须是一个明确的产品选择。说出它的最大年龄,让缓存键能被评审,在提交之后再失效,并且把 miss 路径演练一遍。如果这些决定都不存在,那就先把缓存拿掉,等瓶颈值得冒这个风险再说。