在单机环境中,一般在多并发多线程场景下,出现多个线程去抢占一个资源,这个时候会出现线程同步问题,造成执行的结果没有达到预期。我们会用线程间加锁的方式,比如synchronized,lock,volatile,以及JVM并发包中提供的其他工具类去处理此问题。
但是随着技术的发展,分布式系统的出现,各个应用服务都部署在不同节点,由各自的JVM去操控,资源已经不是在 线程 之间的共享,而是变成了 进程 之间的共享,以上解决线程同步问题的办法已经无法满足。
因此,引入了分布式锁的概念。
分布式锁,既在分布式部署的环境下,通过在外部设置锁,让客户端之间互斥,当多应用发生对共享资源的抢占时,该资源同一时刻只能一个应用访问,从而保证数据一致性
目前主流的有三种,从实现的复杂度上来看,从上往下难度依次增加:
本片文章主要介绍基于Redis来实现分布式锁
分布式锁的出现是为了解决分布式数据一致性问题的,下面就举例说明几个场景
场景1:
分布式应用中的job执行,同一时刻只能一个应用上面的job执行,如果每个应用都执行了该job,可能出现问题(该job是发送邮件的,同一时刻就会发送多封邮件);
Mq中的消费者集群中,广播的消息同一时刻只允许一个应用消费该消息,其他的应用不执行,如果执行了可能出现问题(该消息是用来发送短信的,同一时刻如果多个应用消费了该消息发送了多条相同的短信会出现问题)。
场景2:
商城购物网站的秒杀。同一时刻有N个用户一起下订单,订单服务去调用库存服务去扣减库存量,此时库存服务如果是多节点多实例的,就涉及到了分布式数据一致性问题,控制不好就会导致库存量数据出乱子,造成重大错误。
下面我们就以,订单服务扣减库存服务为例子,来看一看分布式产生的数据问题和分布式锁的作用
既然讨论分布式锁,那就从问题出发,先看一下分布式部署出现的数据一致性问题。
在我的上一篇文章,已经具体描述了如何通过springboot模块化的方式搭建nacos微服务
springboot模块化搭建nacos微服务
只是简单的搭建了2个服务注册到nacos服务与发现,再用feign进行服务之间的接口调用。其他的比如熔断,哨兵什么的都没加。
下面我们在此基础上,再加一个库存服务第二个节点
shopping-common: 公用模块
shopping-order: 订单模块 -------- 端口8001
shopping-stock: 库存模块实例1 --------端口8002
shopping-stock-2: 库存模块实例2 ---------端口8003
都启动起来,已经看到shopping-stock服务已经有2个实例了。
下面,我们测试一下feign,订单服务调用库存服务的接口,看一下调用效果和负载均衡效果
这是订单服务的接口,接口返回端口号,通过这个接口,就可以看到feign是如何请求2个节点的
然后订单服务,通过feign去请求库存服务的hello接口
测试结果:
经过测试,可以看到,feign是轮询去请求8002和8003的。
nacos服务搭建完毕,已经初步的可以做到2个服务之间通过feign来请求了。
下面我们建个商品表结构如下
CREATE TABLE `wares_info` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`wares_code` varchar(255) DEFAULT NULL COMMENT '商品编号',
`wares_name` varchar(255) DEFAULT NULL COMMENT '商品名称',
`manufacturer` varchar(255) DEFAULT NULL COMMENT '生产厂商',
`shop_code` varchar(255) DEFAULT NULL COMMENT '店铺编号',
`Inventory` int(10) DEFAULT NULL COMMENT '库存量',
PRIMARY KEY (`id`),
KEY `update` (`Inventory`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='商品信息表';
其实主要就是操作这个Inventory库存量字段
shopping-stock 服务的2个实例,都加入相同代码操作:
引入mybatisplus,配置好实体类,dao层什么的这种基础功能准备
注意噢,这里的实体类,最好是放到一个公用的模块里,然后再通过在pom引入jar的模式,让各个服务之间共享,建议。
然后写一个根据ID,修改Inventory库存量字段的操作:
controller层
@RequestMapping(value = "/updateWaresInventory")
public int updateWaresInventory(String id,String purchase){
return iWaresService.updateWaresInventory(id,purchase);
}
service层
@Override
public int updateWaresInventory(String id, String purchase) {
int result=0;
WaresInfo waresInfo=waresMapper.selectById(id);
if(waresInfo!=null){
int surplus= waresInfo.getInventory()-Integer.parseInt(purchase);
waresInfo.setInventory(surplus);
QueryWrapper wrapper = new QueryWrapper();
wrapper.eq("id",waresInfo.getId());
result = waresMapper.update(waresInfo,wrapper);
}
return result;
}
很简单明了,ID就是主键ID,purchase就是购买数量。先通过ID去数据库里查询这条数据的
Inventory库存量还有多少,减去purchase购买数量,更新这个字段。
然后 shopping-order 服务加入如下代码,通过feign来调用 shopping-stock 库存服务的接口:
controller层
@PostMapping("/updateWaresInventory")
public int updateWaresInventory(String id,String purchase){
return waresService.updateWaresInventory(id,purchase);
}
feign Service层
@FeignClient("shopping-stock")
public interface WaresService {
@PostMapping("/hello")
String hello(@RequestParam(value = "name") String name);
@PostMapping("/updateWaresInventory")
int updateWaresInventory(@RequestParam(value = "id")String id, @RequestParam(value = "purchase")String purchase);
}
打开我们的JMeter测压工具,来测试一下
注意我们这个请求,purchase参数为1,也就是说每一次请求,库存量字段减去1.
JMeter设置好请求接口,再这样设置,意思就是100个线程在1秒钟之内一起执行
右键,启动。
100个线程,每个线程的操作是数据库字段-1,预期的结果是数据库字段减去100,也就是9900.
然后我们看一下运行结束的结果
9976,差太多了吧。那我们在把处理业务的方法上加个synchronized关键字,给他锁住,再看看结果
9950,很显然,synchronized已经降不住了
redis实现分布式的方式其实有很多,前两种只是单纯介绍实现方式,主要的实践是第三种,前两种实现方式可以直接略过,有兴趣的可以试一试。
大体思路就是设置一个key,这个key是根据业务的需要,来自己定义,比如订单号+ID后几位等等等等,只要唯一就可以。然后设置一个超时时间,这个超时时间是必须设置的,如果发生当前线程出问题的情况,到时间依然可以释放锁,保证其他线程顺利执行,不会造成死锁问题。然后每一个线程进来,判断是否获取锁,获取锁就执行,执行完毕释放锁,没有获取锁就等待获取。
下面介绍几种实现方式
使用RedisTemplate的execute的回调方法,里面使用Setnx方法
Setnx就是,如果没有这个key,那么就set一个key-value, 但是如果这个key已经存在,那么将不会再次设置,get出来的value还是最开始set进去的那个value.
下面直接上代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Component
public class CommonRedisHelper {
//锁名称
public static final String LOCK_PREFIX = "redis_lock";
//加锁失效时间,毫秒
public static final int LOCK_EXPIRE = 300; // ms
@Autowired
RedisTemplate redisTemplate;
/**
*
*
* @param key key值
* @return 是否获取到
*/
public boolean lock(String key){
String lock = LOCK_PREFIX + key;
// 利用lambda表达式
return (Boolean) redisTemplate.execute((RedisCallback) connection -> {
long expireAt = System.currentTimeMillis() + LOCK_EXPIRE + 1;
Boolean acquire = connection.setNX(lock.getBytes(), String.valueOf(expireAt).getBytes());
if (acquire) {
return true;
} else {
byte[] value = connection.get(lock.getBytes());
if (Objects.nonNull(value) && value.length > 0) {
long expireTime = Long.parseLong(new String(value));
// 如果锁已经过期
if (expireTime < System.currentTimeMillis()) {
// 重新加锁,防止死锁
byte[] oldValue = connection.getSet(lock.getBytes(), String.valueOf(System.currentTimeMillis() + LOCK_EXPIRE + 1).getBytes());
return Long.parseLong(new String(oldValue)) < System.currentTimeMillis();
}
}
}
return false;
});
}
/**
* 删除锁
*
* @param key
*/
public void delete(String key) {
if(key没有超时){
redisTemplate.delete(key);
}
}
}
业务代码调用
CommonRedisHelper redisHelper = new CommonRedisHelper();
1
boolean lock = redisHelper.lock(key);
if (lock) {
// 执行逻辑操作
redisHelper.delete(key);
} else {
// 设置失败次数计数器, 当到达5次时, 返回失败
int failCount = 1;
while(failCount <= 5){
// 等待100ms重试
try {
Thread.sleep(100l);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (redisHelper.lock(key)){
// 执行逻辑操作
redisHelper.delete(key);
}else{
failCount ++;
}
}
throw new RuntimeException("请稍等再试");
}
阅读代码,其实主要逻辑就是,首先用redis的setNX命令,加入一个key,value为当前时间戳+超时时间+预留时间。setNX命令,如果没有这个key,就加入,返回true。如果存在这个key,就返回false。
如果返回的是false,当前线程没有获取锁,就进入下面的逻辑,通过key,取出value,value为设置好的超时时间的时间戳,然后判断一下是否已经超时,如果超时,就通过getset()方法再次获取锁,并且设置新的value为过期时间戳,返回key的旧值。再判断key的旧值value的时间戳,来判断是否过期,如果过期,则获得锁,如果没有过期,则获取锁失败。
感觉下面这个图,更加直观的展现了加锁的过程
这里有一个问题。为什么要用getset(),不直接set呢。
这里其实牵扯到并发的一些事情,如果直接使用set,那有可能多个客户端会同时获取到锁,如果使用getset然后判断旧值是否过期就不会有这个问题,设想一下如下场景:
1、T1加锁成功,不巧的是,这时C1意外的奔溃了,自然就不会释放锁;
2、T2,T3尝试加锁,这时key已存在,所以T2,T3去判断key是否已过期,这里假设key已经过期了,所以T2,T3使用set指令去设置值,那两个都会加锁成功,这就闯大祸了;如果使用getset指令,然后判断下返回值是否过期就可以避免这种问题,假如T2跑的快,那T3判断返回的时间戳已经过期,自然就加锁失败;
下面分析解锁过程:
直接上图吧
这里又会产生一个问题,既然是释放,直接delete不就完事了?咋还又判断下是否过期呢?
考虑这样一种场景:
1、T1获取锁成功,开始执行自己的操作,不幸的是T1这时被阻塞了;
2、T2这时来获取锁,由于T1被阻塞了很长时间,所以key对应的value已经过期了,这时T2通过getset加锁成功;
3、T1尘封了太久终于被再次唤醒,醒来以后,不判断过期不过期,直接把这个key给删掉;
4、T3来获取锁,在T2还在正常工作的时候,居然一下就成功了,T3也进入了资源;
这样显然不合理吧,出乱子了吧,T2还没干完活呢,T1就把T2的锁给拆了了,T3进来了,这违反了互斥性,对吧。那这里,就加入一个过期时间的判断,那步骤就变成了,T1醒来,一看锁的时间还没到,不管了,让它时间到了自己解锁吧,当超时以后T3也会正常进入。如果T1醒来一看时间到了,就把锁给解开,T3也可以正常进入。
看似安全了,实则还是有漏洞的存在。再设想一下,就还是这个场景,T1醒来,一看锁过期了,就咔的一下子把锁给删了,这个没问题吧?但是这个时候,T2也没有完成任务,也阻塞住了,干活干了一半,但是他依然是要干完后面的活的,这个时候,T3获得锁,开门进来了,T3和T2就一起干活,操作同一片数据,这依然违反了互斥性。
以上主要是用Redis的setNX和getset命令外加一个判断时间戳的方式,来手写redis分布式锁,这个也是在百度上经常出现的一种redis实现分布式锁的方式,不过不推荐使用。
这个比上面那个厉害了
直接上代码吧,首先引入jedis
redis.clients
jedis
2.9.0
加锁代码:
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。
解锁代码:
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:
简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
这种方式,因为用了lua执行脚本,使得加锁和锁加过期时间有了一个原子性的操作,相比第一种实现方式而言更靠谱一点。不过我没有研究这种,只是单纯的知道有这么一种实现方式,我用的是下一种要介绍的,目前最被认可的一种redis实现分布式锁的方式。
下面学习了https://blog.csdn.net/zhangcongyi420/article/details/89980469这位博主的文章
关于redisson 锁的几点说明
通过下面这张图来简单看看redisson 锁的实现原理
加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器
这里注意,仅仅只是选择一台机器!这点很关键!
紧接着,就会发送一段lua脚本到redis上,那段lua脚本如下所示:
简单解释一下这段lua脚本要做的事情,
1、锁不存在的情况下加锁
KEYS[1]代表的是你加锁的那个key,比如说:
RLock lock = redisson.getLock(“myLock”);
这里你自己设置了加锁的那个锁key就是“myLock”
ARGV[1]代表的就是锁key的默认生存时间,默认30秒
ARGV[2]代表的是加锁的客户端的ID,类似于下面这样:
8743c9c0-0795-4907-87fd-6c719a6b4586:1
给大家解释一下,第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁
如何加锁呢?很简单,用下面的命令:
hset myLock
8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
mylock{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1
}
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。
接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
好了,到此为止,ok,加锁完成了。
锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁。
watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
可重入加锁机制
那如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?
第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:
incrby myLock
8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。
此时myLock数据结构变为下面这样:
大家看到了吧,那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数
释放锁机制
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。
然后呢,另外的客户端2就可以尝试完成加锁了。
这就是所谓的分布式锁的开源Redisson框架的实现机制。
一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁
有了上面的概念,下面来具体说一下springboot整合redisson实现分布式锁的代码整合
上面貌似说了很多废话,有点脱节了。
依然是继续上面已经搭建好的nacos微服务的例子,订单调用库存服务的修改操作
在此基础上,做如下修改
以下操作的都是在库存服务中
首先引入redisson依赖
org.redisson
redisson
3.8.2
再写入一个redsson的配置类,开启redis的链接
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.password}")
private String paw;
@Value("${spring.redis.port}")
private String port;
@Bean
public RedissonClient getRedisson() throws Exception{
RedissonClient redisson = null;
System.out.println(host);
Config config = new Config();
config.useSingleServer()
.setPassword(paw)
.setAddress("http://"+host+":"+port);
redisson = Redisson.create(config);
System.out.println(redisson.getConfig().toJSON().toString());
return redisson;
}
}
就是创建个redisson的链接
下面直接在业务代码上操作
修改之前的updateWaresInventory方法
@Override
public int updateWaresInventory(String id, String purchase) {
int result=0;
System.err.println("=============线程开启============" + Thread.currentThread().getName());
try {
RLock lock = redissonClient.getLock("lock");
boolean isLock= lock.tryLock(100,1, TimeUnit.SECONDS);
if(isLock){
try{
System.err.println("=============获取锁,开始执行============" + Thread.currentThread().getName());
result= updateWaresInventoryLogic(id,purchase);
}finally {
System.err.println("=============操作完毕,释放锁============" + Thread.currentThread().getName());
lock.unlock();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 修改操作
* @param id
* @param purchase
* @return
*/
private int updateWaresInventoryLogic(String id, String purchase){
int result=0;
WaresInfo waresInfo=waresMapper.selectById(id);
if(waresInfo!=null){
int surplus= waresInfo.getInventory()-Integer.parseInt(purchase);
waresInfo.setInventory(surplus);
QueryWrapper<WaresInfo> wrapper = new QueryWrapper();
wrapper.eq("id",waresInfo.getId());
result = waresMapper.update(waresInfo,wrapper);
}
return result;
}
把业务代码抽出来,获取锁就执行。
很简单吧,其实就分几步:
下面用JMeter测一下,参数依然是id=1&purchase=1
得到了预期的结果!
多试几次,看看是不是都是预期的结果。显而易见,肯定都是。
说明我们的分布式锁成功了。
当然,我这里还有点问题,就是线程数设置的多了,feign容易超时报错,这也会导致最后的结果不在预期以内,但是这并不是分布式锁的问题,尝试一下把feign的延迟设置的长一点。关于这个问题,又涉及到另一个调优的问题了,这里只说分布式锁的实现,其他问题就不做分析了。
分布式锁可以解决分布式数据一致性问题,但是同样也会降低性能,能减少使用就减少使用,能用线程锁尽量就用线程锁。