分布式解决方案-分布式锁的实现方案

0.215字数 5205阅读 1264

如未做特殊说明,本文均为原创,转载请注明出处。

[TOC]

前言
为什么要使用分布式锁呢?

Nginx实现负载均衡服务器集群时会产生很多问题,在提高并发的同时,服务器也会产生非常多的问题例如,这些问题应该一一的考虑到。

  • 分布式Session一致性
  • 分布式全局ID生成方案
  • 分布式事务解决方案
  • 分布式任务调度平台
  • 分布式配置中心
  • 分布式锁多种实现方案
  • 分布式日志收集系统
  • 各种网站跨域请求解决方案
  • 高并发下服务降级与限流实战
  • ……

本次就先以分布式锁来探讨一下分布式场景下的使用与注意事项,和为什么要使用分布式锁。


为什么要使用分布式锁

分布锁产生的原因

​ 因为服务器使用了集群方案。

那么在单台服务器上如何生成订单号(保证唯一)

​ UUID、时间戳、redis等。。。

其实有一种取巧的解决方案,可以适当的避免突然的高并发下的服务宕机(或反应慢)的尴尬场景。

--可以使用redis提前生成150W订单号(假设是在下单场景下)。

为什么要用redis去生成订单号?

​ 提前生成好150W订单号,存放在redis中,当客户端下单之后,直接到redis中取对应的订单号即可,因为redis本身是单线程的,如果redis还只剩下50W个订单号的时候,在继续生产100W个订单号。(这种技巧视业务场景而定,一般我们在面对需求或者发生的问题时,可以从根源下手,这种情况明显就是针对在高并发场景下能一定程度的提高程序的响应速度。提高了整个程序的健壮性。)

那么如果在集群环境下使用UUID的方式安全嘛?

​ 答案是肯定的,肯定时不安全的,大概率是会出现重复的ID的。所以这个时候就提出了分布式锁。

​ 可想而知,在多线程下我们可以简单的使用Lock或者synchronized来实现安全性,但是在分布式场景下,这些就显得力不从心了。因为这个时候在微服务场景下,很有可能同一个服务分别安装在不同的服务器上,这样的话,传统的加锁方式根本不可能办证数据的最终一致性。所以分布式锁就诞生了。

​ 在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同

​ 一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单

​ 纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案:

​ 分布式锁一般有三种实现方式:1. 数据库锁;2. 基于redis的分布式锁;3. 基于Zookeeper的分布式锁

分布式锁应该是怎么样的

  • 互斥性 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
  • 这把锁要是一把可重入锁(避免死锁)
  • 不会发生死锁:有一个客户端在持有锁的过程中崩溃而没有解锁,也能保证其他客户端能够加锁
  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
  • 有高可用的获取锁和释放锁功能
  • 获取锁和释放锁的性能要好

数据库锁

基于数据库表

​ 要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。

当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

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、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

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

  • 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个while循环,直到insert成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

这样你是不是觉得离我们分布式微服务宗旨越来越远了呢?😄

基于数据库的排它锁

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

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

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

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

通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

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

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

总结:

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

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

**数据库实现分布式锁的缺点: **会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。

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

乐观锁

乐观锁假设认为数据一般情况下不会造成冲突,只有在进行数据的提交更新时,才会检测数据的冲突情况,如果发现冲突了,则返回错误信息

实现方式:

时间戳(timestamp)记录机制实现:给数据库表增加一个时间戳字段类型的字段,当读取数据时,将timestamp字段的值一同读出,数据每更新一次,timestamp也同步更新。当对数据做提交更新操作时,检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,若相等,则更新,否则认为是失效数据。

若出现更新冲突,则需要上层逻辑修改,启动重试机制

同样也可以使用version的方式。

性能对比

(1) 悲观锁实现方式是独占数据,其它线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是依赖数据库的实现,且在线程较多时出现等待造成效率降低的问题。一般情况下,对于数据很敏感且读取频率较低的场景,可以采用悲观锁的方式

(2) 乐观锁可以多线程同时读取数据,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取频率很高而修改频率较少的场景

(3) 由于库存回写数据属于敏感数据且读取频率适中,所以建议使用悲观锁优化

基于redis的分布式锁

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  • 第一个为key,我们使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑redis单机部署的场景,所以容错性我们暂不考虑。

错误实例:

使用jedis.setnx()jedis.expire()组合实现加锁

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {  
   Long result = jedis.setnx(lockKey, requestId);   
 if (result == 1) { 
       // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁        jedis.expire(lockKey, expireTime);   
     }
 }

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

解锁:

首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)

总结:

可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。

使用缓存实现分布式锁的优点

性能好,实现起来较为方便。

使用缓存实现分布式锁的缺点

通过超时时间来控制锁的失效时间并不是十分的靠谱。

基于Zookeeper实现分布式锁

​ 基于Zookeeper临时有序节点可以实现的分布式锁。大致思想即为:每个客户端对某个方法加锁时,在Zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

​ 原理:zookeeper是利用了自身临时节点且唯一性的优势,可以很好的实现分布式锁机制。当多个服务器同时访问同一个需要安全同步对象时,这个时候可以使用zookeeper实现分布式锁,在多个服务器访问的情况下有一个前提就是需要获取zookeeper下的节点对象,但是创建该节点只能是唯一的且为临时的(当访问结束,断开连接时会被自动释放(删除))。所以在多个服务器同时访问的情况下,只能有一个服务器创建成功该临时节点,这样一来就可以解决在分布式场景下,数据被多台服务器访问的一致性问题。

代码如下:

首先定义一个顶层接口Lock

/**
 * zookeeper 分布式锁 顶层对象
 *
 * @author by Assume
 * @date 2019/3/30 17:04
 */
public interface Lock {
    /**
     * 获取锁
     */
    public void getLock();
    /**
     * 解锁
     */
    void unLock();
}

定义实现Lock接口的抽象类ZookeeperAbstractLock


/**
 * 获取锁 抽象类,利用装饰着模式,让子类具体实现。
 *
 * @author by Assume
 * @date 2019/3/30 17:06
 */
public abstract class ZookeeperAbstractLock implements Lock {
    private static final String ADDR = "127.0.0.1:2181";
    // 创建zk连接
    protected ZkClient zkClient = new ZkClient(ADDR);
    // 定义zookeeper 需要创建的节点
    protected static final String PATH = "/lock";
    @Override
    public void getLock() {
        if (tryLock()) {
            System.out.println("-----获取锁成功");
        } else {
            // 等待
            waitLock();
            // 重新获取锁资源
            getLock();
        }
    }
    // 等待
    abstract void waitLock();
    // 重新获取锁资源
    abstract boolean tryLock();
    /**
     * 释放锁
     */
    @Override
    public void unLock() {
        if (zkClient != null) {
            zkClient.close();
            System.out.println("---释放资源成功");
        }
    }
}

使用装饰者模式来具体实现该类的抽象方法,ZookeeperDistrbuteLock

/**
 * 使用装饰着模式,子类实现未完成的接口
 *
 * @author by Assume
 * @date 2019/3/30 17:12
 */
public class ZookeeperDistrbuteLock extends ZookeeperAbstractLock {
    // 定义信号量,实现节点动作的监听 等待与唤醒
    private CountDownLatch countDownLatch = null;

    @Override
    boolean tryLock() {
        try {
            // 创建临时节点
            zkClient.createEphemeral(PATH);
            return true;
            // 如果该节点已经存在,默认情况下会报错,则证明该节点已被加锁,需要等待
        } catch (Exception e) {
            // 节点已经被创建
            return false;
        }
    }
    @Override
    void waitLock() {
        // 等待,需要监听节点的变化情况,接听节点被删除
        IZkDataListener iZkDataListener = new IZkDataListener() {
            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {}
            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                // 节点被删除,唤醒等待的线程
                if (countDownLatch != null) {
                    countDownLatch.countDown();
                }
            }
        };
        // 注册事件
        zkClient.subscribeDataChanges(PATH, iZkDataListener);
        // 判断该节点是否存在
        if (zkClient.exists(PATH)) {
            countDownLatch = new CountDownLatch(1);
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 删除事件
        zkClient.unsubscribeDataChanges(PATH, iZkDataListener);
    }
}
UML

具体加锁小试牛刀😊

/**
 * @author by Assume
 * @date 2019/3/30 17:25
 */
public class OrderService implements Runnable {
    private Lock lock = new ZookeeperDistrbuteLock();
    @Override
    public void run() {
        getNum();
    }
    private void getNum() {
        try {
            // 使用zookeeper 加锁
            lock.getLock();
            System.out.println("生成订单:" + Thread.currentThread().getName() + UUID.randomUUID());
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unLock();
        }
    }
    public static void main(String[] args) {
        System.out.println("####生成唯一订单号###");
        for (int i = 0; i < 100; i++) {
//            new Thread(() -> new OrderService()).start();
            new Thread(new OrderService()).start();
        }
    }
}

来看下Zookeeper能不能解决前面提到的问题。

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。(但是这张情况下,会产生一点的延时。)

  • 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。

  • 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。

  • 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。可以直接使用Zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。

因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

**使用Zookeeper实现分布式锁的优点: **有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。

**使用Zookeeper实现分布式锁的缺点 : **性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。

推荐阅读更多精彩内容