几个月前帮同事排查一个线上问题,服务里有一段定时任务,业务上要求同一时间只能有一个实例执行。代码里明明加了锁,用的RedisTemplate的setIfAbsent,也设置了过期时间,但压测时就是会出现两个实例同时进方法体。查到最后发现,判断锁是否属于自己、释放锁时删除Key,以及锁意外过期后线程A没跑完、线程B就拿到锁了,这些边界情况根本没有处理好。
如果你也遇到过类似问题,大概率会走上同一条路:在Spring Boot项目里引入Redisson。Redisson能解决的问题远比“加锁”多,但多数人最初整合它,都是冲着分布式锁去的。这篇文章就整理一下Spring Boot整合Redisson的两种主流方式:官方Starter自动装配,以及手动编程式配置。我会把各自的原理、操作步骤、适用场景和容易踩的坑都讲清楚,你可以直接照着选一种方式落地。
1. 先回应一个疑问:RedisTemplate明明能用,为什么还要Redisson
1.1 原生Redis命令写分布式锁的三道坎
用Redis原生命令做分布式锁,最经典的套路是SET key value NX EX seconds。看起来没问题,实际一跑就露馅。
第一道坎是释放锁时的误删。线程A加锁后业务执行超过锁的过期时间,锁自动释放了;线程B随后加锁成功;这时线程A业务终于执行完,顺手执行DEL key,把线程B刚持有的锁给删了。B在临界区里还没跑完,C线程又拿到锁进来,并发保护直接失效。解决办法是加锁时写入唯一标识,释放前先比较标识是否属于自己,再删除。这个比较和删除两步骤必须原子执行,又得靠Lua脚本。
第二道坎是锁过期但业务没跑完。如果锁过期时间设置得很短,比如2秒,但业务方法在4秒内才执行完,那么从第2秒开始锁就形同虚设。设成10秒呢?遇到慢SQL、GC停顿或者下游接口耗时突增,锁照样会先于业务结束而超时。要彻底解决,需要一把“自动续期”的锁,检测到业务还在执行就不断给锁延期。
第三道坎是可重入。同一个线程递归或嵌套调用加锁逻辑时,普通Set命令不感知owner,无法做可重入,容易在嵌套场景下把自己锁死。要支持可重入就得用Hash结构记录线程标识和重入次数,每加一次锁计数加一,释放一次减一。这套逻辑用原生命令写出来,已经是一个不大不小的轮子了。
1.2 Redisson干的活,是把分布式组件“伪装”成本地JUC工具
Redisson把Redis的对象操作封装成了类似Java并发包的接口风格。你要分布式锁,它给你RLock,长得像java.util.concurrent.locks.Lock;你要分布式队列,它给你RQueue;要分布式Map,它就是RMap,用起来接近ConcurrentHashMap。
拿锁来说,redisson.getLock("myLock")返回一个RLock,调用lock()时不需要自己操心过期时间。Redisson里有个看门狗机制,默认锁超时时间是30秒,业务没执行完会自动续期,进程意外宕机又会在租约到期后自动释放,不会死锁。这不只是省代码,而是把分布式场景里最麻烦的边界问题都处理掉了。
明白了这一点,Spring Boot整合Redisson的意义就清楚了:让RedissonClient的生命周期交给Spring容器管理,在应用启动时建立连接池,关闭时优雅释放连接,同时让分布式锁、分布式缓存这些能力能以常规Spring Bean的形式注入业务代码。
2. 方式一:官方Starter自动装配,配置最少也能跑
2.1 依赖引入和Spring Boot版本怎么对号入座
第一种方式最简单,引入官方Starter:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.25.2</version> </dependency>版本号建议你去Maven中央仓库看一下当前Release,别盲目Copy。这个Starter内部会再依赖一层redisson-spring-data-xx,其中的xx要和Spring Boot的小版本对应。Spring Boot 3.x项目如果引了一个针对Spring Boot 2.x设计的旧版Redisson,启动时会莫名其妙报NoClassDefFoundError,而且报错位置往往不在你自己的代码里,排查起来很绕。
我现在的习惯是:先确认自己的Spring Boot大版本,再去Redisson官方Wiki或GitHub的redisson-spring-data模块说明里核对版本对应关系。如果项目从Spring Boot 2.7升级到3.2,Redisson版本也要跟着升,不要指望老版本红isson能在Spring Boot 3.x下平稳运行。
如果项目不使用Spring Boot,单独引入redisson核心包再自己封装也可以,但Spring Boot项目直接用Starter更省事。
2.2 application配置如何被自动读取
引入Starter后,配置工作可以完全放在application.yml里。Spring Boot 3.x的Redis配置前缀是spring.data.redis,Spring Boot 2.x则是spring.redis,别混了:
spring: data: redis: host: 127.0.0.1 port: 6379 password: yourPassword database: 0 timeout: 3000msRedisson的自动装配会读取这些配置,构建出内部的Config对象,再创建RedissonClient。如果你需要更细粒度的调优,可以在resources目录放一个redisson.yaml或redisson.json,里面写连接池、线程池、序列化方式等参数,Starter会优先按这个文件构建。
我自己遇到过一种情况:项目里application.yml和redisson.yaml都配了Redis地址,结果启动时Redisson用的并不是application.yml里的配置,而是redisson.yaml里的。所以如果你的项目两种配置同时存在,最好在启动日志里看一眼Redisson实际加载的配置来源,不要想当然。
2.3 Starter到底往容器里塞了哪些Bean
官方Starter在自动装配阶段会做几件事,了解后对你排查问题很有帮助:
- 自动注册
RedissonClient,这是最核心的Bean,业务代码里直接注入RedissonClient就能用。 - 如果你的项目里已有
spring-data-redis依赖,它会把自己作为Redis连接工厂的实现替换掉Lettuce或Jedis,让RedisTemplate底层也走Redisson客户端。这意味着你可以统一用Redisson管理连接,避免一个项目里存在两套Redis连接体系。 - 自动注册一个
CacheManager,底层是RedissonSpringCacheManager。有了它,Spring Cache注解如@Cacheable、@CacheEvict直接可以用Redisson做分布式缓存实现。 - 提供一个
RedissonAutoConfigurationCustomizer扩展点,允许你在自动装配的基础上一层层改Config对象。这是Starter方式里保持可控性的重要手段。
换句话说,用Starter不仅解决了分布式锁,连RedisTemplate连接和Spring Cache都被一同接管了。这个特性在微服务架构里很舒服,一套客户端,锁、缓存、数据结构操作全搞定。
2.4 写一个最小可运行Demo
一个典型的扣减库存场景,用RLock避免超卖:
@Service public class StockService { private final RedissonClient redissonClient; public StockService(RedissonClient redissonClient) { this.redissonClient = redissonClient; } public boolean deductStock(Long skuId, int count) { RLock lock = redissonClient.getLock("stock:deduct:" + skuId); try { // 尝试获取锁,最多等800毫秒,拿到锁后30秒内必须完成业务 if (lock.tryLock(800, 30, TimeUnit.SECONDS)) { // 这里是真正的业务临界区,扣减库存、写流水等 return true; } else { return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }注意这里tryLock传了leaseTime=30秒,一旦显式指定租约时间,Redisson就不会启动看门狗自动续期,锁到30秒必定释放。如果你希望锁能跟着业务执行时长自动续期,就不要传leaseTime,改用lock.lock()或lock.tryLock(waitTime, TimeUnit.SECONDS)。这个差异很多人不知道,后面还会专门说。
3. 方式二:手动编程式配置,把RedissonClient当成可控的Bean
3.1 Config对象是Redisson的装配图
第二种方式不引Starter,直接在代码里创建Config对象,手动构建RedissonClient。核心思路就是先把装配图画出来,再让Spring容器管理RedissonClient的生命周期。
@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setPassword("yourPassword") .setConnectionPoolSize(64) .setConnectionMinimumIdleSize(24) .setTimeout(3000) .setRetryAttempts(3) .setRetryInterval(1500); config.setCodec(new JsonJacksonCodec()); return Redisson.create(config); } }这段代码有几点值得说明:
setAddress里的地址必须带redis://或rediss://前缀,直接写127.0.0.1:6379会抛异常。rediss://是加了TLS的地址格式,安全要求高的环境要用。
connectionPoolSize和connectionMinimumIdleSize控制连接池大小,timeout是等待Redis响应的最大毫秒数,retryAttempts和retryInterval决定一次命令失败后重试几次、间隔多久。这些参数可以按业务量调整,不要照抄默认值。
setCodec(new JsonJacksonCodec())是我特别想强调的一步。Redisson默认序列化方案在跨系统读写数据时可能和你Redis里的历史数据格式不兼容,统一用JsonJacksonCodec能显著降低理解成本,也方便排查问题。如果项目里没有Jackson依赖,Spring Boot默认自带,放心使用。
很多教程到这就结束了,但我建议你再补一个RedissonClient关闭的钩子。上面代码里写了destroyMethod = "shutdown",这样Spring容器关闭时会调用Redisson的shutdown()方法,优雅释放连接池。漏掉这一步,应用重启时连接不会立即回收,极端情况下会被系统判定为异常占用。
3.2 注册RedissonClient Bean并顺手解决Spring Cache整合
手动方式同样可以享用Spring Cache。配置一个CacheManager:
@Bean public CacheManager cacheManager(RedissonClient redissonClient) { return new RedissonSpringCacheManager(redissonClient); }注册之后,@Cacheable这类注解就自动和Redisson的RMapCache打通了。相比Starter方式,手动方式的优势在于所有配置都在代码里,IDE跳转、断点、配置中心动态刷新都能直接作用到Config对象上,排错路径非常清晰。
手动配置也更适合多套Redis共存的场景。比如一个系统同时连业务Redis和单独做存储的Redis,手动方式可以创建多个RedissonClientBean,命名区分即可:
@Bean("redisClientA") public RedissonClient redisClientA() { ... } @Bean("redisClientB") public RedissonClient redisClientB() { ... }用@Qualifier在业务代码里指定用哪一套,灵活度比Starter高很多。
3.3 手动方式与Starter方式真的“二选一”吗
这里我想先说个结论:它们不是非此即彼的关系,而是互补关系。用了Starter,不代表你完全不能碰Config;想手动精细配置,也可以不引Starter,直接把Redisson核心包加进来。
有一种常见操作:项目用了Starter,希望在自动装配基础上改改连接池大小。正确做法是定义一个RedissonAutoConfigurationCustomizer的实现:
@Bean public RedissonAutoConfigurationCustomizer customizer() { return config -> config.useSingleServer().setConnectionPoolSize(128); }千万别在Starter项目里再手动定义一个RedissonClientBean,否则启动时会因为容器里存在两个RedissonClient实例而冲突报错。
反过来,如果你已经在用一个旧项目,Redis连接配置来自加密配置中心,或者同一服务需要连接多套Redis,这类场景我更推荐纯手动方式。把Starter从依赖里拿掉,核心包自己管,代码即配置,看起来更啰嗦,但问题出现时你能以最快速度定位到是哪一行代码构建了错误的连接。
4. 两种方式同台对比:配置差异只是表面,坑在细节
4.1 一张表看清两种方式的差异
| 对比维度 | 官方Starter自动装配 | 手动编程式配置 |
|---|---|---|
| 依赖复杂度 | 低,一个starter包搞定 | 较高,需自己管理redisson核心包与相关模块 |
| 配置入口 | application.yml + 可选redisson.yaml | 代码里的Config对象 |
| 核心Bean注册 | 自动完成,省心且不容易遗漏 | 手动@Bean,完全可控 |
| Spring Cache集成 | 自动注册CacheManager | 需自行声明CacheManager |
| 自定义序列化 | 通过customizer或yaml配置 | 代码里直接setCodec |
| 多套Redis支持 | 难度较高,需要侵入额外Bean定义 | 天然支持,声明多个Client即可 |
| 可调试性 | 一般,自动装配是半黑盒 | 高,代码即文档,断点随便打 |
| 适合场景 | 新项目、标准化配置、快速落地 | 配置复杂、多Redis、加密配置、平台型项目 |
表格里没有对错,只有合适不合适。大多数团队新项目起步时选Starter是对的,因为配置量最少,踩坑成本最低。
4.2 最容易踩的三个坑
第一个坑是RedissonClientBean冲突。这是Starter方式+手动方式混用的典型问题。自动装配已经注册了一个原生RedissonClient,你又手动注册一个,Spring容器启动时发现两个同类Bean,直接NoUniqueBeanDefinitionException。我之前看到过有人为了改一个连接池参数,在config类里写@Bean返回RedissonClient,结果启动报错,后来才知道应该用RedissonAutoConfigurationCustomizer。
第二个坑是版本不匹配。Redisson版本和Spring Boot版本不是完全无关的,尤其在使用Starter时,它内部要适配spring-data-redis的版本。Spring Boot 3.x强行用Redisson老版本,常见的现象是启动时java.lang.NoClassDefFoundError,错误指向org.springframework.data.redis.*下的类。这不是代码逻辑问题,纯粹是依赖矩阵没对上。
第三个坑是序列化不兼容。Redisson默认的序列化方案可能和你系统里已有的字节数据不兼容。我遇到过用Jedis写进去的普通字符串,用Redisson读出来变成一串乱码,最后统一Codec为JsonJacksonCodec,并保证写入方和读取方一致才解决。如果你改造一个存量系统,建议先在测试环境对存量Key做一轮读写验证再上线。
4.3 我推荐的选择逻辑
如果你要写一个新服务,团队又没有历史包袱,直接用Starter。它帮你省下的配置量,足够抵掉那一点点“黑盒感”。如果你要做中间件、基础平台,或者项目里Redis拓扑复杂、需要连接多套集群,那就手动方式。
从我的实际经验看,Starter项目的日常业务开发基本感知不到Redisson的存在,因为只需要注入RedissonClient就行;而手动方式的项目在交接时需要多花十分钟把配置代码讲清楚。但长期运维时,手动方式又更容易排查问题。两边的天平随着项目复杂度变化而倾斜,你不能只背一个答案。
5. 整合完之后,连接参数、集群切换和故障排查
5.1 连接池与超时参数建议值
不管用哪种方式整合,最终落到Config上的连接参数都值得认真对待。以单机模式为例,我惯用的一组参考值:
setConnectionPoolSize(128):连接池上限。默认64,高并发扣库存、秒杀类场景建议调到128,避免线程等待连接。setConnectionMinimumIdleSize(32):最少保持空闲连接数。默认24,定时任务密集但平时流量低的系统,这个值可以保持默认甚至调低。setTimeout(3000):客户端等待Redis响应超时,单位毫秒。业务对延迟敏感可以降到1500,但别低于Redis慢查询耗时,否则正常请求也会被误杀。setRetryAttempts(3)、setRetryInterval(1500):命令失败重试3次,间隔1.5秒。对实时性要求高的场景,重试次数改成2更合适。
这些参数没有银弹,核心思路是:不要让连接池满了以后大量线程直接卡死,也不要让超时设置小于Redis正常响应耗时。
5.2 从单机切到哨兵/集群的改造点
很多项目一开始单机Redis,后面要切哨兵或集群。手动方式改动量很小:
// 哨兵模式 config.useSentinelServers() .addSentinelAddress("redis://127.0.0.1:26379") .setMasterName("myMaster") .setPassword("yourPassword"); // 集群模式 config.useClusterServers() .addNodeAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:6380") .setPassword("yourPassword");用Starter方式的话,在spring.data.redis下配置sentinel或cluster节点即可。这里有两个必须注意的变化:第一,集群模式下多节点的连接语句、密码配置都会变化,千万别沿用单机那一套;第二,Redis Cluster不支持多个database,单机模式下的database: 2在集群里不生效,代码里对库的选择逻辑要提前改造。
从单机迁移时,先验证锁可以正常获取和释放,再看缓存读写,最后跑一轮并发压测,顺序不要反。
5.3 验证分布式锁正确性的压测与故障演练
锁加完不是终点,得证明它真的能抗并发。最简单的验证:在业务临界区代码里加一行日志,记录“线程名-时间戳-实例IP”,用JMeter开20个并发线程打同一个接口,观察日志里是否存在相同时间戳下两个不同实例同时进入临界区的记录。如果现象复现,说明锁没起作用,回去检查锁Key是否同一个。
故障演练也很重要。模拟Redis宕机,观察业务线程的表现。Redisson的默认行为是尝试连接失败、超时抛出异常,你要保证异常路径不会把核心接口拖死。最简单的手段是给tryLock的等待时间设一个合理的上限,拿不到锁快速失败返回,而不是无限自旋。
释放锁的姿势也要养成肌肉记忆,unlock()之前先isHeldByCurrentThread()判断一下,避免拿着别人的锁或者锁已过期时调unlock()抛IllegalMonitorStateException。
我在过去几个项目里的最终体会是:前期无脑上Starter,开发效率最高;后期遇到配置加密和多套Redis共存的场景,又退回来用手动方式。两种方式不是对立关系,关键是你是否清楚RedissonClient的生命周期由谁管理、配置怎么来的。如果让我接手一个新项目,我会先看团队的现有Redis配置体系再做选择,不会为了形式上“优雅”去手写一堆Config。Redisson这种工具,真正该关注的不是连接代码怎么写,而是你用它的锁和缓存时,有没有把边界问题想清楚。