Java缓存体系实战:从本地缓存到Redis,穿透击穿雪崩一次讲透
2026/9/20 3:47:49 网站建设 项目流程

1. 先别急着写代码,把缓存这盘棋看明白

1.1 缓存到底解决了什么问题

做Java后端这些年,我最大的感受就是:很多系统一开始没有缓存也能跑,等到QPS上来了、数据库连接被打满、接口响应从20毫秒变成2秒的时候,大家才开始补缓存。但补缓存不是加一个Redis就完事,你很快就会遇到缓存穿透、击穿、雪崩、一致性、热key这些连环问题。这篇文章我想把Java缓存体系从头到尾梳理一遍,从JVM本地缓存到分布式缓存,从原理到生产级实践,把我踩过的坑和验证过的方案都写出来,希望能帮你建立一套完整的缓存知识体系,不管是面试还是实际项目都用得上。

缓存的核心目标就一句话:把数据放到离计算更近、读取更快的地方,减少慢速资源也就是数据库和远程接口的压力。你不可能让每次请求都去查MySQL,哪怕做了主从、分了库表,磁盘IO和网络开销就在那里。缓存的价值在于用一份内存空间换取请求耗时和系统吞吐量的巨大提升。

做个简单对比:一次Redis读取通常在1毫秒以内,一次MySQL查询在5到10毫秒,如果数据命中缓存,QPS可以轻松上万;如果全部打到数据库,连接池几百个连接很快就会耗尽。我参与过的一个订单查询接口,原来高峰期数据库CPU一直顶着90%以上,加了一层商品信息缓存后,数据库负载直接降到20%以下,接口P99从800毫秒降到了60毫秒。这个收益不是优化SQL能实现的。

缓存还有一个容易被忽略的作用:削峰。秒杀、大促这类场景,瞬时流量往往是平时的几十倍,数据库根本扛不住。缓存能挡住大部分读请求,把写请求和未命中请求的压力控制在一个稳定水位。可以说,没有缓存体系的高并发系统,就像没有蓄水池的自来水厂,来多少水就直接冲垮下游。

1.2 一套缓存体系里都有哪些角色

很多刚入行的同学以为缓存就是Redis,其实一套完整的Java缓存体系至少包括三层。

第一层是JVM本地缓存,比如Caffeine、Guava Cache,它和应用跑在同一个进程里,访问速度最快,纳秒级,但容量有限,而且每个应用实例各存一份,存在数据冗余和一致性问题。

第二层是分布式缓存,主流就是Redis,集群部署、多实例共享一份数据,容量大、一致性强,是绝大多数系统的主力缓存。

第三层是CDN和浏览器缓存,这层主要面向静态资源和前端页面,一般由网关或Nginx控制,后端接触不多,但理解它的存在有利于设计整条链路。

另外要特别注意区分业务缓存和框架缓存。MyBatis的一级二级缓存是SQL执行层面的缓存,Spring三级缓存解决的是单例Bean循环依赖问题,IDEA缓存换文件夹、VS Code缓存转移说的是开发工具本身的文件缓存。这些都属于“缓存”这个词的延伸含义,但和咱们要讨论的“业务数据缓存”完全是两码事。面试时如果被问到Spring三级缓存原理,要能明确说出它处理的是Bean创建期间的循环依赖,一级缓存存成品Bean,二级缓存存早期暴露的Bean,三级缓存存工厂对象,而不是存储业务数据,否则很容易答偏。

2. 本地缓存:JVM内的那些门道

2.1 从HashMap到Caffeine,本地缓存的演进逻辑

最简单的本地缓存是HashMap,但生产环境没人直接用它,因为线程不安全。于是有了ConcurrentHashMap,线程安全了,却面临无限增长、无过期策略的问题——缓存的数据会一直占着内存,旧数据永远不会失效,用久了就是内存泄漏的定时炸弹。

Guava Cache解决了这个问题,提供了LRU淘汰、过期时间、最大容量等策略。但现在我更推荐Caffeine,它对标Guava Cache做了大量优化,基于Window TinyLFU淘汰算法,在读写性能和高命中率之间取得了很好的平衡。Caffeine的API设计跟Guava Cache几乎一致,迁移成本很低。

一个典型的Caffeine配置:

Cache<String, ProductInfo> productCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build(); ProductInfo product = productCache.getIfPresent(skuId); if (product == null) { product = loadFromDb(skuId); productCache.put(skuId, product); }

这里有几个关键点。maximumSize控制的是条目数而不是内存大小,如果你缓存的是大对象,建议改用maximumWeight按权重估算内存占用。expireAfterWrite是写入后固定过期,适合配置类、字典类数据;expireAfterAccess是访问后过期,适合热点数据,但使用时要特别小心缓存雪崩——所有key在同一时刻过期,会造成瞬间回源。实际项目中,我更习惯把两种策略叠加,比如maximumSize=10000、expireAfterWrite=10分钟,既控制条目数又控制过期时间。

2.2 Spring Cache注解背后的原理

Spring Cache提供了一套统一的缓存抽象,核心就是几个注解:@Cacheable、@CachePut、@CacheEvict。它本身不实现缓存,而是把Caffeine、Redis这些具体实现包装成CacheManager。这个设计的好处是业务代码里不需要直接依赖某个缓存客户端,换缓存实现只需要改配置。

@Cacheable(value = "product", key = "#skuId") public ProductInfo getProduct(String skuId) { return productMapper.selectBySkuId(skuId); }

第一次调用时,Spring通过AOP拦截方法,先查缓存,查不到才执行方法,然后把返回值放入缓存。这个机制听起来简单,生产环境里有很多细节需要注意。

比如key的生成策略,默认是SimpleKeyGenerator,如果方法只有一个参数,key就是参数本身;如果多个参数,key会拼接成一个SimpleKey。自己写key时,一定要把能唯一标识数据的字段加全,否则会出现缓存串数据。我就见过一个事故:同一个方法被两个场景调用,一个传skuId,一个传spuId,漏配key,结果两个不同商品共用了同一个缓存条目,线上出现了价格显示错误,排查了好几个小时。

还有一个很容易踩的坑:@Cacheable默认不缓存null值。原本“查不到数据”会执行方法并返回null,但null不会写入缓存,于是每次都要穿透到数据库。对于不存在的数据,应该返回一个空对象或者抛出业务异常,或者用CacheManager配置allowNullValues=true,但要注意缓存空结果的设计要区分“数据不存在”和“缓存未命中”。

Spring Cache的缓存失效问题也是经典面试题。self-invocation,也就是类内部方法调用,不走代理,@Cacheable完全不生效。因为Spring AOP是基于代理的,同类中A方法调用B方法,B上的注解不会被拦截。解决办法是把调用的方法拆到另一个Bean里,或者注入自身代理。这个问题在实战中经常出现,往往一个注解加了没用,排查半天才发现是内部调用。

2.3 本地缓存的坑与调优

本地缓存最大的问题不是性能,而是多实例数据不一致。部署了10个应用实例,每个实例都有一份本地缓存,A实例更新了数据,B、C实例还在用旧数据。所以本地缓存适合两种数据:一是变化频率非常低的数据,比如数据字典、区域信息、系统配置;二是可以接受短时间不一致的数据,比如商品详情里非关键的展示字段。

本地缓存的内存设置要克制。JVM堆内存通常就几个GB,堆里还有业务对象、线程栈、GC头等,本地缓存挤占太多,Full GC会越来越频繁。我的建议是:单实例本地缓存容量控制在几十MB以内,且必须设置过期时间,防止数据堆积。缓存命中率可以通过recordStats()开启统计,再用micrometer暴露给监控系统。

还有一个细节:本地缓存里的对象被修改时,由于JVM引用共享,可能直接影响了缓存中的对象。比如从缓存取出一个对象,改了几个字段后又写回,这时候缓存里其实已经被改动了。如果这个对象还在别处使用,就会出现莫名其妙的数据错乱。所以缓存对象尽量设计成不可变,或者取出后做防御性拷贝。

3. 分布式缓存:Redis在生产中的正确打开方式

3.1 为什么是Redis

分布式缓存的选型,Redis是绝大多数Java团队的默认选择。它的优势太明显了:单线程IO模型配合epoll,性能极高;数据结构丰富,String、Hash、List、Set、ZSet都能用;原生支持过期时间、持久化、主从复制、哨兵和Cluster模式。相比Memcached,Redis多了持久化和数据结构的优势;相比自研缓存中间件,Redis经过十几年的生产验证,稳定性有保障。

在Java生态里,操作Redis的客户端主要有Jedis、Lettuce和Redisson。Spring Boot 2.x之后默认用Lettuce,它基于Netty,支持异步和响应式编程,连接复用比Jedis更好。Redisson更像一个分布式工具集,提供了分布式锁、分布式对象、限流器等高级功能。生产环境我一般这样用:简单读写用Lettuce,也就是Spring Data Redis默认,需要分布式锁或特殊数据结构时引入Redisson。

3.2 key设计、序列化与内存治理

Redis用得久了,你会发现性能瓶颈往往不在Redis本身,而在key的设计和内存治理。

key命名要遵循业务规范。常见格式是“业务:领域:标识”,比如:

order:detail:{orderId} product:sku:{skuId}:sellCount

这种层级清晰、容易做前缀扫描,也方便按业务线分析。千万不要用不稳定的值做key,比如用户自定义昵称,一旦昵称修改,缓存就彻底丢了。更不要用没有业务含义的自增ID做全局key,出了问题连排查都无从下手。

value序列化是Java项目最容易踩坑的地方。默认的JdkSerializationRedisSerializer会把key存成乱码,而且序列化后体积很大。生产环境我强烈建议统一用JSON序列化器,配置如下:

@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; }

key用String序列化,value用JSON序列化,这样在Redis客户端里看到的key是可读的,value也能直观辨别。但JSON序列化有一个坑:对象里的泛型信息、类型信息可能丢失,反序列化时容易报转换错误,所以涉及复杂的泛型对象时,我会用TypeReference明确指定类型。

内存治理方面,要重点关注大key和热key。大key指的是单个value特别大,比如几MB的列表。大key会导致Redis读写阻塞、网络传输慢、持久化时fork阻塞,删除时也会卡住整个实例。判断大key可以用命令redis-cli --bigkeys,也可以通过内存分析工具遍历所有key的占用。对超大key,要么拆分,比如把一个大列表拆成多个小key,要么用Hash结构分摊,要么压缩value。

3.3 高并发下Redis的读写策略

高并发场景下,Redis本身不是瓶颈,瓶颈通常在于缓存策略设计不合理。

第一个问题是缓存穿透。大量请求查询一个不存在的key,Redis直接没命中,所有请求都打到数据库。解决方案有三种:一是缓存空值,设置较短的过期时间,比如60秒;二是用布隆过滤器,在访问Redis之前先判断key是否可能存在;三是请求参数校验,把明显无意义的参数直接拦截。

第二个问题是缓存击穿。某个热点key过期瞬间,大量请求同时回源查询数据库。这个场景我用互斥锁来解决,核心思路是:当缓存失效时,不是所有线程都去查数据库,而是让一个线程去重建缓存,其他线程等待。用Redisson的锁实现:

public ProductInfo getProduct(String skuId) { ProductInfo product = redisTemplate.opsForValue().get("product:" + skuId); if (product != null) { return product; } RLock lock = redissonClient.getLock("product:lock:" + skuId); boolean locked = lock.tryLock(200, TimeUnit.MILLISECONDS); if (!locked) { Thread.sleep(50); return getProduct(skuId); } try { product = redisTemplate.opsForValue().get("product:" + skuId); if (product != null) { return product; } product = loadFromDb(skuId); redisTemplate.opsForValue().set("product:" + skuId, product, 30, TimeUnit.MINUTES); return product; } finally { lock.unlock(); } }

注意这里有个细节:拿到锁之后要再查一次缓存,这叫双重检查。因为可能在当前线程等待锁的过程中,另一个线程已经重建了缓存,不检查直接查库就又白打了一遍。

第三个问题是缓存雪崩。大量缓存key在同一时间过期,所有请求同时回源。应对策略包括:过期时间加随机值,避免同一时刻失效;多级缓存,本地缓存挡一层;对重要的读接口做限流降级和熔断。

4. 缓存一致性:生产环境最头疼的那道题

4.1 缓存不一致是怎么发生的

缓存和数据库是两套存储,更新一个数据要同时写两个地方,做不到原子性,于是就会出现不一致。最常见的争议就是“先更新数据库,再删除缓存”和“先删缓存,再更新数据库”两种顺序的选择。

从理论上看,先删缓存再更新数据库风险更大:并发场景下,线程A删除缓存后还没来得及更新数据库,线程B读取数据,发现缓存没有,就去数据库读旧数据写回缓存,这时候线程A才更新数据库,缓存里就是旧数据了。这个窗口期虽然短,但在高并发下被放大的概率并不低。

先更新数据库再删缓存,也并非绝对安全:线程A更新数据库后,在删除缓存之前,线程B读到了旧缓存,这是短暂不一致;极端情况下,如果删除缓存失败,缓存就会长期是旧数据。好在数据库更新成功后删除缓存,这个窗口非常小,实际影响有限,所以业界主流是Cache Aside模式:先更新数据库,然后删除缓存。

4.2 Cache Aside模式为什么能成为默认选择

Cache Aside,也叫旁路缓存,是目前生产环境最常用的模式。读的时候先读缓存,没命中就读数据库再回填缓存;写的时候先更新数据库,然后删除缓存。

这里用“删除缓存”而不是“更新缓存”是有道理的。如果直接更新缓存,每次数据库写操作都要把新数据序列化写入Redis,成本高;而且在并发写场景下,后写的缓存可能被先写的数据覆盖,或者网络抖动导致缓存更新失败,缓存变成旧数据。删除缓存则让下一次读请求再去回源,虽然多一次回源开销,但正确性更高。

我见过一套系统曾经用“更新缓存”方案,结果在全链路压测时发现,写接口变慢、Redis写入量暴涨,而且一旦Redis出现短暂不可用,缓存里的数据就不能保证和数据库一致。全部改成删除缓存后,问题立刻消失。当然,删除缓存也有自己的问题:如果删除失败怎么办?最简单的办法是给缓存设置较短的过期时间,让不一致自动收敛,或者引入消息队列,在MQ里重试删除。

4.3 保证一致性的进阶手段

生产级的一致性方案,我通常按下面几个层次叠加。

第一层:合理的过期时间。任何缓存都必须有过期时间,作为最终一致性的兜底。根据业务容忍度,配置几分钟到几小时不等的TTL。这层是保命的,就算你的更新逻辑出了问题,缓存最终也会收敛。

第二层:重试通道。把“删除缓存”封装成可以重试的操作,失败后写入重试队列,由定时任务补偿删除。也可以订阅数据库binlog,通过canal消费变更事件,异步刷新缓存。这种方式适合对实时性要求较高的系统,比如价格、库存这类不能长时间不一致的数据。

第三层:版本号或时间戳。缓存value里带上数据版本,读取时如果版本落后,就主动回源。这种方式适合读多写少的场景,能显著减少无效回源。

第四层:分布式事务。如果业务强一致要求缓存和数据库实时一致,理论上要用分布式事务,但代价极高,日常业务几乎不会用。我更推荐在业务层面容忍短暂不一致,例如“用户下单成功提示后,缓存数据在1秒内收敛”,这种体验完全能接受。生产环境的缓存一致性永远是在正确性、性能和复杂度之间做权衡,没有银弹。

5. 高并发缓存三大杀手:穿透、击穿、雪崩

5.1 缓存穿透的识别与应对

缓存穿透指查询的数据在数据库里根本不存在,导致每个请求都会穿透缓存直达数据库。最容易触发穿透的场景是恶意攻击和大量不存在的请求参数,比如一个被刷的秒杀活动,攻击者不断请求不存在的商品ID。

应对穿透,我第一选择是缓存空值。把null也缓存起来,设置一个较短的过期时间,比如5分钟,这样相同的不存在请求会命中空值缓存,不会再打数据库。实现时要注意:缓存空值一定要区分类型,否则反序列化时空值会被当成异常数据,从而影响整个返回结构。

布隆过滤器是另一个常见方案。它用一个位数组和几个哈希函数判断一个元素是否可能存在于集合中,结论是“一定不存在”或“可能存在”。它的优点是内存占用极小、判断速度快,缺点是有误判率、不能删除元素。使用布隆过滤器需要在项目启动时预热所有合法key,或者定期同步数据。在我做过的商品系统中,是把所有在售商品的ID加载到布隆过滤器里,请求来了先判断,不存在就直接返回空,这样连Redis都不用查。

5.2 缓存击穿与热key治理

缓存击穿和穿透只差一个字,原理完全不同。穿透是“数据库里没有”,击穿是“缓存里没有”,但数据是真实存在的。区别在于:某个key非常热,在它过期的瞬间,大量并发请求同时打到数据库。

热key和击穿往往是伴生问题。判断热key有哪些方法?可以用Redis的monitor命令观察热点,也可以用redis-cli的--hotkeys参数扫描,还可以在客户端统计访问频次。一旦发现热key,常见的处理手段有几个:一是“永不过期”,不设置过期时间,而是由后台任务定期更新缓存值,这样就不会有过期瞬间的回源风暴;二是“逻辑过期”,value里加一个expireTime字段,读取时发现逻辑过期就异步刷新,返回旧值;三是多级缓存,本地缓存放一份热key数据,Redis放一份,请求优先打本地;四是热key复制,把同一个key复制成多个带后缀的key,比如key#1、key#2,分散到不同的Redis分片上。

热key复制要特别小心:只对真正热到单分片扛不住的key做,而且复制后更新逻辑复杂,所有副本都要同步更新,否则会读到旧数据。我的经验是,先用多级缓存和逻辑过期扛住90%的热key问题,只剩下极少数超级热点才考虑复制方案。盲目复制只会让维护成本翻倍。

5.3 缓存雪崩与集群容灾

雪崩指的不是单个key失效,而是大量key同时失效,或者Redis集群整体不可用。大量key同时过期,多见于“固定时间统一写入”的数据,比如每天凌晨刷新的报表数据、定时任务统一更新的配置。解决办法很简单:设置过期时间时加随机扰动,比如基础时间加0到300秒的随机值,让key错峰失效。

Redis集群不可用是更严重的雪崩场景。主从切换、集群扩容、网络分区、内存达到maxmemory触发淘汰策略,都可能导致大范围缓存未命中,所有流量涌向数据库。这时的防护核心是兜底策略:

一是本地缓存兜底。Redis不可用时,本地缓存仍然能扛住一部分读请求。

二是接口级降级。对非核心依赖,比如商品附加信息、推荐内容,Redis挂了直接返回默认值或者兜底数据,不让请求继续往下走。

三是数据库限流。即使Redis全挂了,也要保证数据库连接池不被瞬间打爆,可以通过Sentinel做QPS限流,超出阈值的请求快速失败。

我经历过一次真实故障:一个促销活动上线前,运营批量更新了几万个商品价格,每个商品价格更新时都删除缓存,结果同一时刻大量key被删除,紧接着用户疯狂访问,数据库连接池瞬间被占满,整个服务雪崩。事后复盘,根因就是删除缓存的操作风暴加上没有限流。后来我们给缓存删除操作加了异步队列限速,同时给数据库查询加了熔断,再也没出过类似事故。

6. 完整案例:商品详情页缓存体系搭建

6.1 整体架构与分层设计

用一个最常见的场景——商品详情页——来串一遍前面所有知识点。商品详情页的特点是读多写少、数据来源多、部分字段频繁变更,部分字段几乎不变。刚开始我只用Redis缓存整个商品详情对象,结果发现一个问题:价格变了要更新整个大对象,销量变了要更新整个大对象,一个字段的变化会导致整条缓存失效,命中率很低。

之后我改成了字段级分层的方案:

第一层,静态数据放Caffeine本地缓存。比如商品标题、描述、图片列表,这些字段基本不变化,本地缓存扛大部分读取。

第二层,动态数据放Redis。比如价格、库存、销量,这些字段变化频繁,但单个字段小,用Hash结构存储,更新哪个字段就更新哪个,互不影响。

第三层,聚合服务做多级查询。读取时先查本地缓存,再查Redis,最后回源MySQL或远程服务,把结果组装成详情对象返回。

这种设计的核心思想是“按热度隔离”:变化越频繁、热度越高的数据,越靠近上层;变化频繁的数据,不做过长的缓存。

商品详情的缓存结构我用Redis Hash:

key: product:detail:{skuId} field: baseInfo value: 商品基础信息JSON field: price value: 当前价格 field: stock value: 当前库存 field: sales value: 销量

价格、库存更新时,只更新field,不删除整个key。这样既避免了整key失效,又减少了Redis写入量。

6.2 关键代码与配置

一个典型的读取流程,用Caffeine做一级缓存,Redis做二级缓存,代码结构类似:

public ProductDetailVO getProductDetail(String skuId) { ProductDetailVO local = localCache.getIfPresent(skuId); if (local != null) { return local; } String key = "product:detail:" + skuId; ProductDetailVO remote = redisTemplate.opsForValue().get(key); if (remote != null) { localCache.put(skuId, remote); return remote; } ProductDetailVO db = loadFromDb(skuId); redisTemplate.opsForValue().set(key, db, 30 + ThreadLocalRandom.current().nextInt(300), TimeUnit.SECONDS); localCache.put(skuId, db); return db; }

这里两个细节值得说明:一是二级缓存回填时,TTL加了随机扰动,避免同一批key同时过期;二是本地缓存只用来短暂存储“刚从Redis读到”的数据,不给它设置太长的过期时间,我一般expireAfterWrite设置60到120秒。这样即使本地缓存数据短暂过期,最多多一次Redis查询,影响不大。

更新侧的核心逻辑:

@Transactional public void updatePrice(String skuId, BigDecimal newPrice) { productMapper.updatePrice(skuId, newPrice); redisTemplate.delete("product:detail:" + skuId); localCache.invalidate(skuId); }

删除缓存的时候,本地缓存必须同步失效。很多团队只删了Redis,忽略了本机还有一份Caffeine缓存,结果数据改完了用户还是看到旧数据,排查半天才发现是本地缓存在作怪。这个坑我踩过不止一次,所以现在写更新逻辑时,脑子里永远绷着一根弦:所有缓存层都要失效。

6.3 监控与容量规划

缓存上线之后,监控比开发更重要。我列的必监控项有四个:

第一,命中率。Redis的info stats里有keyspace_hits和keyspace_misses,可以计算总命中率;本地缓存通过recordStats()统计。命中率低于80%就要警惕缓存设计是否合理,是不是大量key设置太短就过期了。

第二,慢查询。Redis的slowlog要设置合理阈值,默认是10000微秒,生产环境我建议调到2000微秒。慢查询往往是存在大key、复杂命令或者网络问题。

第三,内存使用。maxmemory要设置好,淘汰策略建议用allkeys-lru,避免内存溢出导致Redis进程被杀。定期用分析工具检查内存分布,及时清理无效key。

第四,回源量。记录Redis未命中后打到数据库的请求量。如果回源量突然升高,往往是缓存击穿或雪崩的前兆。

容量规划方面,我的经验是先估算数据量,再估算QPS。比如商品总量100万,单个详情JSON按10KB算,Redis里全量缓存需要约10GB内存。单实例Redis通常规划16到32GB内存,100万数据量需要分片或Cluster。QPS方面,单实例Redis能扛10万级读,业务量更大时用Cluster分片。

7. 常见问题与排查技巧实录

7.1 经典故障场景复盘

第一个故障:缓存预热后命中率依然低于50%。排查思路是:先看缓存key是否过期过快,再看是否有开发环境或测试环境在共用Redis实例,再看是否存在“大key被频繁淘汰”的问题。最终发现是业务方在凌晨定时任务里批量刷新数据时,把所有相关key都设置成同一过期时间,早上8点到9点之间集体失效,命中率直线下降。修复方式是设置过期时间时增加随机扰动。

第二个故障:固定用户看到的数据一直不刷新。排查过程很有意思,先看了Redis,发现key已经被删除了,再查数据库,数据也更新了,但接口返回的还是旧值。最后定位到是网关层的Nginx缓存了响应。这类问题提醒我们,排查缓存问题时,要从请求链路看,浏览器缓存、CDN、Nginx、本地缓存、Redis,每一层都有可能,不能只盯着Redis。后来我们在网关层关掉了动态接口的缓存,问题彻底解决。

第三个故障:大key导致Redis偶尔出现读取超时。通过bigkeys命令发现一个用户的购物车列表被存成了一个几MB的String,每次读取和序列化都消耗大量CPU。解决方案是把购物车改成Hash结构,field是商品ID,value是数量,单条记录很小,读取效率大幅提升。

7.2 问题速查表

现象可能原因排查思路解决方案
缓存命中率低key过期过快、缓存粒度不合理、存在无效key查看keyspace命中率,抽样分析key的TTL分布调整TTL、加入随机扰动、优化缓存粒度
接口偶发超时Redis大key、慢查询、网络抖动查看slowlog、bigkeys,检查Redis实例CPU和网络拆分大key、优化序列化、使用连接池监控
数据更新后查询仍为旧值缓存未删除、本地缓存未失效、多级缓存层级遗漏逐层检查Redis、Caffeine、Nginx、CDN统一缓存失效入口、增加版本号
高峰期数据库连接打满缓存穿透、击穿、雪崩或回源量突增监控回源QPS、检查是否存在热点key集中失效空值缓存、布隆过滤器、互斥锁、多级缓存
Redis内存持续上涨缓存key过期时间过长、无淘汰策略、大key堆积分析内存分布、检查maxmemory配置设置合理TTL、开启allkeys-lru、清理大key
删除缓存偶发失败网络超时、Redis不可用、代码未捕获异常查看异常日志、检查删除操作是否放入MQ重试引入重试队列、订阅binlog异步刷新

这张表也算是我这几年排查缓存问题的一种沉淀。每次接到线上告警,我习惯先问几个问题:是单key问题还是全局问题?是读接口问题还是写接口问题?Redis自身有没有异常?数据库回源量有没有变化?按照这个顺序,大部分缓存问题都能在几分钟内定位。

最后再分享一个小技巧:排查线上缓存问题时,先在测试环境复现,再考虑在灰度环境加日志,不要在核心链路上直接Debug。缓存问题往往和并发、时序强相关,本地复现不了很正常,这时候要从监控指标的反常趋势里找线索,比如命中率断崖式下跌、回源QPS突然翻倍,这些信号比堆日志有效得多。缓存治理是个持续迭代的活,没有一套方案能一劳永逸,你只需要把每一层缓存的行为都搞清楚,把监控指标看明白,遇到问题就不会慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询