基于数据库的分布式锁(基于主键id和唯一索引)
1基于主键实现分布式锁
2基于唯一索引实现分布式锁
其实原理一致,都是采用一个唯一的标识进行判断是否加锁。
原理:通过主键或者唯一索性两者都是唯一的特性,如果多个服务器同时请求到数据库,数据库只会允许同一时间只有一个服务器的请求在对数据库进行操作,其他服务器的请求就需要进行阻塞等待或者进行自旋。如何实现的呢?可以理解为同一时间只有一个请求能够拿到锁,当方式执行完成过后,对锁进行释放过后,其他请求就可以拿到锁再对数据库进行操作,这样就避免了数据不安全问题。
阻塞:线程等待锁释放的一种方式
自旋:自旋包括了递归自旋,while自旋。意思就是不断地去尝试获取锁,只有获取锁才会停止自旋过程,没有拿到就会一直尝试获取锁。
以下是一个基于MySQL实现分布式锁的示例代码:
import java.sql.*;
import java.util.Properties;
public class DatabaseLock {
private Connection conn;
private static final String dbUrl = "jdbc:mysql://localhost:3306/test";
private static final String username = "xxxx";
private static final String password = "xxxxx";
// 构造函数,建立数据库连接
public DatabaseLock() throws SQLException {
Properties props = new Properties();
props.setProperty("user", username);
props.setProperty("password", password);
conn = DriverManager.getConnection(dbUrl, props);
}
// 尝试获取锁
public boolean tryLock(String lockId) throws SQLException {
PreparedStatement stmt = conn.prepareStatement("INSERT INTO locks (id) VALUES (?)");
stmt.setString(1, lockId);
try {
stmt.executeUpdate();
return true;
} catch (SQLException e) {
if (e.getErrorCode() == 1062) { // 锁已存在
return false;
} else {
throw e;
}
}
}
// 释放锁
public void releaseLock(String lockId) throws SQLException {
PreparedStatement stmt = conn.prepareStatement("DELETE FROM locks WHERE id=?");
stmt.setString(1, lockId);
stmt.executeUpdate();
}
}
在使用时,通过创建一个DatabaseLock实例来获取和释放锁:
DatabaseLock databaseLock = new DatabaseLock();
// 尝试获取锁,获取成功返回true,获取失败返回false
if (databaseLock.tryLock("lockId")) {
try {
// do something
} finally {
databaseLock.releaseLock("lockId"); // 释放锁
}
} else {
// get lock failed
}
基于Redis的分布式锁
下面就来介绍基于Redis的分布式锁,直接上图;
虚拟机A和虚拟机B两个虚拟机都想对可变的共享资源(广义的概念,可以是数据库的某一张表和数据库中的某一行数据 ),就会出现线程安全问题,就需要基于锁模型实现同步互斥的手段,保证只有一个虚拟机中的线程进而实现这个线程的相对安全。现在虚拟机要对共享资源上锁,锁对象是Redis,操作的对象就是这个共享资源(假如数据库的一行数据)。
基于Redis实现分布式锁执行流程:
1虚拟机实例A根据Hash算法选择Redis节点(Redis采用的是集群部署,每个服务器都是一个节点),执行Lua脚本加锁(就是通过setnx方法,即set一个key(主键id)到Redis当中,判断Redis中是否有当前key,没有,就返回1,表示加锁成功,有就返回0,表示加锁失败),并且设置锁的过期时间。当虚拟机A对共享资源上锁成功过后,就拥有了对共享数据的操作权限,然后就可以对共享数据的操作处理,执行事务处理。
问题:在从Redis获取锁的过程和进行设置锁的过期时间过程中出现宕机,就会出现锁一辈子不会被释放?出现死锁问题?
这时候就需要保证获取锁和设置锁的过期时间两行代码的原子性,就是要么同时成功,要么同时失败,如何实现呢?
这时候只要保证将两行代码变成一行代码即可。
原本的setnx和expire是两行代码
if(jedis.setnx(lock_stock,1) == 1){ //获取锁
//=========在这里出现宕机了=====死锁问题出现了=========
expire(lock_stock,5) //设置锁超时
try {
业务代码
} finally {
jedis.del(lock_stock) //释放锁
}
}
通过set变成一行代码过后,解决了死锁问题。
if(set(lock_stock,1,"NX","EX",5) == 1){ //获取锁并设置超时
try {
业务代码
} finally {
del(lock_stock) //释放锁
}
}
2这时候如果虚拟机B也需要对共享资源进行操作,也去执行lua脚本进行加锁(就是采用setnx的方式--通过set一个key到redis,判断redis中是否已经存储了这个key(行数据的主键id)),如果查询到redis中没有,就会返回1表示加锁成功,如果有就会返回0表示加锁失败 ,这就能保证共享资源同一时间不会被多个虚拟机同时操作。
3当虚拟机A执行完对自己已经加锁的共享资源执行操作完成之后,必须要执行DEL释放锁,不然其他虚拟机包括虚拟机A都不能再对当前共享资源进行加锁操作,
问题:虚拟机A能保证会执行完成过后一定执行DEL释放锁吗? 答案是:不一定的
4当虚拟机A执行对共享资源事务操作完成之后,在执行DEL释放锁之前,代码出现问题,抛出异常就会出现这种问题,虚拟机A就永远不能执行DEL释放锁了,就会导致后续上锁都会失败。
问题:所以就出现上面这个执行流程,如何解决呢?
5这时候起初指执行lua脚本加锁的时候,存储一个过期时间,当不能主动进行DEL释放锁时,到达Redis设置的过期时间,锁就会过期。
这个时候又会出现另一个问题? 就是虚拟机A在设置的过期时间以内还没有执行完对共享资源的操作,锁就过期了,如何解决呢?
6这时候就会执行最后一个流程,后台守护线程(类似于Redission内部提供一个监控锁的看门狗),来定期的检查锁是否存在,如果存在,延长key的过期时间,还需要判断事务是否还在正常执行,如果是异常已经抛出异常,就不用进行后台守护线程了,然后等待锁自动过期。
说这么多?其实就是明白其执行原理,而实际开发过程中,大佬们已经使用Redission封装好了上面的具体实现细节。
Redission实现分布式锁【封装了基于Redis的分布式锁】
什么是Redission?
简单理解为就是操作Redis的一个工具包,让我们使用Redis更加简单,让使用者能够将精力更集中地放在处理业务逻辑上。
Redisson实现分布式锁
Redisson官方文档对分布式锁的解释总结下来有两点
1Redisson加锁自动有过期时间30s,监控锁的看门狗发现业务没执行完,会自动进行锁的续期(重回30s),这样做的好处是防止在程序还没有执行结束,锁自动过期被删除问题
2当业务执行完成不再给锁续期,即使没有手动释放锁,锁的过期时间到了也会自动释放锁。
// 获取锁
RLock lock = redisson.getLock(LOCK_KEY);
try {
// 加锁
lock.lock();
int s = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(REDIS_KEY)));
if (s > 0) {
// 扣库存
s--;
System.out.printf("秒杀商品个数剩余:" + s + "\n");
// 更新库存
stringRedisTemplate.opsForValue().set(REDIS_KEY, String.valueOf(s));
} else {
System.out.println("活动太火爆了,商品已经被抢购一空了!");
}
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + "异常:");
e.printStackTrace();
} finally {
// 释放锁
lock.unlock();
}
基于Zookeeper的分布式锁
什么是Zookeep?
ZooKeeper是一个分布式的协调服务,Zookeeper是基于CP,注重数据的一致性,若主机挂掉则Zookeeper不会对外进行提供服务了,需要选择一个新的Leader出来才能提供服务,不保证高可用性。简单来说zookeeper=文件系统+监听通知机制。
Zookeeper数据模型
Zookeeper会维护一个具有层次关系的树状的数据结构,它非常类似于一个标准的文件系统,如下图所示:同一个目录下不能有相同名称的
每个子目录项如 NameService 都被称作为 znode(目录节点),和文件系统一样,我们能够自由的增加、删除znode,在一个znode下增加、删除子znode,唯一的不同在于znode是可以存储数据的。
有四种类型的znode:
PERSISTENT-持久化目录节点
客户端与zookeeper断开连接后,该节点依旧存在
PERSISTENT_SEQUENTIAL-持久化顺序编号目录节点
客户端与zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号
EPHEMERAL-临时目录节点
客户端与zookeeper断开连接后,该节点被删除
EPHEMERAL_SEQUENTIAL-临时顺序编号目录节点
客户端与zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号
监听通知机制
客户端注册监听它关心的目录节点,当目录节点发生变化(数据改变、被删除、子目录节点增加删除)时,zookeeper会通知客户端。
实现方案:临时顺序目录节点+监听机制
在项目中可以使用curator,这个是Apache封装好的基于zookeeper的分布式锁方案。
总结
1基于数据库实现:通常基于主键,或者唯一索引来实现分布式锁,但是性能比较差,一般不建议使用
2基于Redis实现分布式锁:可以使用setnx来加锁 ,但是需要设置锁的过期时间来防止死锁,所以要结合expire使用.为了保证setnx和expire两个命令的原子性,可以使用set命令组合【将setnx和expire结合成一行代码】。
总之自己封装Redis的分布式锁是很麻烦的,我们可以使用Redissoin来实现分布式锁,Redissoin已经封装好了。
3.基于zookeeper : 使用临时顺序节点+监听实现,线程进来都去创建临时顺序节点,第一个节点的创建线程获取到锁,后面的节点监听自己的上一个节点的删除事件,如果第一个节点被删除,释放锁第二个节点就成为第一个节点,获取到锁。
在项目中可以使用curator,这个是Apache封装好的基于zookeeper的分布式锁方案。
一.简单使用
1、导入依赖
<!--使用redisson作为分布式锁-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
2、新建Redisson配置
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient(){
// 创建配置 指定redis地址及节点信息
Config config = new Config();
config.useSingleServer().setAddress("XXX.XX.XX.X(redis地址):端口").setPassword("xxxxxxxxx");
// 根据config创建出RedissonClient实例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
二.分布式锁
1、可重入锁
基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
@ResponseBody
@GetMapping("/hello")
public String hello() {
// 1.获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redisson.getLock("my-lock");
lock.lock(); // 阻塞式等待。默认加的锁是30s时间
try {
// 1、锁的自动续期,运行期间自动给锁续上新的30s,无需担心业务时间长,锁过期会自动被释放
// 2、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动释放锁,锁默认在30s后自动释放,避免死锁
System.out.println("加锁成功,执行业务代码..."+Thread.currentThread().getId());
Thread.sleep(30000);
} catch (Exception e) {
e.printStackTrace();
}finally {
System.out.println("释放锁..."+Thread.currentThread().getId());
lock.unlock();
}
return "Hello!";
}
结果:
加锁成功,执行业务代码...99
释放锁...99
可以发现,当我们的业务超长时,运行期间,redisson会为我们自动续期锁,业务执行完将不会续期,即使不手动释放锁,锁也会默认在30s后释放。
2、看门狗机制
开一个监听线程,如果方法还没执行完,就帮你重置 redis 锁的过期时间。
原理:
- 启动定时任务重新给锁设置过期时间,默认过期时间是 30 秒,每 10 秒(看门狗默认事件的1/3)续期一次(补到 30 秒)
- 如果线程挂掉(注意 debug 模式也会被它当成服务器宕机),则不会续期
- 只有lock.lock(); 会有看门狗机制;
- lock.lock(10,,TimeUnit.SECONDS);手动设置过期时间的话,则不会有看门狗机制(推荐)
3、读写锁
一次只有一个线程可以占有写模式的读写锁, 但是可以有多个线程同时占有读模式的读写锁.(该数据加写锁、读数据加读锁)
当读写锁是写加锁状态时, 在这个锁被解锁之前, 所有试图对这个锁加锁的线程都会被阻塞.
当读写锁在读加锁状态时, 所有试图以读模式对它进行加锁的线程都可以得到访问权, 但是如果线程希望以写模式对此锁进行加锁, 它必须直到所有的线程释放锁.
测试读写锁代码:
@Autowired
RedissonClient redisson;
@Autowired
RedisTemplate redisTemplate;
@ResponseBody
@GetMapping("/write")
public String writeValue(){
RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
RLock rLock = lock.writeLock();
String s = "";
try {
s = UUID.randomUUID().toString();
// 模拟业务时间
Thread.sleep(30000);
} catch (Exception e){
}finally {
rLock.unlock();
}
redisTemplate.opsForValue().set("writeValue",s);
return s;
}
@GetMapping(value = "/read")
@ResponseBody
public String readValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
//加读锁
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
s = (String) redisTemplate.opsForValue().get("writeValue");
TimeUnit.SECONDS.sleep(10);
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return s;
}
当我们访问 localhost:10000/write写入数据时,因为线程睡眠了30s(模拟业务),此时我们访问 localhost:10000/read 将会一直阻塞,等待写锁释放,读锁才能占锁从而获取执行业务。
4、信号量
信号量为存储在redis中的一个数字,当这个数字大于0时,即可以调用acquire()方法增加数量,也可以调用release()方法减少数量,但是当调用release()之后小于0的话方法就会阻塞,直到数字大于0。
可以应用于秒杀、限流等操作。
简单应用:
@GetMapping(value = "/park")
@ResponseBody
public String park() {
RSemaphore park = redisson.getSemaphore("park");
try {
park.acquire();// 获取一个信号量(redis中信号量值-1),如果redis中信号量为0了,则在这里阻塞住,直到信号量大于0,可以拿到信号量,才会继续执行。
} catch (InterruptedException e) {
e.printStackTrace();
}
return "ok";
}
@GetMapping(value = "/go")
@ResponseBody
public String go() {
RSemaphore park = redisson.getSemaphore("park");
park.release(); //释放一个信号量(redis中信号量值+1)
return "ok";
}
其中:redisson.getSemaphore("park").acquire() 当信号量为0时将会一直阻塞,直到信号量大于0,才会继续执行。但redisson.getSemaphore("park").tryAcquire() 将不会阻塞,能拿到信号量就返回true,否则返回false,lock.tryLock() 同理。
5、闭锁
在要完成某些运算时,只有其它线程的运算全部运行完毕,当前运算才继续下去。
模拟场景:
学校放假,学校门卫锁门必须等待所有班级全部离开,才将学校大门锁住。
@GetMapping(value = "/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
RCountDownLatch lockDoor = redisson.getCountDownLatch("lockDoor");
lockDoor.trySetCount(5); // 设置计数为5
lockDoor.await(); //等待闭锁完成
return "放假啦...";
}
@GetMapping(value = "/go/{id}")
public String go(@PathVariable("id") Integer id) {
RCountDownLatch lockDoor = redisson.getCountDownLatch("lockDoor");
lockDoor.countDown(); // 计数减1
return id+"班都走光了";
}
结果:我们先访问 /lockDoor 线程将会阻塞,连续访问五次 /go/1 ,输出 放假了。
三.缓存数据一致性解决方案
尽量给锁加上过期时间;对于读写状态时,应该加上分布式读写锁。