集群多JVM分布式锁实现

38次阅读

共计 7098 个字符,预计需要花费 18 分钟才能阅读完成。

基于数据库表乐观锁 (根本废除)

要实现分布式锁,最简略的⽅形式可能就是间接创立⼀一张锁表,而后通过操作该表中的数据来实现了了。
当咱们要锁住某个⽅法或资源时,咱们就在该表中减少一条记录,想要开释锁的时候就删除这条记录。
比方创立这样一张数据库表:

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 胜利再返回胜利。
  • 针对⾮重入的? 咱们能够在数据库表中加个字段,记录以后取得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果以后机器的主机信息和线程信息在数据库能够查到的话,间接把锁调配给他就能够了。
  • 基于数据库排他锁 除了能够通过增删操作数据表中的记录以外,其实还能够借助数据中自带的锁来实现分布式的锁。咱们⽤刚刚创立的那张数据库表。能够通过数据库的排他锁来实现分布式锁。基于 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,数据库会在查问过程中给数据库表减少排他锁。当某条记录被加上排他锁之后,其余线程将无奈再在该行行记录上减少排他锁。
咱们能够认为取得排它锁的线程即可取得分布式锁,当获取到锁之后,能够执⾏办法的业务逻辑,执行完之后,通过 connection.commit()操作来开释锁。这种办法能够无效的解决上⾯提到的⽆法开释锁和阻塞锁的问题。
阻塞锁? for update 语句会在执行胜利后⽴即返回,在执行失败时⼀直处于阻塞状态,直到胜利。锁定之后 服务宕机,⽆法开释? 使⽤这种⽅式,服务宕机之后数据库会本人把锁开释掉。然而还是⽆法间接解决数据库单点和可重⼊问题。

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

说了这么多,咱们总结下数据库形式实现。

总结 这两种形式都是依赖数据库的一张表,一种是通过表中的记录的存在状况确定以后是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
长处: 间接借助数据库,容易了解。
毛病: 会有各种各样的问题,在解决问题的过程中会使整个⽅案变得越来越简单。操作数据库须要肯定的开销,性能问题也须要思考。

Redis 实现分布式锁

redis 实现分布式锁在电商开发中是应用的较为成熟和广泛的一种形式,利用 redis 自身个性及锁个性。如高性能 (加、解锁时高性能),能够应用阻塞锁与非阻塞锁。不能呈现死锁。通过搭建 redis 集群高可用性(不能呈现节点 down 掉后加锁失败)。
尝试写伪代码减少了解,咱们先看这种形式的分布式锁如何抢占。

    /**
     * @param key 锁的 key
     * @param lockValue 锁的 value
     * @param timeout 容许获取锁的工夫,超过该工夫就返回 false
     * @param expire key 的缓存工夫,也即一个线程⼀次持有锁的工夫,* @param sleepTime 获取锁的线程循环尝试获取锁的间隔时间
     * @return
     */
    public boolean tryLock(String key, String lockValue, Integer timeout, Integer
            expire, Integer sleepTime) {int st = (sleepTime == null) ? DEFAULT_TIME : sleepTime; // 容许获取锁的工夫,默认 30 秒
        int expiredNx = 30;
        final long start = System.currentTimeMillis();
        if (timeout > expiredNx) {timeout = expiredNx;}
        final long end = start + timeout * 1000; // 默认返回失败
        boolean res ;
        // 如果尝试获取锁的工夫超过了了容许工夫,则间接返回
        while (!(res = this.lock(key, lockValue, expire))) {if (System.currentTimeMillis() > end) {break;}
            try {// 线程 sleep,防止适度申请 Redis,该值能够调整 Thread.sleep(st);
            } catch (InterruptedException e) {​}
        }
        return res;
    }

上⾯的探讨中咱们有一个⾮常重要的假如:Redis 是单点的。如果 Redis 是集群模式,咱们思考如下场景:
客户端 1 和客户端 2 同时持有了同一个资源的锁,锁不再具备安全性。根本原因是 Redis 集群不是强⼀致性的。
那么怎么保障强⼀致性呢—Redlock 算法
假如客户端 1 从 Master 获取了锁。这时候 Master 宕机了,存储锁的 key 还没有来得及同步到 Slave 上。Slave 降级为 Master。客户端 2 从新的 Master 获取到了对应同一个资源的锁。
redLock 实现步骤:

  1. 客户端获取以后工夫,以毫秒为单位。客户端尝试获取 N 个节点的锁,(每个节点获取锁的⽅式和后面说的缓存锁⼀样),N 个节点以雷同的 key 和 value 获取锁。客户端须要设置接⼝拜访超时,接⼝超时工夫须要远小于锁超时工夫,⽐如锁⾃动开释的工夫是 10s,那么接口超时⼤概设置 5 -50ms。这样能够在有 redis 节点宕机后,拜访该节点时能尽快超时,而减⼩锁的失常使⽤。
  2. 客户端统计计算在取得锁的时候破费了多少工夫,以后工夫减去在获取的工夫,只有客户端 取得了超过 3 个节点的锁,⽽且获取锁的工夫⼩于锁的超时工夫,客户端才取得了了分布式锁。
  3. 客户端获取锁的工夫为设置的锁超时工夫减去步骤三计算出的获取锁破费工夫。
  4. 如果客户端获取锁失败了,客户端会顺次删除所有的锁。使⽤用 Redlock 算法,能够保障在挂掉最多 2 个节点的时候,分布式锁服务依然能⼯工作,这相比之前的数据库锁和缓存锁⼤大进步了可用性,因为 redis 的高效性能,分布式缓存锁性能并不比数据库锁差。

然而这种方法就浑然一体吗?毛病在哪里?

  • 招架不住 Full GC 带来的锁超时问题,Redlock 仅仅能绝对提⾼可靠性。

假如客户端 1 在取得锁之后产生了很长时间的 GC pause,在此期间,它取得的锁过期了,⽽客户端 2 取得了锁。当客户端 1 从 GC pause 中恢复过来的时候,它不晓得⾃己持有的锁曾经过期了,它仍然发动了写数据申请,⽽这时锁实际上被客户端 2 持有,因而两个客户端的写申请就有可能抵触(锁的互斥作⽤生效了)。

  • 因为必须获取到 5 个节点中的 3 个以上,所以可能呈现获取锁抵触,即大家都取得了 1 - 2 把锁,后果谁也不能获取到锁,这个问题,redis 作者借鉴了了 raft 算法的精华,通过抵触后在随机工夫开始,能够大大降低抵触工夫,然而这问题并不能很好的防止,特地是在第⼀次获取锁的时候,所以获取锁的工夫成本增加了了。如果 5 个节点有 2 个宕机,此时锁的可用性会极大升高,⾸先必须期待这两个宕机节点的后果超时能力返回,另外只有 3 个节点,客户端必须获取到这全副 3 个节点的锁能力领有锁,难度也加⼤了。如果呈现网络分区,那么可能呈现客户端永远也⽆法获取锁的状况。

长处: 性能好
毛病: ⽆法保障强⼀致性 (即能承受局部数据失落)

Zookeeper 实现分布式锁

原理
多个过程内同一时间都有线程在执行办法 m,那么锁就一把,你取得了锁得以执行,我就得被阻塞,那你执行完了怎么来唤醒我呢?因为你并不知道我被阻塞了,你也就不能告诉我 ” 嗨,小橘,我用完了,你用吧 “。你能做的只有用的时候设置锁标记,用完了再勾销你设置的标记。我就必须在阻塞的时候隔一段时间被动去看看,但这样总归是有点麻烦的,最好有人来告诉我能够执行了。
而 zookeeper 对于本身节点的两大个性解决了这个问题

  • 监听者提供事件告诉性能
  • znode 节点的不可反复个性


节点是什么?
节点是 zookeeper 中数据存储的根底构造,zk 中万物皆节点,就好比 java 中万物皆对象是一样的。zk 的数据模型就是基于好多个节点的树结构,但 zk 规定每个节点的援用规定是门路援用。每个节点中蕴含子节点援用、存储数据、拜访权限以及节点元数据等四局部。

zk 中节点有类型辨别吗?
有。zk 中提供了四种类型的节点,各种类型节点及其区别如下:

长久节点(PERSISTENT):节点创立后,就始终存在,直到有删除操作来被动革除这个节点
长久程序节点(PERSISTENT_SEQUENTIAL):保留长久节点的个性,额定的个性是,每个节点会为其第一层子节点保护一个程序,记录每个子节点创立的先后顺序,ZK 会主动为给定节点名加上一个数字后缀(自增的),作为新的节点名。
长期节点(EPHEMERAL):和长久节点不同的是,长期节点的生命周期和客户端会话绑定,当然也能够被动删除。
长期程序节点(EPHEMERAL_SEQUENTIAL):保留长期节点的个性,额定的个性如长久程序节点的额定个性。

如何操作节点?
节点的增删改查别离是 createdeletesetDatagetData,exists 判断节点是否存在,getChildren 获取所有子节点的援用。

下面提到了节点的监听者,咱们能够在对 zk 的节点进行查问操作时,设置以后线程是否监听所查问的节点。getData、getChildren、exists 都属于对节点的查问操作,这些办法都有一个 boolean 类型的 watch 参数,用来设置是否监听该节点。一旦某个线程监听了某个节点,那么这个节点产生的 creat(在该节点下新建子节点)、setData、delete(删除节点自身或是删除其某个子节点)都会触发 zk 去告诉监听该节点的线程。但须要留神的是,线程对节点设置的监听是一次性的,也就是说 zk 告诉监听线程后须要改线程再次设置监听节点,否则该节点再次的批改 zk 不会再次告诉。

实现

  • 计划一:应用节点中的存储数据区域,zk 中节点存储数据的大小不能超过 1M,然而只是寄存一个标识是足够的。线程取得锁时,先查看该标识是否是无锁标识,若是可批改为占用标识,应用完再复原为无锁标识。

  • 计划二:应用子节点,每当有线程来申请锁的时候,便在锁的节点下创立一个子节点,子节点类型必须保护一个程序,对子节点的自增序号进行排序,默认总是最小的子节点对应的线程取得锁,开释锁时删除对应子节点便可。


两种计划其实都是可行的,然而应用锁的时候肯定要去躲避死锁。计划一看上去是没问题的,用的时候设置标识,用完革除标识,然而要是持有锁的线程产生了意外,开释锁的代码无奈执行,锁就无奈开释,其余线程就会始终期待锁,相干同步代码便无奈执行。计划二也存在这个问题,但计划二能够利用 zk 的长期程序节点来解决这个问题,只有线程产生了异样导致程序中断,就会失落与 zk 的连贯,zk 检测到该链接断开,就会主动删除该链接创立的长期节点,这样就能够达到即便占用锁的线程程序发生意外,也能保障锁失常开释的目标。
那要是 zk 挂了怎么办?sad,zk 要是挂了就没辙了,因为线程都无奈链接到 zk,更何谈获取锁执行同步代码呢。不过,个别部署的时候,为了保障 zk 的高可用,都会应用多个 zk 部署为集群,集群外部一主多从,主 zk 一旦挂掉,会立即通过选举机制有新的主 zk 补上。zk 集群挂了怎么办?不好意思,除非所有 zk 同时挂掉,zk 集群才会挂,概率超级小。

    /**
     * 尝试加锁
     * @return
     */
    public boolean tryLock() {
        // 创立长期程序节点
        if (this.currentPath == null) {
            // 在 lockPath 节点上面创立长期程序节点
            currentPath = this.client.createEphemeralSequential(LockPath + "/", "orangecsong");
        }
        // 取得所有的子节点
        List<String> children = this.client.getChildren(LockPath);
​
        // 排序 list
        Collections.sort(children);
​
        // 判断以后节点是否是最小的, 如果是最小的节点, 则表明此这个 client 能够获取锁
        if (currentPath.equals(LockPath + "/" + children.get(0))) {return true;} else {
            // 如果不是以后最小的 sequence, 取到前一个长期节点
            // 1. 独自获取长期节点的顺序号
            // 2. 查找这个顺序号在 children 中的下标
            // 3. 存储前一个节点的残缺门路
            int curIndex = children.indexOf(currentPath.substring(LockPath.length() + 1));
            beforePath = LockPath + "/" + children.get(curIndex - 1);
        }
        return false;
    }
​
    /**
     * 期待锁
     */
    private void waitForLock() {
        // cdl 对象次要是让线程期待
        CountDownLatch cdl = new CountDownLatch(1);
        // 注册 watcher 监听器
        IZkDataListener listener = new IZkDataListener() {
            @Override
            public void handleDataDeleted(String dataPath) throws Exception {System.out.println("监听到前一个节点被删除了");
                cdl.countDown();}
​
            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {}};
​
        // 监听前一个长期节点
        client.subscribeDataChanges(this.beforePath, listener);
​
        // 前一个节点还存在, 则阻塞本人
        if (this.client.exists(this.beforePath)) {
            try {
                // 直至前一个节点开释锁, 才会持续往下执行
                cdl.await();} catch (InterruptedException e) {e.printStackTrace();
            }
        }
​
        // 醒来后, 表明前一个长期节点曾经被删除, 此时客户端能够获取锁 && 勾销 watcher 监听
        client.unsubscribeDataChanges(this.beforePath, listener);
    }

长处: ⾼可用性,数据强一致性。多过程共享、能够存储锁信息、有被动告诉的机制。
毛病: 没有原⽣⽀持锁操作,需借助 client 端实现锁操作,即加⼀次锁可能会有屡次的网络申请; 长期节点,若在网络抖动的状况即会导致锁对应的节点被⽴即开释,有肯定概率会产⽣并发的状况

正文完
 0