只要你在 Spring Boot 项目里碰过缓存、分布式锁、接口幂等或者排行榜这类场景,Redis 基本就是绕不开的那个组件。而“Redis 的 Spring 配置”这个需求,也是我在带新人做项目时最常被问到的问题之一。很多初级开发第一次在 Spring Boot 里接 Redis 时,跑通一个opsForValue().set()就觉得完事了,结果真到上线或者是交给下一个人维护时,序列化乱码、连接池未生效、缓存注解不工作这些坑会一个个冒出来,而且每一个都让人排查得头大。
这篇文章我不打算讲那种“复制粘贴就能跑”的 demo,而是把 Redis 在 Spring 生态里配置的完整逻辑拆开讲清楚。从依赖选择、连接参数、序列化器,到缓存注解、分布式锁,再到常见的报错排查,把我踩过的坑和推荐的方案都放在一起。适合刚接触 Spring Boot 想集成 Redis 的读者,也适合已经能跑通但想搞明白“为什么要这样配”的开发者。读完这套配置逻辑,你至少能少走我当年走过的几条弯路。
1. 先把环境和依赖立起来
1.1 Redis 服务端怎么准备最省事
不管是用 Windows 本机调试还是 Linux 服务器部署,Redis 服务端本身的安装其实没什么悬念。生产环境我强烈建议直接上 Docker,一条命令就把版本、端口、持久化策略全固定下来,比自己在操作系统里折腾编译安装要可控得多。
docker run -d \ --name redis \ -p 6379:6379 \ -v /data/redis:/data \ redis:7.2-alpine \ redis-server --appendonly yes --requirepass yourpassword这里我额外加了--appendonly yes开启 AOF 持久化,加了--requirepass设置密码。很多人本地测试 Redis 从来不设密码,这个习惯放到服务器上一定要改,Redis 默认监听 0.0.0.0,如果安全组一不小心放开了 6379 端口,扫到就是被挖矿的命。
本机调试的话,Windows 用户直接去 Redis 官网下载 zip 包解压后运行redis-server.exe就行。macOS 用户执行brew install redis很干净。至于 Linux 发行版通过自带的软件源安装的 Redis 版本可能偏老,比如 CentOS 自带源现在还停留在 3.x 版本,虽然基础功能能用,但很多新特性和集群工具支持有问题,能用 Docker 就尽量用 Docker。
1.2 Spring Boot 引入依赖的正确姿势
Spring Boot 项目里接入 Redis,核心依赖只有一个spring-boot-starter-data-redis。它会把 Spring Data Redis 和连接工厂相关的类都带进来,不需要再去手动引入 jedis、lettuce 之类的客户端,除非你有明确的定制需求。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>如果你是做 Spring Boot 2.x 的项目,别忘了额外加一个连接池依赖。注意这个细节:Spring Boot 的 Redis 自动配置里,连接池相关参数只有在 classpath 中存在commons-pool2时才会生效,不加这个依赖,你写再多的连接池配置都不会报错,但也完全不会生效。
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>1.3 选 Lettuce 还是 Jedis
这是老生常谈的问题。Spring Boot 2.x 之后默认的 Redis 客户端就是 Lettuce,而不是很多老项目习惯用的 Jedis。Lettuce 基于 Netty,是线程安全的,一个连接实例可以在多个线程之间复用,所以在并发场景下它对连接数量的消耗比 Jedis 要小不少。Jedis 的实例本身不是线程安全的,传统用法是通过连接池来管理,在连接池长度配置不合理或者并发毛刺上来的时候,很容易碰到“连接不够用”的问题。
实际项目里,如果你没有老项目的包袱,直接使用 Spring Boot 默认的 Lettuce 就好,不用纠结。如果是从老项目迁移,把spring.redis.client-type=jedis配上,再引入 commons-pool2,Spring Boot 就会自动切换成 Jedis 连接工厂。但除非很有必要保留现有代码,否则我建议迁移时顺便切到 Lettuce,这个成本远比项目里其他中间件升级要低。
2. 配置项拆解:每行配置背后都有坑
2.1 application.yml 里的那些参数
Spring Boot 2.x 和 3.x 的 Redis 配置前缀有一个重要区别。2.x 使用spring.redis,而 3.x 改成了spring.data.redis,迁移版本时如果只更新了 Boot 版本而没调整配置前缀,应用能正常启动,但 Redis 会默认连到 localhost:6379,而且不会报错。这个坑特别隐蔽,我见过不止一个项目升级之后缓存“神秘失效”,查了半天最后发现前缀没改。
下面是一份 Spring Boot 3.x 环境下比较完整的配置,我把每项的关键点写在注释里。
spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 timeout: 3s lettuce: pool: max-active: 32 max-idle: 16 min-idle: 8 max-wait: 3s # 如果你真的需要用 jedis,这里放开注释,并保证 commons-pool2 在 classpath # client-type: jedisdatabase这个参数值得多说一句。Redis 默认有 16 个逻辑库(0-15),同一个 Redis 实例里不同业务的 key 可以用不同的 database 做物理隔离。但我不推荐在一个实例上开多个 database 的做法。原因是 Redis 的FLUSHALL会清空所有库,SCAN也只能按库遍历,一旦某一天要按业务维度迁移数据或做容量评估,多个 database 会让运维操作变得非常别扭。现在的实践共识是:不同环境用不同实例,同一个实例内通过 key 的前缀做逻辑隔离,比如user:info:10001。数据库字段保持默认的 0 就好。
timeout是连接建立后的读取超时时间,不是连接池获取连接的超时时间,后者由lettuce.pool.max-wait控制。如果业务里出现过“某个 key 存储了大量数据导致读取很慢”的情况,可以适当把timeout调大。但在正常设计下,单 key 超过几兆本身就说明数据模型设计有问题,应当通过拆 key 或者改用其他存储方案解决,而不是依赖调大超时时间。
2.2 连接池配置何时才会真正生效
接上文说的,spring.data.redis.lettuce.pool.*这组配置一定要配合 commons-pool2 使用,否则会被 Spring Boot 的自动配置直接跳过。很多人填了max-active之后用HikariCP的思路以为“连接池已经配好了”,实际跑压测时连接数量一路涨上去,一堆RedisCommandTimeoutException刷屏才知道连接池压根没起来。
连接池参数不是越大越好。max-active决定的是并发上限,但这个并发背后是网络连接数和 Redis 服务端的文件描述符占用。单机版 Redis 每秒可以处理十几万条简单命令,瓶颈往往在你的应用侧和网络开销上。一般业务把max-active设置在 16 到 64 之间足够。min-idle是连接池保持的最小空闲连接数,如果业务有明显的流量低谷,设置过大会浪费连接,设置过小会导致流量切换瞬间重新建连。常规配置是min-idle取个位数,max-idle跟max-active持平或者略低一点。
有一个常见的误解是 Lettuce 既然线程安全可以复用连接,那还需要连接池吗?答案是也需要。因为 Lettuce 默认是单连接复用,但这个单连接的处理能力是有限的,当它满负荷运行时,新的请求会排队等待。连接池的意义就是把 Lettuce 多个连接实例放进池子里管理,从而突破单连接瓶颈,同时保证连接的可回收和健康检查。
2.3 序列化配置是重灾区,改它要趁早
Redis 是字节存储型的 Key-Value 服务,你在 Spring 里存String、Object、List,最终序列化成字节再写进去。Spring Data Redis 默认使用的序列化器是JdkSerializationRedisSerializer,这个方案的优点是能序列化任意实现了Serializable的 Java 对象,缺点也很致命——序列化出来的内容是 Java 私有的二进制格式。
如果你用 Redis Desktop Manager 或者redis-cli打开客户端看一下,会发现 key 前面跑出来一串\xAC\xED\x00\x05t\x00这样的乱码,value 是一长串看不懂的二进制。这就是没有自定义序列化器的典型症状。这样的数据不仅运维排查问题时没法直观查看,更重要的是它跟 Redis 生态里其他语言或者工具链不兼容,后续如果要做数据同步、数据迁移、或者用其他脚本去消费,都会非常痛苦。
我推荐的方案是自定义一个RedisTemplate,key 使用StringRedisSerializer,value 使用GenericJackson2JsonRedisSerializer。
@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 序列化,保证 key 在控制台可读 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value 用 JSON 序列化,存储时可读性强 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有一个核心的细节:GenericJackson2JsonRedisSerializer在序列化时会往 JSON 里写入一个@class字段,用来保存对象的全限定类名,这样反序列化时可以直接还原成原来的类型,不需要你手动做类型转换。但代价是存储的 JSON 会变大一些,并且你存储的 Java 对象必须有默认构造函数,否则反序列化会失败。
如果你的业务主要存 DTO、VO 这类标准 JavaBean,用GenericJackson2JsonRedisSerializer非常省事。但如果你的对象结构复杂、嵌套深,写出来的 JSON 会显得很臃肿,而且会泄露出你的类名信息。这个时候我倾向使用StringRedisTemplate,value 统一存 JSON 字符串,由业务自己用 Jackson 或者 Fastjson 完成对象和字符串之间的转换。这种方式可控性最强,线上排查问题最直观,而且不存在序列化器版本兼容的隐患。
我自己的项目里,默认方案就是StringRedisTemplate加手动序列化。只有当涉及缓存通用对象、并且团队内能接受 Redis 里出现@class字段的情况下,才使用自定义的RedisTemplate<String, Object>。记住一个原则:Redis 里的数据最终是给整个系统生态看的,不是给某一个 Java 类看的,可读性非常重要。
3. Spring 环境下的缓存与分布式锁落地实践
3.1 缓存注解为什么会“不生效”
Spring 提供了@EnableCaching加@Cacheable这样的注解组合,可以把缓存操作从业务代码中抽离出来,代码看起来非常优雅。但注解不生效的问题,几乎是所有团队都会遇到一次的入门级 Bug。
注解不生效的第一大原因是配置类上忘了加@EnableCaching。这个注解类似一个总开关,没有它,你在任何方法上写的@Cacheable都会被当成普通注解忽略掉。
第二大原因是 Spring AOP 的代理机制限制。@Cacheable、@CacheEvict这类注解是通过 Spring AOP 生成代理类来实现的,所以只有当外部调用经过代理对象时,缓存逻辑才会被拦截执行。如果同一个类内部的方法直接互相调用,走的不是代理对象,而是 this 对象本身,注解就会静默失效。
@Service public class UserService { @Cacheable(value = "userCache", key = "#id") public User getUserById(Long id) { // 查询数据库逻辑 } public User getUserInfo(Long id) { // 这里拿到的是 this.getUserById(id) 的调用,缓存注解不生效 return getUserById(id); } }解决方案很简单:第一种是将被缓存的方法拆到另一个 Service 里,由外部 Bean 调用;第二种是在本类内部注入自己的代理,通过代理调用;第三种就是不要用注解方式,改成手动使用RedisTemplate来实现缓存逻辑。
个人实测下来,最顺手的做法还是方案一。为了让一个注解生效去引入AopContext.currentProxy()这种代码,对于大多数业务场景来说有点绕了。把缓存作为一个独立关注点放进单独的 Service 层,也更符合单一职责。
3.2 用注解缓存时,TTL 必须显式配置
Spring 的@Cacheable注解本身并不直接提供“过期时间”这个属性。它的过期时间由底层CacheManager里每个缓存名的配置决定。默认情况下,Redis 的CacheManager创建的缓存是永不过期的,这在真实场景中非常危险。
举个实际例子:你把用户的详情信息缓存到了 Redis 里,TTL 设为永不失效,某天用户改了昵称,你通过@CacheEvict把缓存删掉了,看起来没问题。但如果你漏掉了一个删除点,或者缓存被错误地存储进了公共缓存名里,用户就会一直看到旧数据。更严重的是,如果某些 key 明明没有热点了,却因为没有 TTL 永远占着内存,Redis 的内存会一点点被打满。
所以 Spring Boot 项目里,我建议统一通过定制RedisCacheManager给不同的缓存名设置不同的过期时间。
@Configuration public class CacheConfig { @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(); // 为特殊缓存名单独覆盖 TTL Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>(); cacheConfigurations.put("userCache", config.entryTtl(Duration.ofHours(2))); cacheConfigurations.put("tokenCache", config.entryTtl(Duration.ofMinutes(10))); return RedisCacheManager.builder(factory) .cacheDefaults(config) .withInitialCacheConfigurations(cacheConfigurations) .build(); } }disableCachingNullValues()这个方法也值得提一下。默认情况下 Spring 会把方法返回的 null 也缓存起来,读取时命中 null 也会返回 null。虽然这能解决一部分“缓存穿透”的问题,但对空值做缓存会让 Redis 里堆积大量无意义的 key,而且这些 key 如果没过期,后续会被重复查询到。我倾向于关闭空值缓存,同时用布隆过滤器或者并发锁来防护真正的穿透流量,这样更可控。
3.3 分布式锁:不要自己造轮子,直接用 Redisson
分布式锁是 Redis 在 Spring 生态里另一个高频场景。网上关于手写 Redis 分布式锁的教程特别多,但绝大多数都只演示了个骨架,真正放到生产环境后,至少会遇到三个问题。
第一个问题是加锁和设置过期时间必须是一个原子操作。很多人第一版代码是这样的:
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId); if (locked) { stringRedisTemplate.expire(lockKey, Duration.ofSeconds(30)); }如果setIfAbsent执行完之后、expire执行之前,应用进程崩了,那么这个锁就永远不会自动释放,其他线程就全部阻塞了。正确写法是使用setIfAbsent(key, value, timeout)这个方法,一步完成加锁和设置过期时间:
Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, clientId, Duration.ofSeconds(30));第二个问题是锁的误删除。线程 A 加锁之后,因为业务执行超过了预定的过期时间,锁被自动释放了。此时线程 B 拿到锁并开始执行业务,而线程 A 终于执行完了,它去删除锁的时候删掉的其实是线程 B 的锁。解决方式是删除前先比对 value 是否是自己的标识,并且这个比对和删除必须是一个原子操作,典型的做法是用 Lua 脚本实现。
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end第三个问题是锁的续期。如果业务执行的时间超过了锁的过期时间,锁会自动释放,导致后面的并发线程趁虚而入。自己实现续期需要起一个定时任务在锁快过期时去重置过期时间,非常繁琐,而且很容易弄巧成拙。
基于这几个痛点,我在实际项目里从不推荐团队自己实现分布式锁,而是直接使用 Redisson。Redisson 内部已经处理好了原子加锁、锁误删、看门狗自动续期、可重入等一整套逻辑,你只需要关心业务本身。
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.2</version> </dependency>@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setPassword("yourpassword") .setDatabase(0) .setConnectionPoolSize(16) .setConnectionMinimumIdleSize(8); return Redisson.create(config); } }使用 Redisson 时要注意一个细节:setAddress的地址前缀必须是redis://或rediss://,如果漏掉了前缀或者写成http://,启动时会直接抛出异常。密码不是必填项,但如果 Redis 服务端设置了密码,这里不填会一直认证失败。
业务代码的写法也很干净:
// 加锁 RLock lock = redissonClient.getLock("order:pay:10001"); boolean locked = false; try { // 尝试获取锁,最多等 3 秒,自动续期由看门狗处理 locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,请稍后再试"); } // 执行业务逻辑 doBiz(); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } }如果你的项目已经引入了 Spring Data Redis,为什么还要多引入一个 Redisson?我的理解是,Spring Data Redis 解决的是“访问 Redis 数据”的问题,而 Redisson 解决的是“分布式场景下的高级数据结构”问题,比如分布式锁、分布式集合、限流器。两者不冲突,甚至可以共存。标准姿势是用 Spring Data Redis 做常规缓存和持久化数据读写,用 Redisson 做分布式锁和信号量这类需要严谨语义的场景。
4. 上线之前,把这些排查技巧先背下来
4.1 连接失败:不只是防火墙的锅
Cannot retrieve initial cluster partitions、Connection refused这类报错,排第一的通常是 Redis 进程没起来。用 Docker 部署时,跑完docker logs redis看启动日志,再用redis-cli ping验证进程是否存活。
排除进程问题后,接着看 Redis 配置。Redis 默认只监听127.0.0.1,也就是只能本机访问。如果 Spring 应用和 Redis 不在同一台机器上,必须在redis.conf中把bind改成0.0.0.0或者内网 IP,生产环境千万别直接改成0.0.0.0并且不设密码,这在互联网环境等同裸奔。
安全组和防火墙也是高频嫌疑对象。云服务器需要在控制台的安全组规则里放行 6379 端口,本机测试时注意 systemd 防火墙或者 iptables 是否拦截了入站连接。
还有一个小细节,很多人在配置里设了 Redis 密码,但 Redis 服务端本身没有开启requirepass。这种情况下应用启动时不会报错,只是第一次执行任何命令时会收到NOAUTH Authentication required。反过来配置了密码但 Redis 端没设,连接也是正常的,直到某个组件开始发送AUTH命令才会暴露问题。
4.2 从乱码到正确序列化,中间只差一个配置类
如果你打开 Redis Desktop Manager,看到 key 是一堆\xAC\xED开头的十六进制字符,value 也完全不可读,那就说明配置类没有生效,或者根本没有自定义RedisTemplate。
排查顺序是这样的:先确认有没有创建自定义RedisConfig配置类,再确认配置类上有没有@Configuration注解,最后确认注入的RedisTemplate在业务代码里真的使用了自定义的 Bean 而不是直接 new 出来的。
一个隐蔽的情况是:Spring 容器里可能同时存在多个RedisTemplate的 Bean,而你业务代码用@Autowired注入的时候,注入到了 Spring Boot 自动配置的默认 Bean。避免方式是在自定义配置类里把方法名定义为redisTemplate,并且如果业务里有多个,用@Qualifier显式指定要用的 Bean。
4.3 Redis 键值看着没问题,但反序列化报错怎么办
Could not read JSON: Cannot construct instance of xxx (no Creators...)这个报错,核心原因是你的对象缺少默认构造方法。GenericJackson2JsonRedisSerializer反序列化时需要通过反射创建对象实例,如果只定义了有参构造函数而没给无参构造函数,Jackson 无法实例化对象。
解决办法就是给对象补上无参构造函数。用 Lombok 的话直接在类上加@NoArgsConstructor。如果你用的是StringRedisTemplate加手动JSON.parseObject的方式,也会遇到同样的问题,这也是为什么我维护 DTO/VO 时总会顺手写一个无参构造。
另外一个常见反序列化异常是InvalidDefinitionException: Java 8 date/time type not supported。这是因为 Jackson 默认没注册 Java 8 时间模块。在自定义RedisTemplate时,需要手动给ObjectMapper注册JavaTimeModule,并且禁用WRITE_DATES_AS_TIMESTAMPS这个配置:
ObjectMapper om = new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(om);不处理这个问题,你存储LocalDateTime类型时会在序列化阶段直接抛异常。处理完之后,Redis 里存的时间格式是 ISO 标准格式,看起来也更人性化。
4.4 缓存偶尔生效偶尔失效的玄学问题
这类问题往往不是玄学,而是 key 的生成策略不一致。@Cacheable注解默认的 key 生成规则是SimpleKeyGenerator,它会把方法的所有参数拼在一起构成 key。如果你同一个方法在不同地方传入的参数一个是Long类型,一个是String类型,生成的 key 会截然不同,缓存命中当然无从谈起。
建议是始终显式声明 key。比如@Cacheable(value = "userCache", key = "#id")直接指定 SpEL 表达式,这样就避免了默认生成器带来的歧义。另一个策略是使用@CacheConfig(cacheNames = "...")在类级别统一缓存名,方法上就不用反复写value了,代码更简洁也更容易维护。
缓存命中率不高的另一个原因是过期时间设置不合理。如果 TTL 太短,缓存数据还没等到被第二次访问就被淘汰了,等于每次都在走数据库。一般热数据的 TTL 建议设置在业务可容忍的最长延迟的三到五倍。比如用户基本信息更新频率不高,容忍十分钟内的延迟,TTL 可以直接设为两小时,命中率会明显提升。
4.5 流量稍微一上来就 Redis 超时的排查套路
RedisCommandTimeoutException是并发上来之后最常碰到的错误。它代表 Lettuce 发送命令之后在配置的超时时间内没有收到响应。出现这个问题的原因要一层层排查。
第一步看 Redis 服务端的 CPU 和内存。如果服务端 CPU 已经打满,那不管客户端怎么调优都治标不治本,优先考虑的是拆分大 key、减少批量命令的数量,或者扩容 Redis 实例。 第二步看客户端连接池的max-active是否真的生效。没有 commons-pool2 时,这个配置形同虚设,连接数会不受限制地涨,最终耗尽系统句柄和内存。 第三步看慢查询。Redis 的慢查询日志默认记录执行时间超过 100ms 的命令,你可以通过SLOWLOG GET查看近期慢命令。如果发现大量KEYS、HGETALL、SMEMBERS这类命令,说明业务代码里有全表扫描级别的操作,这种命令在数据量稍大的时候必然拖垮整体响应。
特别注意:生产环境严禁使用
KEYS命令,它会造成 Redis 单线程阻塞,所有请求都会排队等待。需要按模式查询 key 时,用SCAN命令替代,在 Spring 中可以借助RedisTemplate.scan()方法实现。
写在最后的个人体会
我在实际排查过几十个 Redis 相关的问题之后,最大的感受是:Redis 配置本身不复杂,真正让人头疼的往往是那些“差点意思”的小地方。比如序列化器没改、连接池依赖没加、缓存注解被同类调用绕过。这些坑的共同特点是——它们在启动时不报错,只有跑到特定场景下才暴露,一旦暴露就是线上事故。
所以我的建议是,新项目在搭建阶段就把 Redis 这套配置按本文的思路固定下来,不要贪图省事用默认配置跑通一个 demo 就觉得完事了。尤其是序列化方案,等到数据结构复杂了再换,代价会指数级上升。如果你现在正要配置 Redis,照着我的方案走一遍,遇到问题回到这篇文章的排查清单里找答案,应该能省下不少额外的时间。