缓存的利与弊
There are only two hard things in Computer Science: cache invalidation and naming things. —— Phil Karlton
什么是缓存
缓存(Cache)的本质是一种以空间换时间的设计:把数据放在离使用者更近、访问更快的存储层中,避免每次都去访问更慢、更昂贵的原始存储。缓存得以成立的理论基础是程序访问的局部性原理:
- 时间局部性:刚被访问过的数据,在不久的将来很可能再次被访问;
- 空间局部性:与刚被访问过的数据相邻的数据,很可能紧接着被访问。
只要业务访问模式符合局部性,缓存就能以远小于全量数据的容量,拦截掉绝大多数请求,也就是我们常说的"高命中率"。
缓存无处不在,几乎贯穿了整个计算机体系:
| 层级 | 缓存 | 原始存储 | 量级对比 |
|---|---|---|---|
| CPU | L1/L2/L3 cache | 内存 | ~1ns / ~4ns / ~15ns vs 60~100ns |
| 操作系统 | Page Cache | 磁盘 | 内存级 vs 毫秒级磁盘 IO |
| 数据库 | Buffer Pool(InnoDB 等) | 数据文件 | 内存命中 vs 磁盘 IO |
| 应用 | Caffeine 等本地缓存 | 远程服务/数据库 | 进程内 vs 一次网络往返 |
| 分布式 | Redis / Memcached | 数据库 | 亚毫秒 vs 毫秒级查询 |
| 网络 | CDN、浏览器缓存、DNS | 源站 | 边缘节点 vs 跨地域回源 |
本文聚焦的是应用开发中最常见的一层:位于业务代码与数据库之间的分布式缓存(以 Redis 为代表)。下文如无特殊说明,"缓存"均指这一层。
缓存的利
降低延迟
访问延迟的差距是数量级的。一次 Redis 内存读写延迟在亚毫秒级,而一次 MySQL 查询通常在毫秒级到几十毫秒(取决于索引与负载),一次跨机房网络请求则可能上百毫秒。延迟差距越大,缓存带来的体感提升越明显。
提升吞吐、保护后端
缓存介质与后端存储的性能差距,直接体现在吞吐上:单机 Redis 可以轻松扛住十万级 QPS,而单机 MySQL 的读 QPS 通常只有数千。把大量读请求挡在缓存层,后端数据库的负载会大幅下降,相当于给系统"扩容"。
更重要的是保护作用:数据库是系统中最后一道防线,也是全系统的瓶颈与故障高发区。缓存拦截掉大部分请求后,即使业务量增长,数据库也只需要处理缓存未命中的那一小部分流量。
应对峰值流量
业务流量往往存在明显的波峰波谷(秒杀、大促、热点新闻)。缓存把尖峰削平:只要热点数据提前预热进缓存,峰值流量在缓存层就被消化掉了,后端系统可以按平均负载而非峰值负载来规划容量,成本低一个数量级。
缓存的弊
引入缓存之后,数据有了两份副本(数据库一份、缓存一份),几乎所有的问题都源于这个"第二份副本":
- 一致性:两份数据如何保持同步;
- 成本:内存昂贵且有限,缓存装不下全部数据,必然涉及淘汰;
- 复杂度与可用性:多了一个组件,也就多了一个故障点。
缓存与数据库一致性
这是缓存最核心、也最棘手的问题,开头的名言说的就是它。
Cache Aside 模式
最主流的读写方案,读和写分开处理:
- 读:先读缓存,命中直接返回;未命中读数据库,并回填缓存;
- 写:先更新数据库,然后删除缓存(注意:是删除,而不是更新)。
func GetUser(id int64) (*User, error) {
// 1. 先查缓存
if u, ok := cache.Get(userKey(id)); ok {
return u, nil
}
// 2. 缓存未命中,查数据库
u, err := db.GetUser(id)
if err != nil {
return nil, err
}
// 3. 回填缓存
cache.Set(userKey(id), u, ttl)
return u, nil
}
func UpdateUser(u *User) error {
// 1. 先更新数据库
if err := db.UpdateUser(u); err != nil {
return err
}
// 2. 再删除缓存
return cache.Del(userKey(u.ID))
}为什么写时是删除缓存而不是更新缓存?
- 避免并发写顺序错乱:两个并发写请求,先完成的写不一定先落库;如果都直接写缓存,缓存最终保留的可能是"后写先至"的旧值;
- 避免无效更新:很多数据写完后根本不会再被读(写多读少),更新缓存是纯浪费;
- 简单:删除 + 下次读时回填,让缓存内容始终是"被读出来的值",不用在写路径上维护缓存值的推导逻辑。
先更新数据库还是先删除缓存?
两个顺序都无法做到绝对一致,各有一个竞态窗口。
先删除缓存,再更新数据库:写请求删除缓存后、更新数据库前,读请求到来——缓存未命中,从数据库读到旧值并回填缓存,之后写请求才完成更新。结果:缓存中躺着一个旧值,一直持续到缓存过期。这个竞态窗口横跨整个写事务,概率不低。
先更新数据库,再删除缓存:同样存在一个理论窗口:
| 时间 | 读请求 | 写请求 |
|---|---|---|
| t1 | 缓存未命中 | |
| t2 | 读数据库,得到旧值 A | |
| t3 | 更新数据库为 B | |
| t4 | 删除缓存 | |
| t5 | 把旧值 A 写回缓存 |
结果同样是缓存中留存旧值。但这个竞态要求读请求恰好在"读 DB 之后、回填缓存之前"这极短的时间缝里撞上写请求完成整个写流程,概率远低于"先删缓存"方案,所以业界普遍选择先更新数据库,再删除缓存。
真正的麻烦在于删除缓存失败:数据库更新成功了,缓存删除失败了(网络抖动、Redis 故障),旧值会一直存活到过期。常见的解决思路:
- 重试:删除失败后把操作放入消息队列,异步重试直到成功;
- 订阅 binlog:用 Canal 等组件监听数据库变更日志,由独立的消费者异步删除/更新缓存。本质上是把"删除缓存"从业务写路径上剥离,变成数据库变更的消费者,天然具备重试与补偿能力。
工程实践中,缓存与数据库的一致性几乎总是最终一致:用合理的 TTL 兜底(过期时间一到旧值自然被清掉),叠加重试或 binlog 订阅的补偿机制,把不一致窗口收敛到业务可接受的范围内。如果业务要求强一致,就不应该引入缓存——或者用分布式锁把读写串行化,但那等于放弃缓存的大部分性能收益。
缓存穿透
定义:查询一个根本不存在的数据,缓存和数据库中都没有。缓存形同虚设,每个请求都穿透缓存直达数据库。
典型场景:
- 恶意攻击:用大量随机不存在的 id 打接口;
- 业务上大量查询不存在的 key(爬虫扫描商品 id、用户输入非法 id)。
后果:数据库被无效查询打满,甚至被打挂。
解决:
- 缓存空值:把"不存在"也缓存下来(value 置空或打特殊标记),设置一个较短的 TTL。简单有效,代价是可能缓存大量空值 key、数据后来写入后会有短暂的"假不存在"(TTL 短可缓解);
- 布隆过滤器:在缓存之前加一层布隆过滤器判断 key 是否存在,不存在的直接拒绝,不进缓存也不进数据库。空间效率极高,但有误判率(假阳性),且删除元素困难,需要周期性重建;
- 接口层校验:参数合法性校验(id 必须大于 0、格式校验等),挡掉明显非法的请求。
缓存击穿
定义:某个热点 key 过期的一瞬间,大量并发请求同时发现缓存未命中,同时回源数据库查询,数据库瞬间承受巨大压力。
与穿透的区别:击穿针对的是"存在的热点数据"(key 本来有值,只是恰好过期),穿透针对的是"根本不存在的数据"。
解决:
- 互斥锁(singleflight):同一时刻只放一个请求回源,其他请求等待结果。Go 有现成的
golang.org/x/sync/singleflight:
var g singleflight.Group
func GetHotKey(key string) (any, error) {
v, err, _ := g.Do(key, func() (any, error) {
// 只有第一个请求会执行到这里
return loadFromDB(key)
})
return v, err
}用 Redis 也能实现:SETNX 抢锁,抢到的回源重建,抢不到的 sleep 后重读缓存。
- 逻辑过期:缓存的 value 里带上逻辑过期时间,物理上不过期。读请求拿到数据后判断是否逻辑过期:未过期直接返回;已过期则先返回旧值,同时尝试获取互斥锁,抢到锁的线程异步重建。用户永远不会拿到"未命中",请求不会堆到数据库;
- 热点 key 不过期:识别出热点后直接不设过期时间,配合后台定时刷新。
缓存雪崩
定义:大量 key 在同一时间集中过期,或者缓存服务整体宕机,瞬间所有请求穿透到数据库,把数据库打挂。
解决:
- 过期时间加随机抖动:在基准 TTL 上叠加随机值(如
TTL + rand(0, 300)秒),把过期时间打散,避免同一时刻大面积失效; - 多级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis),Redis 挂了本地缓存还能挡一部分;
- 缓存高可用:Redis 主从 + 哨兵 / Cluster 集群,避免单点;
- 限流、降级、熔断:在数据库前面加保护,检测到数据库压力过大时直接拒绝部分请求或返回兜底数据,保证系统不被打挂。
缓存的其他代价
内存成本与淘汰策略
内存昂贵且有限,缓存必然装不下全量数据,必须决定淘汰哪些数据:
- LRU:淘汰最久未被使用的,符合时间局部性;
- LFU:淘汰使用频率最低的,适合热点明显但访问分布不均的场景;
- Redis 的近似实现:采样 + 淘汰池,避免维护全量链表;LFU 用概率计数器近似统计频率。
淘汰策略本身也可能引入问题:例如偶发的冷数据把热点挤掉(可通过 allkeys-lru / volatile-lru 策略与容量规划缓解)。
热点 key 与大 key
- 热点 key:单个 key 的 QPS 超过单个 Redis 分片的处理能力,读压力集中在单节点;解决:key 拆多份随机取、本地缓存兜底;
- 大 key:单个 key 的 value 过大,造成网络阻塞、删除阻塞(大 key 的 DEL 会阻塞 Redis)、集群迁移困难;解决:拆分 key、用 hash/list 分片存储、渐进式删除。
序列化与网络开销
缓存里存的通常是序列化后的数据(JSON、protobuf),读写都涉及序列化/反序列化的 CPU 开销;跨网络访问 Redis 还有一跳网络延迟。当数据量小、QPS 低时,这些开销可能比省下的时间还多——"小对象 + 低 QPS"场景下缓存未必划算。
引入的复杂度与故障点
缓存服务本身成为关键依赖:缓存挂了,流量全部打到数据库,需要提前准备降级路径。同时还要为缓存付出持续的运维成本:命中率、延迟、内存监控与告警、容量规划、故障预案。这些都是"利"背后隐藏的账单。
权衡:用还是不用
| 维度 | 无缓存 | 有缓存 |
|---|---|---|
| 延迟 | 高 | 低(数量级下降) |
| 吞吐 | 受限于后端 | 大幅提升 |
| 一致性 | 强一致 | 最终一致 |
| 成本 | 低 | 多一份内存成本与运维成本 |
| 复杂度 | 低 | 高 |
适合引入缓存的场景:
- 读多写少;
- 能容忍短暂的不一致;
- 访问存在局部性(热点明显)。
不适合引入缓存的场景:
- 写多读少(缓存频繁失效,回填成本高,甚至成为纯负担);
- 对一致性有强要求且无法设计补偿(如金融核心账务);
- 数据访问完全随机、没有热点(缓存命中率极低,形同虚设)。
总结
缓存是典型的"没有银弹":它用一致性风险、内存成本与系统复杂度,换来了延迟和吞吐的数量级提升。引入缓存之前,先问自己三个问题:
- 这个业务的读写比例和热点分布,撑得起一个缓存吗?
- 我能不能接受"最终一致"?不一致窗口有多大?能不能用 TTL + 补偿把窗口收窄?
- 缓存挂了,我的系统有没有降级路径?
想清楚这三个问题,缓存才能成为系统的加速器,而不是下一个故障源。标题里的"弊"从来不是反对缓存的理由,而是一张必须提前看清的账单——缓存的本质,是把困难从"性能"转移到了"一致性"与"复杂度",前者立竿见影,后者需要长期支付。