Appearance
高并发缓存设计:八个需要验证的边界
原文发布:2026-03-18 · 最后核对:2026-10-12
修订说明:原文声称作者有三年生产经验、十次线上事故,并列出未提供依据的 QPS、命中率和延迟数字。这些表述已删除。以下是设计示例与检查方法,不是本站亲历故障记录。
缓存最容易出问题的时刻,是数据不存在、热门键失效或源数据刚刚更新时。下面的示例采用常见的 cache-aside 模式:先读缓存,未命中时读主库并回填;更新主库后删除对应缓存。Redis 的官方指南说明了这种模式及其陈旧数据窗口。
1. 不存在的数据反复打到数据库
假设请求不断查询一个不存在的商品 ID。缓存每次未命中,数据库每次都要回答“没有”。可在短时间内缓存明确的“未找到”结果,并对输入格式、访问频率和权限做限制。
text
缓存命中且值为“未找到” → 直接返回 404
缓存未命中 → 查询主库
主库没有该记录 → 缓存“未找到”,设置较短 TTL
主库有记录 → 缓存数据,设置常规 TTL负缓存的过期时间由业务决定。若新建商品要立即可见,创建成功后还需主动删除对应负缓存。布隆过滤器可以帮助过滤不可能存在的 ID,但其误判与维护成本需要单独评估。
2. 一批键同时过期
若大量键在同一秒过期,回源请求可能短时集中。对允许自然过期的数据,可以在基础 TTL 上加入随机抖动;对可预测的热点,可在失效前逐步刷新。
text
TTL = 基础时长 + 随机抖动抖动幅度没有通用常数,需根据访问分布、可接受陈旧时间和回源能力确定。TTL 抖动能错开失效时间,但不能解决所有热门键的同时回源;可配合单飞请求(同一个键只允许一个回源任务)。Redis 的缓存架构说明列出了这几种方式。
3. 热门键失效后的并发回源
一个被频繁读取的键刚好失效时,多台应用实例可能同时查询主库。常见做法是给同一键的回源过程加短暂互斥,其他请求等待结果;也可以在业务允许时先返回有界陈旧值,并异步刷新。
后者适用于允许短时间旧数据的页面,如一般商品描述;不适用于必须以最新值做决定的库存扣减。必须设置刷新超时和失败兜底,否则“异步刷新”可能一直失败而持续返回旧值。
4. 主库与缓存之间的竞态
cache-aside 的典型更新顺序是“先提交主库,再删除缓存”。删除失败、并发回填或主库以外的写入路径都可能留下旧值。TTL 只能限制旧值持续的时间,不能保证更新后立即一致。
如果某操作要求强约束,例如防止库存超卖,必须在主库事务、条件更新或等效的原子操作上保证不变量。读取缓存后在库存低于某个阈值时才查数据库,不能证明不会超卖。 缓存可以帮助展示和减轻读压力,不能代替最终写入时的并发控制。
5. 大对象与高成本命令
单个值很大,或集合操作需要扫描大量元素时,序列化、网络传输和 Redis 命令执行都可能拉高尾延迟。不要给所有键设一个没有测量依据的固定大小上限;应观察键大小分布、慢查询、网络与客户端反序列化时间,再决定是否拆分、分页或按需加载。Redis 的延迟诊断文档提供了检查方向。
6. 预热过量
预热整个数据库往往把大量很少访问的数据带进缓存,还可能给主库制造一轮额外读取。更稳妥的方式是先识别真正的热点键,限制预热速率,并测量预热后的命中率与淘汰量。没有访问数据时,不应宣称“少量数据覆盖 80% 流量”。
7. 本地缓存与共享缓存不一致
应用进程内缓存可以减少网络往返,但每个实例都持有独立副本。删除 Redis 键不会自动删除其他实例的本地副本。失效消息可以缩短陈旧时间,但 Redis Pub/Sub 是至多一次投递:订阅者断线期间的消息不会补发。因此本地缓存还需短 TTL、版本号或可靠事件机制,具体取决于允许的陈旧窗口。
8. 只看整体命中率
整体命中率可能掩盖某些热点键、某个接口或某个实例的异常。至少按业务入口观察:命中率、回源次数、回源并发、P95/P99 延迟、超时、淘汰量、键大小和主库负载。对“缓存不可用”也应演练降级:限流、熔断和回源容量需要一起验证。
上线前的最小检查
- 列出哪些数据允许陈旧,以及最长允许多久。
- 对更新路径做并发测试,确认写入约束由主库或原子操作保证。
- 压测单个热点键失效和一批键同时失效的场景。
- 让缓存短时不可用,观察主库是否会被回源流量压垮。
- 为失败的失效消息和回填任务设置监控、重试或过期兜底。