集群多JVM分布式锁实现

1,571 阅读15分钟

阿里工作5年面试官分享Java面试,文章首发在公众号:liebao小奕,回复1024获取Java开发扫盲思维导图。回复加群进粉丝群。

前言

最近忙于项目,每天回家较晚。好久没更了,今天周六,分享总结分布式项目分布式锁相关细节。

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

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

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'

上面说到这种方式基本废弃,那么这种简单的实现会存在哪些问题呢?

  • 这把锁会强依赖数据库的可用性,数据库是一个单点,⼀旦数据库挂掉,会导致业务系统不可⽤。
  • 这把锁并没有失效时间,⼀旦解锁操作失败,就会导致锁记录一直存在数据库中,其它线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的insert操作,⼀旦插⼊入失败就会直接报错。没有获得锁的线程并不会进入排队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重⼊的,同⼀个线程在没有释放锁之前无法再次获得该锁。因为数据已经存在了。当然,我们也可以有其它方式解决上面的问题。

  • 针对数据库是单点问题搞两个数据库,数据之前双向同步。⼀旦挂掉快速切换到备库上。

  • 针对没有失效时间?我们可以做一个定时任务,每隔一定时间把数据库中的超时数据清理理一遍。

  • 针对非阻塞的?搞⼀个自旋while循环,直到insert成功再返回成功。

  • 针对⾮重入的?我们可以在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

  • 基于数据库排他锁 除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。我们⽤刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySqlInnoDB 引擎,可以使用以下方法来实现加锁操作。伪代码如下:

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实现步骤:

  • 客户端获取当前时间,以毫秒为单位。客户端尝试获取N个节点的锁,(每个节点获取锁的方式和前面说的缓存锁⼀样),N个节点以相同的 keyvalue获取锁。客户端需要设置接口访问超时,接口超时时间需要远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms。这样可以在有redis节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。

  • 客户端统计计算在获得锁的时候花费了多少时间,当前时间减去在获取的时间,只有客户端 获得了超过3个节点的锁,而且获取锁的时间⼩于锁的超时时间,客户端才获得了了分布式锁。

  • 客户端获取锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。

  • 如果客户端获取锁失败了,客户端会依次删除所有的锁。使用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中提供了四种类型的节点,各种类型节点及其区别如下:

  • 持久节点:节点创建后,就一直存在,直到有删除操作来主动清除这个节点
  • 持久顺序节点:保留持久节点的特性,额外的特性是,每个节点会为其第一层子节点维护一个顺序,记录每个子节点创建的先后顺序,ZK会自动为给定节点名加上一个数字后缀(自增的),作为新的节点名。
  • 临时节点:和持久节点不同的是,临时节点的生命周期和客户端会话绑定,当然也可以主动删除。
  • 临时顺序节点:保留临时节点的特性,额外的特性如持久顺序节点的额外特性。
如何操作节点?

节点的增删改查分别是create\delete\setData\getData,exists判断节点是否存在,getChildren获取所有子节点的引用。

上面提到了节点的监听者,我们可以在对zk的节点进行查询操作时,设置当前线程是否监听所查询的节点。getDatagetChildrenexists都属于对节点的查询操作,这些方法都有一个boolean类型的watch参数,用来设置是否监听该节点。一旦某个线程监听了某个节点,那么这个节点发生的creat(在该节点下新建子节点)、setDatadelete(删除节点本身或是删除其某个子节点)都会触发zk去通知监听该节点的线程。但需要注意的是,线程对节点设置的监听是一次性的,也就是说zk通知监听线程后需要改线程再次设置监听节点,否则该节点再次的修改zk不会再次通知。

实现

方案一:使用节点中的存储数据区域,zk中节点存储数据的大小不能超过1M,但是只是存放一个标识是足够的。线程获得锁时,先检查该标识是否是无锁标识,若是可修改为占用标识,使用完再恢复为无锁标识。

方案二:使用子节点,每当有线程来请求锁的时候,便在锁的节点下创建一个子节点,子节点类型必须维护一个顺序,对子节点的自增序号进行排序,默认总是最小的子节点对应的线程获得锁,释放锁时删除对应子节点便可。

两种方案其实都是可行的,但是使用锁的时候一定要去规避死锁

方案一看上去是没问题的,用的时候设置标识,用完清除标识,但是要是持有锁的线程发生了意外,释放锁的代码无法执行,锁就无法释放,其他线程就会一直等待锁,相关同步代码便无法执行。

方案二也存在这个问题,但方案二可以利用zk临时顺序节点来解决这个问题,只要线程发生了异常导致程序中断,就会丢失与zk的连接,zk检测到该链接断开,就会自动删除该链接创建的临时节点,这样就可以达到即使占用锁的线程程序发生意外,也能保证锁正常释放的目的。

那要是zk挂了怎么办?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 端实现锁操作,即加⼀次锁可能会有多次的网络请求;临时节点,若在网络抖动的情况即会导致锁对应的节点被立即释放,有一定概率会产生并发的情况。

最后

  • 文章均原创,原创不易,感谢掘金平台,觉得有收获,帮忙三连,笔芯
  • 文章涉及的所有代码、时序图、架构图均共享,可通过公众号留言获取
  • 文章若有错误,欢迎评论留言指出,也欢迎转载,麻烦标注下出处就好