先说个我自己的经历。早年维护过一个电商系统,大促前压测,数据库连接池被打满,DBA半夜打电话把我叫醒,说“你的缓存是不是失效了”。我登上去一看,缓存确实失效了——不是Redis挂了,而是我压根没设过期时间之外的保护机制,几千个热key同一秒过期,数据库直接被流量怼穿。从那以后我就明白,缓存这东西,玩得好是系统的加速器,玩不好就是事故的导火索。
大型分布式系统里,缓存从来不是“存一份数据到Redis”那么简单。它涉及分层设计、一致性保障、异常兜底、容量治理、监控告警一整套链路。这篇文章我把这些年实操下来关于缓存的经验做一个系统梳理,从架构分层到代码细节,从经典问题到线上治理,尽量讲全、讲透,配合可落地的方案和参数,希望能给正在做分布式系统设计或维护线上缓存体系的朋友一些参考。
1. 缓存体系的整体设计思路
1.1 缓存到底解决了什么问题,代价是什么
很多人一上来就谈Redis、谈Caffeine,但很少先想清楚“为什么要缓存”。缓存的核心意义就一句话:用更快的存储,承接更高频的读取,从而保护更慢、更脆弱的存储(通常是数据库)。它能扛住读多写少的业务场景,把响应时间从几十毫秒压到几毫秒,同时在流量尖峰时替数据库挡住大部分请求。
但缓存的代价也容易被低估。第一,数据会不一致,缓存里的数据总是“某个时刻”的副本,如果更新策略不严谨,用户会读到旧数据。第二,缓存本身也是组件,Redis集群一旦出问题,如果客户端没有良好的降级方案,故障范围会被放大。第三,缓存引入了额外的运维复杂度,容量规划、key治理、监控告警都得跟上。想清楚这三条,再决定哪些数据值得缓存,哪些数据坚决不碰缓存,才是靠谱的设计起点。
1.2 什么数据值得进入缓存
选缓存数据有一条经验法则:读多写少、访问热、数据量可控、一致性容忍度可评估。典型适合缓存的包括用户会话、商品详情、配置信息、验证码、热点资讯列表等。不适合直接缓存或需要谨慎缓存的有库存扣减、余额变动、强一致的金融订单状态——这类数据如果为了性能硬上缓存,后续一致性处理会让你痛不欲生。
我这里习惯的做法是先给数据做分级。P0级是强一致数据,不缓存或仅在数据库事务内做短时缓存,并设置极短的过期时间;P1级是允许秒级延迟的数据,比如用户资料的非关键字段,用Cache Aside模式配合过期时间;P2级是可容忍较长时间不一致的数据,比如资讯类内容,可以直接用长过期时间甚至手动失效。分级做完,缓存策略才能有的放矢。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存分层设计:本地缓存与分布式缓存怎么配合
2.1 本地缓存Caffeine为什么是必需品
很多人觉得有了Redis就不需要本地缓存了,这是一个很大的误区。Redis再好,它也是一次网络IO。在一个高并发系统里,单次Redis读取大约0.5到2毫秒(取决于网络和序列化方式),而本地内存读取是纳秒级的。当某个key的访问量达到每秒数万次时,全部打到Redis上,Redis的连接和带宽就会成为瓶颈。
Caffeine是目前Java生态里最强的本地缓存库,支持基于容量、基于时间的淘汰策略,还内置了TinyLFU算法,能更智能地保留高频key。我在业务中常用下面这套配置:
java复制Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
这个配置的意思是:最大存储一万个条目,写入10分钟后过期,开启命中率统计。需要注意,本地缓存的容量不能拍脑袋定,要结合JVM堆内存预算。一台8G堆的实例,如果本地缓存塞进去3G数据,GC会变成噩梦。我一般把本地缓存容量控制在堆内存的5%-10%,并且对放入的对象做体积估算,避免大对象塞爆内存。
2.2 分布式缓存Redis承接的职责
Redis在分布式缓存里的地位不用多说。它负责跨实例共享数据、承接本地缓存未命中的流量、以及通过集群模式提供大容量存储。用Redis做分布式缓存时,关键不是“连上能用”,而是连接池、序列化、健壮性这些细节点。
连接池参数很多团队沿用默认值,遇到流量波动就会出问题。我常用的Lettuce连接池配置大致是:
code复制最小空闲连接:8
最大连接数:32(按实例CPU核数乘以8估算)
最大等待时间:500ms
串行化的选择方面,JDK原生序列化不推荐,性能差且占用空间大。我通常用Jackson或Protobuf,对象结构频繁变化的场景用JSON,性能敏感且结构稳定的场景用Protobuf或Kryo。另外,一定要为Redis客户端配置合理的超时时间,尤其是socketTimeout不能太长,避免Redis假死时业务线程全部挂在连接上。
2.3 多级缓存的读取链路和防击穿
多级缓存的经典读取链路是:本地缓存 -> Redis缓存 -> 数据库。请求进来后先查Caffeine,未命中再查Redis,还未命中才回源数据库,回源后逐级回填。这套链路能把90%以上的读取拦截在最快的两层里,但也带来了新问题:缓存穿透时,热点key在两级缓存里都不存在,大量请求会同时打到数据库。
解决这个问题的核心手段之一是“请求合并”,也就是singleflight。当同一个key回源数据库时,只允许一个线程真正去查库,其他线程等待那个线程的结果。Caffeine本身支持LoadingCache,配合一个简单的锁或者CompletableFuture就能实现请求合并。另一个手段是本地缓存空值,把null结果也短暂缓存几十毫秒到几秒,但空值缓存要记录来源,避免无效key把内存打满。
这里要多说一句:多级缓存不是越级越多越好。每一级缓存都意味着多一份一致性成本和内存开销。一般互联网业务做两级缓存(本地 + Redis)就够用了,除非有极端的热点读场景,才考虑加更前置的CDN层。
3. 缓存更新的核心难题:一致性怎么保证
3.1 Cache Aside模式为什么能成为主流
缓存更新的常见模式有Cache Aside、Read Through、Write Through、Write Behind。理论听起来很多,但实际生产环境里90%以上的系统用的都是Cache Aside。它的规则非常简单:读的时候先读缓存,读不到就读数据库,然后回填缓存;写的时候先更新数据库,再删除缓存。
为什么是“删除缓存”而不是“更新缓存”?因为更新缓存的开销高且容易产生脏数据。比如一个对象有多个字段,一次写操作只改了其中一个字段,更新整个缓存对象就需要重新组装数据;如果并发写的顺序和缓存更新的顺序不一致,缓存里存的就是旧值。而删除缓存要简单得多,下次读取时自然会把最新数据回填进去。
3.2 先删缓存还是先更新数据库
Cache Aside里最经典的问题就是:写操作时到底是“先删缓存再更新数据库”,还是“先更新数据库再删缓存”。理论上两种都有问题,但实操中“先更新数据库,再删除缓存”的坑要少得多。
如果先删缓存再更新数据库,中间有一个窗口期:另一个线程读到空缓存,直接回源数据库并回填旧数据,那么即使数据库之后被更新了,缓存里依然是旧数据,很难自愈。而“先更新数据库,再删缓存”即使删除失败,也只是缓存里短暂保留旧值;只要做好失败重试或延迟双删,最终一致性是能收敛的。所以我的默认方案就是:更新数据库成功后,异步删除缓存。
3.3 延迟双删和最终一致性方案
只靠“先更新库再删缓存”还不足以应对所有并发场景。考虑一个典型竞争:线程A更新数据库为值1,然后删缓存;线程B在A删缓存之前读到了旧值并回填了缓存,那么A的删除操作相当于白做了,缓存里依然是旧值。
要解决这个问题,可以使用延迟双删:第一次删除后,等待几十到几百毫秒(视业务耗时而定),再删一次缓存。第一次删除确保快速失效,第二次删除兜住慢线程回填的脏数据。延迟时间需要经验值,一般取50ms到500ms,设置太短可能没兜住,太长会让陈旧时间变长。延迟双删的删除动作一定要异步执行,不能阻塞主链路。更保险的做法是引入Binlog订阅(比如Canal),在数据库变更后通过消息可靠地触发缓存删除,这是很多中大型系统采用的最终一致性方案。
4. 缓存三大经典难题:穿透、击穿、雪崩
4.1 缓存穿透:查一个不存在的东西
缓存穿透指的是查询一个数据库中也不存在的数据,缓存里自然也不可能命中,每次请求都直接打到数据库。恶意攻击最常见的做法就是扫描不存在的ID。穿透的危害在于它绕过了缓存这层保护伞,让数据库承受了所有流量。
应对穿透有几板斧。第一是缓存空值,将null结果也缓存起来,过期时间设为30到60秒,这能有效拦截大部分无效请求。第二是参数校验,在入口处过滤明显非法的ID,比如负数、超长字符串。第三是布隆过滤器,把存在的ID提前加载进布隆过滤器,查询前先判断ID是否可能存在,如果过滤器说不存在,直接返回,连缓存都不用查。布隆过滤器有一定误判率(会把不存在判断为存在),所以它的定位是“拦截确定不存在的key”,拦截不住的部分再用空值缓存兜底。
4.2 缓存击穿:热点key过期瞬间
缓存击穿专指某个热点key在过期的瞬间,大量并发请求同时回源数据库。这个和穿透的区别在于,击穿的目标key在数据库里是“存在的”,只是缓存刚好失效。典型场景就是微博热搜、爆款商品详情这类流量高度集中的key。
对付击穿的核心是“只允许一个请求回源”。可以结合前面提到的请求合并,或者用分布式锁实现:当缓存未命中时,先尝试获取一把针对该key的锁,拿到锁的线程查数据库并回填缓存,拿不到锁的线程短暂自旋等待后重查缓存。锁的粒度要控制到key级别,不能全局一个锁,否则所有key的回源都会串行化。还有一个偏方,在缓存过期时间上引入随机值,避免多个热点key同时到期,但这只能缓解不能根治。
4.3 缓存雪崩:大面积同时失效
雪崩和击穿的区别在于范围。击穿是单个key失效,雪崩是大量key在同一时间段失效,或者Redis节点直接不可用,导致流量整体压向数据库。数据库瞬间被压垮后,重启也可能被再次压垮,形成恶性循环。
处理雪崩有几个常规操作。一是过期时间加随机值,比如基础过期时间300秒,再附加0到60秒的随机数,让key的失效时间分散开。二是多级缓存兜底,Redis挂了还有本地缓存顶着。三是Redis高可用,部署哨兵或集群模式,避免单点。四是服务降级,在缓存完全不可用时,直接拒绝部分非核心请求或返回降级数据(比如预置的默认信息),保全核心链路。这里特别要提醒,降级逻辑一定要提前做压测,否则真到了雪崩那一刻,降级代码本身也是一次风险。
5. 缓存治理:线上踩过的坑和优化方案
5.1 缓存容量规划与淘汰策略
缓存里的数据不能只进不出,容量规划是日常运维里最容易遗忘的事。很多人把Redis当无限容器用,什么数据都往里面塞,等到内存快满了才发现需要清一清。Redis提供多种淘汰策略,我强烈建议线上环境的maxmemory不要设置为0(不限制),而要根据业务数据和实例规格估算一个合理上限,然后配置合适的淘汰策略。
不同场景的淘汰策略选择可以参考下表:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| volatile-lru | 仅对设置了过期时间的key按LRU淘汰 | 通用缓存场景,日常首选 |
| allkeys-lru | 对所有key按LRU淘汰 | 缓存数据不重要,可以全部淘汰 |
| volatile-ttl | 淘汰剩余存活时间最短的key | 想让快过期的key优先过期 |
| noeviction | 不淘汰,内存满后拒绝写 | 禁止丢数据的场景,要慎重使用 |
我这里大部分业务缓存用的都是volatile-lru,既保留热数据,又能让冷数据自然淘汰。另外要养成给key设计规范的习惯,比如“业务名:模块名:ID”这种格式。虽然看起来啰嗦,但排障时能节省大量时间。
5.2 缓存命中率监控和提效
缓存命中率是衡量缓存效果的核心指标,我把它视为缓存系统的“KPI”。本地缓存命中率低于80%,说明缓存容量不足或淘汰策略不合理;Redis缓存命中率低于90%,就要检查是不是存在大量穿透或失效。监控命中率必须细化到业务模块,不能只看全局平均值。全局命中率好看,但某个核心链路的局部命中率可能已经惨不忍睹。
提升命中率有几个实操手段:一是合理增加缓存容量,特别是把热点数据留在内存里;二是优化过期时间,让数据在业务访问周期内尽量有效;三是引入本地缓存承接超热key;四是定期分析keyspace的访问分布,找出那些“平时不热、偶尔爆热”的数据,提前做预热。还有一种冷门但很实用的手段是启动预热,系统刚启动时,把最近一段时间访问量Top N的key直接加载到本地缓存,避免冷启动阶段大量回源数据库。
5.3 热key治理和淘汰策略的动态调整
热key是缓存场景里的头号敌人。单个Redis分片上的某个key请求量极大时,哪怕整个集群还有大量空闲资源,那个分片的CPU和带宽也会被打满。解决热key问题要从识别和拆解两个方向入手。
识别热key可以使用Redis的hotkey发现机制,比如较新版本的Redis支持使用redis-cli --hotkeys扫描,或者在客户端侧统计访问频次,把超过阈值的key上报到监控系统。拆解热key的常见手段有:给key增加随机后缀,把同一个key的读请求分散到多个副本key上(例如product:1001拆成product:1001:01到product:1001:10);或者在本地缓存中专门为热key增加更长的存活时间。注意,分散key会增加一致性维护成本,因为写操作需要同时更新所有副本key,这个trade-off要结合业务评估。
6. 缓存之外的延伸思考
6.1 从LRU到缓存算法选型
聊缓存绕不开算法。最经典的LRU(Least Recently Used)大家都懂,但纯LRU有一个问题:一次偶然的批量扫描会污染缓存,把真正高频的数据挤出去。所以Caffeine使用了TinyLFU,通过频率统计和衰减机制,尽可能保留“长期高频”的key,抵抗扫描式污染。Redis的近似LRU也是类似的思路,它不维护全量精确的访问时间,而是抽样淘汰,兼顾性能与效果。
算法选型不需要过度设计。大多数业务用LRU就够了,只有当缓存访问分布极度倾斜时,才需要考虑LFU变种。理解这些算法的底层逻辑,不是为了写一个缓存组件,而是为了在调参和排障时知道问题可能出在哪儿。
6.2 KV缓存和大模型推理场景的启发
最近大模型推理的缓存优化很热,核心思路本质上依然是缓存思想。大模型在生成回答时,会把历史token的KV值缓存起来,避免每生成一个token就重复计算前面的注意力,这叫KV Cache。KV Cache的命中率直接影响推理速度,这和业务系统里的Redis缓存命中率概念如出一辙。大模型推理优化里经常讨论的PREFIX CACHING、语义缓存,其实都是缓存思想在不同场景下的变体。
这也是做技术的一个通用体会:缓存的底层逻辑无论放在哪个领域都贯穿始终。你理解了本地的Caffeine、分布式的Redis、数据库的Buffer Pool,再看大模型的KV Cache、浏览器的HTTP Cache、PWA的离线缓存,会发现它们解决的都是同一个问题——更快地读取、更少地重复计算。学习新东西时,带着“这是一种什么样的缓存”的视角,能很快抓住本质。
6.3 离线缓存和浏览器端缓存带来的启发
浏览器的HTTP缓存、Service Worker的离线缓存、小程序里的音频缓存,这些前端缓存做的事情和Redis并没有本质区别,只是存储介质和生命周期不同。Service Worker缓存把网络请求的响应存到浏览器本地,下次访问直接读本地资源;这其实就是把数据库查询结果缓存到本地内存这件事搬到了浏览器里。
做后端的人容易忽略前端缓存,但一个页面加载性能优化,很多时候靠后端Redis是解决不了的,必须前端缓存配合。比如静态资源带上hash并设置长缓存,接口数据用ETag做条件请求,这些都是和Redis同级的缓存实践。系统的性能瓶颈往往出现在最慢的那一环,有时候慢的不是接口而是网络链路。跨端理解缓存,能帮你打开新的优化思路。
最后分享一点个人体会。缓存看起来简单,实际上需要敬畏心。每次往系统里加一个缓存,都在增加一份复杂度和一致性风险;每次设置一个过期时间,背后都要思考数据到底能容忍多久的不一致。我在实际项目中总结出来的经验是:缓存方案一定要先写清楚“回源策略、失效策略、降级策略”三件事,再动手写代码。三件事没想清楚,代码写得再快,后续线上也会用事故来教学。另外,缓存相关的日志和监控一定要从第一天就接入,不要等出问题了再补,事后看,那些当时觉得“没必要”的命中率统计和key分布监控,往往才是救你于水火的关键数据。
