
作为一个Java后端开发几乎每天都要跟缓存打交道。Redis缓存、本地缓存、缓存一致性、缓存穿透……这些概念从面试八股到线上事故无处不在。这篇笔记不是教科书式的讲解而是我自己在项目和踩坑中沉淀下来的实战经验从为什么用缓存、怎么用缓存到缓存出问题怎么排查、怎么保证数据一致一条线完整梳理。无论是刚入行的后端新人还是准备面试的进阶开发者这篇内容应该都能给你一些不一样的启发。1. 缓存到底解决了什么问题从一次接口优化说起1.1 读多写少才是缓存存在的理由我记得很清楚去年接手一个积分商城项目用户积分明细查询接口的QPS一上来数据库连接池就告警。当时那个接口的逻辑非常简单就是从订单表、积分流水表、用户表join出一大堆数据然后按时间倒序分页返回。单次查询大概需要200ms数据库CPU直接飙到90%。后来我做的第一件事不是上Redis而是先看业务场景。这个接口的调用方是积分商城首页用户每次打开首页都会调用一次典型的读多写少场景。积分流水只在用户下单、签到、兑换商品时才会新增读和写的比例大概在100:1以上。这种场景如果不做缓存就是在用数据库的命换接口的速度属于最典型的缓存适用场景。缓存的核心价值其实就一句话把热点数据从慢速存储挪到快速存储用空间换时间。后端开发里常见的存储层级从CPU寄存器、内存、本地磁盘到远程数据库速度差了五六个数量级。Redis单线程处理几十万QPS很轻松MySQL在几百QPS时就可能开始抖动这个数量级的差距决定了缓存架构的位置。但这里要强调一点不是所有数据都适合缓存。写多读少的数据上缓存是负优化因为每次写入都要淘汰缓存或更新缓存表面看是缓存问题实际是把简单问题复杂化。我在项目里做过一个教训很大的需求把用户的登录日志也拿来缓存结果缓存命中率不到1%还引入了缓存和数据库不一致的问题最后不得不把缓存去掉。1.2 缓存命中率是判断价值的第一指标很多新手上来就设计一个缓存方案张口闭口Redis、多级缓存但问他准备缓存哪些数据、数据访问分布是什么样往往答不上来。这就本末倒置了。判断一个缓存方案有没有价值第一指标永远是命中率。我一般会先在日志里打印缓存命中率观察一周。计算公式很简单命中率 命中次数 / (命中次数 未命中次数)。超过80%说明缓存设计合理低于50%就要反思了是不是把不该缓存的数据放进了缓存或者key设计得不合理导致无法复用。举个key设计失败的案例。之前团队里有个同事缓存用户信息key用的是用户的手机号结果用户改绑手机号后查不到缓存造成了一次线上事故。后来我们复盘结论是key应该用userId而不是手机号因为userId是业务实体的唯一标识手机号是可变属性。缓存key的设计原则是用稳定不变的业务主键而不是可变属性这一点在缓存设计初期就要定好规则。2. 缓存的选型与架构为什么Redis是默认答案2.1 Redis之外的选项其实也不少聊到缓存很多人的第一反应就是Redis但真实场景里选择不止这一个选型逻辑也值得聊聊。内存型缓存的几个主流选手各有侧重。Caffeine和Guava Cache是本地缓存数据存在应用进程里读写最快但多个应用实例之间数据不共享没法用于分布式场景。Ehcache是老牌本地缓存框架支持把数据溢出到磁盘适合单机应用。Redis是独立的缓存服务支持分布式部署、数据持久化、丰富的数据结构是目前分布式缓存的绝对主流。Memcached是Redis的前辈只支持字符串类型现在用的人已经很少了。我的选型原则比较简单单机应用、数据量不大、多个实例之间不共享数据直接用Caffeine就够了性能比Redis还快。一旦涉及分布式环境或者需要多个服务共享同一份缓存数据那基本就是Redis了。项目里我通常是两级缓存一起用Caffeine做一级本地缓存Redis做二级分布式缓存。读请求先查本地缓存未命中再查Redis还没命中再查数据库然后回填。这样既能利用本地缓存的极速访问又能通过Redis保证多实例间的一致性。当然两级缓存带来的数据一致性维护成本会更高这个后面单独说。2.2 Redis高性能背后的三个底层机制很多初学者用Redis就像用一个大号HashMap存进去取出来对性能背后的原理并不理解。直到面试被问“为什么Redis快”才去找八股文背。这里我用自己的理解拆解一下。第一Redis的请求处理是单线程的省去了线程切换和锁竞争的开销。单线程听起来像是性能瓶颈但在内存操作场景下CPU根本不是瓶颈网络IO才是。Redis单线程配合I/O多路复用一个线程管理成千上万的客户端连接性能依然出色。第二Redis的核心数据操作都在内存中完成内存本身比磁盘快几个数量级。但Redis不是就不用磁盘了它把磁盘用来做持久化AOF和RDB两种方式兼顾了可靠性与性能。第三Redis高效的数据结构设计。比如它的哈希类型用的是ziplist加hashtable的渐进式rehash字符串用的是SDS都不是简单套用传统数据结构而是针对实际场景做了大量优化。理解了这几个底层机制很多使用上的问题就迎刃而解了。比如有人问为什么Redis官方不建议用Keys命令扫大key列表就是因为Keys命令会阻塞单线程在线上环境那是致命的。3. Java后端落地的完整姿势从依赖到可运行的项目3.1 Spring Boot集成Redis的依赖与连接配置说完了选型和原理看代码怎么写。我以Spring Boot 3.x为例其他版本大同小异。第一步加Maven依赖。注意Spring Boot 3.x对Redis的starter做了调整老项目从2.x升级的话依赖坐标里的javax要改成jakarta。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency第二个依赖是连接池用到的如果不加就会碰到连接创建频繁、性能上不去的报错。第二步配置application.yml。这段配置里有很多细节值得注意spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 4 max-wait: 2s这里我特意加了连接池配置。很多教程不带这个生产环境一旦QPS上来就会出问题——Redis连接频繁创建销毁端口和文件描述符耗尽接口大面积超时。连接池一旦配好连接复用的问题就解决了。第三步配置序列化器。这一步是新手最容易忽略的坑。Spring Boot默认用JdkSerializationRedisSerializer来序列化网上几乎所有经验帖都不建议直接用默认的。原因是JDK序列化出来的二进制可读性极差在redis-cli里看到的全是乱码排查问题非常痛苦。更麻烦的是JDK序列化的数据只有Java能读将来如果业务需要跨语言访问同一份缓存数据比如用Python做数据分析去读直接就废了。我习惯用GenericJackson2JsonRedisSerializer做value序列化器key用StringRedisSerializer。原因也很直观key在Redis里应该短小精悍字符串最合适value要兼容各种数据结构JSON格式对人可读、对机器可解析是平衡性能和可维护性的首选。Configuration public class RedisConfig { 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; } }这里还有个细节GenericJackson2JsonRedisSerializer在序列化对象时会默认把类信息写进JSON里反序列化时才能还原成正确的类型。如果业务上对缓存数据的体积很敏感可以自定义ObjectMapper只保留必要的类型信息用Jackson的DefaultTyping机制做控制这一步属于进阶优化。3.2 用注解还是用RedisTemplate两种姿势我都用理论上一个接口的缓存逻辑可以直接在Service里用RedisTemplate写。但这种方式会造成大量样板代码每个方法都重复“查缓存、判断为空、查库、回填缓存”而且缓存逻辑和业务逻辑强耦合在一起。我后来把缓存代码切到Spring Cache注解上骨架就清爽多了。Spring Cache的核心注解有三个Cacheable、CachePut、CacheEvict。Cacheable会在方法执行前先查缓存命中则直接返回不执行方法体未命中则执行方法体再把结果写入缓存。CachePut更直接不管缓存有没有方法体一定会执行执行完再更新缓存适合写操作后刷新缓存。CacheEvict用来删除缓存适合删数据场景。看一下实际用法。以之前的积分明细查询为例Cacheable(value pointDetail, key #userId : #pageNum, unless #result null) public PageResultPointDetailVO queryPointDetail(Long userId, Integer pageNum, Integer pageSize) { // 查询数据库逻辑 }这里有个加粗经验unless #result null必须要加。缓存穿透有个很常见的场景就是查询结果本身为空如果不加这个条件空结果也会被缓存下来后续大量查询直接返回空缓存反而掩盖了真实情况。再说说key的设计。我把userId和pageNum组合成缓存的key因为分页查询同一用户不同的页码应该存不同的缓存如果只用userId做key翻页就全乱了。这里有个编码陷阱SpEL表达式里#pageNum是数字类型和字符串拼接时要注意类型转换否则缓存的key会出人意料。3.3 自定义缓存注解AOP应对复杂缓存场景的组合拳Spring Cache能覆盖大部分常规场景但遇到“删除用户时顺便删除该用户所有的积分明细缓存”这种一对多的缓存清理需求时注解就有点力不从心了——你根本不知道这个用户名下有多少个key要清理。这时候我用组合拳自定义注解AOP切面。先定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface CacheDelByPrefix { String cacheName(); String keyPrefix(); }然后在切面里拦截加了注解的方法方法执行成功后用SCAN命令匹配前缀并删除所有key。注意这里不能用Keys命令一次性全量匹配会让Redis阻塞几秒甚至更久在生产环境这是大忌。SCAN是游标式渐进遍历每次返回少量数据不阻塞主线程配合循环可以安全删完所有匹配的key。Aspect Component public class CacheDelAspect { Around(annotation(cacheDelByPrefix)) public Object handle(ProceedingJoinPoint joinPoint, CacheDelByPrefix cacheDelByPrefix) throws Throwable { Object result joinPoint.proceed(); String pattern cacheDelByPrefix.cacheName() : cacheDelByPrefix.keyPrefix() :*; deleteByPattern(pattern); return result; } private void deleteByPattern(String pattern) { ScanOptions options ScanOptions.scanOptions().match(pattern).count(100).build(); try (Cursorbyte[] cursor redisTemplate.executeWithStickyConnection( connection - connection.scan(options))) { while (cursor.hasNext()) { redisTemplate.delete(new String(cursor.next())); } } } }这个方法我至今还在用尤其是涉及“父实体删除子级所有缓存”的场景非常顺手。4. 缓存穿透、击穿、雪崩三个高频面试题背后的真实事故4.1 缓存穿透不是只靠空值缓存就能解决的缓存穿透指大量请求直接绕过缓存打到了数据库常见诱因是查询一个确实不存在的ID。用户恶意构造一个不存在的商品ID每次请求Redis都查不到于是每次都穿透到MySQL数据库瞬间被压垮。业界方案分几层。第一道防线是参数校验对明显的非法请求直接拒绝比如负数ID、超长字符串。不过参数校验拦不住“合理但不存在的ID”比如随机数字撞库所以第二道防线是缓存空值查询结果为空时也在缓存里存一个短生命周期的空占位符比如60秒这样同样ID的请求后续都能命中缓存同时占位符过期后会自动释放空间。第三道防线是布隆过滤器把存在的ID全量加到过滤器里查询前先用过滤器判断ID是否存在。如果过滤器判不存在那一定不存在直接返回如果判存在才走缓存和数据库。布隆过滤器的问题是存在误判率而且不支持删除ID删除了布隆过滤器里还留着所以它适合只增不减的场景。我在实际项目里用的组合方案是参数校验 缓存空值短TTL布隆过滤器在数据规模大到空值缓存覆盖不过来的场景下才上。4.2 缓存击穿热点key瞬间失效当场打崩数据库缓存击穿和穿透一字之差场景完全不同。击穿指某个热点key在缓存过期的瞬间大量并发请求同时查询这个key发现缓存为空于是全部打到数据库相当于把一次数据库压力放大了N倍。我踩过的真实案例是首页Banner的配置接口。运营晚上8点更新Banner配置缓存key在那一秒过期瞬间涌入的请求直接压垮了一个读库分片。那次事故的修复让我彻底理解了三个方案——互斥锁、逻辑过期、永不过期加定时刷新。互斥锁是重写查缓存的逻辑拿到数据为null时不直接查数据库而是先尝试获取分布式锁获取成功的那个线程去查数据库并回填缓存其他线程阻塞等待后重新查缓存。这种方式对一致性有保障但会短暂提高接口耗时。public Object getDataWithLock(String key) { Object value redisTemplate.opsForValue().get(key); if (value ! null) return value; String lockKey lock: key; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (locked) { try { value queryDatabaseAndSetCache(key); return value; } finally { redisTemplate.delete(lockKey); } } else { // 拿不到锁的线程短暂休眠后重试 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getDataWithLock(key); } }逻辑过期方案则是缓存里存两个值真实数据和过期时间读取缓存时发现逻辑过期了先用旧数据返回给调用方同时异步线程去刷新缓存。这种方式接口响应快但数据会有短暂的不一致。用在首页Banner这种允许短暂延迟的场景非常合适。永不过期加定时刷新是我个人最偏爱的方案实现简单写个定时任务每隔几分钟主动刷新热点key的TTL把过期时间压在业务低峰期绕过去。配合热点key的二次判断基本能避免击穿。4.3 雪崩比击穿更可怕压力不是打在一个key上雪崩是大量key在同一时间集中过期导致大量请求同时落到数据库。和击穿一个key不同雪崩是一批key同时失效。最常见诱因是缓存批量初始化时设置了相同的过期时间比如把100个商品信息都设为30分钟过期30分钟后它们齐齐失效。规避手段不外乎三种。第一种是过期时间加随机值比如30分钟随机0到5分钟把失效时间打散。第二种是改用多级缓存本地缓存Redis配合即使Redis里大量key失效还有本地缓存扛一阵。第三种是服务降级数据库压力达到阈值时直接返回缓存旧值或默认值宁可牺牲一部分用户体验也要保数据库不挂。我个人最推崇第一种实现成本最低收益最直接。随机值不是瞎随机要围绕预期过期时间有一个合理的上下界比如缓存20分钟的数据可以设置实际过期时间在18到25分钟之间。5. 缓存与数据库的一致性网上争论很久我的落地实践方案5.1 先写库再删缓存最不至于出错的原则缓存与数据库一致性是个永恒话题网上有无数种方案Cache Aside、Read Through、Write Through、延迟双删……落到实际业务里我自己最稳妥的一套是Cache Aside模式更新数据库后删除缓存而不是更新缓存。为什么是删除而不是更新因为更新缓存的开销不一定匹配得上业务实际读取的复杂度。比如缓存里存的是用户积分明细的分页数据你拿到用户新增一条积分流水的消息后想更新缓存你得先知道要更新哪几页数据这个成本极高。但删除缓存就太容易了下次查询时未命中再回填就行天然做到了懒加载的效果。删除缓存和更新数据库之间还有个顺序问题先删缓存再写库和先写库再删缓存各有利弊。我试过两种方案线上实践下来先写库再删缓存的出问题概率明显更小。具体原因在于先删缓存后写库的窗口期内如果有一个读请求进来发现缓存空了去数据库里读到的还是旧数据就会把旧数据回填到缓存而此时数据库马上要被更新缓存和数据库就不一致了。相反先写库再删缓存即使读请求读到旧值并回填随后缓存被删除下次读请求取到的一定是新值不一致的窗口被压缩到了极小。5.2 延时双删把删除这一步做重一点但在高并发场景下先写库再删缓存也没那么完美。假设一个线程A写了数据库准备删除缓存但删除动作还没执行另一个线程B读缓存正好读到了旧值然后把旧值回填了缓存。这就出现了一个极小概率但确实存在的不一致窗口。我解决这个问题的办法是延时双删。在第一次删除缓存之后隔一段时间比如500ms视业务量级而定再删除一次缓存。因为回填缓存的操作理论上不会晚于这个延时窗口第二次删除能把错误回填的旧值也清掉。public void updateData(Data data) { database.update(data); redisTemplate.delete(cacheKey(data.getId())); // 延时再删除一次 scheduledExecutor.schedule(() - redisTemplate.delete(cacheKey(data.getId())), 500, TimeUnit.MILLISECONDS); }不过延时双删有个天然矛盾延时太短回填还没发生就删了白删延时太长中间窗口期的不一致时间就长了。我一般用500ms到1s具体取值要看接口的平均响应耗时。假设接口平均响应时间200ms500ms的延迟基本能覆盖绝大多数回填场景了。这个方案扛了快两年线上没有出现过可见的缓存一致性问题。5.3 终极方案订阅数据库binlog异步刷新缓存如果业务对数据一致性要求极高比如账户余额、库存这类资金相关数据延时双删还是会让人觉得不踏实。这种情况下可以上终极方案通过Canal订阅MySQL的binlog变更把变更事件异步推送到消息队列消费者拿到事件后主动刷新缓存。这个方案的思路是把数据变更变成一个事件流不管上层应用通过哪个入口改了数据库甚至DBA在数据库工具里直接改都会被binlog捕获到并刷新缓存天然解决了代码层面漏通知的问题。代价是架构复杂度明显上升需要额外部署Canal、消息队列运维成本大幅增加。我一般只在资金账户、订单状态这类强一致场景使用普通业务数据用延时双删方案就够了。架构设计是门生意任何方案都要在一致性、性能、成本之间做取舍没有银弹。6. 实用且容易忽略的缓存细节大key、热key和内存抖动的排查6.1 大key和热key会导致什么用Redis写缓存最怕两个问题大key和热key。大key指单个key存储的value特别大比如往一个List里塞了几百万条积分流水或者value是个几十MB的JSON字符串。大key的危害是第一网络传输耗时变长接口响应时间被拉高第二Redis单线程处理大key操作时会阻塞其他命令的执行第三删除大key可能瞬间卡住整个Redis实例。我遇到过比较典型的大key事故是一次需求把用户最近一年的操作日志用List缓存结果用户操作一多List动辄几十万条每次用LRANGE分页读取时Redis明显卡顿。后来把List拆解成按天为粒度的多个小key大key问题迎刃而解。热key指同一个key在短时间内被海量请求访问比如明星八卦话题的详情页、秒杀商品的库存一个key扛了几十万QPS即使Redis性能再好也会成为瓶颈。应对热key最常用的手段是本地缓存把热点key的数据复制到应用内存里应用内读取完全不经过Redis。Caffeine的写法我已经在前面说过了再配合一个定时任务每5秒刷新一次本地缓存能极大减轻Redis压力。6.2 日志与监控排查缓存问题的三板斧排查缓存问题的三板斧一是看日志里的命中率和耗时二是用Redis自带的命令查看运行状态三是看慢查询。我在Service层给缓存操作统一封装了一个工具类里面强制打印三样东西命中还是未命中、耗时多少、key是什么。日志格式大概是[cache-access] hittrue keypointDetail:1001:1 costMs1.2 [cache-access] hitfalse keypointDetail:1001:1 costMs0.9有了这些日志即使出现线上问题也能快速定位是不是缓存层出的问题。Redis命令里INFO命令可以查看内存使用情况、缓存命中率、客户端连接数redis-cli --stat可以实时查看命令处理速率和内存变化SLOWLOG可以查看超过指定时间的慢查询记录。有一次我排查线上延迟就是靠SLOWLOG发现某个大key的LRANGE操作耗了800ms一删除延迟立竿见影地降下来了。6.3 内存淘汰策略怎么选不是推荐LRU那么简单Redis内存满了会触发内存淘汰策略。八种淘汰策略里最常见的是noeviction不淘汰直接报错、allkeys-lru所有key中按LRU淘汰和volatile-lru在设置了过期时间的key中按LRU淘汰。我推荐大多数业务场景用allkeys-lru因为缓存数据本来就应该允许被淘汰缓存里没有“必须存在”的数据。但要注意如果缓存里混了需要长期保存的数据比如用户登录token、分布式锁就不能一刀切用allkeys-lru因为LRU可能把token淘汰掉导致用户被迫下线。这种情况下我对不同业务用不同逻辑过期时间key到期后自动删除结合业务容忍度来设计。写代码时也不要忘了给每一次写缓存的动作设置合理的TTL如果所有key都没有过期时间Redis内存迟早会被撑爆那时候再设置淘汰策略已经太晚了。7. 我踩过的那些缓存坑面试不问、文档不写、线上必炸的细节最后分享几个非常细节的坑都来自我实实在在的线上经历面试官一般不问文档里一般不写但踩了是真的炸。第一个坑RedisTemplate默认的JDK序列化导致排查困难。我用Spring Boot集成Redis时一开始偷懒没配序列化器结果缓存里的数据在redis-cli里全是\t\xAC\xED一类的乱码根本看不出来存的是什么。后来统一换成String和JSON序列化之后排查问题的效率瞬间翻倍。强烈建议从第一天就配置好序列化器不要碰JDK默认序列化。第二个坑SCAN命令和Keys命令的区别。我在生产环境用Keys命令删过一次缓存前缀当时匹配到的key数量有几万个执行完以后Redis直接阻塞了将近三秒线上接口大面积超时。从那以后凡是需要匹配key的操作一律用SCAN这是我在缓存操作上最深刻的教训。第三个坑缓存淘汰策略默认值。有的Redis版本默认是noeviction也就是说内存满了以后新的写操作会直接报错。很多人在开发环境测试时数据量小永远触发不了淘汰一上线内存打满就直接宕机。所以上线前一定要确认Redis实例的maxmemory和maxmemory-policy配置不要依赖默认值。第四个坑缓存和数据库一致性处理中别只在主流程里加逻辑。换个直接连数据库改数据的同事、一个后台管理系统的更新按钮、一个数据库工具批量改数都可能绕过你的代码更新逻辑。对于重要数据要么在改动后手动触发一次缓存清理要么接上binlog订阅方案否则线上会出现一些很奇怪的数据不一致排查起来极其痛苦。缓存看起来是很基础的知识但真正沉淀下来里面全是细节。希望这篇笔记能帮你在设计缓存方案时少走一些弯路。