前言

在后端面试里,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);
    });
}

五、整体分配流程

客户服务人员分配流程可以抽象成下面几步:

  1. 查询待分配的续约订单和相关订单
  2. 按客户唯一标识进行分组
  3. 查询这些客户是否已有历史绑定关系
  4. 已绑定客户直接复用历史服务人员
  5. 未绑定客户按服务人员配置比例进行分配
  6. 保存客户-服务人员绑定关系
  7. 回写订单上的服务人员
  8. 记录服务人员分配日志
    其中第 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 中释放锁。这样保证了同一个客户的绑定操作互斥,同时不同客户之间仍然可以并发分配。

这段回答里最好补充三个点:
锁粒度:按客户维度,而不是全局锁或订单维度锁
加锁范围:只保护查无绑定再新增的关键区
兜底方案:数据库唯一索引和后续任务补偿

十一、面试官可能追问

  1. 为什么不用 synchronized?
    因为服务是多实例部署,synchronized 只能保证单个 JVM 内互斥,不能保证不同机器之间互斥。
  2. 为什么不把整个分配任务都加锁?
    整个任务加锁会导致所有客户串行处理,吞吐量太低。真正需要互斥的是同一个客户的绑定关系写入,所以锁粒度应该放在客户维度。
  3. 为什么拿到锁之后还要再查一次?
    因为等待锁期间,其他线程可能已经插入了绑定关系。拿到锁后重新查询可以避免重复新增。
  4. Redisson 锁的 leaseTime 怎么设置?
    要根据关键区执行时间设置。太短可能业务没执行完锁就释放,太长则异常时资源占用时间过久。这个场景只保护查询和插入,执行时间较短,所以可以设置几十秒级别。
  5. 获取锁失败怎么办?
    不能绕过锁继续写入。可以记录日志,抛出异常,交给下一轮定时任务补偿;如果业务实时性要求更高,也可以设计重试队列。
  6. 有了分布式锁还需要唯一索引吗?
    建议需要。分布式锁是应用层并发控制,唯一索引是数据库层兜底。两者解决的问题不同。
  7. 如果锁释放失败怎么办?
    Redisson 的锁有租约时间,超过 leaseTime 会自动释放,避免永久死锁。但代码里仍然要在 finally 中主动释放,减少锁占用时间。

十二、总结

Redisson 分布式锁本身并不难,难的是讲清楚它在项目中的使用边界。
在客户服务人员分配场景里,它解决的是:
多实例并发执行
同一客户多订单同时分配
先查再写导致重复绑定
服务人员责任人不一致
设计时要重点考虑:
锁粒度按客户维度
加锁范围只包住关键区
拿到锁后再次查询
finally 主动释放锁
数据库唯一约束兜底
失败后通过日志或任务补偿
如果面试里能把这些点讲清楚,Redisson 就不再只是一个八股知识点,而是一个真实后端业务问题的解决方案。

Logo

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

更多推荐