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配置CacheString, 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在同一时刻过期会造成瞬间回源。实际项目中我更习惯把两种策略叠加比如maximumSize10000、expireAfterWrite10分钟既控制条目数又控制过期时间。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配置allowNullValuestrue但要注意缓存空结果的设计要区分“数据不存在”和“缓存未命中”。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模式。相比MemcachedRedis多了持久化和数据结构的优势相比自研缓存中间件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 RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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本身不是瓶颈瓶颈通常在于缓存策略设计不合理。第一个问题是缓存穿透。大量请求查询一个不存在的keyRedis直接没命中所有请求都打到数据库。解决方案有三种一是缓存空值设置较短的过期时间比如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 Hashkey: 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是商品IDvalue是数量单条记录很小读取效率大幅提升。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突然翻倍这些信号比堆日志有效得多。缓存治理是个持续迭代的活没有一套方案能一劳永逸你只需要把每一层缓存的行为都搞清楚把监控指标看明白遇到问题就不会慌。