Redisson 分布式锁在客户分配场景中的使用:避免并发绑定重复服务人员
前言
在后端面试里,Redis 分布式锁是一个很常见的话题。
很多同学能说出 setnx、过期时间、Redisson、看门狗这些概念,但如果面试官继续问:“你在项目里什么场景用过?为什么必须加锁?锁粒度怎么选?锁失败怎么办?”就容易讲得比较虚。
这篇文章结合我实习项目中的一个真实业务场景,复盘一下 Redisson 分布式锁的使用:客户续约服务人员分配时,如何避免同一个客户被并发绑定多个服务人员。
为了避免涉及内部信息,下面会把业务背景泛化处理。可以把它理解为一个续约系统:系统每天会定时(例如凌晨)扫描新增的续约订单,根据预设的分配规则(如服务人员负载、客户地域、产品类型等),将客户分配给对应的续约服务人员。分配完成后,服务人员会通过系统接收到待跟进的客户列表,并负责后续的续费提醒、定期回访、处理客户疑问以及跟进异常订单等工作。整个分配过程需要保证高效、准确,并且对于同一个客户,其服务人员应保持稳定,以避免给客户带来混乱的体验。
一、业务背景:为什么客户需要绑定服务人员
在续约业务里,一个客户可能有多张保单,也可能同时存在不同类型的续约订单。
业务上希望做到:
同一个客户尽量由同一个服务人员跟进
已经绑定过服务人员的客户,后续订单继续沿用历史绑定关系
新客户按照服务人员配置比例进行分配
分配后要回写到续约订单或相关业务数据上
分配过程需要记录操作日志,方便后续追踪
所以系统中会维护一张“客户与服务人员关系表”。这张表的核心作用是:
客户唯一标识 -> 服务人员
比如:
客户 A -> 服务人员 001
客户 B -> 服务人员 002
客户 C -> 服务人员 001
问题来了:如果同一个客户在同一时间被多个任务处理,会发生什么?
二、并发问题:为什么会重复绑定
客户分配逻辑大致是这样的:
查询客户是否已经绑定服务人员
-> 如果已经绑定,直接使用历史服务人员
-> 如果没有绑定,按分配算法选择一个服务人员
-> 保存客户与服务人员绑定关系
-> 回写订单上的服务人员信息
单线程下没有问题。
但在分布式系统里,可能存在多个节点、多个定时任务、手动触发和自动任务同时执行的情况。于是会出现经典并发问题:
线程 A:查询客户 A,没有绑定
线程 B:查询客户 A,没有绑定
线程 A:分配给服务人员 001,并插入绑定关系
线程 B:分配给服务人员 002,并插入绑定关系
最终结果可能是:
同一个客户被绑定多次
不同订单上的服务人员不一致
后续跟进责任人混乱
操作日志出现冲突
本质上,这是一个典型的“先查再写”并发问题。
所以这里需要加锁。
三、为什么用 Redisson 分布式锁
如果系统只有一个 JVM,理论上可以用 synchronized 或本地锁。
但真实后端服务通常是多实例部署:
服务实例 A
服务实例 B
服务实例 C
本地锁只能锁住当前 JVM,锁不住其他机器上的线程。因此客户绑定这种跨实例共享资源,必须使用分布式锁。
项目里使用 Redisson 的 RLock,核心原因是:
API 比原生 Redis setnx 更好用
支持锁过期时间,避免死锁
支持等待时间,获取不到锁可以快速失败
封装了加锁、解锁、续期等细节
和 Spring 项目集成比较方便
@Resource
private RedissonClient redissonClient;
/**
* 使用 Redisson 分布式锁执行临界区代码
*/
private <T> T executeWithRedisLock(
String lockKey,
long waitTime,
long leaseTime,
TimeUnit timeUnit,
Supplier<T> supplier) {
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(waitTime, leaseTime, timeUnit);
if (!locked) {
throw new RuntimeException("获取分布式锁失败,lockKey=" + lockKey);
}
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取分布式锁被中断,lockKey=" + lockKey, e);
} catch (Exception e) {
throw new RuntimeException("执行加锁业务失败,lockKey=" + lockKey, e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
四、锁粒度怎么选:按客户维度加锁
分布式锁最关键的问题之一是:锁粒度怎么选?
如果锁太粗,比如整个任务只用一个锁:
assign.customer.all
那么所有客户分配都会串行执行,吞吐量很差。
如果锁太细,比如按订单 ID 加锁:
assign.order.10001
assign.order.10002
又无法解决同一个客户多张订单并发绑定的问题,因为两个不同订单可能属于同一个客户。
所以更合理的锁粒度是:按客户唯一标识加锁。
客户唯一标识可以由这些字段组合得到:
客户来源 + 用户 ID + 证件类型 + 证件号
在一些特殊来源下,如果用户 ID 不稳定,也可以用:
客户来源 + 证件类型 + 证件号
最终构造出的锁 key 类似:
assign.customer.key:{customerKey}
这样可以做到:
同一个客户的绑定操作互斥
不同客户之间可以并发执行
锁粒度足够小,不会严重影响任务吞吐
private CustomerRelation buildCustomerRelation(CustomerGroup group, Date now) {
String lockKey = "assign.customer.key:" + group.getCustomerKey();
return executeWithRedisLock(lockKey, 50, 50, TimeUnit.SECONDS, () -> {
return getOrSaveCustomerRelation(group, now);
});
}
五、整体分配流程
客户服务人员分配流程可以抽象成下面几步:
- 查询待分配的续约订单和相关订单
- 按客户唯一标识进行分组
- 查询这些客户是否已有历史绑定关系
- 已绑定客户直接复用历史服务人员
- 未绑定客户按服务人员配置比例进行分配
- 保存客户-服务人员绑定关系
- 回写订单上的服务人员
- 记录服务人员分配日志
其中第 6 步是并发风险最高的地方。
因为它的逻辑是:
查客户绑定关系
-> 如果不存在
-> 新增绑定关系
所以分布式锁主要保护这一小段关键区,而不是把整个分配任务都锁住。
六、Redisson 加锁代码思路
核心伪代码如下:
private CustomerRelation buildCustomerRelation(CustomerGroup group, Date now) {
String lockKey = "assign.customer.key:" + group.getCustomerKey();
return executeWithRedisLock(lockKey, 50, 50, TimeUnit.SECONDS, () -> {
return getOrSaveCustomerRelation(group, now);
});
}
真正的加锁模板可以抽象成:
private <T> T executeWithRedisLock(
String lockKey,
long waitTime,
long leaseTime,
TimeUnit timeUnit,
Supplier<T> supplier) {
boolean locked = false;
RLock lock = null;
try {
lock = redissonClient.getLock(lockKey);
locked = lock.tryLock(waitTime, leaseTime, timeUnit);
if (!locked) {
throw new RuntimeException("get lock failed: " + lockKey);
}
return supplier.get();
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
} finally {
if (locked) {
lock.unlock();
}
}
}
这里有几个关键点。
第一,使用的是 tryLock,不是无限等待。lock.tryLock(waitTime, leaseTime, timeUnit)
它的含义是:waitTime:最多等多久去获取锁leaseTime:拿到锁后多久自动释放timeUnit:时间单位
在这个场景里,等待时间和租约时间都设置为 50 秒。意思是:最多等 50 秒,如果拿到锁,锁最多持有 50 秒。
第二,只在拿到锁之后执行关键逻辑。
return supplier.get();
这里的关键逻辑不是整个客户分配任务,而是“查询客户绑定关系,如果不存在就新增”。
第三,必须在 finally 中释放锁。if (locked) { lock.unlock(); }
否则业务代码一旦抛异常,锁可能无法及时释放。
七、加锁保护的核心逻辑
锁里面真正执行的是:
private CustomerRelation getOrSaveCustomerRelation(CustomerGroup group, Date now) {
CustomerRelation relation = queryByCustomer(group);
if (relation == null) {
relation = buildRelation(group, now);
save(relation);
}
return relation;
}
这个写法的重点是:加锁后还要再查一次。
为什么?
因为线程 B 等待锁时,线程 A 可能已经完成了插入。
如果线程 B 拿到锁后不重新查询,而是直接新增,仍然可能重复插入。
正确流程应该是:
线程 A 获取锁
线程 A 查询无绑定
线程 A 新增绑定
线程 A 释放锁
线程 B 获取锁
线程 B 再次查询
线程 B 发现已有绑定
线程 B 直接复用
这就是常见的 double check 思路。
八、为什么数据库唯一索引仍然重要
有了分布式锁,是不是数据库就不需要唯一约束了?
我的理解是:最好仍然要有。
原因很简单,分布式锁是应用层保护,数据库唯一索引是数据层兜底。
应用层可能出现:
锁 key 构造不一致
某些历史入口没加锁
业务代码绕过了统一方法
Redis 异常或锁提前释放
如果客户关系表没有唯一约束,脏数据就可能真的写进去。
更稳妥的设计是:
应用层:Redisson 锁降低并发冲突
数据库层:唯一索引兜底防止重复数据
在面试里也可以这样说:
分布式锁不是数据库约束的替代品。锁主要用于减少并发冲突和保证业务流程互斥,唯一索引用来保证最终数据不被写坏。
九、锁失败怎么办
如果 tryLock 等待超时仍然没有拿到锁,通常有几种处理方式:
直接抛异常,等待下次定时任务补偿
记录失败日志,跳过当前客户
放入重试队列,稍后异步重试
降级为只查询不新增
在客户分配这种后台任务里,我更倾向于:
记录日志 + 抛出异常或跳过 + 后续任务补偿
因为客户分配不是用户实时强依赖接口,短时间失败可以通过下一轮任务修复;但如果强行忽略锁继续写入,反而可能制造脏数据。
十、这段项目经历怎么讲给面试官
如果面试官问:“你项目里用过 Redis 分布式锁吗?”
可以这样回答:
用过。在续约系统的客户服务人员分配场景里,同一个客户可能有多张续约订单,也可能被多个定时任务或服务实例同时处理。为了避免同一个客户被并发绑定到不同服务人员,我在保存客户-服务人员关系前,按客户唯一标识构造 Redisson 锁 key。拿到锁后再次查询客户是否已有绑定,如果没有才新增绑定关系,最后在 finally 中释放锁。这样保证了同一个客户的绑定操作互斥,同时不同客户之间仍然可以并发分配。
这段回答里最好补充三个点:
锁粒度:按客户维度,而不是全局锁或订单维度锁
加锁范围:只保护查无绑定再新增的关键区
兜底方案:数据库唯一索引和后续任务补偿
十一、面试官可能追问
- 为什么不用 synchronized?
因为服务是多实例部署,synchronized 只能保证单个 JVM 内互斥,不能保证不同机器之间互斥。 - 为什么不把整个分配任务都加锁?
整个任务加锁会导致所有客户串行处理,吞吐量太低。真正需要互斥的是同一个客户的绑定关系写入,所以锁粒度应该放在客户维度。 - 为什么拿到锁之后还要再查一次?
因为等待锁期间,其他线程可能已经插入了绑定关系。拿到锁后重新查询可以避免重复新增。 - Redisson 锁的 leaseTime 怎么设置?
要根据关键区执行时间设置。太短可能业务没执行完锁就释放,太长则异常时资源占用时间过久。这个场景只保护查询和插入,执行时间较短,所以可以设置几十秒级别。 - 获取锁失败怎么办?
不能绕过锁继续写入。可以记录日志,抛出异常,交给下一轮定时任务补偿;如果业务实时性要求更高,也可以设计重试队列。 - 有了分布式锁还需要唯一索引吗?
建议需要。分布式锁是应用层并发控制,唯一索引是数据库层兜底。两者解决的问题不同。 - 如果锁释放失败怎么办?
Redisson 的锁有租约时间,超过 leaseTime 会自动释放,避免永久死锁。但代码里仍然要在 finally 中主动释放,减少锁占用时间。
十二、总结
Redisson 分布式锁本身并不难,难的是讲清楚它在项目中的使用边界。
在客户服务人员分配场景里,它解决的是:
多实例并发执行
同一客户多订单同时分配
先查再写导致重复绑定
服务人员责任人不一致
设计时要重点考虑:
锁粒度按客户维度
加锁范围只包住关键区
拿到锁后再次查询
finally 主动释放锁
数据库唯一约束兜底
失败后通过日志或任务补偿
如果面试里能把这些点讲清楚,Redisson 就不再只是一个八股知识点,而是一个真实后端业务问题的解决方案。
更多推荐



所有评论(0)