1. 先从基础说起:Redis装好了,后面才不会反复折腾
聊到Redis的Spring配置,其实很多问题不是出在Spring代码上,而是Redis基础环境没搭好。我见过不少团队把代码层面排查了个遍,最后发现是本地Redis是Windows老版本,配置文件里连密码都没设置,或者用了一个特别老的可视化客户端,连Redis 6之后引入的RESP3协议都不认识,白折腾一晚上。
1.1 本地装Redis的几个版本细节
如果你是本地开发调试,Windows上常见的做法是去GitHub找一个Redis的Windows移植版本,比如tporadowski/redis,支持到Redis 5.x。但说句实在话,建议直接用WSL或者Docker跑官方Linux版本,因为线上环境大概率是Linux,本地方言和线上越接近,越能提前暴露问题。
Docker方式最省事,一条命令就能拉起来:
docker run -d --name redis-local \ -p 6379:6379 \ -v /data/redis/redis.conf:/etc/redis/redis.conf \ redis:7.2-alpine \ redis-server /etc/redis/redis.conf这里有个小建议:一定要挂载配置文件,不要用默认参数直接跑。Redis官方默认配置对本地开发够用,但bind 127.0.0.1和protected-mode yes这套组合,在Docker映射端口后经常把连接挡住,反而让人误以为是Spring配置写错了。
如果用的是Linux服务器部署,我更偏向直接编译安装或者用apt/yum装稳定版,然后单独建redis用户跑,避免用root起服务。配置文件里至少要确认几个关键参数:
bind:要么注释掉改成监听所有网卡,要么精确指定IP,别用0.0.0.0。protected-mode:设了密码之后这个可以保持yes,不设密码就建议改成no,但如果你直接对公网开放6379,裸奔状态分分钟被扫描工具盯上。requirepass:测试环境我一般也会加一个强密码,因为从Redis 6开始有ACL体系,生产环境更是要单独建业务账号,别用默认的default账号走天下。maxmemory和maxmemory-policy:这个很多人忽略,本地测试可能无所谓,但一旦数据量涨起来,没有淘汰策略,Redis可能在内存耗尽时直接OOM崩溃。
还有一点我得提醒:appendonly。本地调试如果不想留持久化文件,可以关掉;但如果你在开发缓存功能时把Redis当临时存储,比如存验证码、存临时token,那至少把快照RDB开着,省得重启后数据全没了,还得重新构造测试数据。
1.2 选一个顺手的可视化客户端
可视化工具这块,现在比较主流的是Redis Insigh(原Redis Desktop Manager的继任者)、Another Redis Desktop Manager这两款。我个人用下来觉得Another Redis Desktop Manager在Windows环境更轻快,支持SSH隧道、集群节点透视、慢日志查询,界面也直观。
不过要提醒的是,可视化工具只是辅助,真正排查线上Redis问题,命令行才是王道。我习惯在Spring项目里保留一个CommandLineRunner,启动时打印Redis连接状态,或者用redis-cli -a 密码 ping快速验证网络和认证是否正常。很多连接问题用可视化工具看是"连不上",用命令行一敲就知道是网络不通还是密码不对。
2. Spring Boot整合Redis的第一步:依赖和连接参数
Spring Boot的Redis自动配置其实已经把大部分事情做完了,但这不代表你可以完全不看底层依赖。我在项目里见过太多组合错乱的pom文件,比如同时引入了spring-boot-starter-data-redis和jedis,又手动塞了一个lettuce-core的旧版本,导致连接池参数完全不生效。
2.1 依赖怎么选,选哪个
先明确一个基本概念:Spring Data Redis里的底层连接工厂有两种实现,一种是Lettuce,一种是Jedis。Spring Boot 2.x之后默认用的是Lettuce,因为它的响应式支持和连接复用做得更好;Jedis是阻塞IO模型,API简单直接,但多线程下表现不如Lettuce。
所以除非你有非常特殊的理由,否则直接用默认的Lettuce就行,不要额外引入Jedis,避免配置文件里写的连接池参数到底归谁管都搞不清楚。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 如果用连接池,一定要显式引入commons-pool2 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>这里有个极其关键的坑:Lettuce默认是不启用连接池的,而是每个线程/请求单独获取连接。你以为配置了lettuce.pool.max-active就一定生效?不一定,Spring Boot官方文档里写得很清楚,只有在classpath里有commons-pool2且spring.data.redis.lettuce.pool.enabled=true(实际上官方默认true,但要依赖存在)时才启用连接池。不引commons-pool2,配置了等于白配。
2.2 application.yml里的核心配置项
下面这份配置是生产中实际用过的,我加了注释说明每个参数的含义和常见取值:
spring: data: redis: host: 127.0.0.1 port: 6379 password: your-redis-password database: 0 timeout: 3s # Lettuce 连接池配置 lettuce: pool: enabled: true max-active: 16 # 连接池最大连接数 max-idle: 8 # 最大空闲连接数 min-idle: 2 # 最小空闲连接数 max-wait: 3s # 获取连接最大等待时间,超过抛异常 shutdown-timeout: 200mstimeout这个参数值得展开说说。它指的是连接Redis的超时时间,不是读写超时。默认是60秒,但在高并发场景下,如果你一次请求卡住60秒才报错,调用方早就超时了。我建议设成1-3秒,快速失败比傻等更有意义。一旦报错,业务侧能迅速走降级逻辑,而不是把线程池占满。
database这个字段也容易被忽略。Redis默认有16个逻辑库,很多团队喜欢0-15按业务隔离。但注意,如果你后面用Redis Cluster,多库是不生效的,只能用一个库。我建议要么早点规划好key的前缀策略,比如user:info:{userId},要么干脆用多实例隔离,别把多个业务的缓存全堆在0库,上线后排查数据归属很痛苦。
2.3 连接池参数到底怎么调
连接池参数不是越大越好。我见过有人为了压测把max-active调到200,结果Redis端连接数被占满,而且Redis是单线程模型,连接数太多反而在线程上下文切换上浪费性能。
一个比较实用的估算口径:假设你的应用有200个并发请求,Redis操作耗时平均3ms,那么稳定状态下同时需要的连接数大约是200×3ms/1000ms = 0.6个,按这个算其实很小。但实际操作有抖动,预留3-5倍的余量就差不多了。所以我一般推荐max-active在16-32之间,max-idle在8-16之间,mini-idle保持2左右保证低峰期也能快速响应。
如果业务侧经常拿到连接失败异常,别急着调大连接池,先看看是不是Redis端timeout参数设得太短,服务端把空闲连接提前回收了,客户端这边连接池又不知道,还在池里复用一个已经被服务端断开的连接。这种问题排查起来很隐蔽,通常会在日志里看到RedisConnectionFailureException,但Redis明明还活着。
3. 序列化方案:九成项目这里都会踩坑
Spring Boot默认会给RedisTemplate配置两个序列化器:key用StringRedisSerializer,value用JdkSerializationRedisSerializer。看到这个组合,你就该知道要动手改了——因为JDK序列化在生产里几乎是毒瘤。
3.1 默认JDK序列化的问题
JDK序列化有几个明显缺点:
第一,存进去的数据肉眼不可读。你要是直接在redis-cli查key,看到的value是一串以\xAC\xED\x00\x05t开头的二进制乱码,排查数据时特别痛苦。
第二,序列化体积大。JDK序列化会附带大量类元数据、类信息,同样一个对象,用JSON存可能只有几十字节,JDK序列化后可能几百字节。缓存一多,额外浪费的内存相当可观,数据落RDB和AOF时体积也会跟着膨胀。
第三,反序列化版本兼容性差。类的serialVersionUID一旦变化,之前的缓存数据全部反序列化失败,直接抛InvalidClassException。这在升级实体类时很常见,我就踩过:属性加了一个字段,忘了更新serialVersionUID,结果线上缓存大面积报错,后来全是靠清缓存才恢复,教训深刻。
3.2 生产环境推荐的序列化组合
我这边推荐一套组合,也是目前业界用得最多的:
- key:
StringRedisSerializer - value:
GenericJackson2JsonRedisSerializer
key用String这个没悬念,Redis本身就是字符串字典,key弄成对象序列化不仅难读,而且keys搜索和TTL设置都别扭。
value用GenericJackson2JsonRedisSerializer而不是普通的Jackson2JsonRedisSerializer,两者区别在于前者会把对象的类信息写到JSON里,反序列化时能自动还原成目标类型,不需要你手动指定Class。代价是JSON字符串里多了一个@class字段,体积略大,但换来的是极大的便利。
有一些团队会用Jackson2JsonRedisSerializer并指定具体的类,这样做缓存结构更干净,但redisTemplate的方法要绑定类型,比如opsForValue().set("user", user, Duration)没问题,但opsForHash().put("hash", "field", user)时泛型推导容易出问题。个人经验:项目里如果缓存对象类型很多,用GenericJackson2JsonRedisSerializer省心;如果缓存对象就一两个固定类型,用Jackson2JsonRedisSerializer+指定Class更可控。
3.3 自定义RedisTemplate配置类
聊到配置类,很多文章只给片段,我把自己项目里稳定跑了一阵子的完整版本放出来:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 使用 GenericJackson2JsonRedisSerializer 序列化 value GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); // key 和 hash 的 key 使用 String 序列化 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }注意一个细节:setHashKeySerializer和setHashValueSerializer必须单独设置。因为RedisTemplate默认对hash的key和value使用了JdkSerializationRedisSerializer,不覆盖的话,你用opsForHash()往Redis里塞数据,hash field会出现乱码,然后在业务侧执行hashOps.get("user", "name")时能查到,但直接看Redis里的key就是一堆\xAC\xED开头的东西,特别容易引起误会。
还有一个经验供参考:对纯字符串的缓存操作,直接用StringRedisTemplate更顺。StringRedisTemplate的key和value默认就是StringRedisSerializer,不会出现序列化八竿子打不着的情况。我一般在代码里两个都注入,规整的缓存对象走RedisTemplate,Token、验证码、分布式锁这类纯字符串场景走StringRedisTemplate,分工清晰。
4. 和Spring Cache集成:注解式缓存背后的公平与陷阱
Spring Cache注解缓存属实好用,一个@Cacheable就把缓存逻辑塞进去了。但好用归好用,配置上还是有不少值得研究的地方。毕竟Spring Cache和Redis结合时,缓存过期时间不是直接写Redis的TTL,而是通过CacheManager内部去设置,稍不留神就出现"缓存永不过期"的IMO问题。
4.1 开启缓存与CacheManager配置
首先要加@EnableCaching注解,放在启动类或者配置类上都行。
然后需要定义一个RedisCacheManager的Bean,但这里有个巨坑:Spring Boot自动配置里是有默认的CacheManager的,但默认的RedisCacheManager用的是RedisCacheConfiguration的默认配置,value序列化器是JdkSerializationRedisSerializer。也就是说,就算你前面自定义了RedisTemplate,缓存注解走的那套是另一套配置,两者不互通。
我见过有人自定义完RedisTemplate,然后用@Cacheable去存对象,结果Redis里value又是乱码了。核心原因就是CacheManager的序列化没跟着改。
一个比较规范的自定义写法:
@Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); }entryTtl(30分钟)这个默认超时时间,建议按业务来。如果不设置,默认就是永不过期,等到你缓存数据量越来越大才反应过来,Redis内存早就吃紧了。
4.2 @Cacheable、@CachePut、@CacheEvict的使用细节
这三个注解用起来简单,但细节上有些容易踩的坑:
@Cacheable是"先查缓存,没有再调方法,然后缓存结果"。适用于查询路径。要注意的是,如果方法内部抛了异常,缓存是不生效的,因为Spring Cache默认只在方法正常返回后写缓存。
@CachePut是"每次调用方法,再更新缓存"。适用于数据变更场景,比如更新用户信息时同步更新缓存。这个注解的返回值会被写进缓存,所以方法必须返回需要缓存的数据。
@CacheEvict是"删除缓存"。特别要注意beforeInvocation参数,默认是false,也就是说方法执行成功后才删缓存。如果方法执行抛异常,缓存就不删了。有些场景需要先把缓存删掉再执行方法,那就要设置beforeInvocation = true,但这样也有风险:方法执行失败后缓存也没了,下次查询就要穿透到数据库。
还有个细节:@CacheEvict支持allEntries = true清空整个缓存分区。这个操作要慎用,如果一个分区下面有大量的key,清空操作对Redis其实是原子的,但如果并发高,清空瞬间可能会有大量请求同时打到数据库。这种情况建议用"双删+延迟删除"的策略,或者干脆让缓存自然过期,别主动清。
4.3 缓存分区与key的设计
Spring Cache里的value或者cacheNames对应的是缓存分区,在Redis里会以cacheNames::key的格式存储。比如@Cacheable(cacheNames = "user", key = "#userId"),最终Redis里的key是user::123。
这里有个常见的坑:多个业务模块如果用了同一个cacheName,key很容易冲突。比如"list"这种泛泛的名称,不同方法都往"list"分区里写,A方法写进去的缓存对象反序列化时可能被B方法的返回值类型解析失败,报错现场很难排查。我的经验是cacheName要尽量带上业务含义,比如user:info、order:detail、product:list,用冒号分隔层级,也符合Redis key的组织习惯。
key的SpEL表达式也得注意精度。用#user.id而不是#user,因为如果用整个对象作为key,那对象里任何一个字段变化都会导致缓存key变化,缓存命中率会大打折扣。还有一点,key拼出来如果是空的或者null,Spring Cache会报InvalidCacheKeyException,调用前最好校验一下关键参数。
5. 实战问题排查与避坑清单
这部分我准备了一份自己平时排查Redis配置问题时的思路,按频率从高到低排序,每一条都是真实踩过的坑。
5.1 连接不上Redis,先从这四步查
遇到连接异常,别先怀疑代码。我这边习惯按这个顺序排查:
第一步,先确认为什么连不上。用telnet 127.0.0.1 6379或者redis-cli -h 你的Redis地址 -p 6379 -a 密码 ping,验证网络和端口。如果telnet都连不上,大概率是Redis没启动,或者防火墙拦了端口,Spring配置里的host/port改来改去都是无用功。
第二步,确认密码。Redis如果设置了requirepass,而Spring配置里没填password,会报NOAUTH Authentication required。注意这个异常不是连不上,是连上了没授权。
第三步,确认database是否存在。如果你配置了database: 2,但Redis的databases参数设置的是16,一般默认都有,但如果改了databases参数就会出问题。此外默认配置是select 2时不会报错,也是坑。
第四步,确认是否触发了maxclients限制。Redis配置里默认maxclients是10000,但如果连接数满了,新连接会被拒绝。此时redis-cli info clients一看就清楚。这种情况先看日志有没有max number of clients reached,有的话再回头调客户端连接池,而不是无脑加大池子。
5.2 反序列化异常的一类典型现场
如果你在日志里看到ClassCastException或者RedisSerializationException,先不用着急,大概率是序列化配置不统一导致的。
我之前就遇到过一个奇葩问题:代码里用RedisTemplate存了对象,后来又改用StringRedisTemplate去读取。虽然存的都是字符串形式的值,但RedisTemplate默认是JdkSerializationRedisSerializer,StringRedisTemplate是StringRedisSerializer,两边序列化方式不一致,读出来的数据就完全解析不了。
还有一种情况是:项目从早前的自定义序列化方案切到了GenericJackson2JsonRedisSerializer,旧的缓存数据没有清理,反序列化时报错。这种情况只能清掉对应key的旧数据,或者写一个兼容反序列化器,在项目启动时做一段数据迁移。没有一劳永逸的办法,所以序列化方案越早定下来越好。
5.3 容易被忽略的几个配置细节
有几个时钟相关的坑,这里统一絮叨一下。第一,Spring Boot 2.x里的spring.redis.*配置已经废弃,要改用spring.data.redis.*。如果你在网上搜到的是老文章,复制了spring.redis.host这份配置,在Spring Boot 3.x里根本不生效,连接还是默认的localhost。这个坑太常见了,升级Spring Boot版本时配置不迁移,全是"玄学"问题。
第二,RedisTemplate在注入时要注意是否有多个Bean。如果项目里同时有多个RedisConnectionFactory,比如本地一个、生产一个,那RedisTemplate的@Qualifier一定得标注清楚,否则启动时直接NoUniqueBeanDefinitionException。
第三,别忘了@Transactional和Redis操作的组合问题。Spring事务默认只控制数据库事务,Redis的操作不受事务管理。如果业务里先写数据库,再写Redis,数据库回滚后Redis里的数据就成了脏数据。一个简单的规避策略是:数据库事务提交成功后再去更新Redis,或者监听事务提交事件,在afterCommit里执行Redis操作。
第四,关于key的TTL设置。用opsForValue().set(key, value, Duration)设过期时间时,Duration不能太长也不能为null。我之前有个需求是缓存不过期,结果直接不传Duration,结果真的没过期,后面一直成了历史包袱。
5.4 缓存穿透、击穿、雪崩的初步防御
虽然标题是"配置",但既然做缓存,这三个词迟早躲不开。我简单说下配置层面的应对思路,细节就不展开了。
缓存穿透,是查询一个一定不存在的数据。可以在代码里对空值也做缓存,缓存一个短的过期时间,比如3分钟。Spring Cache里,如果方法返回null,默认是不写缓存的,除非你开启cacheNullValues,但这样缓存里大量空值会影响命中率。更推荐用Set集合,把存在的id都维护一份,启动时加载,或者用布隆过滤器挡一下。
缓存击穿,是某个热点key过期,瞬间大量请求打到数据库。可以设置热点数据永不过期,或者用分布式锁控制重建缓存的并发。配置层面可做的是把热点key的过期时间打散,别让多个热点同时过期。
缓存雪崩,是大量key在同一时刻过期。可以在设置过期时间时加一个随机偏移量,比如10分钟过期时间基础上再加0到2分钟的随机值。Spring Cache里统一配置的TTL没法做随机,所以对需要错峰过期的数据,建议不走注解,而是手动用RedisTemplate设置TTL,每个key单独加随机值。
6. 分享一个我常用的扩展思路
上面这些配置基本够日常项目用了,但如果你想更顺手一点,可以再往前迈一步:自己封装一个缓存工具类,把常用操作收敛起来。
比如封装一个CacheService,里面提供get、set、setIfAbsent、setWithRandomExpire、delete等方法,底层注入StringRedisTemplate,value统一JSON序列化。这样每次写业务代码时,不会在RedisTemplate和StringRedisTemplate之间纠结,也更方便做统一的异常捕获和降级处理。
另一个建议是监控这一层。Redis连接数、命令执行时延、缓存命中率这些指标,即使不做专业监控系统,也至少要在Redis里开慢日志:
CONFIG SET slowlog-log-slower-than 10000 CONFIG SET slowlog-max-len 128然后把慢查询定期拉出来看看。缓存出问题,通常不是配置瞬间崩掉的,而是慢查询、大key、热点key这些问题一点一点拖出来的。早发现、早处理,比出了问题再调配置省心得多。
就我个人经验来说,Redis的配置没有一套放之四海皆准的参数,核心是把每个参数背后的原理弄明白,再根据自己项目的并发量、数据量去微调。本文这套配置适合中小型项目起步,等到规模上去了,还要考虑分片、主从、多级缓存这些更复杂的架构决策。到那一步,配置的边界会越来越模糊,你对Redis本身的理解才是真正可靠的东西。