← 返回博客
2026-09-09 08:00:01

缓存架构设计:多级缓存与缓存一致性,到底该怎么权衡?

缓存架构设计:多级缓存与缓存一致性,到底该怎么权衡?

缓存,是架构里最容易被“过度设计”也最容易“埋雷”的部分。很多团队一上来就搞 Redis Cluster + 本地缓存 + CDN,结果数据错乱、运维复杂,最后不得不推倒重来。今天用一套电商商品详情页的案例,把多级缓存和一致性这两个老大难问题拆开揉碎讲清楚。

业务场景:商品详情页的读多写少困局

先看一个具体场景:商品详情页,日均 PV 过亿,QPS 峰值能到 10 万,但商品信息(标题、价格、库存)的写操作(改价、上下架)一天也就几千次。典型的读多写少。

最粗暴的方案是直接查数据库。但 10 万 QPS 打到 MySQL 上,再好的机器也扛不住,连接池瞬间被打满,数据库 CPU 直接飙到 100%。这时候必须上缓存。

问题是,缓存怎么上?上几层?每一层缓存的数据怎么保证和数据库一致?这是今天要聊的核心。

第一层:本地缓存(Caffeine)为什么省不掉的

很多架构师第一反应是“我有 Redis 了,干嘛还要本地缓存?”

看一组容量评估数据(数据来源:某头部电商平台 2024 年双十一压测报告,具体数值按比例缩放):单机 Redis 客户端读取耗时约 1-2ms,而本地内存读取耗时约 0.01ms。当 QPS 单机达到 5000 时,如果全部打到 Redis,Redis 服务端 CPU 会成为瓶颈,单实例撑死也就 8-10 万 QPS。但本地缓存可以让 80% 的热点请求根本不出应用进程。

更关键的是网络开销。一次 Redis 访问要走 TCP 连接、序列化、反序列化,就算内网延迟只有 0.5ms,在 10 万 QPS 下也是巨大的资源浪费。

所以设计第一层缓存,用 Caffeine 做本地缓存,KV 结构,过期时间设置 60 秒。代码逻辑不复杂,但有一个关键点容易踩坑:

// 本地缓存,key为商品ID,value为商品详情JSON
Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(60, TimeUnit.SECONDS)
        .build();

关键点在于 expireAfterWrite(60)。这 60 秒是本地缓存的“容忍窗口”。在这 60 秒内,本地缓存的数据可能已经和 Redis 不一致了。这个窗口时间怎么定?取决于业务对数据新鲜度的容忍度。商品标题、描述这种非关键字段,容忍 60 秒没问题;但价格、库存这种用户直接感知的字段,60 秒就可能引起客诉。

所以一个常见的做法是:本地缓存只缓存“非敏感字段”,价格和库存永远走 Redis 层。这就是分字段缓存策略,很多电商就是这么干的。

第二层:Redis 缓存的一致性方案,缓存什么、什么时候失效

本地缓存是“锦上添花”,Redis 才是“雪中送炭”。但 Redis 和数据库之间的一致性,是设计里最容易出问题的环节。

常见的缓存模式有两种:Cache-Aside(旁路缓存)和 Write-Through(写穿透)。大部分业务场景用 Cache-Aside 就够了,因为写穿透适合“写多读少”的场景,而商品详情页是“读多写少”,Cache-Aside 的代价更低。

但 Cache-Aside 有个经典的一致性问题:更新数据库成功后,是先删缓存、还是先更新缓存?这里有两个方案,方案 A 是“先更新数据库,再删缓存”,方案 B 是“先删缓存,再更新数据库”。

方案 B 有个问题:如果在删完缓存还没更新数据库的间隙,有一个读请求进来,发现缓存没命中,就会去数据库读旧数据,然后回填到缓存里。等数据库更新完,缓存里还是旧数据,脏数据就产生了。

方案 A 虽然也有类似问题(更新数据库和删除缓存不是原子的),但概率低很多。所以选择方案 A,先更新数据库,再删缓存。

但方案 A 有一个致命场景:如果删缓存失败了呢?比如 Redis 网络抖动,删除操作超时了。缓存里还是旧数据,接下来的读请求全部命中旧缓存。这时候需要一个“兜底机制”,常用的做法是给 Redis 缓存设置一个较短的过期时间,比如 10 分钟。过期后自动回源数据库,数据自然就一致了。虽然这样做会有 10 分钟的数据延迟,但至少不会永久不一致。

这还没完,还有一个更隐蔽的坑:并发场景下的“缓存击穿”。假设缓存里的商品数据过期了,同一时刻有 1000 个请求发现缓存未命中,全部打到数据库上。数据库瞬间压力飙升。解决方案是“互斥锁”(Mutex),只有一个请求能去数据库回源,其他请求等待或者返回旧数据。代码实现不复杂,但要注意锁的粒度,锁的是“这个商品的 key”,而不是全局锁。

第三层:缓存更新策略,为什么用了消息队列而不是定时任务

到了这一层,上面的方案还够不够?实际运行中会发现,商品价格变动后,Redis 缓存删除了,但本地缓存的 60 秒窗口期内,用户还是看到旧价格。对于秒杀场景,这是不可接受的。

怎么解决?两个思路:一是缩短本地缓存的过期时间,比如改成 5 秒。但这会降低本地缓存的命中率,等于白搭。二是主动通知:数据库更新后,通过消息队列(比如 RocketMQ)广播一个“商品变更”事件,所有应用实例收到事件后,主动删除本地缓存。

方案二看起来更优雅,但代价是什么?引入了消息队列,系统复杂度上升,还要处理消息丢失、重复消费的问题。这里就要做 trade-off 了。

如果商品变更频率很低(一天几千次),而读请求量巨大,那么引入 MQ 的代价是值得的。但如果商品变更频繁(比如每分钟上万次),那 MQ 广播会变成另一个热点,本地缓存被频繁删除,命中率大幅下降,得不偿失。

实际项目中,可以这么折中:用 RocketMQ 广播变更事件,但事件里带一个“版本号”。本地缓存每次读取时,除了比较过期时间,还比较版本号。如果版本号不一致,说明数据已过期,主动回源 Redis。这样既解决了强一致问题,又不会因为 MQ 延迟导致缓存长期失效。

关键点在于:版本号是全局自增的,还是用更新时间戳?用全局自增需要引入分布式 ID 生成器,复杂度高;用更新时间戳(精确到毫秒)够用,但要注意时钟回拨问题(NTP 同步可能导致时间倒退)。实际项目中,更稳妥的做法是直接用数据库的 update_time 字段,只要保证同一商品的 update_time 单调递增就行。

演进路线:从单机缓存到多级缓存,再到一致性协议

很多团队一开始只用了 Redis,跑得挺好。后来 QPS 上去了,加了本地缓存,问题开始出现。再后来业务要求数据实时性,又上了 MQ 广播。这是典型的三步演进路径。

但演进不是无限的。当系统复杂度到了一定程度,是不是可以考虑引入更重的方案?比如 CQRS(命令查询职责分离)或者读写分离的架构。但这些都是“大炮打蚊子”,对于商品详情页这种场景,上面的“本地缓存 + Redis + MQ 广播”已经够用。如果追求更强的最终一致性,还可以考虑对 Redis 开启持久化,但代价是写入性能下降。

最后说点实际的

架构设计没有银弹。多级缓存能极大提升读性能,但每一级缓存都引入了一致性风险和运维成本。核心决策点就三个:

  1. 业务能容忍多长的数据延迟?容忍 60 秒,就用 Caffeine 本地缓存;容忍 10 分钟,Redis 过期时间设长点也没事;一秒都不能忍,那只能放弃缓存,直接查数据库或者上强一致方案。
  2. 变更频率和数据量级是多少?变更越频繁,越不适合多级缓存,因为缓存命中率会下降。
  3. 团队有没有能力运维 MQ 和缓存集群?引入 MQ 不是免费的午餐,消息堆积、重复消费、延迟,每一个问题都要有人处理。

下一步行动建议:如果你现在用的是单层 Redis 缓存,先别急着加本地缓存。先用监控工具看看 Redis 的 QPS 和响应时间,如果 Redis CPU 已经超过 70%,再加本地缓存不迟。加的时候,先用 30 秒的过期时间跑一周,观察数据一致性问题,再逐步调整。

本文关键词:多级缓存、缓存一致性、Caffeine、Redis、消息队列