三种分布式锁实现方式

为什么要使用分布式锁

        在之前的单体架构中 , 面对线程安全的问题可能使用 Java 提供的 ReentrantLcok 或 Synchronized 即可。但是随着业务不断发展,这时单机满足不了,于是采用分布式部署的方式。虽然一定程度解决了性能的瓶颈 , 但是也带来了许多分布式相关的问题。就分布式锁而言,看如下图:

        单体应用部署结构:

三种分布式锁实现方式_第1张图片
        分布式部署架构:

三种分布式锁实现方式_第2张图片

         如上如所示,可以看出为什么需要分布式锁:

        在电商业务采用分布式架构后,程序部署在3个tomcat容器中(1个tomcat容器代表一个服务器,3个tomcat可理解在北京上海深圳都有部署电商服务),成员变量A代表商品数量。在北京的Alice,上海的Bob,深圳的Tom,都分别发起了购买或取消iPhone12的用户请求,经过Nginx负载均衡将Alice的请求发给了北京服务器,Bob的请求发给了上海服务器,Tom的请求发给了深圳服务器,这时候每台服务器都会对iPhone12这个商品数量进行更改,Alice的请求是将商品数量加到200,Bob的请求是将商品数量减少100,Tom的请求是将商品数量加1,如果对于商品数量的修改没有任何限制,整体就会乱起来,可能Bob得先减少,Tom的在增加,数据就完全乱了,所以需要分布式锁解决方案。

分布式锁需要具备哪些条件

         1. 获取锁和释放锁的性能要好

         2. 判断是否获得锁必须是原子性的,否则可能导致多个请求都获取到锁

         3. 网络中断或宕机无法释放锁时,锁必须被清除,不然会发生死锁。即:具备锁失效机制

         4. 具备可重入特性

分布式锁实现方式

        目前分布式锁的实现方案主要包括三种:

        1. 基于数据库(唯一索引)

        2. 基于缓存(Redis,memcached,tair)

        3. 基于Zookeeper

基于数据库的分布式锁

        1. 基于数据库表

        要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

        例如,我们需要对某个方法进行分布式锁操作。我们可以创建一个这样的表:

CREATE TABLE `methodLock` (
	`id` INT (11) NOT NULL AUTO_INCREMENT COMMENT '主键',
	`method_name` VARCHAR (64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
	`desc` VARCHAR (1024) NOT NULL DEFAULT '备注信息',
	`update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE     
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '锁定中的方法';

        当我们想要锁住某个方法时,执行以下SQL:

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’);

        因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

        当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:

delete from methodLock where method_name ='method_name';

        上面这种简单的实现有以下几个问题:

        (1)这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
        (2)这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
        (3)这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
        (4)这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

        2、基于数据库排他锁

        除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。

        我们还用刚刚创建的那张数据库表,将需要加锁的方法执行插入sql初始化到数据库中。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:

    public boolean lock(){
        connection.setAutoCommit(false)
        while(true){
            try{
                result = select * from methodLock where method_name=xxx for update;
                if(result==null){
                    return true;
                }
            }catch(Exception e){
     
            }
            sleep(1000);
        }
        return false;
    }

        在查询语句后面增加for update数据库会在查询过程中给数据库表增加排他锁(InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁

        我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

    public void unlock(){
        connection.commit();
    }

        通过connection.commit()操作来释放锁。这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题

        for update语句会对该行数据进行加锁操作,其他服务通过for update进行获取锁时会阻塞

        for update加的排它锁在服务器宕机后数据库会自己把锁释放掉

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

总结

       1. 使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。    

        2. 数据库实现分布式锁的优点:直接借助数据库,容易理解。

        3. 操作数据库需要一定的开销,性能问题需要考虑。

        4. 使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候

        5. 分布式效果最差的实现方式,不建议使用  

     

基于Redis的分布式锁

1.基于setnx、expire及del三个命令来实现

        (1)通过redis setnx(set if not exist)方式,当缓存里key不存在时,才会去set,否则直接返回false。如果返回true则获取到锁,否则获取锁失败,为了防止死锁,我们再用expire命令对这个key设置一个超时时间来避免。非原子实现

        可以如下命令来实现设置key及设置过期时间的原子操作

SET key_name my_random_value NX PX 30000

        操作可以看成setnxexpire的结合体,是原子性的。

        NX 表示if not exist 就设置并返回True,否则不设置并返回False。

        PX 表示过期时间用毫秒级, 30000 表示这些毫秒时间后此key过期。

        (2)执行完业务操作之后,删除该锁。

简单的java实现:

        获取锁:

/**
 * 获得锁
 */
 public boolean getLock(String lockId, long millisecond) {

     //非原子操作
     //Boolean result = redisTemplate.opsForValue().setIfAbsent(lockId, "lockValue");
     //redisTemplate.expire("lockKey", millisecond, TimeUnit.SECONDS);

    
    //原子操作
     Boolean success = redisTemplate.opsForValue().setIfAbsent(lockId, "lock",
                                         millisecond, TimeUnit.MILLISECONDS);
     return success != null && success;
 }

        setIfAbsent方法,就是当键不存在的时候,设置,并且该方法可以设置键的过期时间。

        释放锁:

public void releaseLock(String lockId) {
     redisTemplate.delete(lockId);
 }

        释放锁的操作比较简单,直接删除之前设置的键即可。其实,基于redis实现分布式锁的方式,在释放锁的时候,是存在释放失败的风险的(比如网路抖动什么的),这也是为什么在设置锁的时候需要设置过期时间的原因,可以防止在出现异常的时候,锁会自动的消失掉。

        但是这样设置的分布式锁并不是完美的,还是会有问题。举个例子:比如设置一个分布式锁的过期时间为10S,A线程过来拿到锁后执行该业务,需要执行15S(也说明锁过期时间没有遇预估好),那么在第10秒的时候锁就过期了,因此10S到15S期间,B线程进来就直接可以拿到锁了(此时A线程还没有释放锁,其实锁已经过期了),这时在B线程的执行期间,A线程执行完毕(15S到了),需要释放锁,但是此时的锁是B线程加的,结果被A线程给释放掉了,相当于B线程此时也没有锁了,那么如果其他线程进来也就可以直接拿到锁了。

        因此,上述例子中有两个问题:1.如何确保加锁和解锁的唯一性 2.如何避免锁过期

如何确保加锁和解锁的唯一性

        加锁时,value值设置为唯一值;在解锁的时候,需要验证value是和加锁的一致才删除key。

/**
 * 获得锁
 */
 public boolean getLock(String lockId, long millisecond) {
     String value = UUID.randomUUID().toString();
    
    //原子操作
     Boolean success = redisTemplate.opsForValue().setIfAbsent(lockId, value,
                                         millisecond, TimeUnit.MILLISECONDS);
     return success != null && success;
 }

/**
 * 删除锁
 */
public void releaseLock(String lockId, String value) {
    if(value.equals(redisTemplate.opsForValue().get(lockId))){
        redisTemplate.delete(lockId);
    }
 }

如何避免锁过期

        当某个线程获取到锁时,可以再开一个线程,该线程定时重新设置锁过期时间。定时时间可以设置为过期时间的三分之一,例如锁过期时间为30S,那么每隔10S就可以重新设置一次锁过期时间,还是设置为30S,避免出现锁过期的情况。

        那么上诉这样过后是不是就不会有任何问题了呢,其实不是的。如果是单机版的redis,那么上诉操作基本上就没啥大问题了,但是如果单点故障了呢?那redis直接不可用了,锁也就没用了。但是如果redis在哨兵模式下呢?

        上述方式只作用在一个redis节点上,redis在哨兵模式(单master节点)下如果这个master节点由于某些原因发生了主从切换,在redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,slave节点升级为master节点,导致锁丢失

2.基于Redisson来实现

代码实现
        引入maven依赖


    org.redisson
    redisson
    3.15.5

        单机模式下:

        初始化Redisson配置:

package com.redistext;

import org.redisson.Redisson;
import org.redisson.config.Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
@EnableCaching
public class RedistextApplication {

	public static void main(String[] args) {
		SpringApplication.run(RedistextApplication.class, args);
	}

	@Bean
	public Redisson redisson(){
		//此为单机模式
		Config config = new Config();
		config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
		return (Redisson)Redisson.create(config);
	}


}

       Redisson实现分布式锁:

package com.redistext.controller;

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private Redisson redisson;

    @GetMapping(value = "/get")
    public void userNum() {
        //获取一把锁,只要锁的名字一样,就是同一把锁
        RLock redissonLock = redisson.getLock("num");
        try {
            //尝试加锁,最多等待5秒,上锁以后10秒自动解锁。阻塞方法。
            redissonLock.tryLock(5,10, TimeUnit.SECONDS);
            int userNum = Integer.parseInt(stringRedisTemplate.opsForValue().get("num"));
            if (userNum > 0){
                int realUserNum = userNum -1;
                stringRedisTemplate.opsForValue().set("num", realUserNum + "");
                System.out.println("人数缩减成功, 当前人数:" + realUserNum);
            }else {
                System.out.println("人数不足,无法缩减");
            }
        } catch (Exception e) {
            redissonLock.unlock();
        }
    }

}

Redisson看门狗机制

      如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,假如一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,Redisson给出了自己的答案,就是 watch dog 自动延期机制。  

        Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。

        默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。看门狗会每隔10S重新给锁设置过期时间。

3.redis非单机模式下的分布式锁解决方案

        redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。redis作者antirez提出的redlock算法大概是这样的:

        在redis的分布式环境中,我们假设有N个redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在redis单实例下相同方法获取和释放锁。现在我们假设有5个redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

        这里需要说明一点上面描述中的N个redis master并不是在集群环境下的N个master,对于Redlock而言,单机、主从、集群都只能代表一个节点(一个master),因此如果需要N个redis master节点,那么就需要N个单机、N个cluster或N个sentinel集群。

            因此个人认为:能妥协一定可靠性的话,我会选择redis的set key value px 1000 ex实现分布式锁;否则,选zk。这种方式需要部署多套环境,比较麻烦。

        为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

代码实现
        引入maven依赖


    org.redisson
    redisson
    3.15.5

        假设有3个redis节点:

Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.0.1:5378")
        .setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.0.1:5379")
        .setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.0.1:5380")
        .setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

String resourceName = "REDLOCK_KEY";

RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 向3个redis实例尝试加锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
    // isLock = redLock.tryLock();
    // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
    isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    System.out.println("isLock = "+isLock);
    if (isLock) {
        //TODO if get lock success, do something;
    }
} catch (Exception e) {
} finally {
    // 无论如何, 最后都要解锁
    redLock.unlock();
}

基于Zookeeper的分布式锁

        关于zookeeper的介绍这里不做过多赘述。

 大致原理

        使用zookeeper的临时有序节点,每个线程获取锁就在zookeeper创建一个临时有序的节点,比如在/lock目录下。

        创建节点成功后,获取/lock目录下的所有临时节点,再判断当前线程创建的节点是否是所有的节点的序号最小的节点;如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功;如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。

        比如当前线程获取到的节点序号为/lock/003,然后所有的节点列表为[/lock/001,/lock/002,/lock/003],则对/lock/002这个节点添加一个事件监听器。如果锁释放了,会唤醒下一个序号的节点,然后重新执行判断是否自己的节点序号是最小。比如/lock/001释放了,/lock/002监听到事件,此时节点集合为[/lock/002,/lock/003],则/lock/002为最小序号节点,获取到锁。

 Curator框架实现分布式锁

        原生java API实现分布式锁,可看Zookeeper入门介绍_有个疙叫瘩儿的博客-CSDN博客

        那么原生java API实现分布式锁存在的问题有哪些呢?

        1. 会话连接是异步的,需要自己去处理。比如使用CountDownLatch。

        2. Watch需要重复注册,不然不能生效。

        3.开发复杂性较高。

        4.不支持多节点删除和创建,需要自己去递归。

        Curator是一个专门解决分布式锁的框架,解决了原生java API开发分布式遇到的问题。

java实现

        pom依赖:

		
			org.apache.curator
			curator-framework
			4.3.0
		

		
			org.apache.curator
			curator-recipes
			4.3.0
		

		
			org.apache.curator
			curator-client
			4.3.0
		

        代码实现:

package com.redistext.controller;

import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;

/**
 * @author: maorui
 * @description: TODO
 * @date: 2021/10/21 15:01
 * @description: V1.0
 */
public class CuratorTest {


    public static void main(String[] args) {

        InterProcessMutex lock1 = new InterProcessMutex(getCuratorFramework(),"/locks");

        InterProcessMutex lock2 = new InterProcessMutex(getCuratorFramework(),"/locks");

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    lock1.acquire();
                    System.out.println("线程1,获取到锁");

                    lock1.acquire();
                    System.out.println("线程1,再次获取到锁");

                    Thread.sleep(1000);

                    lock1.release();
                    System.out.println("线程1,释放锁");

                    lock1.release();
                    System.out.println("线程1,再次释放锁");
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    lock2.acquire();
                    System.out.println("线程2,获取到锁");

                    lock2.acquire();
                    System.out.println("线程2,再次获取到锁");

                    Thread.sleep(1000);

                    lock2.release();
                    System.out.println("线程2,释放锁");

                    lock2.release();
                    System.out.println("线程2,再次释放锁");
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }
        }).start();

    }

    private static CuratorFramework getCuratorFramework(){
        ExponentialBackoffRetry exponentialBackoffRetry = new ExponentialBackoffRetry(3000, 3);
        CuratorFramework client = CuratorFrameworkFactory.builder().connectString("10.192.140.1:2181")
                .connectionTimeoutMs(2000)
                .sessionTimeoutMs(2000)
                .retryPolicy(exponentialBackoffRetry).build();

        client.start();
        System.out.println("zookeeper 启动成功");
        return client;
    }

}

        测试结果:

三种分布式锁实现方式_第3张图片

 

总结

对于redis的分布式锁而言,它有以下缺点:

  • 它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁比较消耗性能
  • 另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮,即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题
  • 但是另一方面使用redis实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的极端复杂场景,所以使用redis作为分布式锁也不失为一种好的方案,最重要的一点是redis的性能很高,可以支撑高并发的获取、释放锁操作
  • redis获取锁的客户端如果挂了,那么只能等到超时时间到之后才能释放锁

对于zk分布式锁而言:

  • zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁
  • 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小
  • 但是如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大
  • 如果获取锁的客户端挂了,因为zk中创建的是临时znode,只有客户端挂了,znode就没了,锁就释放了。

       怎么选用要看具体的实际场景了,如果公司里面有zk集群条件,优先选用zk实现,但是如果说公司里面只有redis集群,没有条件搭建zk集群。那么其实用redis来实现也可以,另外还可能是系统设计者考虑到了系统已经有redis,但是又不希望再次引入一些外部依赖的情况下,可以选用redis

参考文档:

        浅谈分布式锁--基于数据库实现篇_Eric的博客-CSDN博客

 什么是分布式锁? 为啥需要分布式锁?_AKALXH的博客-CSDN博客_分布式锁是为了解决什么问题

        Java分布式锁看这篇就够了 - seesun2012 - 博客园

        Java基于redis实现分布式锁(SpringBoot) - happyjava - 博客园

        Redlock:Redis分布式锁最牛逼的实现 - 简书

        可不敢吹自己会Redis-02 - 三斤的博客 | ThreeJin Blog

        Redis与Zookeeper实现分布式锁的区别 - __Meng - 博客园

你可能感兴趣的:(分布式学习笔记,memcached,数据库,java)