Skip to content

缓存的利与弊 ​

There are only two hard things in Computer Science: cache invalidation and naming things. —— Phil Karlton

什么是缓存 ​

缓存(Cache)的本质是一种以空间换时间的设计:把数据放在离使用者更近、访问更快的存储层中,避免每次都去访问更慢、更昂贵的原始存储。缓存得以成立的理论基础是程序访问的局部性原理:

  • 时间局部性:刚被访问过的数据,在不久的将来很可能再次被访问;
  • 空间局部性:与刚被访问过的数据相邻的数据,很可能紧接着被访问。

只要业务访问模式符合局部性,缓存就能以远小于全量数据的容量,拦截掉绝大多数请求,也就是我们常说的"高命中率"。

缓存无处不在,几乎贯穿了整个计算机体系:

层级缓存原始存储量级对比
CPUL1/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 通常只有数千。把大量读请求挡在缓存层,后端数据库的负载会大幅下降,相当于给系统"扩容"。

更重要的是保护作用:数据库是系统中最后一道防线,也是全系统的瓶颈与故障高发区。缓存拦截掉大部分请求后,即使业务量增长,数据库也只需要处理缓存未命中的那一小部分流量。

应对峰值流量 ​

业务流量往往存在明显的波峰波谷(秒杀、大促、热点新闻)。缓存把尖峰削平:只要热点数据提前预热进缓存,峰值流量在缓存层就被消化掉了,后端系统可以按平均负载而非峰值负载来规划容量,成本低一个数量级。

缓存的弊 ​

引入缓存之后,数据有了两份副本(数据库一份、缓存一份),几乎所有的问题都源于这个"第二份副本":

  1. 一致性:两份数据如何保持同步;
  2. 成本:内存昂贵且有限,缓存装不下全部数据,必然涉及淘汰;
  3. 复杂度与可用性:多了一个组件,也就多了一个故障点。

缓存与数据库一致性 ​

这是缓存最核心、也最棘手的问题,开头的名言说的就是它。

Cache Aside 模式 ​

最主流的读写方案,读和写分开处理:

  • 读:先读缓存,命中直接返回;未命中读数据库,并回填缓存;
  • 写:先更新数据库,然后删除缓存(注意:是删除,而不是更新)。
go
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))
}

为什么写时是删除缓存而不是更新缓存?

  1. 避免并发写顺序错乱:两个并发写请求,先完成的写不一定先落库;如果都直接写缓存,缓存最终保留的可能是"后写先至"的旧值;
  2. 避免无效更新:很多数据写完后根本不会再被读(写多读少),更新缓存是纯浪费;
  3. 简单:删除 + 下次读时回填,让缓存内容始终是"被读出来的值",不用在写路径上维护缓存值的推导逻辑。

先更新数据库还是先删除缓存? ​

两个顺序都无法做到绝对一致,各有一个竞态窗口。

先删除缓存,再更新数据库:写请求删除缓存后、更新数据库前,读请求到来——缓存未命中,从数据库读到旧值并回填缓存,之后写请求才完成更新。结果:缓存中躺着一个旧值,一直持续到缓存过期。这个竞态窗口横跨整个写事务,概率不低。

先更新数据库,再删除缓存:同样存在一个理论窗口:

时间读请求写请求
t1缓存未命中
t2读数据库,得到旧值 A
t3更新数据库为 B
t4删除缓存
t5把旧值 A 写回缓存

结果同样是缓存中留存旧值。但这个竞态要求读请求恰好在"读 DB 之后、回填缓存之前"这极短的时间缝里撞上写请求完成整个写流程,概率远低于"先删缓存"方案,所以业界普遍选择先更新数据库,再删除缓存。

真正的麻烦在于删除缓存失败:数据库更新成功了,缓存删除失败了(网络抖动、Redis 故障),旧值会一直存活到过期。常见的解决思路:

  1. 重试:删除失败后把操作放入消息队列,异步重试直到成功;
  2. 订阅 binlog:用 Canal 等组件监听数据库变更日志,由独立的消费者异步删除/更新缓存。本质上是把"删除缓存"从业务写路径上剥离,变成数据库变更的消费者,天然具备重试与补偿能力。

工程实践中,缓存与数据库的一致性几乎总是最终一致:用合理的 TTL 兜底(过期时间一到旧值自然被清掉),叠加重试或 binlog 订阅的补偿机制,把不一致窗口收敛到业务可接受的范围内。如果业务要求强一致,就不应该引入缓存——或者用分布式锁把读写串行化,但那等于放弃缓存的大部分性能收益。

缓存穿透 ​

定义:查询一个根本不存在的数据,缓存和数据库中都没有。缓存形同虚设,每个请求都穿透缓存直达数据库。

典型场景:

  1. 恶意攻击:用大量随机不存在的 id 打接口;
  2. 业务上大量查询不存在的 key(爬虫扫描商品 id、用户输入非法 id)。

后果:数据库被无效查询打满,甚至被打挂。

解决:

  1. 缓存空值:把"不存在"也缓存下来(value 置空或打特殊标记),设置一个较短的 TTL。简单有效,代价是可能缓存大量空值 key、数据后来写入后会有短暂的"假不存在"(TTL 短可缓解);
  2. 布隆过滤器:在缓存之前加一层布隆过滤器判断 key 是否存在,不存在的直接拒绝,不进缓存也不进数据库。空间效率极高,但有误判率(假阳性),且删除元素困难,需要周期性重建;
  3. 接口层校验:参数合法性校验(id 必须大于 0、格式校验等),挡掉明显非法的请求。

缓存击穿 ​

定义:某个热点 key 过期的一瞬间,大量并发请求同时发现缓存未命中,同时回源数据库查询,数据库瞬间承受巨大压力。

与穿透的区别:击穿针对的是"存在的热点数据"(key 本来有值,只是恰好过期),穿透针对的是"根本不存在的数据"。

解决:

  1. 互斥锁(singleflight):同一时刻只放一个请求回源,其他请求等待结果。Go 有现成的 golang.org/x/sync/singleflight:
go
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 后重读缓存。

  1. 逻辑过期:缓存的 value 里带上逻辑过期时间,物理上不过期。读请求拿到数据后判断是否逻辑过期:未过期直接返回;已过期则先返回旧值,同时尝试获取互斥锁,抢到锁的线程异步重建。用户永远不会拿到"未命中",请求不会堆到数据库;
  2. 热点 key 不过期:识别出热点后直接不设过期时间,配合后台定时刷新。

缓存雪崩 ​

定义:大量 key 在同一时间集中过期,或者缓存服务整体宕机,瞬间所有请求穿透到数据库,把数据库打挂。

解决:

  1. 过期时间加随机抖动:在基准 TTL 上叠加随机值(如 TTL + rand(0, 300) 秒),把过期时间打散,避免同一时刻大面积失效;
  2. 多级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis),Redis 挂了本地缓存还能挡一部分;
  3. 缓存高可用:Redis 主从 + 哨兵 / Cluster 集群,避免单点;
  4. 限流、降级、熔断:在数据库前面加保护,检测到数据库压力过大时直接拒绝部分请求或返回兜底数据,保证系统不被打挂。

缓存的其他代价 ​

内存成本与淘汰策略 ​

内存昂贵且有限,缓存必然装不下全量数据,必须决定淘汰哪些数据:

  • 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"场景下缓存未必划算。

引入的复杂度与故障点 ​

缓存服务本身成为关键依赖:缓存挂了,流量全部打到数据库,需要提前准备降级路径。同时还要为缓存付出持续的运维成本:命中率、延迟、内存监控与告警、容量规划、故障预案。这些都是"利"背后隐藏的账单。

权衡:用还是不用 ​

维度无缓存有缓存
延迟高低(数量级下降)
吞吐受限于后端大幅提升
一致性强一致最终一致
成本低多一份内存成本与运维成本
复杂度低高

适合引入缓存的场景:

  • 读多写少;
  • 能容忍短暂的不一致;
  • 访问存在局部性(热点明显)。

不适合引入缓存的场景:

  • 写多读少(缓存频繁失效,回填成本高,甚至成为纯负担);
  • 对一致性有强要求且无法设计补偿(如金融核心账务);
  • 数据访问完全随机、没有热点(缓存命中率极低,形同虚设)。

总结 ​

缓存是典型的"没有银弹":它用一致性风险、内存成本与系统复杂度,换来了延迟和吞吐的数量级提升。引入缓存之前,先问自己三个问题:

  1. 这个业务的读写比例和热点分布,撑得起一个缓存吗?
  2. 我能不能接受"最终一致"?不一致窗口有多大?能不能用 TTL + 补偿把窗口收窄?
  3. 缓存挂了,我的系统有没有降级路径?

想清楚这三个问题,缓存才能成为系统的加速器,而不是下一个故障源。标题里的"弊"从来不是反对缓存的理由,而是一张必须提前看清的账单——缓存的本质,是把困难从"性能"转移到了"一致性"与"复杂度",前者立竿见影,后者需要长期支付。

Reference ​

  1. 缓存那些事 - 美团技术团队
  2. Scaling Memcache at Facebook - USENIX NSDI 2013
  3. Redis 官方文档 - Key eviction
  4. singleflight - golang.org/x/sync
  5. 为什么 Redis 单线程还能这么快 - draveness