“把缓存用起来”这句话,我听得太多了。但真正落到SpringBoot项目里,很多人的第一反应是:加个依赖,配置一下地址,然后往代码里塞各种缓存逻辑。等上了生产环境,问题接踵而来:缓存穿透把数据库打挂了、缓存跟数据库的数据对不上、序列化出来的key在Redis里跟乱码一样,排查了半天才发现是序列化策略的问题。
这篇文章,就围绕SpringBoot整合Redis这件事,把缓存配置和API实战这两个核心点彻底讲透。内容基于我自己在项目里的实操总结,适合刚接触缓存、以及用过但没仔细研究过底层细节的开发者参考。
1. 整合前夜:依赖选择与基础配置
1.1 依赖引入的两种典型方式
SpringBoot整合Redis,第一步当然是引入依赖。这一块虽然简单,但有一个细节值得留意:到底用spring-boot-starter-data-redis还是spring-boot-starter-cache?
严格来说,这两个依赖并不冲突,后者是缓存抽象层的支持,前者是Redis客户端的基础设施。如果项目里需要用到RedisTemplate操作数据,前者是必须的;如果只是想在Service层方法上加@Cacheable这类注解,那后者的缓存抽象也离不开底层的Redis实现。
我个人的习惯是:两个都加,后面做方案选型的时候更从容。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>1.2 连接池的必要性
很多人忽略了一点:默认情况下,spring-boot-starter-data-redis用的Lettuce连接是没有连接池的,意味着每个线程去访问Redis时都会创建新的连接。虽然Lettuce本身是线程安全的、可以共享一个连接,但在高并发场景下,没有连接池的资源复用机制,性能和稳定性都会受影响。
所以我的建议是,把连接池加上。SpringBoot 2.x之后,连接池用的是commons-pool2,需要额外引入依赖,再在配置里开启。
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>配置方面,除了常规的地址端口密码,还需注意几个参数:
spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 max-wait: -1ms这里的max-wait,我建议设置一个明确的值,比如2000ms,不要用-1。-1代表无限等待,在高负载下,线程会全部阻塞在获取连接上,引发雪崩式的响应延迟。设置成有界等待,配合熔断降级,至少保证系统在极端情况下还能快速失败。
1.3 序列化策略:最容易踩坑的地方
配置好连接,直接使用RedisTemplate时,很多人会踩一个经典陷阱:默认的序列化器是JdkSerializationRedisSerializer,存在两个明显问题。
第一,key和value都会以二进制形式存储,在Redis客户端查看时,数据长这样:\xAC\xED\x00\x05t\x00...,完全不可读,排查数据全靠猜。第二,这个序列化方式生成的字节数组很长,浪费存储空间,而且跨语言读取几乎不可能。
解决方式是在配置类中显式替换序列化器。我在项目中使用的策略是:key用StringRedisSerializer,value用Jackson2JsonRedisSerializer,并自定义序列化为JSON格式时对日期等类型的处理。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping( mapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); jacksonSerializer.setObjectMapper(mapper); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jacksonSerializer); template.setHashValueSerializer(jacksonSerializer); template.afterPropertiesSet(); return template; } }注意:
activateDefaultTyping会在JSON中写入@class元信息,反序列化时才能还原成原始类型。但如果项目跨语言消费Redis数据,这个信息会干扰对方解析。这种场景下,建议value统一由业务层定义DTO结构,序列化器直接用GenericJackson2JsonRedisSerializer,或者干脆在业务侧手动控制序列化与反序列化。
2. 缓存配置细节与失效策略
2.1 缓存管理器配置
有了RedisTemplate还不够,如果你要在Service层使用缓存注解,还需要配置CacheManager。Spring Boot的RedisCacheConfiguration提供了便捷的链式API来定制默认配置。
@Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(factory) .cacheDefaults(config) .withInitialCacheConfigurations(customConfigs) .build(); }这里有一个非常关键的配置点:disableCachingNullValues()。默认情况下,Spring允许缓存null值,这本来是一个为了避免缓存穿透的好特性,但有个副作用:作为一个“合法值”被缓存下来后,缓存和数据库之间就产生了数据语义上的分歧。更好的做法是,开启这个配置,明确不允许缓存null,然后在业务代码层面用其他手段解决穿透问题。
还有一个容易被忽视的参数是entryTtl。缓存的过期时间,需要根据业务数据的实时性要求来定,不能所有缓存都套同一个时间。比如用户基本信息可以缓存30分钟,首页推荐位数据可能只需要5分钟。这就需要withInitialCacheConfigurations来针对不同缓存名做差异化配置。
Map<String, RedisCacheConfiguration> customConfigs = new HashMap<>(); customConfigs.put("userInfo", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(60)) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))); customConfigs.put("hotNews", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(5)) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())));2.2 缓存穿透、击穿、雪崩的处理思路
缓存这块,业务逻辑并不复杂,真正的难点在于“三种经典故障”的防御。
缓存穿透指的是请求的数据在缓存和数据库中都不存在,导致每次请求都打到数据库。我常用的方案是:布隆过滤器先行拦截,再配合缓存空值兜底。
布隆过滤器判断元素“一定不在集合中,但可能在集合中”,所以它能拦截掉绝大多数不存在的key请求,而漏掉的极少部分则由缓存空值策略兜底。
缓存击穿指某一个热点key过期瞬间,大量请求同时访问这个key,直接打到数据库。解决方案就是加锁。在SpringBoot里,最简单的实现是@Cacheable(sync = true),它会在缓存未命中时,对同一个key的并发请求做同步,让一个线程去加载数据,其余线程等待。
缓存雪崩指大量key同时失效,请求全部打到数据库。常用手段包括:在key过期时间上增加随机值、使用多级缓存(本地缓存+Caffeine+Redis)、以及限流降级。
还有一个容易被忽略的点:缓存预热。系统刚启动时,冷启动状态下热点数据并不在Redis中,第一次请求必然穿透到数据库。我通常在项目启动时通过ApplicationRunner或者@PostConstruct加载热点数据,防止刚上线就出现缓存穿透。
2.3 过期时间设置的心得
对于TTL的设置,我的建议是遵循“二八原则”:80%的流量只会访问那20%的热点数据。这20%的数据,TTL可以适当拉长,比如用户基本信息缓存1小时;剩下的非热点数据,TTL设置短一些,比如5分钟或10分钟。
好的做法不是手动在配置中写死TTL,而是结合业务类型做一个通用的TTL策略表:
| 业务数据类型 | 建议TTL | 失效策略 |
|---|---|---|
| 配置类数据 | 1小时 | 主动刷新 |
| 用户基本信息 | 30分钟 | 失效后懒加载 |
| 商品详情 | 10分钟 | 预热+失效后懒加载 |
| 首页聚合数据 | 5分钟 | 定时刷新 |
3. RedisTemplate 核心 API 实战
3.1 常用数据类型操作
Redis支持的数据类型不只String一种,RedisTemplate也提供了完整的操作封装。我在项目中常用的操作主要包括:
String类型:
redisTemplate.opsForValue().set("user:1001", userInfo); UserInfo user = (UserInfo) redisTemplate.opsForValue().get("user:1001"); redisTemplate.opsForValue().increment("page:view:1001", 1);String类型有一个值得关注的API:setIfAbsent。这个方法可以实现分布式锁,它的语义是:仅当key不存在时才设置成功。配合过期时间,可以做到原子性的“加锁+设置过期时间”。
Boolean locked = redisTemplate.opsForValue().setIfAbsent("lock:order:1001", "1", Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 业务逻辑 } finally { redisTemplate.delete("lock:order:1001"); } }这里需要提防一个坑:使用lua脚本或者setIfAbsent时,锁的释放必须确保是持锁线程自己来删。如果只是单纯地delete,有可能删掉了别人后来获取的同一把锁。正确姿势是保存当前线程的唯一标识(如UUID),删除前校验值是否匹配。这个操作要保证原子性,所以最好配合Lua脚本。
Hash类型:
// 设置单个字段 redisTemplate.opsForHash().put("item:1001", "name", "手机"); redisTemplate.opsForHash().put("item:1001", "price", "1999"); // 获取多个字段 List<Object> multiValues = redisTemplate.opsForHash().multiGet( "item:1001", Arrays.asList("name", "price") );Hash类型适合存储对象的部分字段更新场景。如果直接用String存储整个对象JSON,每次改一个字段就要整体读取、反序列化、修改、再序列化、再写入,性能差很多。而Hash天然支持字段级更新。
List类型:
List类型在消息队列、最新消息列表场景中非常实用。leftPush表示从左边推入,rightPop表示从右边弹出,组合起来就是FIFO队列。
redisTemplate.opsForList().leftPush("task:queue", "task-1"); String task = (String) redisTemplate.opsForList().rightPop("task:queue");但要注意,用List做消息队列时,rightPop在没有数据时会立即返回null,导致空轮询。这里可以用rightPop的阻塞版本,替代忙等机制。
// 阻塞队列,最多等待5秒 String task = (String) redisTemplate.opsForList().rightPop("task:queue", 5, TimeUnit.SECONDS);Set类型:
Set类型适合去重、交集、并集运算场景。
redisTemplate.opsForSet().add("tag:java", "SpringBoot", "JVM"); redisTemplate.opsForSet().add("tag:backend", "SpringBoot", "MySQL"); Set<String> intersect = redisTemplate.opsForSet().intersect("tag:java", "tag:backend");对于“共同关注”、“好友推荐”这类场景,Set的集合运算可以很优雅地解决。
ZSet类型:
ZSet类型带score字段,天然支持排行榜。
redisTemplate.opsForZSet().add("ranking:2024", "user-1001", 95.0); redisTemplate.opsForZSet().add("ranking:2024", "user-1002", 88.0); // 获取前10名 Set<String> top10 = redisTemplate.opsForZSet().reverseRange("ranking:2024", 0, 9);3.2 pipeline 批量操作优化
注意一个常见性能瓶颈:循环执行单个Redis命令。假设需要一次性往Redis写入1000个用户标签,用普通for循环就是1000个RTT(往返时延),每个命令都要走一次网络。
用pipeline可以把多个命令批量发送,一次网络请求处理完成,性能提升非常明显。
List<Object> results = redisTemplate.executePipelined( new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (String userId : userIdList) { operations.opsForValue().set("user:tag:" + userId, tag, Duration.ofHours(1)); } return null; } } );需要注意的是,pipeline并不是原子操作。如果要求一批命令要么全部成功要么全部失败,应该使用事务或Lua脚本。pipeline适合对实时一致性要求不高的批量写入场景。
3.3 Lua 脚本与原子操作
上面提到分布式锁的释放需要Lua脚本保证原子性。除了这一场景,Lua脚本还常用于“限流”、”库存扣减“等需要复合操作的场景。
比如限流,最简单的实现是“固定窗口”计数:每个窗口期内最多允许N次请求。
-- KEYS[1]: 限流key -- ARGV[1]: 窗口大小,单位秒 -- ARGV[2]: 窗口内最大请求数 local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end if current > tonumber(ARGV[2]) then return 0 end return 1这一脚本用一次原子执行完成了计数、设置过期时间、判断是否超限三个步骤,避免了INCR之后设置EXPIRE中间的窗口期。
SpringBoot中执行Lua脚本需要加载DefaultRedisScript:
@Bean public DefaultRedisScript<Long> rateLimitScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("rateLimit.lua")); script.setResultType(Long.class); return script; } // 执行 Long result = redisTemplate.execute(rateLimitScript, Arrays.asList("ratelimit:user:1001"), "60", "100");3.4 key 设计规范
我说过很多次,Redis的key设计直接影响线上问题排查效率。建议的规范是“业务前缀:实体类型:ID[:子属性]”,例如:order:20240101:1001。
同时要避免大key。某个Hash集合如果存储了数十万字段,对内存、网络传输和持久化都有很大压力。经验值建议:单个key的value大小不要超过1MB,最好控制在100KB以内,Hash内的字段数量不要超过1000个。如果业务上确实需要大数据结构,优先考虑拆分或使用其他存储方案。
4. 缓存注解实战与问题避坑
缓存注解是Spring Cache抽象的核心用法,当使用@EnableCaching开启缓存功能后,在方法上加上注解,就能自动应用缓存逻辑。但在生产环境的实战中,有不少人在这里踩了坑。
4.1 @Cacheable 缓存查询结果
@Cacheable作用于查询方法,先查缓存,命中则直接返回,未命中则执行方法并缓存结果。
@Override @Cacheable(cacheNames = "userInfo", key = "#userId") public UserInfo getUserInfo(Long userId) { // 从数据库查询 return userMapper.selectById(userId); }这里有几个隐藏问题需要提醒。
第一:缓存默认key的生成规则。如果不显式指定key,Spring使用SimpleKeyGenerator生成,参数组合成key。如果方法没有参数且不指定key,缓存中永远只有同一个key,多租户场景直接数据错乱。我的建议是,在任何@Cacheable上都显式指定SPEL表达式作为key。
第二:类的内部调用问题。@Cacheable是基于Spring AOP的,只在通过代理对象调用方法时生效。同类内部方法调用(this.getUserInfo(userId))会绕开代理,缓存不生效。这是高频踩坑点,很多人调试半天发现缓存没起效,原因就在这里。
第三:方法的返回值不允许为null。如果开启disableCachingNullValues,返回null时会抛出异常。业务中确实存在数据不存在的场景,比如用户不存在、商品已下架。通常的做法是返回一个空对象或者Optional包装,再或者单独处理穿透场景。此外,如果你用的JDK动态代理,目标方法必须是public且类需要实现接口,不然AOP拦截不了。如果类没有接口直接用CGLIB代理,实际开发中我会在Spring Boot中默认设置spring.aop.proxy-target-class=true。
4.2 @CachePut 更新缓存
@CachePut是更新缓存的关键注解,它会执行方法体,然后把方法的返回值写入缓存。这个注解适合实现“修改数据库后同步更新缓存”的场景。
@Override @CachePut(cacheNames = "userInfo", key = "#userInfo.userId") public UserInfo updateUserInfo(UserInfo userInfo) { userMapper.updateById(userInfo); return userInfo; }注意点在于,@CachePut的执行时机是在方法调用之后,无论结果如何都会写入缓存。配合@Cacheable使用时,要注意缓存更新的顺序:先更新数据库,再更新缓存。
4.3 @CacheEvict 删除缓存
@CacheEvict用于失效缓存,支持两种方式:删除单个key或清空整个cacheNames下的所有缓存。
@Override @CacheEvict(cacheNames = "userInfo", key = "#userId") public void deleteUser(Long userId) { userMapper.deleteById(userId); }清空所有缓存的操作要慎重,如果缓存中存在大key,清空的时候Redis会阻塞较长时间。尽量使用精确key删除,避免allEntries = true大面积清空。
4.4 缓存一致性问题
缓存与数据库的一致性是分布式系统里最经典的问题。我的经验是不要追求绝对一致,而是要做到“最终一致,并且让不一致窗口期尽可能短”。
在单体应用里,我推荐“Cache Aside”模式:
- 读请求:先读缓存,命中则返回,未命中则读库、写缓存。
- 写请求:更新数据库,然后删除缓存(注意是删除,而不是更新)。
为什么更新数据库后不直接更新缓存,而是删除缓存呢?主要原因有两个。
第一,更新缓存这个操作可能是写了一个错误值进去,比如一个复杂查询的结果,更新缓存时可能带上旧逻辑的计算结果,而删除缓存可以强制下次读取时重新走一遍查询逻辑。
第二,如果并发场景下,一个线程更新数据库,另一个线程同时读数据库并写缓存,可能会导致缓存中写入旧值。删除缓存后即使发生并发,由于读MySQL有事务隔离和主从延迟的问题,最多出现短暂的空缓存穿透,下一次查询就能恢复。
这个模式下最常见的坑是“更新数据库成功,但删除缓存失败”。比如删除Redis时网络抖动,缓存没删掉,数据库的新数据又无法回填缓存,就会形成脏数据。解决思路是引入重试机制:把删除失败的key发送到消息队列,由消费者异步重试。
还有一种做法是订阅MySQL的binlog变更,通过CDC组件把变更事件发送到消息队列,消费者解析后删除对应缓存。这种方案引入了额外组件和延迟,适合缓存数据种类多、一致性要求高的平台型项目。
4.5 缓存Key 的失效与预热
前面说过缓存预热,这里再补充一个细节:预热不应该放在业务主流程中,否则会拖慢项目启动。较好的做法是独立一个CacheWarmer组件,在项目启动后异步执行。
@Component public class CacheWarmer implements ApplicationRunner { @Autowired private ProductService productService; @Override public void run(ApplicationArguments args) { // 异步加载热点商品 CompletableFuture.runAsync(() -> { List<Long> hotProductIds = productService.getHotProductIds(); for (Long id : hotProductIds) { productService.getProductDetail(id); } }); } }5. 缓存实战中的高阶用法
5.1 多级缓存架构
Redis虽然快,但每次读取还有一次网络IO。对于访问量极大的热点数据(比如秒杀商品详情),单靠Redis不一定够用,可以考虑在应用内存中再加一层本地缓存。
本地缓存用Caffeine,访问速度为纳秒级,完全无网络开销。结构上:先查Caffeine,未命中查Redis,再未命中查数据库。
不过多级缓存有一个明显的挑战:数据一致性更难控制。本地缓存分布在每个应用节点上,更新一个节点的缓存并不能同步到其他节点。解决思路一般有几种:引入消息总线广播失效事件、让本地缓存TTL尽量短(比如30秒)、或者仅对一致性要求不高的数据启用本地缓存。
5.2 缓存统计与监控
生产环境必须对缓存命中率有可见性。如果命中率持续低于50%,说明缓存配置有问题,要么key设计不合理,要么TTL过短,要么数据访问本身就不存在热点。
Spring Boot Actuator对Redis提供了健康指标,可以配合Micrometer暴露给监控系统。更细致的业务指标可以自己埋点:
- 缓存请求总数
- 缓存命中数
- 缓存过期数
- 穿透数(缓存未命中去查DB的请求数)
- 平均响应时间
把这些指标通过MeterRegistry上报到Prometheus,配合Grafana就可以做成可视化大盘。我在项目中深有体会:没有监控就相当于盲人摸象,只有看到数字,才能知道缓存配置是否合理。
5.3 使用 Redisson 实现分布式锁
之前用RedisTemplate手动实现分布式锁,有释放锁校验值配合Lua的复杂度。生产环境建议直接使用Redisson,它是Redis官方推荐的分布式Java客户端,内置了非常完善的分布式锁实现。
@Autowired private RedissonClient redissonClient; public void bizMethod(Long orderId) { RLock lock = redissonClient.getLock("lock:order:" + orderId); try { // 尝试加锁,最多等待3秒,锁自动过期时间30秒 if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // 业务处理 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }Redisson的锁底层用的是Lua脚本保证原子性,并且内置了“看门狗”机制,会在锁到期前自动续期,避免业务没执行完锁先过期的问题。
虽然没有在项目中使用其他分布式组件,但Redisson这套方案在并发控制方面确实解决了很多实际问题。
6. 常见问题与排查技巧实录
6.1 问题速查表与排查步骤
整理一份Redis接入和生产使用过程中的高频问题,附带排查思路和解决建议。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 缓存key在Redis显示乱码 | 未自定义序列化器,使用默认Jdk序列化 | key用StringRedisSerializer,value用JSON序列化 |
| 缓存注解不生效 | 方法内部调用、Spring AOP代理失效 | 避免同类内部调用,注入自身代理 |
| Redis连接超时、线程阻塞 | 连接池max-wait=-1 | 设置合理的有界等待时间 |
| 大量请求打到数据库 | 缓存穿透 | 参数校验+布隆过滤器+缓存空值 |
| 缓存雪崩 | 大量key同时过期 | TTL增加随机化、热点key永不过期+逻辑过期 |
| 缓存与DB数据不一致 | 更新DB成功后缓存删除失败 | 引入消息队列异步重试删除 |
| List队列消费时CPU飙高 | rightPop空轮询 | 改用BRPOP阻塞弹出 |
| 生成环境Redis内存暴涨 | 大key未治理、TTL过长或永不过期 | 大key拆分、设置合理的TTL和淘汰策略 |
| 批量操作耗时过长 | 逐条命令执行 | 使用pipeline批量发送 |
| 分布式锁失效 | 锁过期时间太短、业务执行过长 | 使用Redisson看门狗自动续期 |
6.2 一个比较隐蔽的并发问题
讲一个我实际排查过的案例:某次上线后,用户余额数据偶发性错乱。代码逻辑是先从Redis读余额,修改后写回Redis,再更新数据库。
排查后发现问题出在“先读再写”不是原子操作。两个线程同时读到余额为100,各自加10,最后写回Redis的值都是110,而不是120。
这个问题的本质是对同一个key的“读-改-写”缺少并发控制。解决方式不是加synchronized(分布式环境不生效),而是使用分布式锁包住整个操作,或者用Redis的Lua脚本把“读-改-写”做成原子操作,或者用Hash和HINCRBY这类原子自增命令。
6.3 序列化兼容性的经验补充
还有一次,排查一个反序列化异常,发现是Jackson2JsonRedisSerializer默认配置下,ObjectMapper没有注册JavaTimeModule,导致LocalDateTime类型字段反序列化直接报错。
如果你使用JDK 8及以上,项目中大量使用LocalDateTime、LocalDate,建议给ObjectMapper加模块支持,并且关闭WRITE_DATES_AS_TIMESTAMPS特性,否则序列化出来的时间是个数字数组,非常不利于排查。
mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);这个细节,我建议一上来就直接配好,否则上线后就会遇到“本地没问题,测试环境报错”的情况。
6.4 应用监控预警实战
最后分享一个监控配置层面的经验。缓存场景下的监控不能只看Redis本身的指标,还要结合业务侧的数据。
重点监控两个指标:缓存命中率和缓存读写延迟。
命中率长期走低,说明缓存没有起到应有的作用,大概率是key设计问题或TTL设置过短。读写延迟突然飙升,先看网络、再看慢查询(比如KEYS命令在Redis 6.0之前会阻塞)或者大key操作。
另外,Redis的INFO命令提供了丰富的运行时指标。INFO stats能看到keyspace_hits和keyspace_misses,可以直接算出命中率。这个指标我建议做成报警项,低于阈值就告警。
6.5 项目实战的清理策略
Redis缓存不像数据库有事务回滚,一旦代码有bug写入脏数据,清理起来不方便。所以我在项目中固定启用一套清理策略:
- 系统启动时清空业务缓存前缀下的所有key(仅限测试环境)。
- 生产环境禁止执行
FLUSHALL或FLUSHDB,如需清理,通过脚本按前缀匹配SCAN后逐个删除。 - 针对大key,在低峰期分批删除,避免阻塞Redis。
同时,针对业务代码中的缓存key,我建议做一个统一的key管理类,避免同一业务含义的key散落各处。
public final class CacheKey { private CacheKey() {} public static String userInfo(Long userId) { return "cache:user:info:" + userId; } public static String productDetail(Long productId) { return "cache:product:detail:" + productId; } }key统一管理之后,配合代码搜索就能快速定位所有缓存读写点,排查问题时能省下大量时间。
这篇文章写到这儿,基本上把SpringBoot整合Redis从依赖、配置、序列化、API实战到缓存注解、分布式锁、常见问题排查的全链路都覆盖了一遍。我自己的体会是,Redis本身不难,难的是把“缓存设计”这件事想清楚:哪些数据适合缓存、缓存多久、失效后怎么恢复、并发下如何保证一致性,这些才是区分“会写代码”和“能扛线上”的分水岭。你接下来要做的,不是把以上API背下来,而是回到自己的业务场景里,重新审视一遍现有的缓存方案,把上面提到的坑逐一检查一遍。你会发现,值得优化的地方,比想象中要多。