☰
Spring Boot整合Redis实战:配置、缓存与防穿透击穿雪崩
2026/10/21 2:03:12 网站建设 项目流程

1. 从一次缓存穿透事故说起:为什么要认真对待 Redis 接入这件事

1.1 那场故障里到底发生了什么

先说一个让我印象挺深的场景。某天凌晨我维护的某社区系统突然报警,数据库连接数直接拉满,慢查询日志刷屏,接口响应从几十毫秒涨到几十秒。查了一圈发现问题出在缓存层:所有热点数据的本地缓存同时过期,线上流量像开闸一样直接涌到数据库,纯粹是教科书级的缓存雪崩。

当时项目里用的还是最原始的ConcurrentHashMap做本地缓存,根本没有统一的缓存中间件。那次事故之后,我把Spring Boot 整合 Redis当成了所有后端项目的标配动作,也把缓存穿透、击穿、雪崩这三件事写进了每次团队分享的必聊清单。

很多刚接触 Spring Boot 的开发者会把 Redis 当成一个"会用命令就行"的存储,实际接入的时候才发现坑不少:序列化读出来是一堆二进制、连接池参数看不懂、注解缓存不生效、分布式锁写了个半吊子。这篇文章我想按一条完整链路来讲——从环境准备、核心配置、功能实现到实战案例,最后补上线上排错的经验,争取让读者能直接照着落地。

1.2 为什么是 Redis:它解决了本地缓存解决不了的问题

选 Redis 不是因为它"快",而是因为它补上了本地缓存和数据库之间的关键缺口。

第一点是多实例共享。本地缓存只活在单个应用进程里,后面挂了负载均衡、起了三四个实例,每个实例的数据互相看不见。Redis 是独立进程,所有应用实例读写同一份数据,这就让缓存真正变成了服务层的公共资源。

第二点是数据结构丰富且操作原子。普通的 Map 只能做 get/put,而 Redis 的 ZSet、Hash、List 能做排行榜、购物车、消息队列这类场景,而且incr、hset、zadd这类命令本身是原子的,不需要自己加锁。

第三点是过期与持久化机制。给 key 设置过期时间,相当于把"缓存失效策略"下沉到了存储层;开启 AOF/RDB 之后,缓存数据重启也能恢复一部分,比进程一死就全部丢失的本地缓存稳得多。

但这不意味着所有项目都该上 Redis。如果只有单机小应用、数据量几千条、对一致性要求极高,直接查数据库反而是更理性的选择。Redis 适合的是"读多写少、热点集中、数据可以短暂不一致"的场景,先想清楚自己的业务形态,再决定引不引入。

2. 环境准备与版本选型:这些细节决定你能少填多少坑

2.1 本地开发环境的三种搭建方式

本地开发我试过三种方式:Windows 直接安装、Docker 启动、连远程测试环境。

Windows 下装 Redis 最省事的是用官方提供的 MSI 安装包,装完会注册成 Windows 服务,默认端口 6379,配置文件在安装目录下的redis.windows-service.conf。但要注意 Windows 版本的 Redis 更新相对滞后,某些新特性(比如部分命令族)可能用不了,如果你跟的是新项目,建议优先考虑 Docker。

Docker 方式是我现在最推荐的做法,一条命令就能拉起一个干净环境:

docker run -d --name redis-local \ -p 6379:6379 \ -v /data/redis:/data \ redis:7.2-alpine \ redis-server --appendonly yes

这里我特意加了--appendonly yes,开发阶段就开启 AOF 持久化,避免调试到一半重启容器数据全丢。生产环境 AOF 要不要开要单独讨论,但本地开没毛病。

远程测试环境的方式我劝新手慎用。大家共用一套 Redis 容易互相覆盖 key,排查问题的时候根本分不清数据是谁写的。自己本机拉一个最干净。

2.2 Spring Boot 版本与 Redis 客户端的匹配关系

版本匹配是新手踩坑重灾区。Spring Boot 2.x 和 3.x 的 Redis 配置前缀不一样,很多人把网上的配置直接粘过来,发现根本读不到。

Spring Boot 2.x 用的是spring.redis.*,Spring Boot 3.x 改成了spring.data.redis.*。同时 Spring Boot 3.x 强制要求 Java 17+,默认客户端是 Lettuce,底层基于 Netty,支持响应式。如果你还在用 Jedis,需要手动排除 Lettuce 再引入 Jedis 依赖。

我整理过一张常用版本对照表:

Spring Boot 版本配置前缀默认客户端最低 Java 版本常见问题
2.3.xspring.redis.*Lettuce 5.xJava 8连接池需额外依赖 commons-pool2
2.7.xspring.redis.*Lettuce 6.xJava 8/11与高版本 Redis 6/7 兼容良好
3.0.xspring.data.redis.*Lettuce 6.xJava 17配置前缀变更,切勿沿用旧配置
3.2.xspring.data.redis.*Lettuce 7.xJava 17默认开启响应式支持,注意依赖范围

我的建议是:新项目直接用 Spring Boot 3.x + Lettuce,别在 Jedis 上纠结。Lettuce 的连接管理更智能,线程安全,性能上限更高,Spring Boot 默认选择它是有道理的。

2.3 依赖引入的完整清单与常见缺包问题

在pom.xml里引入:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>

第二个依赖commons-pool2经常被漏掉。如果你在application.yml里配置了 Lettuce 的pool参数,但没有引入这个包,启动时会报ClassNotFoundException: org.apache.commons.pool2.impl.GenericObjectPoolConfig。Spring Boot 官方文档里写了这是可选依赖,不会自动带进来,所以必须手动加。

提示:如果你项目里引入了spring-boot-starter-data-redis,也引入了spring-boot-starter-web,有些版本会连带引入 Jedis,导致 Lettuce 和 Jedis 同时存在。碰到这种诡异情况,用mvn dependency:tree看一下依赖树,把多余的那个排除掉。

3. 核心配置拆解:序列化、连接池与过期策略一个都不能少

3.1 为什么默认的 JdkSerializationRedisSerializer 不好用

Spring Boot 自动配置的RedisTemplate默认使用JdkSerializationRedisSerializer做序列化。这个序列化器有几个明显问题。

第一,它把 Java 对象序列化成二进制字节流,存到 Redis 里直接看redis-cli的输出就是一堆乱码,排错非常痛苦。第二,它强依赖 Java 反序列化机制,对象类结构稍微一变(比如加了字段、改了包名),老数据就读不出来,报InvalidClassException。第三,如果你的数据要跟其他语言写的服务共享,比如 PHP、Go 那边也要读这个缓存,JDK 序列化等于直接锁死了 Java 生态。

所以接入 Redis 的第一件事,就是先把序列化策略定下来,而不是默认配置先跑起来再回头改。我的习惯是:key 全部用 String 序列化,value 用 JSON 序列化。这样在 Redis 里看到的是user:123:info和{"name":"张三","age":30}这类可读内容,排查问题效率会高一个量级。

3.2 一套通用且够用的 RedisTemplate 配置

下面这段配置是我在很多项目里直接复用过的,基本覆盖了常见需求:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 与 hash 的 key 使用 String 序列化 StringRedisSerializer stringSerializer = new StringRedisSerializer(); // value 与 hash 的 value 使用 JSON 序列化 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

注意两个细节。第一,GenericJackson2JsonRedisSerializer序列化时会往 JSON 里写入@class字段记录类型信息,反序列化时才能还原出正确的 Java 类型,代价是 JSON 里多出一段内容,体积略大,可读性略差,但换来的是通用性。如果对体积敏感,可以换成Jackson2JsonRedisSerializer配合ObjectMapper显式指定类型,但通用性会变差。

第二,afterPropertiesSet()这行不能漏。手动new RedisTemplate()之后,序列化器是在afterPropertiesSet阶段才真正生效的,漏掉这行会出现序列化器配了但没挂上的诡异问题。

3.3 Spring Cache 注解与 RedisCacheManager 的配合

除了手动操作RedisTemplate,Spring 的@Cacheable、@CacheEvict注解也值得用起来。注解方式是声明式的,代码侵入小,适合那些"查完直接放缓存"的读接口。

要让它跑起来,需要单独定义RedisCacheManager:

@Configuration @EnableCaching public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .computePrefixWith(cacheName -> cacheName + ":"); return RedisCacheManager.builder(factory) .cacheDefaults(defaultConfig) .build(); } }

这里有几个容易被忽略的点。computePrefixWith是设置缓存 key 前缀分隔符的,默认前缀是cacheName::,两个冒号看起来别扭,我习惯改成单冒号。entryTtl是默认过期时间,你可以为不同缓存设置不同 TTL,用withCacheConfiguration单独指定,比如热点数据 5 分钟、基础配置 1 小时。

用注解的时候,业务代码里只要这样写:

@Cacheable(value = "user:info", key = "#userId") public UserInfo getUserInfo(Long userId) { return userMapper.selectById(userId); } @CacheEvict(value = "user:info", key = "#userId") public void updateUserInfo(Long userId) { userMapper.updateById(userId); }

缓存 key 会变成user:info:123,对应 Redis 里的结构一目了然。使用注解的副作用是需要在@Cacheable方法内部处理缓存穿透问题,注解本身不会帮你挡住不存在的 key,这个后面进阶部分再展开。

3.4 连接池参数不是越大越好

生产环境里连接池参数是最容易被乱调的。很多人觉得并发高就把max-active拉满,结果把 Redis 连接数撑爆,反而拖垮服务。

我这几年实践下来,常规单体服务用下面这套参数足够了:

spring: data: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 0 max-wait: 2s

max-wait是获取连接的最大等待时间,当连接池耗尽时,请求最多等 2 秒,超过就抛异常,这是防止线程无限堆积的关键。timeout是读写超时,3 秒足够,设太大会让接口长时间挂起,设太小在 Redis 负载高时会频繁误报。

提示:开连接池的前提是引入了 commons-pool2。如果项目只是简单场景,也可以不配 pool,直接用默认的单连接管理,但高并发下性能上限较低,建议还是把池配起来。

4. 功能实现顺序:缓存、分布式锁、计数限流与发布订阅

4.1 缓存读写:注解优先,模板兜底

在项目里我会优先用@Cachable注解,理由很简单:代码最简洁,团队其他人也好维护。但注解解决不了所有问题,比如缓存里存的是多个接口拼出来的聚合数据、或者需要手动控制过期时间,这时候就轮到RedisTemplate出场。

手动写缓存的标准姿势是这样,业界管这个叫Cache Aside Pattern:

public ProductInfo getProductWithCache(Long productId) { String key = "product:info:" + productId; // 1. 先读缓存 Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return (ProductInfo) cached; } // 2. 缓存没命中,查数据库 ProductInfo product = productMapper.selectById(productId); if (product != null) { // 3. 写回缓存,设置过期时间 redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10)); } return product; }

写回缓存的时候一定要带上 TTL,不带过期时间的缓存就是定时炸弹,哪天数据变了,旧值能一直活到你手动清。数据更新时,我的习惯是先更新数据库,再删除缓存,而不是更新缓存。更新缓存会有并发写覆盖的问题,而删除缓存让下次查询自然回填,简单可靠。

4.2 分布式锁:从 SetNX 到 Redisson 的取舍

分布式锁是面试里高频问、项目里高频率用错的东西。最经典的做法是利用SET key value NX EX seconds的原子性,同一个 key 只有第一次设置能成功,成功的人就拿到了锁:

public boolean tryLock(String lockKey, String requestId, long expireSeconds) { return redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); }

这里有两个必须注意的点。第一,requestId必须是唯一标识(可以用 UUID),这是为了释放锁时确认"锁还是我的"。第二,释放锁要配合 Lua 脚本,先比对requestId再删除,两步必须原子,否则可能把别人刚抢到的锁删掉:

private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; public void unlock(String lockKey, String requestId) { redisTemplate.execute( new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class), List.of(lockKey), requestId); }

别用get后再delete的两步操作,中间隔着的任何一个瞬间,锁都可能已经过期被别的线程拿走,然后你删掉的是别人的锁。很多人写分布式锁的时候觉得"反正概率很低",但线上问题往往就是这么来的。

如果业务里锁的逻辑复杂,需要可重入、自动续期,直接引入 Redisson 更省心,它的RLock封装好了这些细节。注意 Redisson 单独配置连接,不走 Spring Boot 默认的 Lettuce 连接。

4.3 计数器与限流:incr 的原子性怎么用

Redis 的incr命令是原子的,这个特性非常适合做计数器。我整理了一个固定窗口限流的小工具,可以防止某个接口被刷:

/** * 固定窗口限流,每个窗口最多允许 limit 次请求 */ public boolean tryAcquire(String key, long limit, long windowSeconds) { Long current = redisTemplate.opsForValue().increment(key); if (current != null && current == 1L) { // 第一次计数时设置窗口过期时间 redisTemplate.expire(key, Duration.ofSeconds(windowSeconds)); } return current != null && current <= limit; }

用的时候比如限制每个用户每分钟最多调用 10 次接口:

if (!tryAcquire("rate:sendSms:" + userId, 10, 60)) { throw new BizException("操作太频繁,请稍后再试"); }

固定窗口的局限是窗口边界可能瞬间涌进两倍流量,严格一点可以用滑动窗口(ZSet 记录时间戳、统计窗口内数量),但代码复杂度会上去。对大部分业务场景,固定窗口已经够用,先用简单的方案顶上,指标告诉你不够再升级。

4.4 发布订阅:用 Redis 做应用内消息通知

Redis 的发布订阅功能也有实用价值,比如某个配置变更后,让所有应用实例都收到通知刷新本地缓存。发布端很简单:

redisTemplate.convertAndSend("channel:config:refresh", "config changed");

订阅端需要注册一个监听器容器:

@Bean public RedisMessageListenerContainer messageListenerContainer(RedisConnectionFactory factory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(factory); container.addMessageListener((message, pattern) -> { String body = new String(message.getBody(), StandardCharsets.UTF_8); // 收到通知后刷新本地缓存... }, new ChannelTopic("channel:config:refresh")); return container; }

不过话说回来,Redis 发布订阅是"即发即弃"的,消息不持久化、没有消费确认、消费者掉线消息就丢了。做真正的消息队列还是得上 RocketMQ 或 Kafka,Redis 这个功能适合轻量通知场景,千万不要拿来当 MQ 替代品。

5. 实战案例落地:热点榜单、购物车会话与登录态保持

5.1 案例一:实时热点榜单(ZSet)

之前做某电商项目,运营每天要看到实时热销商品榜,还要按小时、按天切换。如果用数据库直接ORDER BY统计,热点期间压力很大。ZSet 天生就是干这个的:

// 用户浏览商品时加分 redisTemplate.opsForZSet() .incrementScore("product:rank:real", productId, 1); // 获取当前 TOP 10 商品 Set<Object> topProducts = redisTemplate.opsForZSet() .reverseRange("product:rank:real", 0, 9);

ZSet 的每个成员对应一个 score,reverseRange按分数从高到低取。相比数据库方式,这个操作的耗时是毫秒级的,而且读写都是单条命令,没有并发问题。

这里有一个业务细节:榜单分小时、分天怎么实现?我的做法是把 key 设计成product:rank:2025061410这种带时间粒度的形式,然后写一个定时任务,每小时开始的时候把上一小时的 key 离线导出到数据库归档。这样既不影响实时展示,又不丢历史数据。

5.2 案例二:购物车数据(Hash)与会话保持

购物车非常适合用 Hash 存储:外层 key 是用户维度,内层 field 是商品 SKU,value 是购物车条目信息。结构清晰,还能单独操作某个商品:

// 加入购物车 String cartKey = "cart:" + userId; redisTemplate.opsForHash().put(cartKey, skuId, cartItemJson); // 删除某件商品 redisTemplate.opsForHash().delete(cartKey, skuId); // 获取整个购物车 Map<Object, Object> cart = redisTemplate.opsForHash().entries(cartKey); // 获取购物车数量 Long size = redisTemplate.opsForHash().size(cartKey);

购物车数据不能丢,所以每次用户操作后我会刷新 TTL,让不活跃用户的购物车自然过期释放内存:

redisTemplate.expire(cartKey, Duration.ofDays(7));

这里要提醒一个坑:opsForHash().expire()不存在这个 API,对 Hash 设置过期时间也得通过redisTemplate.expire(key, ...)走 key 维度。新手经常在 Hash 上找设置 TTL 的方法,找了半天发现没有,其实这就是 Redis 的设计——过期时间属于 key 而不是某个 field。

5.3 案例三:登录态与 Token 管理(String + expire)

登录态管理也是 Redis 的经典场景。用户登录后生成一个随机 token 作为 key,用户信息 JSON 作为 value,设置过期时间:

String token = UUID.randomUUID().toString().replace("-", ""); String loginKey = "login:token:" + token; redisTemplate.opsForValue().set(loginKey, userJson, Duration.ofHours(2));

每次请求进来,拦截器里拿着 token 查 Redis,命中就说明会话有效。这里有个体验优化点:续期策略。用户一直在操作,会话不应该 2 小时后突然失效。我的做法是"滑动续期"——距离过期时间小于某个阈值(比如 30 分钟)时刷新 TTL:

Long ttl = redisTemplate.getExpire(loginKey, TimeUnit.SECONDS); if (ttl != null && ttl > 0 && ttl < 1800) { redisTemplate.expire(loginKey, Duration.ofHours(2)); }

登出就是把 key 删掉,改密码、踢人下线也都是同样的逻辑。相比传统的HttpSession,Redis 存登录态的优势是多个应用实例共享,随便怎么扩容都行。

6. 进阶防坑:穿透、击穿、雪崩的三层防线

6.1 缓存穿透:布隆过滤器与空值缓存

缓存穿透指的是查询一个必然不存在的数据,缓存里没有,数据库里也没有,每次请求都打到数据库。比如恶意攻击者拿不存在的商品 ID 刷接口,数据库压力会瞬间飙升。

最常见的解决手段是缓存空值:查不到数据时,把空对象也写进 Redis,TTL 设短一点(比如 3 到 5 分钟)。这样后续同样 key 的查询会命中"空缓存",不会继续打到数据库:

if (product == null) { redisTemplate.opsForValue().set(key, "", Duration.ofMinutes(5)); return null; }

更彻底的办法是上布隆过滤器。布隆过滤器能快速判断"这个 key 一定不存在",判断不存在时直接返回,不再查任何存储。Redisson 提供了现成的RBloomFilter:

RBloomFilter<String> bloomFilter = redissonClient .getBloomFilter("product:id:filter"); bloomFilter.tryInit(1000000L, 0.01); // 商品创建时加入过滤器 bloomFilter.add(String.valueOf(productId)); // 查询前先用过滤器判断 if (!bloomFilter.contains(String.valueOf(productId))) { return null; }

布隆过滤器的缺点是误判率存在(判断"存在"不一定真存在),但判断"不存在"一定准确。它适合那些 key 集合稳定、数量可控的场景,而且要注意过滤器预热——初始化后要把存量数据都加进去,否则老数据会被误杀。

6.2 缓存击穿:互斥锁重建与逻辑过期

缓存击穿说的是某个热点 key 在过期的一瞬间,大量请求同时发现缓存失效,全部去数据库重建缓存。如果这个 key 的访问量是每秒上万,数据库瞬间就扛不住了。

互斥锁方案的核心是"只让一个线程去重建缓存,其他线程等着":

public ProductInfo getProductWithMutex(Long productId) { String key = "product:info:" + productId; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return (ProductInfo) cached; } // 尝试加锁,只有成功的线程才查数据库 String lockKey = "lock:product:info:" + productId; String requestId = UUID.randomUUID().toString(); boolean locked = tryLock(lockKey, requestId, 10); if (!locked) { // 拿不到锁说明别的线程正在重建,短暂等待后重试 Thread.sleep(50); return getProductWithMutex(productId); } try { // 双重检查:可能锁等待期间别的线程已经重建完了 Object cachedAgain = redisTemplate.opsForValue().get(key); if (cachedAgain != null) { return (ProductInfo) cachedAgain; } ProductInfo product = productMapper.selectById(productId); redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10)); return product; } finally { unlock(lockKey, requestId); } }

互斥锁方案逻辑清晰,但代码侵入性强,而且要处理锁等待和重试。更现代的做法是逻辑过期:缓存里不设真正的 TTL,而是存一个过期时间戳,查询时发现逻辑过期后尝试加锁并异步重建,旧数据在重建期间还能继续提供,用户几乎无感知。这个方案更平滑,但对数据一致性要求高,适合允许短暂返回旧数据的场景。

6.3 缓存雪崩:过期时间错峰与多级本地缓存

缓存雪崩是大面积的 key 同时过期,或者 Redis 实例直接宕机,导致所有流量打到数据库。之前说的那场事故,根因就是所有热点 key 用了相同的过期时间,一到整点集体失效。

错峰是成本最低的解决办法。设置 TTL 时加一个随机偏移,让过期时间自然散开:

long ttl = 60 * 60 + ThreadLocalRandom.current().nextLong(0, 600); redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttl));

核心思想是给统一过期时间"加抖动",打散周期性的流量尖峰。

如果 Redis 实例整体挂了,上面这些缓存全部失效,这时候只能靠多级缓存兜底。应用本地再放一层 Caffeine 缓存,Redis 查不到先查本地,本地再没有才查数据库。代价是数据一致性问题要多处理一层,但换来的是 Redis 宕机这段时间服务的存活能力。我的建议是核心链路必须降级到本地缓存或默认值,即使数据不是最新,也比接口整体 500 强。

6.4 兜底策略:降级与熔断

无论缓存层做得再好,Redis 总有出问题的可能。应用侧必须配套超时、降级、熔断。

调 Redis 的读写超时不要设太长,之前建议的 3 秒读超时已经偏保守,Redis 卡住时每请求等 3 秒,100 个线程就是 300 秒的堆积。配合连接池的max-wait2 秒,一个异常 Redis 撑死拖住每个请求 5 秒,再配上熔断组件就能把损失锁在一定范围内。

遇到 Redis 不可用,业务方法要有降级逻辑:

ProductInfo product = null; try { product = getProductFromCache(productId); } catch (RedisException e) { log.warn("Redis 不可用,直接查库,productId={}", productId); product = productMapper.selectById(productId); }

降级不是可有可无的按钮,而是在设计缓存层的时候就应该想清楚的那条退路。

7. 性能监控与排错清单:线上问题不再抓瞎

7.1 几个必看的 Redis 运行指标

我排查 Redis 问题时,第一个动作永远是看这几类指标:

指标项获取方式关注点
命中率INFO stats里的keyspace_hits/keyspace_misses命中率低于 80% 说明缓存策略可能有问题
内存碎片率INFO memory里的mem_fragmentation_ratio大于 1.5 或小于 1 都需要警惕
慢查询SLOWLOG GET 10找出耗时长的命令,多数是大 key 或复杂操作
连接数INFO clients里的connected_clients连接数暴涨往往对应连接池配置不当
ops/secINFO stats里的instantaneous_ops_per_sec判断 Redis 当前负载水平

命中率是最直观的缓存健康指标。如果命中率低,说明大部分请求根本没用到缓存,那接 Redis 的意义就存疑了。可以用redis-cli --stat快速查看实时吞吐。

7.2 应用侧最常见的三类报错

接入 Spring Boot 后,线上最常见报错无非这三类。

第一类是连接相关,比如RedisConnectionFailureException、Cannot get Jedis connection。这类问题先查连接池耗尽、Redis 网络不通、认证失败,别一上来就怀疑代码。之前我遇到一个"每天固定时间段连接超时",排查半天发现是同一时间段的定时任务把连接池占满,其他请求拿不到连接。

第二类是序列化异常,比如ClassCastException或 JSON 反序列化失败。这类问题几乎都出在"类型信息对不上":老数据是旧结构序列化的,代码升级字段改了,反序列化就炸了。解决办法是改数据结构前先清掉相关缓存,或者用兼容性更好的序列化方式。

第三类是ERR value is not an integer or out of range,通常是拿get出来的字符串去做算术操作,或者对 String key 用了incr。这时候去 Redis 里看真实数据,确认类型匹配。

7.3 排查和验证的实用路径

我现在的排查习惯可以用三步概括:先看 Redis 侧,再看应用侧,最后看数据侧。

Redis 侧开redis-cli连上去,看INFO、SLOWLOG、MONITOR(注意MONITOR只适合短时间观察,线上长时间开着会拖垮 Redis)。应用侧看 Spring Boot Actuator 暴露的 Redis 连接池指标,比如lettuce连接数、等待线程数。数据侧用SCAN配合匹配模式找出相关 key,检查过期时间和序列化内容。

提示:线上不要直接跑KEYS *,这个命令会阻塞 Redis 单线程,导致全站卡顿。用SCAN游标遍历,一次取几百个 key 足够。

使用工具方面,Redis 自带的redis-cli --bigkeys能扫出大 key,对大 key 要特别小心,DEL超大 Hash 或 ZSet 可能导致阻塞。可以用UNLINK异步删除,Redis 4.0 之后都支持。

最后再分享一个小技巧,我开发时习惯给 Redis 的 key 统一加业务前缀,比如user:、order:、product:,这样在 Redis Desktop Manager 或者命令行里按前缀找数据非常快。换新环境的时候,也可以用SCAN按前缀快速清掉某个模块的缓存而不影响其他业务。项目里的所有 key 命名规则最好沉淀到文档里,新同事接手的时候,单看 key 就知道数据属于哪个业务、什么数据结构,比注释管用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询