「这是我参与2022首次更文挑战的第9天,活动详情查看:2022首次更文挑战」
分布式锁场景
秒杀、抢优惠券、接口幂等性校验等
单机单线程
随着互联网的发展,老王看到了互联网发展的红利,因此自己辞职之后,就创办了一个电商服务平台,吭哧吭哧开发了一段时间,项目发布上线,正好618马上就到到了,想着发一些优惠券做一下大促,活跃一下用户,顺便小赚一笔!然后老王表叔家哥哥媳妇的弟弟王二麻子写了一个抢优惠券的程序。代码如下:
@RequestMapping("/get_coupon")
public String deductStock() {
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
return "success";
}
代码解析:
1、首先从redis中获得优惠券数量
2、如果优惠券数量大于0则-1,并且将减后的数量再次放入redis。
王二麻子 心想这样不就实现了一个抢优惠券的功能,自己本地测试也没问题,然后就提交到了测试。
测试同学用jmeter并发压测一跑,结果本来1000的优惠券,超卖了何止几百个,马上提交测试报告,反馈给王二麻子。
单机多线程并发
王二麻子看到测试结果,仔细推敲了一下代码,发现如果并发多了,还真会能超卖
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100"));
以上代码如果N条线程同时进来,获得的优惠券数量都是1000,后面代码再-1,也就没啥意义了。
A 线程:1000-1=999,然后放入redis
B 线程:1000-1=999,然后放入redis
这样redis里面的优惠券数量肯定会有问题。王二麻子灵机一动,之前学习过synchronized,给这段代码加上一把锁,这样即时并发多了,也是一个线程一个线程的执行库存-1操作,这样不就没问题了,于是代码更新成如下:
@RequestMapping("/get_coupon")
public String deductStock() {
synchronized (this){
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
}
return "success";
}
然后王二麻子又重新提交了测试。
测试同学拿到提测,又用jmeter跑了一下并发,嘿!还真没事了,优惠券不会超卖了,心想王二麻子这小伙解决bug效率还不错嘛,于是测试同学就把代码发布到了预发布环境,预发布环境是这样的:
结果发现优惠券,又被抢超了,测试同学于是又把bug打回到了王二麻子。
分布式锁
redis setnx的应用
王二麻子发现bug又回了,然后又仔细推敲了一下,结果发现,如果是多态服务器,客户端线程通过nginx分发到tomcat,多个tomcat之间无法用synchronized进行加锁,于是得想一个分布式锁才行,于是经过王二麻子改造,代码又更新成这样:
@RequestMapping("/get_coupon")
public String deductStock() {
String couponKey="coupon_100";
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
if(!lock){
return "排队中";
}
//----业务处理---begin
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
//----业务处理---end
stringRedisTemplate.delete(couponKey);
return "success";
}
代码解析:通过使用redis的setnx方法,第一个线程进来之后先setnx一个值,这样后面的线程再setnx就无法成功执行了,只能等第一个线程delete key之后,后面的线程才能继续执行setnx,一个简单的分布式锁搞定。
通过finally确保redis delete key成功
不过经过前两次被测试两次打回bug,小王同学决定,再仔细推敲一下代码,万一再遗留问题,结果还真发现一个问题,万一在业务处理这部分代码出现异常,那stringRedisTemplate.delete(couponKey);就无法执行,那以后的线程就都无法进来了,因此代码又进行了一下升级,如下:
@RequestMapping("/get_coupon")
public String deductStock() {
String couponKey="coupon_100";
try {
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
if(!lock){
return "排队中";
}
//----业务处理---begin
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
//----业务处理---end
}finally {
stringRedisTemplate.delete(couponKey);
}
return "success";
}
这样即使业务代码执行过程中出现异常,通过finally也能执行stringRedisTemplate.delete(couponKey);这样就不用担心程序发生异常无法执行delete key了。
redis key 有效期的使用(stringRedisTemplate.expire的使用)
小王同学还是不放心,又推敲了一下代码,发现万一程序在执行过程中,在业务代码这块,服务器宕机了,那stringRedisTemplate.delete(couponKey);就永远无法执行了,想起来一阵后怕,还好没提交测试,要不自己在测试大美丽心里又要受到鄙视了。于是代码又进行了改造,如下:
@RequestMapping("/get_coupon")
public String deductStock() {
String couponKey="coupon_100";
try {
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
//只锁定线程10秒,10秒后自动删除key
stringRedisTemplate.expire(couponKey,10, TimeUnit.SECONDS);
if(!lock){
return "排队中";
}
//----业务处理---begin
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
//----业务处理---end
}finally {
stringRedisTemplate.delete(couponKey);
}
return "success";
}
这样加上下面这段代码,即使在处理业务过程中宕机,也可以删除key,不用单选宕机,无法删除key了,
//只锁定线程10秒,10秒后自动删除key
stringRedisTemplate.expire(couponKey,10, TimeUnit.SECONDS);
不过万一执行了下面这段代码之后就宕机,那上面这段代码也执行不到,还得想一个办法来处理
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
redis set key同时设置有效时间
代码再次改造如下:
@RequestMapping("/get_coupon")
public String deductStock() {
String couponKey="coupon_100";
try {
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock",10,TimeUnit.SECONDS);
if(!lock){
return "排队中";
}
//----业务处理---begin
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
//----业务处理---end
}finally {
stringRedisTemplate.delete(couponKey);
}
return "success";
}
在redis set key的时候,同时设置有效期
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock",10,TimeUnit.SECONDS);
小王心想这样代码应该就没问题了吧?不过谨慎起见,还得再仔细考虑考虑,结果还真又发现了问题,如下图
讲解:
当第一个线程进来并加锁10s,但是线程1的业务代码执行时间超过了10s,线程1的锁就会自动失效,这个时候线程2就可以进来加锁,并执行业务代码,当线程2执行业务代码的时候,线程1的业务代码正好执行完成,进行删除锁操作,结果把线程2的锁删掉了,这个时候线程3就可以进来,并且加锁,这样所有的线程锁都不是自己删除,都被前面的线程给提前删除,数据锁也就失去了意义。
小王想,怎么才能让后面的线程无法删除前面线程的说呢?
设置锁的唯一值
于是小王经过深思熟虑对代码又进行了改造,如下
@RequestMapping("/get_coupon")
public String deductStock() {
String couponKey="coupon_100";
String lockId= UUID.randomUUID().toString();
try {
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, lockId,10,TimeUnit.SECONDS);
if(!lock){
return "排队中";
}
//----业务处理---begin
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
//----业务处理---end
}finally {
if(lockId.equals(stringRedisTemplate.opsForValue().get(couponKey))){
stringRedisTemplate.delete(couponKey);
}
}
return "success";
}
代码解析:
给每个线程都设置一个唯一ID(UUID)。在删除锁的时候,判断一下是不是自己线程的ID,这样就无法删除其他线程的锁了,如下:
if(lockId.equals(stringRedisTemplate.opsForValue().get(couponKey))){
stringRedisTemplate.delete(couponKey);
}
这样虽然解决了线程只能删除自己的锁,但是业务代码执行时长是不可控的,也就是加锁时间也就无法做到准确,只要锁超时,业务代码就有可能被多个线程同时执行,锁也就无意义了。
锁续命
设想:如果业务代码在无异常,并且仍然在执行的情况下,我们加一个可以自动将锁时间延长的功能,等业务代码执行完毕删除了锁,然后自动延迟锁功能停止运行,这样就可以解决锁超时的问题了。如下图:
释放锁如何保持原子性
释放锁的时候,万一正好执行到了equals,正执行删除的时候,出现宕机,这样每10s的锁延长就停止运行,然后redis等到30s的时候,锁自动删除,不影响程序继续执行。那如果出现异常,每10s的锁延长还在继续执行,又该如何处理?又是一个问题
finally {
if(lockId.equals(stringRedisTemplate.opsForValue().get(couponKey))){
stringRedisTemplate.delete(couponKey);
}
}
redisson 工具类
上面说到的锁延迟以及删除锁的原子性,它都帮忙做解决了,下面一步一步来简写使用,及源码分析。
redisson官网:
redisson.org/
springboot集成redisson
pom.xml引入redisson
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.1</version>
</dependency>
springboot启动初始化redisson
在springboot启动文件中添加如下代码
@SpringBootApplication
public class MyredisApplication {
public static void main(String[] args) {
SpringApplication.run(MyredisApplication.class, args);
}
@Bean
public Redisson redisson(){
Config config=new Config();
//redis单机使用redisson
config.useSingleServer().setAddress("redis://192.168.253.131:6379").setDatabase(0);
/* //redis使用集群
config.useClusterServers()
.addNodeAddress("redis://192.168.253.131:8001")
.addNodeAddress("redis://192.168.253.131:8002")
.addNodeAddress("redis://192.168.253.132:8003")
.addNodeAddress("redis://192.168.253.132:8004")
.addNodeAddress("redis://192.168.253.133:8005")
.addNodeAddress("redis://192.168.253.133:8006");*/
return (Redisson) Redisson.create(config);
}
}
我这边测试使用,就使用单机连接
同时redisson提供了不同类型redis部署的连接方案
redisson的具体使用
public class CouponController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private Redisson redisson;
@RequestMapping("/get_coupon")
public String deductStock() {
String couponKey="coupon_100";
//拿到锁对象
RLock redissonLock=redisson.getLock(couponKey);
try {
//加锁(同时实现加锁,看门狗锁续命的功能)
redissonLock.lock();
//----业务处理---begin
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
if (coupon > 0) {
int currCoupons = coupon - 1;
stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + currCoupons);
} else {
System.out.println("扣减失败,库存不足");
}
//----业务处理---end
}finally {
//解锁
redissonLock.unlock();
}
return "success";
}
}
以上代码主要添加:
1、将redisson注入进controller
@Autowired
private Redisson redisson;
2、获得redisson锁
//拿到锁对象
RLock redissonLock=redisson.getLock(couponKey);
3、加锁,底层代码同时实现加锁以及看门狗锁续命功能
//拿到锁对象
RLock redissonLock=redisson.getLock(couponKey);
4、释放锁
//解锁
redissonLock.unlock();
这样简单的引入就实现了高并发分布式锁的应用,因为篇幅过长,下篇文章再介绍redisson底层实现代码原理,有兴趣同学可以看一篇文章。