缓存:让「数据过期」成为一个明确的决定

缓存拿新鲜度和复杂度,换来速度和承载量。如果没人说得清键、归属、生命周期和失效规则,那你没有缓存设计,你只有一堆延迟很低的旧数据。

🎙️ 发布并录制于: ·

01缓存是一份带约定的副本

数据的真值归属在源头。缓存存的是一份读起来更便宜的副本。一份有用的缓存约定要回答四个问题:哪个具体请求映射到哪一个键,这份副本什么时候变得不可接受,谁负责删除或替换它,以及缓存不可用时会发生什么。速度是最容易的那部分,约定才是工程。

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。加一层网络缓存反而可能更慢,还更难推理。

我的原则从真值源头开始。只有当你能说出它消掉的是哪个瓶颈时,再加缓存。「以后可能要扛量」不是瓶颈,缓存失效也不是什么免费的提前准备。

02把缓存放在重复劳动发生的地方

浏览器缓存省掉一趟公网往返。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 缓存。把每一层缓存都堆上去不叫成熟,那只是造出好几个地方,让昨天的值继续活着。

线上常见的谜案管理员改了商品,Redis 也清了,客户看到的还是旧页面。响应里带着 Cache-Control: public, max-age=3600,所以浏览器和 CDN 各留了自己的副本。清 Redis 是清错了层。删任何东西之前,先看 AgeVia 和缓存状态响应头。

03缓存键决定了正确性

两个请求只有在所有能改变答案的输入都体现在键里时,才可以共用一份缓存值。租户、用户权限、语言、货币、功能开关、查询过滤条件和数据结构版本,是最常被漏掉的输入。把等价的输入归一化,别让查询参数换个顺序就多出一份副本。

# 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}

对格式变更来说,键上带版本号是最干净的失效方式。上线读得懂第七版的代码,写第七版,让第六版自己过期。别把旧字节反序列化成新对象结构,然后指望可选字段能兜住。在基数或键长逼你做哈希之前,让键保持可读。

这是安全事故,不只是数据过期有个响应缓存只按 URL 做键,结果把一位客户的账单发给了另一位客户,因为鉴权身份没进键里。先处理泄露:关掉缓存、清掉受影响的条目、审查访问日志、走事故流程。然后把私有响应改成不可缓存,或者在键里加上稳定的鉴权范围。永远不要把原始令牌或个人信息写进键名。

04TTL 是一个产品决定

生存时间限定的是一份副本在没有新决定之前能保留多久。它不保证到期那一刻会刷新。过期通常意味着值被删掉或被忽略,下一个请求要为一次 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 加上随机抖动,别让一次批处理写进去的几百万条条目,在同一秒集体过期。

按后果来选库存数量和权限需要很短的生命周期,或者显式失效,因为过期的值会造成真实损害。国家列表可以放几个小时。如果团队说不出用产品语言表达的最大可接受时长,别把这份不确定包装成一个三十分钟的 TTL。

05写入提交之后再失效

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 不是失效策略。

06一次 miss 不能变成一千次查询

一个热门键过期时,很多 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 刷新。第五步:在改数据库容量之前,先对冷缓存路径做压测。

07HTTP 本来就有一套缓存协议

在自己发明客户端存储之前,先用 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-storeno-cache 不是「不要存」,它的意思是复用之前先去校验。

查清是哪一层缓存答的跑两次 curl -I,对比 AgeETagCache-ControlVary 和服务商的缓存状态响应头。登录态和匿名态各测一遍。如果内容会随 cookie 或 authorization 变化,就要证明缓存键也跟着安全地变化了。

08Redis 是数据结构服务器,不是魔法内存

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
WRONGTYPE 按顺序修第一步:在和出错应用完全相同的 Redis 库和环境里跑 TYPE key第二步:看命令本身,GET 要的是字符串,HGET 要的是哈希。第三步:查出是哪次上线写进了冲突的类型。第四步:上线一个带版本的键名,比如 user:v2:42第五步:确认没有旧读者之后,让旧键自己过期或者删掉它。直接把键删了,很可能被旧的写入方重新造出同样的冲突。

除非产品真的要求,永远不要把 Redis 可用性当成普通读操作的前置条件。设一个短的缓存超时,用有上限的并发回源兜底,避免重试风暴。如果兜底会把数据库压垮,那就拒绝或降级一部分请求,别假装缓存是可选的。

09盯住 miss、数据年龄和对源头的保护

单看命中率会骗人。一千万次廉价对象的命中,能盖住一个昂贵键的反复 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

主动去测冷启动和缓存故障。一条从没经历过生产级流量的兜底路径,只是一个理论。给连接和命令都设明确的超时。如果一次短暂的缓存故障会让每个应用实例重启、把事故放大,那就别把缓存健康度放进应用的基础就绪检查里。

淘汰量本身就是证据如果内存已满、服务端报出淘汰,那么延长 TTL 反而会因为把低价值条目留得更久而拉低命中率。按复用频率和重建成本给条目排个序。少缓存一些东西、把值缩小、把不同负载拆开,或者在搞清策略之后再加内存。

10缓存设计与排查速查表

把这些信息写在创建缓存的代码旁边,不要写进一张没人更新的架构图里。凌晨两点排查数据过期的那个人,需要的是确切的键和归属规则。

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 路径演练一遍。如果这些决定都不存在,那就先把缓存拿掉,等瓶颈值得冒这个风险再说。

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.