资讯详情

资讯详情

Spring Boot Redis配置全解:从序列化到分布式锁的实践指南

只要你在 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency如果你是做 Spring Boot 2.x 的项目别忘了额外加一个连接池依赖。注意这个细节Spring Boot 的 Redis 自动配置里连接池相关参数只有在 classpath 中存在commons-pool2时才会生效不加这个依赖你写再多的连接池配置都不会报错但也完全不会生效。dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency1.3 选 Lettuce 还是 Jedis这是老生常谈的问题。Spring Boot 2.x 之后默认的 Redis 客户端就是 Lettuce而不是很多老项目习惯用的 Jedis。Lettuce 基于 Netty是线程安全的一个连接实例可以在多个线程之间复用所以在并发场景下它对连接数量的消耗比 Jedis 要小不少。Jedis 的实例本身不是线程安全的传统用法是通过连接池来管理在连接池长度配置不合理或者并发毛刺上来的时候很容易碰到“连接不够用”的问题。实际项目里如果你没有老项目的包袱直接使用 Spring Boot 默认的 Lettuce 就好不用纠结。如果是从老项目迁移把spring.redis.client-typejedis配上再引入 commons-pool2Spring 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 生态里其他语言或者工具链不兼容后续如果要做数据同步、数据迁移、或者用其他脚本去消费都会非常痛苦。我推荐的方案是自定义一个RedisTemplatekey 使用StringRedisSerializervalue 使用GenericJackson2JsonRedisSerializer。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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 会显得很臃肿而且会泄露出你的类名信息。这个时候我倾向使用StringRedisTemplatevalue 统一存 JSON 字符串由业务自己用 Jackson 或者 Fastjson 完成对象和字符串之间的转换。这种方式可控性最强线上排查问题最直观而且不存在序列化器版本兼容的隐患。我自己的项目里默认方案就是StringRedisTemplate加手动序列化。只有当涉及缓存通用对象、并且团队内能接受 Redis 里出现class字段的情况下才使用自定义的RedisTemplateString, 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 MapString, 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 groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependencyConfiguration 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照着我的方案走一遍遇到问题回到这篇文章的排查清单里找答案应该能省下不少额外的时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →