ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot整合Redis缓存实战:配置、序列化与API详解

SpringBoot整合Redis缓存实战:配置、序列化与API详解

“把缓存用起来”这句话,我听得太多了。但真正落到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背下来,而是回到自己的业务场景里,重新审视一遍现有的缓存方案,把上面提到的坑逐一检查一遍。你会发现,值得优化的地方,比想象中要多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进