分布式锁的三种解决方案 数据库、redis、zookeeper

为什么要使用分布式锁

加锁,我们都知道,就是为了在并发环境下,使一段代码在同一时间只能有一个线程执行。我们通常通过synchronized或ReentrantLock来实现加锁。比如:

synchronizedthis{
	//业务逻辑代码
}

这样的代码在单机环境下是可行的,但是不管是synchronized还是ReentrantLock都是JVM层面的,所以在分布式环境下,就不能这么写。

加锁的本质

其实加锁的本质都是在某个地方打一个标志,这个标志必须所有线程可见,线程在执行一块同步代码的时候,需要先打标志,如果标志已经存在,则需要等待拥有标志的线程结束同步代码块后取消标志。
不同加锁方式,打标志的方式也不同,但都必须满足所有线程能够看见的条件。如synchronized是在对象头打标志,Lock接口的实现类基本上都有一个由volitile修饰的int型变量,来保证每个线程都能拥有该int的可见性和原子性修改。Linux内核中也是利用互斥量或信号量等内存数据做标记。

知道了加锁的本质,我们就更清楚问什么synchronized和ReentrantLock都只能在单机下使用了,因为它们打的标志都是在当前JVM进程的内存中,对于其他JVM进程的线程并不可见。

那么在分布式环境下,应该怎么加锁的答案也就鱼跃而出了。我们需要将标志打在一个所有JVM进程都能看得见的地方。

分布式锁的解决方案

基于数据库做分布式锁

基于表主键唯一做分布式锁

思路:利用数据库主键唯一的特性,多个请求同时提交到数据库时,只有一个操作会成功,我们认为这个操作对应的线程获取了该方法的锁,当方法执行完毕后,删掉这条记录,来释放锁。

该方法存在下面几种问题:

  • 这把锁强依赖数据库,数据库是一个单点,一旦数据库挂掉,则会导致业务系统不可用
  • 这把锁没有失效时间,一旦解锁失败,锁记录一直在数据库中,其他线程将无法再获取锁
  • 这把锁只能是非阻塞的,因为数据的插入失败后,就会直接报错,没有获得锁的线程不会进入排队队列,想要再次获得锁就要再次触发获得锁的操作
  • 这把锁是非重入锁,同一线程在没有释放锁前无法再次获得该锁,因为锁记录已经存在数据库中
  • 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁
  • 在MySQL数据库中采用主键冲突防重,在大并发情况下有可能造成锁表现象

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。(自旋很占CPU)
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
  • 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
  • 比较好的办法是在程序中生产主键进行防重。

说句实话,这个方法问题多,实现起来也麻烦,了解一下思路就可以了。

基于表字段版本号做分布式锁

类我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。

基于数据库排他锁做分布式锁

在查询语句后面添加for update,数据库会在查询过程中添加排他锁,但要注意InnoDB引擎在只有使用索引进行检索的时候,才会使用行级锁,否则会使用表级锁。所以如果我们需要使用行级锁,我们就需要为执行的方法字段添加索引。

我们认为获得排他锁即获得分布式锁,获得锁后,才可以执行方法的业务逻辑,执行方法后,通过connection.commit()操作来释放锁。

排他锁解决了阻塞锁和无法释放锁的问题:

  • 阻塞锁?for update语句执行成功后立即返回,在执行失败时一直处于阻塞状态,知道成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点问题和可重入问题。

这里可能还存在俩个问题:

  • 即使我们为执行的字段添加了唯一索引,但是MYSQL是根据执行计划来决定是否走索引的,如果MySQL认为走索引的效率还没有全表扫描快,那么它就不会走索引,这种情况下使用的就是表锁而不是行锁。
  • 如果排他锁长时间不提交,就会一直占用数据库的连接,一旦类似的连接变多了,就可能把数据库连接池撑爆。

优点: 简单,容易理解

缺点: 会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)

基于Redis做分布式锁

我们可以使用redis的setnxexpire命令来实现分布式锁。

setnx:向redis中存放一个键值对,key不存在执行成功返回1,key存在返回0。我们认为setnx执行成功,即拿到分布式锁。

expire: 给指定键设置过期时间。可以用来做自动释放锁。

原理: 在执行业务代码之前,先使用setnx往redis中保存一个键值对,执行成功则获取分布式锁,在业务执行完后,再删除这个键值对,从而释放锁。

我们要求setnxexpire必须是一个原子操作,避免setnx执行后,程序宕机,expire没执行,或者delete执行之前程序宕机,导致锁无法被释放。整合Spring Boot后的代码如下:

//        stringRedisTemplate.opsForValue().setIfAbsent(productKey, "1");
//        stringRedisTemplate.expire(productKey,10,TimeUnit.SECONDS);
		//合并了上面俩条语句,使它们成为一个原子操作
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);//超时时间为10秒
        try {
            if (result){
                // 获取锁成功,执行业务代码
            }else{
            	//获取锁失败
			}
        } finally {
            // 释放锁
            stringRedisTemplate.delete("lockKey");
        }
    }

使用这种方法,已经可以满足大多数普通的并发场景了,但是对于高并发的场景还是会出现一些问题。

当业务代码还未执行完时,锁自动失效了,导致误删除别人锁的情况,如下图:
在这里插入图片描述
线程1的锁在10秒后,自动失效,此时线程2成功加锁。线程1在5秒后业务代码执行完删除锁,但此时的锁是线程2加的,这就出现了误删的情况。

当然,我们可以使用一个唯一标识来判断是不是自己加的锁,如下:

	//唯一标识
     String clientId = UUID.randomUUID().toString();
     try {
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);//超时时间为30秒
   	 	if (result){
             // 获取锁成功,执行业务代码
         }else{
         	//获取锁失败
		}
     } finally {
         // 自己释放自己的锁
         if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))){
             stringRedisTemplate.delete(lockKey);
         }
     }

虽然这样做可以防止删除别人加的锁,但是仍然无法避免锁在业务完成前失效,导致其他线程提前获取到锁。

我们可以使用锁续命技术来解决这个问题。

锁续命技术:假设锁的过期时间是30秒,那么开一个线程,每过10秒(30/3)对锁进行一次检查,如果锁还存在,就重新将过期时间设置为30秒,如果锁不存在,就是被释放了呗。

如何实现?一个优秀的框架redision已经帮我们实现了。

redission

使用步骤:

1、导入依赖

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.6.5</version>
</dependency>

2、编写配置类

 @Bean(destroyMethod="shutdown")
 public RedissonClient redisson() {
 		// 此为单机模式
        Config config = new Config();
  		config.useSingleServer().
      		javasetAddress("redis://118.190.160.249:6379").setDatabase(0);
        // 集群版本
        /*config.useClusterServers()
                .addNodeAddress("redis://192.168.0.61:8001")
                .addNodeAddress("redis://192.168.0.62:8002")
                .addNodeAddress("redis://192.168.0.63:8003")
                .addNodeAddress("redis://192.168.0.61:8004")
                .addNodeAddress("redis://192.168.0.62:8005")
                .addNodeAddress("redis://192.168.0.63:8006");*/
        return (Redisson) Redisson.create(config);
 }

3、使用RedissonClient客户端

 @Autowired
 RedissonClient redisson;

 RLock redissonLock = redisson.getLock(lockKey);
 try {
     //加锁,并实现锁续命功能
     redissonLock.lock();
   	 //业务代码...
 } catch (Exception e) {
     e.printStackTrace();
 } finally {
 	//释放锁
     redissonLock.unlock();
 }
}

使用redisson,可以向使用ReentrantLock一样来加锁解锁,很方便。

基于Zookeeper做分布式锁

首先回顾一下Zookeeper结点的四种类型:

  1. 持久节点: 默认节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。
  2. 持久顺序节点: 所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号
  3. 临时节点: 与持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。
  4. 临时顺序节点: 在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。

Zookeeper分布式锁的原理

原理: 利用zk的临时有序节点,以及监听节点删除事件。

具体步骤:

  1. 客户端连接zk,在/lock中创建临时有序节点,第一个客户端对应的临时节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。
  2. 当节点删除事件触发时,客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听自己前一位子节点的变更消息,获得子节点变更通知后重复此步骤直至获得锁;
  3. 执行业务代码;
  4. 完成业务流程后,删除对应的子节点释放锁。

几个注意点提一下:

  • 为什么使用临时节点而不是持久节点?为了避免加锁成功的客户端宕机,导致锁不能被释放。临时节点客户端断开连接后,会自动被删除。
  • 为什么要使用顺序节点?为了避免出现羊群效应,假设所有的客户端都监听同一个节点,当这个节点被删除后,就会使所有等待中的客户端蜂拥而入,从而造成瞬间的高并发。使用顺序节点后,每个节点都只监听它前一个节点,这样就可以一个一个被唤醒。
  • Zookeeper可能带来并发问题。如果由于网络原因,使zk误以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁,就可能尝试并发问题。但这种问题不常见,因为zk有重试机制,并不会马上删除临时节点。

与redis一样,zookeeper也有一个优秀的框架curator可以简单的实现分布式锁。但其实我对zookeeper并不是很了解,所以关于zookeeper这块的内容以后有机会再补充。

总结

Redis

优点:性能高

缺点:如果没有获取到锁,需要不断的自旋尝试获取锁,比较消耗性能

zookeeper

优点:获取不到锁,只需注册个监听器即可,不需要不断尝试获取锁,性能开销较小。

缺点:性能较低。因为加锁和释放锁都是通过动态创建、销毁节点实现的

Mysql

虽然容易理解,实现起来复杂,问题还挺多,性能不也行,我想应该很少用吧。

本篇博客内容概述:
在这里插入图片描述
脑图链接地址

参考文章
https://www.cnblogs.com/seesun2012/p/9214653.html
https://mp.weixin.qq.com/s/ZqQHWLfVD1Rz1agmH3LWrg

是博客亦是日记
用博客打造属于自己的知识体系
记录自己的成长

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值