续约项目 Redis 与 Redisson 分布式锁使用总结
本文基于续约项目源码整理,重点分析项目中 Redis、Redisson
RLock以及业务幂等锁的落地方式。
1. 项目中 Redis 的总体使用方式
pluto-renewal 是一个续期/续保业务项目,Redis 主要承担三类职责:
- 分布式锁:通过 Redisson
RLock控制并发任务、MQ 消息、批量导入、规则执行等关键流程。 - 轻量级幂等状态锁:通过
StringRedisTemplate#setIfAbsent标记业务消息处理状态,避免重复消费。 - 缓存与临时数据容器:web 模块启用 Spring Cache;部分定时/节假日外呼数据使用 Redisson
RList暂存。
需要先说明一点:项目里没有发现 RedissonRedLock、RedissonMultiLock 或多 Redis 主节点 RedLock 算法的实现。本文提到的“红锁”,指项目中实际使用的 Redisson 分布式锁 RLock,不是严格意义上的 RedLock 算法。
2. 依赖情况
项目在parent父工程中管理了 Redisson 版本:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.14.0</version>
</dependency>
相关模块依赖情况:
| 模块 | 作用 |
|---|---|
pluto-renewal-common |
引入 Redisson,并提供 LockUtil 工具类 |
pluto-renewal-core |
引入 spring-data-redis,大量业务服务使用 RedissonClient / StringRedisTemplate |
pluto-renewal-server |
提供服务端 Redis 配置、MQ 消费、异步处理 |
pluto-renewal-web |
提供 web 端 Redis 配置、Spring Cache 配置 |
3. Redis 与 Redisson 配置
项目的 Redis 配置分散在 server 与 web 两个模块中,核心思想一致:使用 Redis Sentinel 哨兵模式连接 Redis,同时初始化 StringRedisTemplate 和 RedissonClient。
3.1 server 模块配置
位置:
pluto-renewal-server/src/main/java/com/huize/pluto/renewal/server/config/RedisConfig.java
核心配置包括:
@Value("${redis.password}")
private String password;
@Value("${redis.database}")
private int database;
@Value("${redis.sentinel.master}")
private String master;
@Value("${redis.sentinel.nodes}")
private String nodes;
Redisson 使用哨兵模式的相关配置:
// 1. 创建Redisson核心配置对象
Config config = new Config();
// 2. 指定使用Redis哨兵集群模式连接
config.useSentinelServers()
// 开启TCP长连接保活机制,避免空闲连接被防火墙/中间链路断开
.setKeepAlive(true)
// 添加哨兵节点地址数组,格式示例:127.0.0.1:26379、192.168.1.100:26380
.addSentinelAddress(node.toArray(new String[0]))
// 订阅频道专用连接池:最小空闲连接数,维持基础长连接,减少频繁创建销毁开销
.setSubscriptionConnectionMinimumIdleSize(10)
// 订阅频道专用连接池:最大连接总数,发布订阅场景并发过高时扩容上限
.setSubscriptionConnectionPoolSize(200)
// 指定哨兵集群中主库的名称(sentinel.conf中sentinel monitor配置的master名称)
.setMasterName(master)
// 选择Redis逻辑库,默认0库,取值范围0~15
.setDatabase(database)
// 命令执行超时时间,单位毫秒;读写、订阅命令超过该时间未响应则抛出超时异常
.setTimeout(3000)
// Redis服务密码,无密码可传null/空字符串
.setPassword(password);
// 根据配置初始化并返回Redisson客户端实例(全局建议单例复用,勿频繁创建)
return Redisson.create(config);
StringRedisTemplate 则基于 JedisConnectionFactory:
// 1. 构建Redis哨兵配置
// master:哨兵配置里的主节点名称(sentinel monitor 后的名字)
// nodes.split(","):逗号分隔的哨兵地址集合,如 127.0.0.1:26379,127.0.0.1:26380
RedisSentinelConfiguration config =
new RedisSentinelConfiguration(master, new HashSet<>(Arrays.asList(nodes.split(","))));
// 2. 根据哨兵配置创建Jedis连接工厂(SpringDataRedis底层用Jedis实现连接管理)
JedisConnectionFactory factory = new JedisConnectionFactory(config);
// 设置Redis访问密码
factory.setPassword(password);
// 指定使用几号逻辑库(0~15)
factory.setDatabase(database);
3.2 web 模块配置
位置:
pluto-renewal-web/src/main/java/com/huize/pluto/renewal/web/conf/RedisConfig.java
web 模块除了 StringRedisTemplate、RedissonClient 外,还额外配置了:
@EnableCaching
并定义了 JSON 序列化的 RedisTemplate<String, Object> 与 RedisCacheManager:
redisCacheManager.setDefaultExpiration(60);
expires.put("queryDicItemByTypeCodeByReturnSet", 3600L);
expires.put("transferProductType", 3600L);
redisCacheManager.setUsePrefix(true);
redisCacheManager.setTransactionAware(true);
redisCacheManager.setCachePrefix(cacheName ->
("pluto-renewal-web:cache:" + cacheName + "-").getBytes(StandardCharsets.UTF_8));
这说明 web 层缓存默认 TTL 为 60 秒,部分字典类缓存 TTL 为 1 小时,并统一加上 pluto-renewal-web:cache: 前缀,便于隔离和排查。
4. 公共锁工具类 LockUtil
位置:
pluto-renewal-common/src/main/java/com/huize/pluto/renewal/common/util/LockUtil.java
LockUtil 是项目对 Redisson RLock 的一个轻量封装:
public RLock getLock(String key) {
return redisson.getLock(key);
}
public RLock lock(String key, long waitTimeout) {
RLock lock = getLock(key);
try {
boolean b = lock.tryLock(waitTimeout, TimeUnit.MILLISECONDS);
if (b) {
return lock;
} else {
return null;
}
} catch (InterruptedException e) {
log.error("加锁被中断 key->{}", key, e);
return null;
}
}
这里使用的是:
lock.tryLock(waitTimeout, TimeUnit.MILLISECONDS)
该重载只传了等待时间,没有显式传 leaseTime。因此锁成功后,Redisson 会启用 watchdog 自动续期机制,避免业务执行时间超过默认锁过期时间后锁被提前释放。
公共锁 key 常量定义在:
pluto-renewal-common/src/main/java/com/huize/pluto/renewal/common/constant/LockKeyConstant.java
典型 key:
public static final String RENEWAL_STATUS_CHANGE_LOCK = "renewalStatusChange:";
public static final String ASSIGN_CUSTOMER_KEY_PREFIX = "assign.customer.key:";
5. Redis 幂等锁:IS 批改消息处理
位置:
pluto-renewal-server/src/main/java/com/huize/pluto/renewal/server/renewal2/endorsement/IsEndorsementRenewalDecisionService.java
该类没有使用 Redisson RLock,而是直接使用 StringRedisTemplate 做业务幂等锁。
核心 key:
private static final String REDIS_KEY_PREFIX = "pluto_renewal:is_endorsement:";
private static final String PROCESSING = "PROCESSING";
业务处理时根据批改号拼接幂等 key:
String idempotencyKey = "IS_ENDORSEMENT_336:" + parsedMessage.endorsementMessage.getEndorsementNum();
String redisKey = REDIS_KEY_PREFIX + idempotencyKey;
加锁逻辑:
// 尝试抢占分布式锁:SET key value NX,key不存在才设置成功,返回true;已存在返回false
if (!Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(redisKey, PROCESSING))) {
// 抢占锁失败,查询当前key存储的值
String redisValue = stringRedisTemplate.opsForValue().get(redisKey);
// 判断:key有值 并且 当前值不是以PROCESSING开头(代表旧锁异常残留、脏数据)
if (StrUtil.isNotBlank(redisValue) && !redisValue.startsWith(PROCESSING)) {
// 删除脏数据旧key
stringRedisTemplate.delete(redisKey);
// 删除后重新尝试抢锁
if (!Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(redisKey, PROCESSING))) {
// 二次抢锁依旧失败,返回可重试错误标识
return RETRYABLE_ERROR;
}
} else {
// 两种场景进这里:
// 1. key值为空(并发下刚被其他线程删锁)
// 2. key值正常是PROCESSING开头,代表其他线程正在执行业务持有锁
// 不处理,直接返回可重试错误
return RETRYABLE_ERROR;
}
}
// 走到此处代表初次setIfAbsent抢锁成功,执行业务逻辑
处理开始后设置过期时间:
stringRedisTemplate.expire(redisKey, 30L, TimeUnit.MINUTES);
终态或异常时删除 key:
if (decision.getStatus() == NOT_RENEWAL_DATA
|| decision.getStatus() == RENEWAL_ACCEPTED
|| decision.getStatus() == TERMINAL_REJECT) {
stringRedisTemplate.delete(redisKey);
}
这个场景的特点是:
| 点 | 说明 |
|---|---|
| 锁类型 | Redis SETNX 风格幂等锁 |
| key 粒度 | 批改号 |
| value | PROCESSING |
| TTL | 30 分钟 |
| 失败策略 | 返回可重试状态 |
| 适用场景 | MQ 消息重复投递、业务处理幂等 |
这里的设计不是为了保护一段临界区的线程互斥,而是为了防止同一业务事件被重复处理。
6. Redisson RLock 的主要使用场景
6.1 新增续期信息防重复
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/subject/service/impl/RenewalInfoServiceImpl.java
配置项:
@Value("${renewalInfo.lockKeyPrefix:pluto_renewal:addRenewalInfo:}")
String addRenewalInfoLockKeyPrefix;
@Value("${renewalInfo.lock.time:600}")
private Integer leaseTime;
锁 key 粒度为:
String lockKey = addRenewalInfoLockKeyPrefix + insureNum + ":" + policyCompanyNum;
加锁:
lock = redissonClient.getLock(lockKey);
locked = lock.tryLock(0, leaseTime, TimeUnit.SECONDS);
业务上先查重,再插入:
List<TRenewalInfo> renewalInfoList =
renewalInfoMapper.selectRenewalByInsureNum(insureNum, policyCompanyNum);
if (!CollectionUtils.isEmpty(renewalInfoList)) {
throw new RuntimeException("重复添加");
}
effect = renewalInfoMapper.insert(info);
此外,该类还用 StringRedisTemplate 维护一个短期 check key:
redisTemplate.opsForValue().set(checkKey, UUID.randomUUID().toString(),
addRenewalInfoCheckKeyExpire, TimeUnit.SECONDS);
这是“双保险”设计:Redisson 锁控制并发,Redis check key 辅助防重复。
6.2 MQ 保单状态变更串行化
位置:
pluto-renewal-server/src/main/java/com/huize/pluto/renewal/server/rabbit/handler/AsyncMessageHandler.java
pluto-renewal-server/src/main/java/com/huize/pluto/renewal/server/rabbit/handler/PosMessageHandler.java
两个 MQ handler 都使用:
LockUtil lockUtil = LockUtil.create(redissonClient);
RLock rLock = lockUtil.lock(
LockKeyConstant.RENEWAL_STATUS_CHANGE_LOCK + topicMsg.getInsureNum(),
10000
);
业务含义是:同一投保单号的续期状态变更、退保状态变更需要串行处理,避免 MQ 并发消费导致状态覆盖或顺序错乱。
对应 key:
renewalStatusChange:{insureNum}
6.3 批量导入记录状态抢占
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/renewal2/batchimport/InsureInfoImportService.java
导入任务创建后异步执行。执行前先抢占导入记录:
private static final String IMPORT_RECORD_LOCK_PREFIX = "renewal2:batch-import:record:";
加锁:
RLock lock = redissonClient.getLock(IMPORT_RECORD_LOCK_PREFIX + recordId);
locked = lock.tryLock(5, TimeUnit.SECONDS);
锁内执行状态 CAS:
boolean updated = batchImportRecordService.updateStatusIfCurrent(
recordId,
STATUS_PENDING_IMPORT,
STATUS_PROCESSING
);
这个设计很经典:Redis 锁防并发进入,数据库状态更新防重复执行。即使锁层出现边界情况,DB 状态仍然是最终保护线。
6.4 批量导入逐行更新续期信息
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/renewal2/batchimport/InsureInfoImportRowUpdateService.java
锁前缀:
private static final String LOCK_PREFIX = "renewal2:insure-info-import:";
该类可能一次更新多条续期记录,因此会构建多个 lock key。为了避免死锁,项目先对 key 去重并排序:
if (lockKeys.stream().allMatch(this::isNumericKey)) {
lockKeys.sort(Comparator.comparingLong(Long::parseLong));
} else {
lockKeys.sort(String::compareTo);
}
然后按顺序加锁:
RLock lock = redissonClient.getLock(lockKey);
if (!tryLock(lock)) {
throw new IllegalStateException("当前投保单正在导入处理中,请稍后重试");
}
acquiredLocks.add(lock);
释放时倒序释放:
for (int i = locks.size() - 1; i >= 0; i--) {
RLock lock = locks.get(i);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
这是项目里分布式锁使用比较规范的一处:多锁排序获取 + 反向释放 + 判断当前线程持有锁。
6.5 续期/保单维度导入更新
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/renewal2/batchimport/RenewalInfoImportUpdateService.java
锁前缀:
private static final String LOCK_PREFIX = "renewal2:renewal-info-import:";
锁粒度为投保单号:
RLock lock = redissonClient.getLock(LOCK_PREFIX + bill.getInsureNum());
if (!lock.tryLock(10, 60, TimeUnit.SECONDS)) {
throw new IllegalStateException("当前投保单正在导入处理中,请稍后重试");
}
加锁后在事务中更新保单、险种、续期信息,并处理后续期次级联更新。
6.6 续期单分配规则执行
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/subject/service/impl/RenewalBillAllocServiceImpl.java
锁 key:
public static final String EXEC_ALLOC_RULE_CONFIG_LOCK_KEY =
"pluto_renewal_exec_alloc_rule_config:";
实际 key 包含规则 ID 和分配状态:
String key = EXEC_ALLOC_RULE_CONFIG_LOCK_KEY
+ allocRuleConfigId + ":" + allocStatusEnum.getValue();
加锁方式:
locked = lock.tryLock(0, leaseTime, TimeUnit.SECONDS);
该锁用于避免同一分配规则、同一分配状态的任务被多个线程/节点同时执行。
6.7 触达提醒规则执行与补偿检查
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/base/service/NoticeRuleService.java
该类有两个典型锁:
private static final String NOTICE_RULE_EXEC_LOCK_KEY =
"pluto_renewal_exec_notice_rule:";
private static final String NOTICE_SEND_RECORD_CHECK_LOCK_KEY =
"pluto_renewal_notice_send_record_check:";
规则执行锁:
String lockKey = NOTICE_RULE_EXEC_LOCK_KEY + rule.getId();
locked = lock.tryLock(0, noticeRuleLeaseTime, TimeUnit.SECONDS);
待发送记录检查锁:
String lockKey = NOTICE_SEND_RECORD_CHECK_LOCK_KEY + sendDateText;
locked = lock.tryLock(10, noticeRuleLeaseTime, TimeUnit.SECONDS);
这里的粒度分别是“规则 ID”和“发送日期”,用于防止定时任务重复扫描、重复生成或重复修复触达记录。
6.8 客户服务人员分配锁
位置:
pluto-renewal-core/src/main/java/com/huize/pluto/renewal/core/base/service/CustomerServerRelationService.java
锁 key 前缀:
public static final String ASSIGN_CUSTOMER_KEY_PREFIX = "assign.customer.key:";
根据客户维度构造 key:
private String buildAssignRedisKey(String customerKey) {
return LockKeyConstant.ASSIGN_CUSTOMER_KEY_PREFIX + customerKey;
}
加锁后再执行查询或保存客户服务关系:
locked = lock.tryLock(waitTime, leaseTime, timeUnit);
if (locked) {
return supplier.get();
}
这个锁保护的是“客户 - 服务人员绑定关系”的唯一性,避免新客户分配时并发插入多条关系。
7. Redisson 作为分布式数据结构使用
项目不只用 Redisson 做锁,也用到了 RList。
位置:
pluto-renewal-server/src/main/java/com/huize/pluto/renewal/server/smartcall/v2/annual/SmartCallHandler.java
pluto-renewal-server/src/main/java/com/huize/pluto/renewal/server/smartcall/v2/SmartCallHandler.java
春节期间不外呼时,将待外呼数据暂存到 Redis List:
RList<AnnualSmartCallResultDto> list =
redisson.getList("infoList" + "_" + smartCallStrategy.getKey());
list.addAll(resultDtos);
list.expire(5, TimeUnit.DAYS);
节后恢复外呼时再取出并删除:
resultDtos.addAll(list);
list.delete();
这个场景把 Redis 当作短期任务缓冲区使用,TTL 为 5 天。
8. 项目中锁 key 设计汇总
| 场景 | key 前缀 | 粒度 | 主要类 |
|---|---|---|---|
| 新增续期信息 | pluto_renewal:addRenewalInfo: |
投保单号 + 保单号 | RenewalInfoServiceImpl |
| 新增续期 check key | pluto_renewal:addRenewalInfo:check: |
投保单号 + 保单号 | RenewalInfoServiceImpl |
| IS 批改幂等 | pluto_renewal:is_endorsement: |
批改号 | IsEndorsementRenewalDecisionService |
| 保单状态变更 | renewalStatusChange: |
投保单号 | AsyncMessageHandler / PosMessageHandler |
| 导入记录抢占 | renewal2:batch-import:record: |
导入记录 ID | InsureInfoImportService |
| 投保单信息导入 | renewal2:insure-info-import: |
投保单号 | InsureInfoImportRowUpdateService |
| 续期信息导入 | renewal2:renewal-info-import: |
投保单号 | RenewalInfoImportUpdateService |
| 分配规则执行 | pluto_renewal_exec_alloc_rule_config: |
规则 ID + 状态 | RenewalBillAllocServiceImpl |
| 触达规则执行 | pluto_renewal_exec_notice_rule: |
规则 ID | NoticeRuleService |
| 触达记录检查 | pluto_renewal_notice_send_record_check: |
发送日期 | NoticeRuleService |
| 客户分配 | assign.customer.key: |
客户唯一标识 | CustomerServerRelationService |
9. 总结
续约项目中的 Redis 使用比较典型:配置上采用 Redis Sentinel 保证可用性;业务上通过 Redisson RLock 控制跨节点并发;对于 MQ 消息幂等,则使用 StringRedisTemplate#setIfAbsent 实现更轻量的状态锁;web 层还额外接入了 Spring Cache。
从业务角度看,分布式锁主要保护这些高风险场景:
- 同一投保单的状态变更串行处理
- 续期信息重复创建
- 批量导入任务重复执行
- 导入逐行更新并发冲突
- 定时规则重复执行
- 客户服务关系重复绑定
整体来说,项目的锁 key 粒度设计比较清晰,多数地方遵循了 try-finally 释放锁的基本原则。后续如果继续优化,可以重点关注三点:统一锁封装、获取锁失败后的判空处理、以及 SETNX 幂等锁的原子 TTL 设置。
更多推荐




所有评论(0)