本文基于续约项目源码整理,重点分析项目中 Redis、Redisson RLock 以及业务幂等锁的落地方式。

1. 项目中 Redis 的总体使用方式

pluto-renewal 是一个续期/续保业务项目,Redis 主要承担三类职责:

  1. 分布式锁:通过 Redisson RLock 控制并发任务、MQ 消息、批量导入、规则执行等关键流程。
  2. 轻量级幂等状态锁:通过 StringRedisTemplate#setIfAbsent 标记业务消息处理状态,避免重复消费。
  3. 缓存与临时数据容器:web 模块启用 Spring Cache;部分定时/节假日外呼数据使用 Redisson RList 暂存。

需要先说明一点:项目里没有发现 RedissonRedLockRedissonMultiLock 或多 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,同时初始化 StringRedisTemplateRedissonClient

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 模块除了 StringRedisTemplateRedissonClient 外,还额外配置了:

@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 设置。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐