Redis分布式锁失效场景分析
本文主要介绍了分布式锁失效的三种场景,并通过一些案例进行分析。
本文主要介绍了分布式锁失效的三种场景,并通过一些案例进行分析
背景
最近在项目中发现一些因为错误使用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分布锁会存在安全性问题
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了。
- slave节点被晋升为master节点
- 客户端B取得了同一个资源被客户端A已经获取到的同一个锁。
3.2 问题分析
首先要说明一点,出现这种情形的概率是很低的。针对于这种情况,Redis的作者antirez设计出了RedLock算法,然而RedLock算法依赖时钟正确性,存在争议。
RedLock算法
设置N个相互独立,不存在主从复制或者其他集群协调机制的Redis master节点
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。
例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。
如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
RedLock算法争议
Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。
- 客户端 A 获取节点 1、2、3 上的锁。由于网络问题,无法访问 4 和 5。
- 节点 3 上的时钟向前跳跃,导致锁到期。
- 客户端 B 获取节点 3、4、5 上的锁。由于网络问题,无法访问 1 和 2。
- 客户端 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
更多推荐
所有评论(0)