搞了六年多的后端,我发现自己对缓存的态度一直在变。刚入行那会儿觉得缓存无非就是把热点数据往内存里一塞,读的时候先查缓存,查不到再查库,写回去,完事。后来系统规模上来了,线上问题爆过几轮之后才明白,大型分布式系统里缓存这一环,水比想象中深得多。这篇东西不是教科书,是我这些年踩坑踩出来的经验总结,覆盖缓存选型、一致性、穿透击穿雪崩、热点大Key治理、容量规划,以及一个多级缓存的真实落地案例。适合正在做后端开发、系统架构设计,或者已经被线上缓存问题折磨过的同学。
1. 缓存的两个层次:本地缓存和分布式缓存怎么选
1.1 本地缓存:进程内的最后一层防线
本地缓存就是跑在应用进程内部的那一块内存缓存,Java 生态里最常见的是 Caffeine、Guava Cache,Go 生态里常见的有 bigcache、freecache,本质上都是基于堆内存储的 KV 结构。
本地缓存最大的优势是快,因为它压根不走网络,直接从进程内存里拿数据。一个 HashMap 的 get 操作是纳秒级别的,而一次 Redis 的网络往返,即使在同一机房,也得花上几百微秒到一毫秒。这个差距在高并发场景下会被放大到极致。
但它的问题也很突出。首先是数据一致性难保证,每个服务实例都存了一份自己的副本,某个实例的缓存更新了,其他实例还是旧数据。其次是容量受限,JVM 堆内存是有限的,本地缓存塞太多会挤压业务对象的内存空间,直接导致 GC 频繁,反而拖垮整个应用。
所以本地缓存的定位要清楚:它是用来挡最后一层压力的,而不是用来存全量数据的。适合放那些访问量极高、对短暂不一致容忍度高的热点数据,比如商品详情页的商品名称、价格,用户维度的部分配置信息。
1.2 分布式缓存:跨节点的共享缓存
分布式缓存的核心价值是跨节点共享。所有服务实例访问的是同一份缓存数据,某个节点写入的数据,其他节点能立刻读到,一致性天然比本地缓存强一个档次。
Redis 是当下最主流的方案,没有之一。它的优势很明确:单线程模型配合内存操作,吞吐量极高;支持 String、Hash、List、Set、ZSet 一整套数据结构,覆盖大部分业务场景;主从复制、哨兵、Cluster 集群方案成熟,能支撑几十上百 GB 甚至 TB 级别的数据量。
但在选型时也不能无脑上 Redis,得看清场景。如果只是简单 key-value 缓存,对数据结构和持久化要求不高,Memcached 依旧能打,它内存利用率极高,纯内存操作,性能非常稳定。如果业务对最终一致性要求更高,并且已经重度依赖数据库,Tair 这类阿里系方案也可以纳入考量。还有一点,自建 Redis 的成本不只是机器,还有运维体系,比如监控告警、数据迁移、集群扩缩容,这些隐性成本往往比机器本身更高。
1.3 两个层次怎么搭配才合理
本地缓存和分布式缓存不是二选一,而是协同作战。经典做法是两级缓存架构:应用先查本地缓存,查不到再查 Redis,Redis 也没有就回源数据库。
为什么要这么设计?因为 Redis 虽然快,但在极高 QPS 场景下,每次请求都打 Redis 也会成为瓶颈,特别是网络带宽和连接数。本地缓存相当于在 Redis 前面又加了一层缓冲,把最简单的热点请求拦截在进程内。
需要注意的是,本地缓存的 TTL 必须比 Redis 短。比如本地缓存设 5 秒,Redis 设 10 分钟,这样即使本地缓存和 Redis 数据不一致,也顶多持续几秒,很快能自愈。另一个关键点是,加了本地缓存之后,单个请求的链路变长了,需要对整条链路的耗时做监控,防止某个环节拖慢全局。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存一致性:最折磨人的部分
2.1 到底是删除缓存还是更新缓存
缓存一致性问题上,绝大多数团队的第一道坎就是:数据库更新后,到底该删除缓存还是更新缓存?
直接更新缓存的坑非常深。举个例子,一个商品库存,线程 A 读到库存 10,线程 B 也读到库存 10,然后 A 把它扣减成 9 并写库,B 把它扣减成 8 并写库。数据库最终值是 8,但如果 A 先写缓存 9,B 再写缓存 8,缓存最终是 8,表面上没问题。但反过来,如果 B 先更新缓存 8,A 后更新缓存 9,缓存里就成了 9,而数据库是 8,数据就错了。
所以我的习惯是,Cache Aside 模式下,数据库更新成功之后,直接删除缓存,而不是更新缓存。道理很简单:缓存里存的不是一个必须和数据库同步的副本,而是一个可以被重建的结果,删掉它,让下一次读请求去回源,重建出的数据一定是最新的。
2.2 延迟双删到底怎么删
只删一次缓存还不够,因为在极端并发下还是会出问题。场景是这样的:线程 A 读缓存,没命中,回源数据库读到了旧值 10;线程 B 更新数据库为 8,然后删除缓存;这个时候线程 A 才把旧值 10 写入缓存。结果缓存里存的是旧值,数据库是新值,缓存永远和数据库不一致了。
延迟双删就是为了解决这个时间差。操作流程是:
- 线程 A 更新数据库
- 线程 A 删除缓存
- 线程 A sleep 一段时间(比如 500ms)
- 线程 A 再次删除缓存
第二次删除的意义在于,尽量把读请求把旧值写回缓存的那个窗口覆盖掉。sleep 时间怎么定?核心考虑是,如果业务用了主从架构,要覆盖主从同步延迟 + 一次读请求回源写缓存的时间。经验上,取主从同步延迟的峰值,一般是几百毫秒到一两秒之间。
不过延迟双删也不是银弹。第二次删除失败了怎么办?我一般用消息队列补偿,删除失败后发一条延迟消息,由一个独立的消费者来重试删除。再就是延迟双删只能缩小不一致窗口,不能完全消除,如果业务对一致性要求近乎苛刻,就得考虑下面更重的手段。
2.3 更进阶的姿势:订阅数据库变更日志
如果团队规模允许,我强烈推荐考虑用 Canal 订阅 MySQL 的 binlog,把缓存重建做成一个异步事件。
流程是:业务只改数据库,Canal 监听 binlog 里的数据变更事件,发布到 MQ,消费者拿到变更事件后,删除对应缓存或者把最新数据写入缓存。这样业务代码不需要感知缓存的存在,一致性由底层机制兜底,代码侵入小且逻辑清晰。
这个方法也不是没有代价。首先,Canal 本身是一个额外的中间件,部署和运维需要成本;其次,binlog 推送有延迟,极端情况下可能秒级,对实时性要求极高的场景不适合。它的最佳战场是读多写少、对不一致容忍度稍高的数据,比如商品信息、用户资料、文章内容。
但有一点必须明确:即便用了 Canal,我还是会保留一个兜底机制,就是给缓存设 TTL。因为不管链路设计得多完善,总有消息丢失、消费失败的情况,一个过期时间能保证数据最终一定被修正,最多损失一点实时性。
3. 缓存穿透、击穿、雪崩,三板斧怎么接
3.1 穿透:查了根本不存在的数据
缓存穿透是指查询一个数据库里压根不存在的 key,缓存永远不可能命中,每次请求都直接打到数据库。正常业务下影响不大,但如果是恶意攻击,或者有人写了一个循环查询不存在的 ID 的脚本,数据库瞬间就可能被打垮。
最朴素的方案是缓存空值。查不到数据就把空结果缓存起来,TTL 设短一点,比如 60 秒。这样同一个不存在的 key,后续请求都走缓存,不再打库。但要注意,如果攻击者不断变换 key,比如 user:id1、user:id2 一直到 id99999,缓存里会塞入大量无意义的空 key,内存占用会膨胀。所以缓存空值的 TTL 一定要短,并且建议对 key 做一层合法性校验,比如 ID 是否符合规则,先拦截掉无效请求。
另一个更优雅的方案是布隆过滤器。把所有存在的 ID 预先加载进布隆过滤器,查询前先判断 ID 是否存在,不存在直接返回异常。布隆过滤器有两个关键参数:预估数据量和误判率,它们决定位数组大小和哈希函数个数。有个近似公式:m = -(n * ln(p)) / (ln2)^2,m 是位数组 bit 数,n 是预估元素量,p 是允许的误判率。比如预估一亿条数据,误判率设 1%,大约需要 9.6 亿 bit,也就是约 114MB 内存,这个量级是可以接受的。
3.2 击穿:热点 key 失效的一瞬间
缓存击穿指的是某一个热点 key 在过期瞬间,大量请求并发涌入,同时回源数据库。和高并发配合起来,数据库很难扛住这种瞬时冲击。
最常用的方案是互斥锁。在缓存失效时,只允许一个线程去回源数据库,其他线程等待锁,拿到锁的线程把数据写回缓存后,其他线程从缓存读取。Redis 里可以用 setnx 命令实现分布式锁,设置一个短超时防止死锁。
还有一套进阶玩法是逻辑过期加后台刷新。value 里存一个逻辑过期时间,比如实际 TTL 设成一小时,但 value 里塞入一个 expiresAt 字段,读的时候判断逻辑是否过期。逻辑没过期就直接返回,逻辑过期了就先返回旧数据,同时用一个后台线程池去异步刷新缓存。这种方式的好处是用户体验不会出现集中迟延,坏处是每次读都要多一次比对,且数据在刷新完成前可能是旧值。
如果业务对实时性要求不苛刻,还有一个非常省事的办法:热点 key 永不过期,由后台定时任务主动更新。这样就完全绕开了击穿问题,代价是数据更新有延迟,且缓存永远依赖后台上一次刷新的成功。
3.3 雪崩:大量 key 同时失效
缓存雪崩是比击穿更可怕的状态,指的是大量 key 集中在同一个时间点失效,导致巨量请求直接打向数据库。典型场景是系统刚重启,缓存全空,或者一批设置了相同过期时间的 key 在同一时刻集体过期。
缓解方案有几个,可以叠加使用。第一,过期时间加随机值,比如 TTL = 5分钟 + random(0, 60秒),让 key 的失效时间均匀分散开。第二,引入多级缓存,本地缓存和分布式缓存搭配使用,即使 Redis 的 key 大面积失效,本地缓存还能挡一层。第三,Redis 自身高可用,通过集群多副本保证缓存服务不整体宕机。
这里提醒一句,很多人以为加了随机值就万事大吉了,其实如果整个 Redis 实例宕机重启,全部缓存一空,雪崩照样发生。所以更稳妥的做法是:Redis 集群保证多副本,应用侧做好降级方案,数据库侧设置连接池保护和限流,层层设卡。
4. 缓存治理:热点 Key、大 Key 和容量规划
4.1 热点 Key 怎么治
热点 Key 是分布式缓存场景里最常见也最头疼的问题。一个商品大促、一个明星八卦、一条热门新闻,都可能让某一个 key 的访问量瞬间暴增,导致 Redis 单分片被打满,甚至整个集群出现倾斜。
第一步是发现热点。可以用 Redis 自带的 MONITOR 命令,但生产环境不建议高频使用,因为会损耗性能。更常用的手段是,在访问缓存的代码层做计数器统计,比如用内存里的滑动窗口算法记录 top N key;或者通过代理层统计,如果用了 Codis、自研 Proxy,这些中间件一般都有 key 访问统计。
发现之后怎么处理?最直接的办法是拆 key。把原本的一个大 key 拆成多个小 key,例如 hot:1、hot:2 一直到 hot:N,请求随机访问其中一个,把压力分散到多个分片。这个方法简单有效,但会让代码变复杂,也要注意拆完之后每个小 key 的数据一致性。
还有一种思路是本地缓存挡掉极热流量。热点 key 的数据量一般不大,可以在每个服务实例的本地缓存里放一份,TTL 设得短一点,即使有短暂不一致,也比打到 Redis 单分片强得多。这也是多级缓存架构在热点治理中的威力所在。
4.2 大 Key 治理
所谓大 Key,在 Redis 里一般指 value 特别大的 String,或者集合类型里元素特别多的 Hash、List、Set、ZSet。判断标准可以看具体数据量,但经验上,单个 String 超过 10KB,或者集合元素超过 5000 个、总占用超过几 MB,就可能引发问题。
大 Key 的危害很隐蔽。Redis 是单线程模型,处理一个大 Key 的读写会阻塞后面所有请求,造成命令延迟飙高;大 Key 在集群模式下也容易导致内存分布不均,让某些分片比其他分片压力大得多。还有一个更隐蔽的坑:删除大 Key 的时候,del 命令本身就会阻塞 Redis,官方推荐用 unlink 异步删除,避免删了一个 1GB 的 key 把整个 Redis 卡死几秒钟。
发现大 Key 可以用 redis-cli --bigkeys 做扫描,也可以写脚本定期扫描集合类型的长度和内存占用。处理手段:字符串类的大 value 要压缩或者考虑上层拆分;集合类的大 key 要考虑改成多个小集合,或者想办法让业务按时间和维度拆开存储。没有一劳永逸的方案,但对每一个具体大 Key 做具体分析,这个动作本身就能避免很多线上事故。
4.3 容量规划与淘汰策略
缓存容量规划不是拍脑袋定数字,而是可以算出来的。核心公式是:所需内存 = 平均单个 key 的内存占用 (key大小 + value大小 + 元数据开销约 50~100字节) × 预估 key 数量 × 每条数据在缓存里的平均生命周期。
举个例子,一个商品详情缓存,每条数据 key 加 value 平均约 2KB 大小,预估热点商品 50 万个,TTL 平均 2 小时。那么峰值所需内存大约是 2KB × 50万 = 1GB,加上 Redis 自身的元数据开销、碎片率,实际建议按 2 到 2.5 倍预留,也就是 2GB 到 3GB 左右。这样算出来的容量才有依据,而不是上线之后发现内存告急。
淘汰策略也要选对。Redis 提供的 maxmemory-policy 中,缓存场景最常用的是 volatile-lru 和 allkeys-lru。如果所有 key 本来都设了过期时间,用 volatile-lru 就够;如果有些 key 是永久的,但又希望内存超限时能淘汰,就得用 allkeys-lru。还有一种情况是访问频次差异极大的场景,可以选用 allkeys-lfu,LFU 能更好地保留高频 key。
缓存命中率是衡量容量规划是否合理的第一指标。一个健康的缓存系统,Redis 命中率应该在 95% 以上,本地缓存命中率在 90% 左右或者更高。如果命中率低于 80%,说明 TTL 设置太短、key 设计不合理,或者淘汰策略有问题,需要回头重新审视。
5. 一个实战案例:商品详情页的多级缓存落地
5.1 场景和问题
拿我之前负责过的商品详情页举例。这个页面承载了商家后台、买家端、运营后台的多方流量,一个爆款的商品详情页,峰值 QPS 能到几万。最初的方案很朴素,所有请求直接打 Redis,Redis 没命中再查数据库。
问题很快暴露出来。第一个问题是热点商品集中,单个爆款商品的 Redis QPS 突破单分片极限,集群其他分片还闲着,但整个请求已经变慢。第二个问题是数据库压力波动大,每次缓存统一的 30 分钟过期时间一到,一大波请求同时涌向数据库,延迟直接攀升到秒级。
而且当时还没有做本地缓存,每次详情页请求都要走一次网络访问 Redis,网络 IO 和序列化开销叠加,响应时间一直压在 80ms 左右上不去。
5.2 架构和关键参数
后来我做了两级缓存架构的调整。整体链路是:请求到达后先查本地 Caffeine,没命中再查 Redis,Redis 也没命中才回源数据库,并回填两级缓存。
关键参数是这么定的:
本地缓存 Caffeine:最大容量设置为 1 万条,TTL 设 5 秒。为什么 TTL 设这么短?因为本地缓存没有失效通知机制,各实例之间数据不一致的风险最大,5 秒已经是能接受的节奏了。
Redis 缓存:TTL 设 10 分钟,同时给过期时间加了 0 到 60 秒的随机值,避免大量 key 同时失效。缓存的 value 是商品详情 JSON 序列化后的字符串,平均大小约 8KB。
数据库回源:加了互斥锁,同一时刻只允许一个线程回源,其他线程等待锁释放后直接从缓存读取,防止缓存击穿。
这套架构上线之后的效果很明显。本地缓存命中率在热点商品上达到 85%,Redis 命中率保持在 96% 以上,整体响应时间从 80ms 降到了 20ms 左右,数据库最大 QPS 从峰值几千降到了几百,压力瞬间小了很多。
5.3 踩过的坑和自我修正
再好的架构也是踩坑踩出来的,这里说几个印象深刻的。
第一个坑是本地缓存的脏数据。某个运营活动改了商品主图,但本地缓存 TTL 还没到,买家端看到的还是旧图,被运营投诉了两次。后来我加了一个版本号机制,运营后台每次修改商品信息,Redis 里更新数据同步递增版本号,本地缓存每次读的时候检查版本号,不一致就放弃本地缓存直接走 Redis。版本号的值很小,这个额外检查的开销可以忽略。
第二个坑是后台刷新线程池的并发控制。逻辑缓存过期后会有异步刷新,但第一次上线时没做并发控制,同一个热点商品被多个请求同时触发刷新,数据库还是被打了几个尖峰。后来在刷新逻辑里用分布式锁串行化,并且加了线程池队列,这个问题才算根治。
第三个坑是 Redis 内存碎片。大促期间大量写入和淘汰 key,碎片率一度超过 1.5,内存使用率虚高。后来设了定时重启和主从切换维护窗口,并且把 value 做了一层压缩,内存整体下降了约 30%,稳定性明显提升。
这个案例给我最深的体会是,缓存架构永远不是一锤子买卖。设计的时候要留出观测手段,上线的前几周要持续盯命中率、响应时间、GC、内存水位这些指标,发现问题快速调整参数。缓存这个东西,调参的余地非常充足,但前提是要有数据支撑,不能靠猜。
最后再分享一个小技巧:如果你负责的系统还没做好缓存监控,优先把 Redis 的命中率、延迟、内存使用率、网络带宽这四个指标接入告警,最好再加上 key 级别的访问量 Top N 统计。这几个指标能帮你提前发现大部分缓存隐患,很多时候,热点 Key 和大 Key 在酿成事故之前,这些指标已经给足了信号,只是当时没人看而已。
