ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot+Redis缓存实战:穿透击穿与一致性

Spring Boot+Redis缓存实战:穿透击穿与一致性 做后端开发这几年Spring Boot 加 Redis 这套组合我几乎在每个项目里都用过。缓存做得好接口响应能快出一个量级做得不好轻则数据不一致重则线上事故。很多人一开始接触 Redis 缓存无非是在查询接口上贴一个 Cacheable 注解觉得“缓存就这么回事”。但真正把它用在生产环境之后各种坑才会一个个浮出来序列化报错、缓存击穿、连接池被打满、数据对不上……这篇文章我想从实际工程角度把 Spring Boot 集成 Redis 缓存这条路上最值得注意的设计思路、优化套路和排障经验系统性地梳理一遍给正在做缓存选型或者被缓存问题折磨的朋友一个可以照着操作的完整方案。1. 为什么选择 Spring Boot 集成 Redis 作为缓存方案1.1 缓存不是“往内存里塞数据”这么简单很多人以为缓存就是把数据复制一份扔到某个高速存储里读取的时候先查它查不到再去数据库。实际上缓存方案的设计远远不止“读写”两步。你需要先想清楚一个问题你的系统里到底哪些数据适合被缓存通常标准是读多写少、热点集中、数据一致性要求不是极高。比如用户列表、商品详情、配置信息这类数据命中一次缓存可能省掉几十次甚至上千次数据库查询缓存的价值就非常大。但如果一个数据每分钟都在更新且更新后要求用户立刻看到最新值那就得谨慎设计否则缓存很容易变成一个“脏数据制造机”。Redis 之所以在这个场景里成为主流选择有几个非常硬核的理由。第一它是基于内存的键值存储读写性能极高单实例 QPS 轻松可以跑到十万以上比数据库的承受能力强出太多。第二它提供了丰富的数据结构不只是 String还有 Hash、List、Set、ZSet 等可以精准匹配业务场景。第三它本身支持过期时间缓存自动失效这个功能很重要让缓存代码不用自己写定时清理任务。第四虽然不是必须的但 Redis 还支持持久化、主从复制、哨兵和集群模式后期扩展能力很足。选型的时候我见过不少项目用 Caffeine 之类的本地缓存做替代或者干脆用 ConcurrentHashMap 硬顶。本地缓存的问题在于多实例部署时每个节点各存各的数据不一致的概率很高而 Redis 是集中式缓存所有实例共享同一份缓存内容一致性天然更容易控制。当然本地缓存因为零网络开销性能确实更高所以在实践里比较常见的做法是“本地缓存 Redis 分布式缓存”两级 Cache一级保命一级提速。但如果你只能在两者中选一个作为主力我的经验是先上 Redis把所有公共接口的缓存收拢到一个统一管理层本地缓存后面再叠加也不迟。1.2 缓存策略要先行代码永远排在设计后面我接手过不少缓存代码发现最容易出问题的不是代码本身而是团队没想清楚缓存策略就开始写代码。常见的错误是把所有查询方法都加上 Cacheable上一个小时的数据也要缓存十分钟结果用户改完资料刷新页面还是旧值或者设置几天的过期时间导致缓存雪崩时数据库瞬间被打崩。这类问题本质上是缺乏“策略先行”的意识。做缓存设计前至少要先把这些问题写在文档里缓存的数据模型是什么样的一条记录是一个 key还是一个列表是一个 key读多写少的比例大概多少允许的最大数据不一致时间是多少过期时间怎么定更新数据库时是主动销毁缓存还是更新缓存如果同时存在多个微服务调用同一个缓存由谁负责写入和维护这些问题看起来琐碎但直接影响你后面所有代码的结构。我自己的习惯是画三张表一张业务数据与缓存 key 的映射表一张读写路径与缓存操作的关系表一张异常场景与应对策略表。三张表整理完写代码其实只是按图索骥。缓存更新策略里最经典也最好用的是 Cache Aside Pattern也就是读的时候先读缓存读不到再读数据库然后把数据塞回缓存写的时候先更新数据库再删除缓存。删除缓存而不是更新缓存原因很简单更新缓存需要额外查一次最新数据而且并发条件下容易和读请求产生竞争反而更容易造成脏数据删除缓存则是幂等的下一次读自然会把新值回填。这个策略简单可靠但也不是没有讲究比如在极高并发下可能出现“先删缓存后更新数据库”导致的一段时间数据不一致所以衍生出了延迟双删之类的改良方案后面我会专门讲。2. 基础集成与常见配置误区2.1 依赖引入和基础配置Spring Boot 集成 Redis 的官方 starter 是 spring-boot-starter-data-redis它把 Redis 连接工厂、RedisTemplate、StringRedisTemplate 这些基础组件都自动装配好了你不用再手动创建连接工厂。版本选择上跟着 Spring Boot 的依赖管理走即可。需要提醒的是默认的连接客户端是 Lettuce而不是老项目里常见的 Jedis。Lettuce 基于 Netty 实现能复用连接、支持异步和响应式这点在并发场景下比 Jedis 的每次请求独占连接要高效不少。如果你不想换也可以把 Jedis 依赖显式引入并把 spring-boot-starter-data-redis 排除掉但我的建议是不要折腾Lettuce 足够稳。配置文件里最基础的内容长这样spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: -1s这里面有一个细节经常被踩如果使用连接池需要额外引入 commons-pool2 依赖否则 lettuce.pool 下面的配置会被静默忽略而应用启动时也不会报错。换句话说你以为自己配了连接池但实际上每次操作窗口的 Redis 连接都没有被复用。这个问题非常隐蔽排查线上连接数偏高时经常发现是这个原因。database 参数也要注意Redis 默认有 16 个逻辑库使用 database: 0 表示第一个。很多项目喜欢把不同业务的数据放在不同 db 下比如缓存用 db0任务队列用 db1。这个做法在单机 Redis 上可行但一旦使用 Redis Cluster 集群不同数据库之间的数据无法自动迁移而且集群模式根本不支持多 db。所以在设计阶段还是要尽量把所有数据规划到 db0用 key 前缀区分业务比如 user:info:123或者 product:detail:10001。这样无论以后迁移到集群还是做混合部署都不会被 db 限制卡住。2.2 序列化方案为什么你的 Redis 里全是乱码默认情况下RedisTemplate 使用的是 JdkSerializationRedisSerializer也就是说它会把对象通过 Java 原生序列化成一大串带类型信息的二进制数据。如果你直接用 RedisTemplate 往缓存里写数据再用 redis-cli 查看会看到一堆“\xAC\xED\x00\x05”这样的乱码前缀而且 key 也同样被序列化了。这让排查非常痛苦同时也占存储空间更麻烦的是它要求所有对象实现 Serializable 接口不然反序列化直接抛异常。我建议使用一个自定义的 RedisTemplatekey 用 String 序列化器value 用 Jackson JSON 序列化器。这样缓存数据在 Redis 里是可直接阅读的 JSON调试方便也便于其他语言的服务读取。配置代码是Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); ObjectMapper objectMapper new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); Jackson2JsonRedisSerializerObject jsonSerializer new Jackson2JsonRedisSerializer(Object.class); jsonSerializer.setObjectMapper(objectMapper); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }注意 activateDefaultTyping 这个操作它会在 JSON 里写入 class 类型信息反序列化时 JVM 才能准确还原对象类型。好处是解决 List 和泛型对象反序列化时的类型丢失问题坏处是稍微增加存储开销并且有反序列化安全风险如果 Redis 中的内容被外部篡改就可能造成安全问题。对于内网环境下的缓存逻辑大多数团队会选择开启但如果你对安全性有较高要求也可以关闭默认类型信息改用 GenericJackson2JsonRedisSerializer 并提供目标类型。真正常见的错误是在配置 Jackson 序列化器时忘了设置 ObjectMapper 的默认类型导致缓存读出来是 LinkedHashMap然后强转失败报 ClassCastException。3. 核心实战注解缓存与手动缓存的正确姿势3.1 用注解优雅处理读写和失效Spring 的缓存抽象以注解为核心使用方式非常清晰。首先在启动类或者任意配置类上加 EnableCaching然后在需要缓存的方法上添加注解。最简单的例子Cacheable(cacheNames user, key #id) public User getUserById(Long id) { return userMapper.selectById(id); } CacheEvict(cacheNames user, key #id) public void updateUser(User user) { userMapper.updateById(user); }getUserById 第一次被调用时会执行方法体、从数据库查数据并把返回值放入名为 user 的缓存区key 是参数 id。后续相同的 id 再次调用时方法体不会执行缓存框架直接把缓存值转换成 User 返回。updateUser 里的 CacheEvict 会在方法执行完成后删除对应缓存保证用户更新后下次读取可以拿到最新数据。这三个注解使用频率最高细节足够写个小册子但我觉得最该记住的点是Cacheable 默认 key 生成规则是“cacheNames::SimpleKey[]”这种形式虽然也能用但可读性太差最好都通过 key 或 keyGenerator 自定义。缓存命中条件也可以用 condition 和 unless 控制比如查询结果为 null 就不缓存可以写 unless #result null或者只有参数大于某个值才缓存写 condition #id 100。注意 condition 在方法调用前判断unless 在方法调用后判断一个决定要不要查缓存一个决定要不要存缓存别搞混。另一个我踩过坑的点是缓存穿透问题。当查询 id 不存在的用户时Cacheable 会调用方法拿到 null默认 null 是不会被缓存的。于是恶意请求用一堆不存在的 id 打进来每次都穿透到数据库。解决方案有两个一是使用 Optional 作为返回值把空对象包装后缓存二是配置 RedisCacheManager 设置 allowCacheNullValues允许缓存 null 值并用空字符串或特殊标记代替。推荐业务上宁可多缓存一些空值也比被穿透打垮数据库更安全。3.2 手动缓存为什么不用注解注解确实方便但它也有局限。比如有些缓存逻辑不是围绕方法返回值设计的某个接口需要一次性查询多个 key然后组装结果或者需要在事务提交后再删除缓存或者缓存的使用方是别的服务写完缓存后还需要追加其他结构。这类情况硬套注解会非常别扭这时候就需要直接使用 RedisTemplate 做手动操作。我一般会写一个 RedisCacheService 包装类把常用操作收敛起来而不是在业务代码里到处 new RedisTemplate。里面一些基础方法public void set(String key, Object value, Duration timeout) { redisTemplate.opsForValue().set(key, value, timeout); } public T T get(String key, ClassT clazz) { Object value redisTemplate.opsForValue().get(key); if (value null) { return null; } return objectMapper.convertValue(value, clazz); } public Boolean setIfAbsent(String key, Object value, Duration timeout) { return redisTemplate.opsForValue().setIfAbsent(key, value, timeout); } public Boolean expire(String key, Duration timeout) { return redisTemplate.expire(key, timeout); } public void delete(String key) { redisTemplate.delete(key); }这些操作看似基础但要注意几个细节。第一setIfAbsent 是底层 setnx 命令它只有键不存在时才会写入这是实现分布式锁和防缓存击穿的重要工具。第二手动缓存时一定要显式设置过期时间除非你真的想让它永久存活。很多开发用 set 命令时忘了加 timeout结果 Redis 内存被无限增长的 key 耗尽。第三如果缓存的是集合类型泛型信息在运行时会被擦除反序列化时很容易丢类型。解决方法是借助 TypeReference或者封装成单独的类对象避免返回 List 这种“裸泛型”。在生产代码里我通常会给缓存 key 加上版本号或者业务环境前缀比如 app:v1:user:123。这样做的好处是当缓存结构发生不兼容升级时直接换一个版本前缀让旧缓存自然过期不用写复杂的迁移逻辑也让同一套 Redis 上不同环境的 key 互不干扰。这个方法在快速迭代型项目里特别实用。4. 性能优化从连接池到序列化再到过期策略4.1 连接池参数到底怎么调Lettuce 默认使用共享连接机制但这并不代表不需要连接池。在高并发场景下每次请求如果都新建连接、关闭连接TCP 握手和 Redis 协议交互的开销不可小觑。配置连接池不是越多越好这里要算一笔账。比如你的 Redis 服务最大可承载 QPS 是 5 万客户端平均一次操作耗时 1ms那么理论上一个连接在 1 秒内能完成 1000 次操作5 万 QPS 只需要 50 个连接。当然实际因为网络阻塞、GC 暂停、慢查询利用率达不到 100%所以把 max-active 设置成核心线程数的 2 到 3 倍是比较合理的。我举一个实际项目的例子。一个 8 核 16G 的 Java 服务核心线程池 200接口平均会打 2 次 Redis压测时 QPS 接近 6000。把 max-active 设为 50 时连接池一直处于满负载且频繁等待调到 150 时连接等待次数明显下降P99 延迟从 25ms 降到 8ms。但继续调到 1000 之后延迟没有明显变化Redis 服务端的连接数倒是涨了一大截占用了一些文件描述符。最终结论连接池大小围绕“QPS × 平均耗时 / 1000 × 冗余系数”这个公式去估算在一个量级内试压调整即可。另外 max-wait 不建议设置成 -1也就是无限等待。当一个业务需要快速失败时无限等待只会让线程全部卡在池上拖垮整个应用。我习惯设置为 3 到 5 秒超时直接报错并走降级逻辑。4.2 序列化方案与内存占用的权衡常有人问我缓存数据用 JSON 好还是二进制好。这个问题不能一概而论。JSON 的好处是可读性强方便开发和排查坏处是体积大、CPU 序列化开销高。如果你缓存的是小型对象比如几 KB 的用户信息这点开销完全无所谓。但如果你要缓存的是几十 MB 的大结果集或者非常热门的列表JSON 的体积和解析成本就会被放大。生产环境中更常用的折中方案是普通业务对象用 JSON大对象或高吞吐热点数据用 Protostuff、Kryo 这类的二进制序列化方案并提前测试序列化体积、CPU 占用和跨语言需求。Spring Boot 里的 RedisCacheManager 默认只能配一种序列化器但你可以创建多个 cache manager。比如一个 manager 用 JSON另一个用 Kryo在注解上指定不同的 cacheManager 属性。这个做法的意义在于让不同业务按需选择。另外还有一个容易被忽略的优化点是压缩。对非常大的 String 值可以使用 GZIP 压缩后再写入 Redis读取时再解压。压缩后的体积能缩小 60% 到 80%但会多消耗 CPU。这个策略更适合大文本、JSON 大列表而不适合图片视频等已经在服务端压缩过的数据。4.3 过期时间别让雪崩来得毫无防备缓存过期时间如果设置成固定值比如所有 key 都是 30 分钟那么整点过后的 30 分钟那一瞬间大量 key 同时到期所有请求同时穿透到数据库这就是缓存雪崩的典型场景。解决思路很简单不要给所有 key 一个相同的过期时间。可以在基础过期时间上加上一个随机扰动比如 30 分钟再加上一个 1 到 10 分钟的随机值。这样不同 key 的失效时间会错开流量自然被稀释。还有一种常见做法是“逻辑过期”不直接依赖 Redis 的 TTL而是把过期时间作为一个字段存在 value 中。访问时先看当前时间是否小于逻辑过期时间如果没到期就直接返回如果过了逻辑过期时间先获取一个互斥锁然后异步重建缓存同时把旧值先返回给用户。这样用户几乎无感知也不会因为缓存重建导致接口耗时增加。这种方案实现起来稍复杂但对于热点 key 的击穿效果很好。内存淘汰策略也要提前理解。当 Redis 内存写满时会按照 maxmemory-policy 策略淘汰 key。对缓存场景最常见的配置是 volatile-lru 或者 allkeys-lru。前者只淘汰设置了过期时间的 key适合“缓存数据都有 TTL”的情况后者会淘汰任意 key包括一些可能不想被淘汰的数据。如果你用的是纯净的缓存 Redis 实例我一般建议 allkeys-lru因为它把内存利用率最大化而且所有缓存数据本来就是可以重新生成的。如果 Redis 里还存了任务锁、分布式 ID 等不希望被动淘汰的数据那就要用 volatile-lru 并保证这些数据不设置 TTL从而避开淘汰。5. 高频坑位缓存穿透、击穿和一致性5.1 缓存穿透不存在的请求也能打垮数据库缓存穿透指的是请求的 key 在 Redis 中不存在在数据库也不存在。系统查询不到结果自然不会写缓存下一次同样的 key 依然会穿越缓存直接查库。恶意攻击时可以在参数里带上大量不存在的 id几万个并发请求就能把数据库的连接池占满。解决方式从三方面入手。第一是接口层做参数校验比如 id 必须有意义、范围合法从入口挡住一部分无效请求。第二是缓存空结果查询数据库为 null 时仍然往 Redis 写一个空值标记比如空字符串或特殊对象并设置一个较短的过期时间比如 2 到 5 分钟。第三是布隆过滤器方案启动时把所有可能的业务 id 加载到一个大布隆过滤器中请求进来先判断 id 是否可能存在不存在直接返回不落缓存也不打库。布隆过滤器有一定误判率但它不会漏判所以用于“拦截不存在”是安全的。对大流量的冷门数据平台这个方案非常值钱。我有个朋友的项目曾经因为穿透问题出过事故。活动期间大量用户领取奖励没有判断用户 id 是否存在直接查库组装数据导致数据库 SELECT 语句堆积CPU 飙到 100%最后只能重启服务。后来加了空值缓存数据库负载立刻降下来。所以建议所有对外暴露的查询接口在缓存层务必把“空值也缓存”作为一个默认行为。5.2 缓存击穿热点 key 过期瞬间的并发风暴缓存击穿和雪崩容易混淆。击穿说的是某一个非常热点的 key在缓存过期的瞬间大量请求同时发现缓存不存在全部去数据库查询数据库连接瞬间被打满。比如秒杀商品详情、爆款文章阅读量都属于单 key 热点。它的特点不是“很多 key 同时过期”而是“同一个 key 过期时被高并发同时访问”。最简单的应对方式就是我们在手动缓存部分提到的 setnx 互斥锁。核心代码逻辑是public Product getProduct(Long id) { // 先查缓存 Product product cacheService.get(product: id, Product.class); if (product ! null) { return product; } // 获取锁 String lockKey lock:product: id; Boolean locked cacheService.setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (Boolean.TRUE.equals(locked)) { try { // 双重检查锁成功后可能已经有别的线程重建了缓存 product cacheService.get(product: id, Product.class); if (product ! null) { return product; } product productMapper.selectById(id); cacheService.set(product: id, product, Duration.ofMinutes(30)); } finally { cacheService.delete(lockKey); } } else { // 未抢到锁的线程短暂等待后重试或者直接读取旧值 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProduct(id); } }这个写法的关键点在于锁释放。如果使用 redisTemplate.delete(lockKey) 删除锁可能出现误删其他线程锁的情况正确的做法是用 Lua 脚本比较值后再删除或者直接引入 Redisson 的分布式锁实现它内部会自动处理看门狗续期和原子释放问题。对于大多数业务短时间互斥也能接受但如果是金融支付类强一致场景还是别在小锁上节省成本。5.3 缓存与数据库一致性延迟双删到底靠不靠谱一致性问题的根源是数据库写操作和缓存删除操作不是原子的。比如你先更新数据库再删除缓存删除缓存遇到了网络抖动失败那接下来很长一段时间缓存里都是旧数据。为了解决这个失败场景很多项目采用了延迟双删先删除缓存再更新数据库然后再隔一段时间删除一次缓存。第二次删除是为了把更新数据库过程中并发请求写入的旧缓存清掉。延迟双删是目前实践里最简单一致性的方案之一但它的窗口期仍然存在。假设 A 线程更新数据库的同时B 线程查询数据库得到旧值并回写缓存删完缓存后如果 B 线程回写发生在第二次删除之后缓存里还是旧数据。这里的概率虽然低但高并发下依旧可能出现。想要彻底解决就得引入更重的方案比如 binlog 订阅 异步删除缓存。使用 Canal 监听 MySQL binlog一旦发生写操作就把“删除对应缓存”的消息投递到消息队列由消费者执行删除。这个方案把缓存更新从业务代码中彻底解耦几乎能覆盖所有不一致场景代价是增加组件维护成本。我给出的建议是一般业务系统用 Cache Aside 延迟双删足够了核心资金系统才需要考虑 binlog 方案没必要为低概率问题无脑引入复杂架构。5.4 Spring AOP 自调用与事务边界导致的缓存失效Cacheable 底层用的是 Spring AOP 动态代理。如果你在同一个类内部通过 this 调用被 Cacheable 修饰的方法代理不会生效缓存逻辑完全不会执行。很多人第一次碰到这个问题都会困惑为什么我的注解方法第一次执行后再次调用还是查数据库Debug 一看发现 controller 调 service 走的是代理对象service 内部方法之间互调用的是 this 对象自然绕过了拦截器。解决方法有三种把需要缓存的方法拆分到另一个 service 类中或者在类中注入自己的代理对象或者使用 AopContext.currentProxy()。我更推荐拆分方法简单清晰也让缓存边界一眼能看出来。事务边界问题同样隐蔽。如果一个 CacheEvict 方法上有 Transactional事务可能尚未提交缓存却已经被删除了。此时另一个线程来查询发现缓存没有直接查数据库但数据库事务还没提交读取到的仍然是旧数据或者脏数据。所以删除缓存的操作必须排在事务提交之后。一种方法是把缓存删除逻辑放到事务同步器中使用 TransactionSynchronizationManager.registerSynchronization在 afterCommit 回调中删除另一种是直接在事务方法外层包一层非事务的删除逻辑。在实际项目中为了避免事务长度不确定我倾向于使用事件机制事务提交后发送 Spring 事件监听器里执行缓存删除。6. 常见问题排查实录从现象到根因6.1 设置好的 key 在 Redis 里变成了二进制乱码这个问题的根因基本就是序列化配置没生效。常见情况是项目里有多套 RedisTemplate Bean或者某个地方直接 new 了 RedisTemplate没有使用 Spring 容器中的自定义配置。排查时可以写一个临时接口输出当前 RedisTemplate 的 keySerializer 类型确认是否为 StringRedisSerializer。另一个很隐蔽的原因是 RedisCacheManager 默认使用 JdkSerialization而你只配置了 RedisTemplate没有配置 RedisCacheManager 的序列化器。注解缓存走的是 RedisCacheManager它里面的序列化器是独立配置的所以即使你自定义了 RedisTemplate注解缓存依然会产生乱码。因此要同时留意两套体系RedisTemplate 与 RedisCacheManager。6.2 缓存明明设置了过期时间Redis 的 key 却没有消失出现这个问题时第一步看 Redis 配置的淘汰策略第二步看 key 本身的 TTL。如果执行 ttl 命令返回 -1说明这个 key 没有过期时间。可能的原因是代码里先 setValue 后 expire但 expire 失败也可能是设置了 expire 之后又对这个 key 执行了写操作某些写操作比如 append、setrange会清除 TTL更常见的问题是 setIfAbsent 设了过期时间但后续业务又调用 opsForValue().set 对该 key 重新赋值这个新 set 没有带超时参数导致 TTL 被清掉。踩过几次坑之后我的原则是不要对同一个缓存 key 拆成“先 set 后 expire”两步而是使用一次性带过期时间的 set 方法尽量确保操作的原子性。6.3 缓存数据能查到但是反序列化一直报错这个问题八成出在类型的泛型丢失上。你缓存了一个 List 对象读取的时候直接写 redisTemplate.opsForValue().get(key) 强转 List 运行后却报 ClassCastException 或者 LinkedHashMap 无法转换为 User。原因在于 Jackson 在默认配置下反序列化时并不知道容器内部的元素类型。解决办法有几种一是存储时把整个列表包装成自定义对象比如 UserListWrapper这样类型信息便携带在包装类中二是读取时使用 ParameterizedTypeReference或者通过 ObjectMapper 的 convertValue 配合 TypeReference 来完成转换三是禁用缓存存储容器结构尽量对单个对象缓存列表用拼接 key 的方式缓存成 JSON 字符串读取后解析时显式指定 CollectionType。6.4 缓存命中率低接口响应没有明显提升如果缓存覆盖率不低但命中率还是不理想要优先评估 key 的设计。比如缓存列表数据时用了用户 ID 外加分页参数不同用户访问同一份列表数据key 完全不同命中率自然上不去。这时应该考虑把“公共数据”从“私有数据”中剥离商品分类、城市列表、配置项这类全站共用数据用固定的公共 key 缓存只有真正和用户绑定的数据才用 userId 做维度。另外缓存命中率低也可能是因为缓存时间太短业务还没形成热点就过期了。我自己的做法是给缓存监控埋点使用 Redis 的 INFO 命令和客户端的命中统计指标定期检查命中率。如果命中率长期低于 50%就要重新审视缓存粒度划分和业务访问模型。6.5 Redis 连接时不时超时后台报 RedisConnectionFailureException这种问题首先排查网络层面客户端到 Redis 服务器的网络延迟、防火墙策略、Redis 服务器的 maxclients 是否已达上限。然后是客户端配置Lettuce 在默认情况下的超时时间是 60 秒如果你没有显式设置 timeout一旦连接被阻塞请求可能长时间挂住。建议在配置文件中把 timeout 设置为 1 到 3 秒并开启连接池的 max-wait 限制。另外还有一个容易被忽略的点Lettuce 默认使用一个共享连接会被多个线程并发使用如果某个线程长时间占用连接执行阻塞命令比如订阅、阻塞队列读取就可能影响其他请求。在这个场景下可以把订阅等阻塞操作单独拆到一个专用连接工厂中去避免和普通缓存读写混在一起。最后再分享一个小技巧。无论项目多忙Redis 缓存上线之前一定要压测。压测不是测接口能扛多少 QPS而是看缓存策略在极端场景下的表现每秒模拟 100 个不同的不存在 id看数据库是否被穿透模拟热点 key 刚好过期看数据库和 Redis 的耗时曲线模拟网络抖动看应用是否会雪崩。提前把这些问题在测试环境里引爆总比广告大促时在线上炸出来强得多。缓存不是越多越好也不是越快越好它是一套需要认真设计的系统。愿少踩几个坑。
返回列表