企业实战之分布式锁方案一步步的演变历程!
前言
在我们学习多线程开发的时候,在线程同时针对同一个资源进行操作的时候都需要加锁;一般会用到reentrantLock和synchronized两种锁方案,至于他们之间的区别也是面试的时候经常问到的,小伙伴们可自行网补。这里介绍企业经常用到的另一种锁,分布式锁。大家肯定听说过,但是就不一定用对哦。今天就深入的介绍一下分布式锁方案的演变。
常见用法
我们也不免俗套来举个并发扣除库存的例子
我们来看一下代码
//扣除商品库存
//产品id: productId
//扣除数量: count
public void reduce(int productId,int count){
//步骤1 从数据库获得产品实体
Product product = getProduct(productId);
//步骤2 获得当前库存数量
int stockCount = product.getStock();
if(stockCount >= count){
//步骤3 扣除库存
product.setStock(stockCount - count);
//步骤4 把产品实体更新到数据库
productService.update(product);
log.info("购买成功!")
}else{
log.info("库存不足,无法购买!")
}
}
购买场景
当前产品的库存数为10
请求A买了2个产品,那应该扣除2
请求B买了3个产品,那应该再扣除3
那最终的库存剩余为5
上面代码在分布式环境中,只要稍微流量大点,这边就会出现扣减库存不是预期的情况。原因就是
两个请求同时到来时,都同时执行了步骤1,在同一时刻都获取到了同一个产品库存当前库存都为10;但在步骤3的时候都是用10减count值,那么不管是请求A和请求B哪个先执行步骤4,库存剩余要么剩余是8或者7;都不是最终的5。
原因知道了,那怎么解决?小伙伴想到的就是弄个锁,而且还要分布式锁。
分布式锁登场
上面的问题很多小伙伴应该都知道要用分布式锁,那用什么技术方案呢?我相信很多小伙伴都会说用redis方案,很简单setnx就行了。
setnx命令 是redis的一条原生命令大意为 set if not exists, 在指定的key不存在的情况下,命令执行成功,如果key存在就命令执行不成功。
这个方案是很多公司都这么用的,那我们调整一下代码
需要考虑到一些业务异常,需要把锁释放掉,加上try/finally,这个千万不要忘了
当是还是有一些问题,就是如果加锁成功后,业务没有完成。突然断电或者运维人员用kill -9命令把线程删除了;那就导致了锁一直没有释放,因为不会执行finally里面的代码了。
那怎么办呢?有经验的小伙伴应该就知道解决方案了
优化分布式锁
方案还是比较简单的,加个过期时间就行了
这样即使断电,过了10秒钟之后锁也会自动过期,也就是失效;别的请求就可以正常请求了
现在到了这里,就是很多公司应用分布式锁的常用方案了。小伙伴们这样就没有问题了吗
问题分析
我们来看看问题出现在哪里?我们来调整一下业务代码
因为我们扣库存的业务,不可能像写的很简单的业务;正式场景中业务是比较多的,不可能就这么简单;如果业务代码执行的时间超出了锁的过期时间,那么锁到期失效了,但业务代码还没有执行完;这种场景就会导致数据错乱。
那这个问题怎么解决呢?
解决思路
这个问题的本质是锁在没有执行完成业务时,到期失效了;那我们可以不让他失效不就行了吗?那怎么不让他失效呢?
方案很简单
启动一个后台线程,可以每3秒或者5秒执行一次,找到这个锁的key,延长这个锁key的过期时间;这样就达到了锁过期时间续期的功能了。是不是很简单?
我们自己写代码去实现是没有问题的,但是现在市面上已经有了轮子了,不需要我们自己再去写这个代码了,直接用人家的轮子;这个就是大名鼎鼎的Redisson。
分布式锁Redisson
redisson是针对redis分布锁的强大的工具包,他提供了自动续期的功能,以及重入锁的功能,只需要引入
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.2</version>
</dependency>
然后实例化
@Bean
publicRedisson redisson(){
Config config = new Config();
config.useSingleServer().setAddress("redis://xxx:6379").setPassword("*
").setDatabase(0);
return (Redisson) Redisson.create(config);
}
修改代码
用法非常简单。我们接下来分析他的原理以及源码,看看他是怎么续期的,和实现可重入的
源码分析
我们先来看看他的加锁流程图
加锁机制
1、线程去获取锁,获取成功: 执行lua脚本,保存数据到redis数据库。
2、线程去获取锁,获取失败: 一直通过while循环尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。即会阻塞线程
redisson是执行的lua脚本的核心代码tryLockInnerAsync如下
redis执行lua脚本是原子性的,要么都成功,要么都失败
下面的代码就是获取锁失败,就自旋;一直尝试获取锁
续期机制
续期的业务就是上面流程图中看门狗做的,这里需要注意的是,如果要让看门狗有效果,就不要设置过期时间,如果设置了过期时间看门狗就不起作用了;看源码
续期的代码就是this.scheduleExpirationRenewal(threadId);
注意判断条件,是if (leaseTime == -1L)的时候才生效,才会启动一个续期任务线程
主从问题
小伙伴看到这里,是不是觉得这样实现分布锁应该就没有问题了吧,那是不是这样呢?
我们来看一个问题
上图中我们发现,如果突然出现redis的主节点突然挂掉了,而且在设置锁key的时候,还没有来得及同步到Slave从节点中,
主节点挂了,从节点会顶上成为主节点;但是这个时候新的主节点是没有这个锁key的。
那问题就来了,其他的请求再起请求的锁,就会获取锁;这样就造成了业务的混乱。
当然这个情况还是蛮特殊的,蛮少见的。那这个问题怎么去解决呢?
解决方案
我们来分析一下上面的问题,主要就是因为主从同步数据有延迟导致的。
我们先来了解一下分布式系统中的CAP理论
C表示数据一致性,A表示高可用性,P表示分区容错性
CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
那我们redis架构是AP原则,就是保证高可用性,牺牲了数据一致性;即主从数据有一定的延迟。
我们在来看看市面上还有另一种分布式锁的方案,就是zookeeper;
zookeeper的架构原则是CP,就是保证了数据一致性,牺牲了高可用性,zookeeper的原理就是在写入数据时候,要保证主节点写入成功,而且还要保证大多数follow从节点也要写入成功,都写入成功,才会返回客户端成功。这样就保证了数据没有延迟。
不过小伙伴们应该能够发现,zookeeper的写入性能是稍微低点的,因为要保证主从都要写入成功才行。
总结
在整个主流方案中,如果一定要保证数据一致性,保证锁不会有问题,可以选择zk方案实现分布式锁;但可以接受特殊情况下的锁错误,那就用redis的redisson方案(推荐)。可以根据不通业务去选择,即在同一个平台中,可实现不同的方案。
看完三件事❤️
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
- 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
- 关注公众号 『 阿风的架构笔记 』,不定期分享原创知识。
- 同时可以期待后续文章ing