本文主要介绍了分布式锁失效的三种场景,并通过一些案例进行分析

背景

最近在项目中发现一些因为错误使用Redis分布式锁导致锁失效的问题。在此整理了一下使用分布式锁时要注意的点。

什么是分布式锁

分布式锁是控制分布式系统之间同步访问共享资源的一种方式。

分布式锁常用的实现方式有哪些

在这里插入图片描述
本文重点分析Redis分布式锁的失效场景,不对分布式锁的实现过多介绍。

主要列举了三种场景,其中场景一和场景二在开发过程中会经常遇到。场景三出现的机率比较小,但是能加深我们对分布式锁的理解。

一、场景一:在事务内部使用锁,锁在事务提交前释放

1.1 场景描述

假设有这样一个需求:
创建付款单,要求不能重复创建相同业务单号的付款单。

为了保证幂等,我们需要判断数据库中是否已经存在相同业务单号的付款单,并且需要加锁处理并发安全性问题。

@Transactional
public void createPaymentOrderInnerLock(PaymentOrder paymentOrder){
    RLock lock = redissonClient.getLock(paymentOrder.getBizNo());
    //采用的redisson可重入锁,提供watchdog机制,在锁释放前默认每10s重置锁失效时间为30s
    lock.lock();
    try {
        LambdaQueryWrapper<PaymentOrder> paymentOrderLambdaQueryWrapper = new LambdaQueryWrapper<>();
        paymentOrderLambdaQueryWrapper.eq(PaymentOrder::getBizNo,paymentOrder.getBizNo());
        //判断数据库中是否存在相同业务单号的付款单
        long count = this.count(paymentOrderLambdaQueryWrapper);
        //存在相同业务单号的付款单则抛异常
        if(count>0){
            throw new RuntimeException("不可重复提交付款单");
        }else{
            //无重复数据,创建付款单
            this.save(paymentOrder);
            //其他DB操作
            ...
        }
    } finally {
        //释放锁
        lock.unlock();
    }
}

通过JMeter测试并发下的结果,出现了重复业务单号的数据
在这里插入图片描述

1.2 问题分析

上述问题的流程图如下:
在这里插入图片描述
接下来通过源码来分析下
在不了解源码的情况下,可以通过debug的形式通过方法调用栈中找到切入点
1、首先在方法入口处打个断点
在这里插入图片描述
2、在方法调用栈中找到 invokeWithinTransaction
在这里插入图片描述
3、定位到Spring源码org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction, 可以发现锁在事务提交前释放了
在这里插入图片描述

1.3 解决方案

为了避免锁在事务提交前释放,我们应该在事务外层使用锁,用法如下:

public void createPaymentOrderOuterLock(PaymentOrder paymentOrder) {
    RLock lock = redissonClient.getLock(paymentOrder.getBizNo());
    //采用的redisson可重入锁,提供watchdog机制,在锁释放前默认每10s重置锁失效时间为30s
    lock.lock();
    try {
        applicationContext.getBean(PaymentOrderService.class).createPaymentOrderNoLock(paymentOrder);
    } finally {
        //释放锁
        lock.unlock();
    }
}
 
@Transactional
public void createPaymentOrderNoLock(PaymentOrder paymentOrder) {
    LambdaQueryWrapper<PaymentOrder> paymentOrderLambdaQueryWrapper = new LambdaQueryWrapper<>();
    paymentOrderLambdaQueryWrapper.eq(PaymentOrder::getBizNo,paymentOrder.getBizNo());
    long count = this.count(paymentOrderLambdaQueryWrapper);
    if(count>0){
        log.info("不可重复提交付款单");
        throw new RuntimeException("不可重复提交付款单");
    }else{
        this.save(paymentOrder);
        //其他DB操作
        ...
    }
}

注意Spring事务的传播机制

/**
* Spring事务传播机制默认为Propagation.REQUIRED,
* createPaymentOrderNoLock的事务会加入到batchCreatePaymentOrder的事务中,
* 此时又会出现锁在事务内部使用,锁提前释放的问题。
**/
@Transactional
public void batchCreatePaymentOrder(List<PaymentOrder> paymentOrderList) {
    for (PaymentOrder paymentOrder : paymentOrderList) {
        paymentOrderService.createPaymentOrderOuterLock(paymentOrder);
    }
}

Spring事务传播机制默认为Propagation.REQUIRED,createPaymentOrderNoLock的事务会加入到batchCreatePaymentOrder的事务中,此时又会出现锁在事务内部使用,锁提前释放的问题。

采用声明式注解
有时候为了开发方便,我们会采用声明式注解的方式使用锁。
下面定义了一个注解DistributeLock

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributeLock {
    String value() default "";
}
 
@Slf4j
@Aspect
@Component
//!!!注意此处的优先级设置,如果不设置优先级,和@Transactional一起使用时,会出现锁在事务提交前释放的问题
@Order(PriorityOrdered.HIGHEST_PRECEDENCE)
public class DistributeLockAspect {
 
    @Pointcut(value = "@annotation(com.example.lockfailure.demo.common.aop.annotation.DistributeLock)")
    public void distributeLockPointCut() {
 
    }
    @Around("distributeLockPointCut()")
    public Object doIdempotent(ProceedingJoinPoint point){
        //省略非关键代码
        ...
    }
 
}

使用注解的形式时要注意,如果非要和@Transactional一起使用,注意设置切面的优先级,避免锁在事务提交前释放。具体原理可以看下spring AOP的拦截器链

@Transactional
@DistributeLock(value = "#paymentOrder.bizNo")
public void createPaymentOrderLockAnnotation1(PaymentOrder paymentOrder) {
    LambdaQueryWrapper<PaymentOrder> paymentOrderLambdaQueryWrapper = new LambdaQueryWrapper<>();
    paymentOrderLambdaQueryWrapper.eq(PaymentOrder::getBizNo,paymentOrder.getBizNo());
    long count = this.count(paymentOrderLambdaQueryWrapper);
    if(count>0){
        log.info("不可重复提交付款单");
    }else{
        this.save(paymentOrder);
    }
}

1.4 补充

1.4.1 如何执行到invokeWithinTransaction的?

在上述代码调试中,我们定位到了invokeWithinTransaction,那是如何执行到invokeWithinTransaction的呢?或者说@Transactional是如何生效的?

以@EnableTransactionManagement为切入点进行分析,@EnableTransactionManagement利用@Import注入两个组件AutoProxyRegistrar、ProxyTransactionManagementConfiguration
在这里插入图片描述

1.4.2 事务什么时候开启的?

上述流程图中,我描述的事务开启是在加锁之后,为什么这么说呢?

首先明确下事务的启动有哪些方式?

  • 第一种:使用启动事务的语句,这种是显式的启动事务。比如 begin 或 start transaction 语句。与之配套的提交语句是 commit,回滚语句是 rollback。
  • 第二种:autocommit 的值默认是 1,含义是事务的自动提交是开启的。如果我们执行 set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

Spring 声明式事务采用的是第二种方式,Spring源码org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
在这里插入图片描述
一般我们会认为 begin/start transaction 是事务开始的时间点,也就是一旦我们执行了 start transaction,就认为事务已经开始了。
其实begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才算是真正启动,因为这时才分配事务ID。

在事务方法内第一条sql操作处打个断点
在这里插入图片描述
此时执行下

select * from information_schema.innodb_trx;

发现并没有正在执行的事务,执行完第一条sql之后,事务才真正开启。

二、场景二:业务未执行完,锁超时释放

2.1 场景描述

需求:创建付款单,要求不能重复创建相同业务单号的付款单
@Override
public void createPaymentOrderRenault(List<PaymentOrder> paymentOrderList){
    if(!CollectionUtils.isEmpty(paymentOrderList)){
        for (PaymentOrder paymentOrder : paymentOrderList) {
            /**
             * 采用公司框架提供的分布式锁
             * 10---等待锁释放时间
             * 1---尝试获取锁时间间隔
             * 5---锁失效时间
             * 注意:此处设置锁失效时间为5秒,在createPaymentOrderNoLock中睡眠5秒模拟耗时操作,此时会出现业务未执行完,锁超时释放的问题
             */
            try (AutoReleaseLock lock = acquireLock(paymentOrder.getBizNo(),  10, 1, 5, TimeUnit.SECONDS)) {
                if(lock != null) {
                    paymentOrderService.createPaymentOrderNoLock(paymentOrder);
                } else {
                    log.info("未获取到锁!");
                }
            }catch (CacheParamException e) {
                log.info("获取锁失败");
            }
        }
    }
}
 
 
@Override
@Transactional
public void createPaymentOrderNoLock(PaymentOrder paymentOrder) {
    LambdaQueryWrapper<PaymentOrder> paymentOrderLambdaQueryWrapper = new LambdaQueryWrapper<>();
    paymentOrderLambdaQueryWrapper.eq(PaymentOrder::getBizNo,paymentOrder.getBizNo());
    long count = this.count(paymentOrderLambdaQueryWrapper);
    if(count>0){
        log.info("不可重复提交付款单");
        throw new RuntimeException("不可重复提交付款单");
    }else{
        this.save(paymentOrder);
        //模拟耗时操作...
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

通过Jmeter模拟并发情况,结果出现了重复创建相同业务单号的情况

2.2 问题分析

出现上述问题是因为在指定的锁的失效时间内(并且没有续命机制),锁内部的业务代码没有执行完,锁超时释放了。
尤其我们财务端处于业务链下游,处理的数据量一般都比较大,交互的端比较多,尤其要注意这种情况。
下列情形都有可能出现代码没有执行完,锁超时释放的问题

  • 锁的失效时间设置的太短
  • 锁的粒度太大,处理链路冗长
  • 锁内部包含很多耗时操作,比如远程调用、大数据量处理等

2.3 解决方案

  • 首先会想到,把失效时间设置长一点,确实可以。但设置多长合适呢,设置过长有可能存在拿到锁的客户端宕掉了,此时就要等锁过期才能释放,其他节点处于阻塞状态,降低了系统吞吐。又或者预估了一个失效时间在项目初期没问题,随着数据量增多,或者其他一些不确定因素造成了超时,也会出现问题。
  • 可以采用类似Redisson的watchdog机制给锁续命。另外,注意减小锁的粒度,把存在并发安全性问题的关键代码锁住即可,增加系统吞吐量。同时也要注意减小事务的粒度,把查询操作、甚至一些远程调用放到事务外部(注意读写分离的情况),避免出现大事务问题。

三、场景三:Redis节点主从切换

3.1 场景描述

我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于当主库异常宕机时,哨兵可以实现故障自动切换,把从库提升为主库,继续提供服务,以此保证可用性。
当【主从发生切换】时,Redis分布锁会存在安全性问题

  1. 客户端A从master获取到锁
  2. 在master将锁同步到slave之前,master宕掉了。
  3. slave节点被晋升为master节点
  4. 客户端B取得了同一个资源被客户端A已经获取到的同一个锁。
    在这里插入图片描述

3.2 问题分析

首先要说明一点,出现这种情形的概率是很低的。针对于这种情况,Redis的作者antirez设计出了RedLock算法,然而RedLock算法依赖时钟正确性,存在争议。

RedLock算法

设置N个相互独立,不存在主从复制或者其他集群协调机制的Redis master节点
为了取到锁,客户端应该执行以下操作:

  1. 获取当前Unix时间,以毫秒为单位。
  2. 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。
    例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。
    如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

在这里插入图片描述
RedLock算法争议

Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。

  1. 客户端 A 获取节点 1、2、3 上的锁。由于网络问题,无法访问 4 和 5。
  2. 节点 3 上的时钟向前跳跃,导致锁到期。
  3. 客户端 B 获取节点 3、4、5 上的锁。由于网络问题,无法访问 1 和 2。
  4. 客户端 A 和 B 现在都相信他们持有锁。
    在这里插入图片描述

Redisson弃用RedLock

起初Redisson也提供的RedLock的实现,但在3.12.5版本后弃用了。

//redisson 3.12.5版本之前 RedLock 使用示例,基于RedissonMultiLock实现
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
 
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();

redisson-3.12.5版本后弃用了
在这里插入图片描述
Redisson 的开发者认为 Redis 的红锁存在争议,但是为了保证可用性,RLock 对象执行的每个 Redis 命令执行都通过 Redis 3.0 中引入的 WAIT 命令进行同步。
WAIT 命令会阻塞当前客户端,直到所有以前的写命令都成功的传输并被指定数量的副本确认。如果达到以毫秒为单位指定的超时,则即使尚未达到指定数量的副本,该命令也会返回。
WAIT 命令同步复制也并不能保证强一致性,不过在主节点宕机之后,只不过会尽可能的选择最佳的副本(slaves)。

3.3 解决方案

Redis分布式锁在极端情况下,不一定是安全的。
如果你对并发安全性带来的问题零容忍,为了保证正确性,我们可以做一些兜底工作,
例如

  • 建立唯一索引
  • 监控、告警、提供补偿方案

参考资料

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
http://antirez.com/news/101
http://redis.cn/topics/distlock.html
https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers/#84-redlock

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐