1.分布式锁介绍
- 在计算机系统中,锁作为一种控制并发的机制无处不在。
- 单机环境下,操作系统能够在进程或线程之间通过本地的锁来控制并发程序的行为。
而在如今的大型复杂系统中,通常采用的是分布式架构提供服务。 - 分布式环境下,基于本地单机的锁无法控制分布式系统中分开部署客户端的并发行为,
此时分布式锁就应运而生了。
一个可靠的分布式锁应该具备以下特性:
- 互斥性:作为锁,需要保证任何时刻只能有一个客户端(用户)持有锁
- 可重入: 同一个客户端在获得锁后,可以再次进行加锁
- 高可用:获取锁和释放锁的效率较高,不会出现单点故障
- 自动重试机制:当客户端加锁失败时,能够提供一种机制让客户端自动重试
2.分布式锁场景
通过以下代码来讲解,如下是一个减库存的操作,假设有A,B,C三个线程同时访问deductStock方法,
此时库存数量50,当A,B,C三个线程同时拿到库存数量并减1时,并写回redis时,
此时redis数据库中实际库存数量是49,而不是理论上的50-3=47,就会造成商品的超卖现象
那么要如何解决以上商品的超卖现象呢?可用synchronized来解决这个问题
为什么要使用synchronized?
在并发编程中存在线程安全问题,主要原因有:1.存在共享数据 2.多线程共同操作共享数据。
关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,
同时synchronized可以保证一个线程的变化可见(可见性),即可以代替volatile。
加了synchronized是否就解决了以上分布式锁的问题呢?其实不然,我们知道,在大部分互联网公司,
一个web应用都是被打成war包,部署在多个tomcat上面,如果一个程序在一个tomcat上面运行,
那么使用synchronized就没有问题,但是如果是一个web应用都是被打成war包,部署在多个tomcat上面,
做成一个集群架构,以上方法就不适用了。虽然synchronized可以解决数目不一致的问题,但是缺点也很明显,
那就是慢,因为synchronized修饰的方法是同步的,也就是说每次只有一个线程访问这个方法,
而且synchronized只适用于单点的情况。
可以用以下命令实现简单的分布式锁
入门级别分布式锁
- 使用redis的setnx命令,可以简单的解决集群下的分布式锁问题
那么上面分布式锁是否就没有问题了呢,答案是否定的,假设第一个线程减库存操作时抛出了一个异常,stringRedisTemplate.delete(lockKey);这行代码就无法执行,那么其他线程就永远无法执行了。
分布式锁优化1
- 针对上面的问题,我们对代码进行了优化,在减库存操作加上了try{…}finally{…}语句块,
保证stringRedisTemplate.delete(lockKey);可以成功执行
那么加上try{…}finally{…}语句块是否就没有其它bug呢?答案也是否定的,
假设在执行减库存操作时,web服务器突然宕机了,那么finally{…}里面的语句块也不会执行,锁没有释放,其他线程无法访问
分布式锁优化2
- 针对上面的问题,我们对代码进行了优化,给lockKey加上了超时时长10s, 假设web服务器突然宕机,那么也不会有影响,因为lockKey在redis的超时时长是10s, 10s后自动删除lockKey,释放锁
上面代码既解决了异常也解决了宕机,那么是否还有其它问题?
假设第一个线程拿到锁lockKey,但是还没给锁加上超时时长,服务器就宕机了,这时也会出现死锁的问题,即lockKey没有释放
分布式锁优化3
- 针对上面的问题,我们再次对代码进行了优化,Redis为我们控制了后端的原子操作
上面分布式锁代码已经基本完善了,在低并发的软件公司可以使用,但是在高并发的软件公司还会出现其它小问题
假设有三个线程访问,线程1执行完代码要15s,执行到10s的时候,lockKey由于设置的超时时长是10s,lockKey被redis删除了,锁释放,此时线程2就会拿到锁lockKey,线程2执行完代码要8s, 当线程2执行了5s,线程1已经执行完,那么线程1就会释放线程2正在使用的lockKey,删除锁,这就不合适了,此时由于锁再次被释放,那么线程3就可以拿到锁,线程3执行代码需要5s,当线程3执行了3s,线程2已经执行完,那么线程2就会释放线程3正在使用的lockKey,删除锁,所以在高并发的情况下,锁可能会失效
分布式锁优化4
- 针对上面的问题,我们对代码进行了优化,原则就是自己加的锁lockKey自己释放,通过clientId标识当前线程,判断当前执行的线程是否和拿到锁lockKey的线程是同一个线程,如果是同一个线程,执行完代码就会释放锁lockKey
- 上面分布式锁代码已经很完善了,但还有一些问题,由于图中锁的超时时长为10s, 那么假如有的线程执行完超过10s, 那么也会导致上面的问题,由于线程的执行时间是不确定的,即使加大锁的超时时长,也可能会出现bug
- 解决思路
- 针对上面的问题,我们进行了分析,有以下解决方案,当线程拿到锁lockKey时,会开启一个分线程,在分线程里开启一个定时器,判断当前锁是否存在,如果存在,重新给锁设置超时时长expire。
- 假设线程1拿到锁lockKey,锁lockKey设置的超时时长为30s, 那么每隔10s,分线程就会判断当前线程是否还在执行,如果还在执行,则给锁lockKey重新设置的超时时长为30s,相当于给锁lockKey续命,以确保线程执行完再释放锁lockKey
redission的使用–高性能分布式锁
- 概述:在一些高并发的场景中,比如秒杀,抢票,抢购这些场景,都存在对核心资源,商品库存的争夺,控制不好,库存数量可能被减少到负数,出现超卖的情况,或者产生唯一的一个递增ID,由于web应用部署在多个机器上,简单的同步加锁是无法实现的,给数据库加锁的话,对于高并发,1000/s的并发,数据库可能由行锁变成表锁,性能下降会厉害。那相对而言,redis的分布式锁,相对而言,是个很好的选择,redis官方推荐使用的Redisson就提供了分布式锁和相关服务。下面介绍下如何使用Redisson。
- Redisson的使用方式十分简单,详见官方文档:https://github.com/redisson/redisson/wiki/2.-%E9%85%8D%E7%BD%AE%E6%96%B9%E6%B3%95
- 加入jar包的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.2</version>
<optional>true</optional>
</dependency>
- 配置Redisson
public class RedissonManager {
private static Config config = new Config();
//声明redisso对象
private static Redisson redisson = null;
//实例化redisson
static{
config.useSingleServer().setAddress("127.0.0.1:6379");
//得到redisson对象
redisson = (Redisson) Redisson.create(config);
}
//获取redisson对象的方法
public static Redisson getRedisson(){
return redisson;
}
}
- 锁的获取和释放
Redisson实现Redis分布式锁的底层原理
Redisson底层用lua脚本实现
为啥要用lua脚本呢?
因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。