@TOC
转自blog.csdn.net/qq_32681589...
一个分布式案例引发的思考
先假设,我们当前线上有一个项目,使用nginx分别轮循到2个tomcat上。它的模型如下: 如上图,为了减缓节点压力,我们把项目部署成了2个tomcat,分别是8080端口和8081端口。并且采用的是轮询策略,客户端每次过来一条请求,将按序依次分流到这2个tomcat上。 然后,这个tomcat项目提供了如下这个接口:
java
@RequestMapping("/deduct_stock")
public String deductStock1() {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
return "end";
}
1. Redissetnx解决
案例分析:这个案例很简单,就是提供一个扣减库存的接口,模拟外部电商系统购买物品之后,扣减库存。但是,大家一定要注意到以下几个点:
- 无论我们部署多少个tomcat,库存肯定是被共享的。假设,我只有100个优惠产品,那肯定只能被卖出去100件
- 我们把库存量放到了redis中去了,每卖出去一件,stock - 1,并且写回缓存
但是两个tomcat就会出现并发问题,并且用synchronized不能解决两个tomcat问题
改正后的代码如下:
java
@RequestMapping("/deduct_stock")
public String deductStock1() {
String lockKey = "lock:product_101";
// 使用redis的setnx命令
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge");
if (!result) {
return "error_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
stringRedisTemplate.delete(lockKey);
}
return "end";
}
这边很简单,就是使用了stringRedisTemplate.opsForValue().setIfAbsent,即:Redissetnx命令。然后,如果Redis返回的result不是true,那就返回一个错误码,提示客户端【上锁失败】就好了。那同学们,这样就行了吗?我们画个图吧,嘿嘿嘿
但是会出现如下问题: 就像上图这样,显然,从目前来看,是没问题,确实已经实现了,多个tomcat情况下,都能控制共享资源了。但是,万一,真的出现了客户端1在拿到锁之后,还没走到释放锁的代码就宕机了,那完了,资源没办法被释放!怎么办?难道我手动删除不成?这就是,单纯利用setnx会遇到的第一个问题:==死锁。==
2. 解决Redissetnx死锁
setnx + 过期时间
java
@RequestMapping("/deduct_stock")
public String deductStock1() {
String lockKey = "lock:product_101";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge");
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
if (!result) {
return "error_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
stringRedisTemplate.delete(lockKey);
}
return "end";
}
跟上述代码一样,我们在setIfAbsent之后,加一个过期时间函数expire。这个方案其实是不行的。很显然,目前这两步操作不是【原子性】的,Java代码嘛,肯定是一条一条按顺序执行的,就跟上面的例子一样,当我们出现极端情况,诶,==还真就执行完setIfAbsent之后,expire之前宕机了呢?一样完犊子==,会出现死锁,所以,正常我们是利用setIfAbsent另一个重载方法,它会帮我们【原子】地操作这两步,如下:
3. 解决设置过期时间语句 的原子性问题
java
@RequestMapping("/deduct_stock")
public String deductStock1() {
String lockKey = "lock:product_101";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhuge", 30, TimeUnit.SECONDS);
if (!result) {
return "error_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
stringRedisTemplate.delete(lockKey);
}
return "end";
}
好了,目前是原子性的了。并且呢,我们给这个锁,+了一个30秒的过期时间。可以了吗?啊,不完全对。大家能想明白吗? 很显然啊,==你这个过期时间是固定30秒的,万一我业务30秒内完成不了呢==?嘿,你是不是想说什么业务30秒完成不了,哈,真可能出现,比如IO阻塞了什么的。 那有朋友会继续建议:那我设置60秒?120秒?240秒?丢,我设置超长时间,总行了吧???咳咳咳,啊这个,有点道理的 由于客户端A在执行业务期间锁就过期了,此时,客户端B进来加锁肯定是能成功的。但是客户端A在没有出现错误的情况,肯定会继续执行下去的,并且最终会释放锁。那最终这个释放锁释放的是谁的锁呢?客户端B的呀!此时,又有一个新的客户端C过来加锁,那不是成功了吗?显然这样做是有问题的,【错误释放别人的锁】,并且自己的业务还不一定执行完了!
4. 解决锁误删问题
其实针对这个问题,还是有解决方案的。那就是每次上锁的时候,+一个uuid,==最后释放锁的时候判断一下uuid是不是跟当前的uuid一样就好了==。如下:
java
@RequestMapping("/deduct_stock")
public String deductStock1() {
String lockKey = "lock:product_101";
String clientId = UUID.randomUUID().toString();
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
if (!result) {
return "error_code";
}
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
return "end";
}
关键代码如上:最后finaly块判断释放的时候,里面的value值是不是当初我们设置的那个。但其实这仅仅只是解决了我们其中一个问题而已。还有个关键的【拒绝服务】问题呢。追根揭底,还是【锁时间】到底该如何确定的问题。 于是有人提出了一个方案:锁续命。顾名思义,就是设置一个相对不那么长的时间,但是临到期前或者某个时间点,重新设置过期时间。
5. 解决锁续命问题
Redisson实现分布式锁:setnx + 过期时间 + 锁续命
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
java
@RequestMapping("/deduct_stock")
public String deductStock1() {
String lockKey = "lock:product_001";
//获取锁对象
RLock redissonLock = redisson.getLock(lockKey);
//加分布式锁
redissonLock.lock(); // .setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
//解锁
redissonLock.unlock();
}
return "end";
}
==上面的代码很简单,我们无需关心之前提到的那几个问题了,Redisson在封装的api里面已经帮我们做好了一切。我们只需要简单的调用lock跟unlock而已。==
Redisson客户端实现的分布式锁源码分析
本次源码分析的入口,就是【3.3】中最后给出的代码示例redissonLock.lock()。为了方便大家理解,这里我们给出这个源码实现的原理图: