分布式锁 详解

单纯的 Java API 并不能提供分布式锁的能力,目前比较常见的分布式锁有如下几种方案 :
1> 基于数据库实现分布式锁
2> 基于缓存 (redis,memcached,tair) 实现分布式锁
3> 基于 Zookeeper 实现分布式锁

分布式锁的要求 :
1> 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行
2> 锁要是一把可重入锁 (避免死锁)
3> 锁最好是一把阻塞锁 (根据业务需求考虑要不要这条)
4> 有高可用的获取锁和释放锁功能
5> 获取锁和释放锁的性能要好


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

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


2> 基于数据库排他锁
除可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁
还是利用上面的数据库表,通过数据库的排他锁来实现分布式锁,基于 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() 操作来释放锁

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题
1> 阻塞锁 : for update语句 会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功
2> 锁定之后服务宕机,无法释放 : 使用这种方式,服务宕机之后数据库会自己把锁释放掉
但是还是无法直接解决数据库单点和可重入问题。另外这种方式有一种问题,虽然对 method_name 使用唯一索引,并且显示使用 FOR UPDATE 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。此外还有一种问题,使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆

使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁
数据库实现分布式锁的优点 : 1> 直接借助数据库,容易理解
数据库实现分布式锁的缺点 : 1> 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂
                                              2> 操作数据库需要一定的开销,性能问题需要考虑
                                              3> 使用数据库的行级锁并不一定靠谱,尤其是当锁表并不大的时候


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

目前有很多成熟的缓存产品,包括 Redis、memcached、Tair,以下以 Tair 为例,基于Tair的实现分布式锁其实和Redis类似,其中主要的实现方式是使用 TairManager.put 方法来实现
public boolean trylock(String key) {
    ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0);
    if (ResultCode.SUCCESS.equals(code)) {
        return true;
    } else {
        return false;
    }
}
public boolean unlock(String key) {
    ldbTairManager.invalid(NAMESPACE, key);
}

这种方式实现存在几个问题 :
1> 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁
2> 这把锁只能是非阻塞的,无论成功还是失败都直接返回
3> 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在,无法再执行put操作

同样也有解决方案 :
1> 没有失效时间 : tair的put方法支持传入失效时间,到达时间之后数据会自动删除
2> 非阻塞 : while重复执行
3> 非可重入 : 在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者

但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在

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


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

能够解决的问题 :
1> 锁无法释放 : 使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在 Zookeeper 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉 (Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁
2> 非阻塞锁 : 使用 Zookeeper 可以实现阻塞的锁,客户端可以通过在 Zookeeper 中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑
3> 不可重入 : 使用 Zookeeper 也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队
4> 单点问题 : 使用 Zookeeper 可以有效的解决单点问题,Zookeeper 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务

可以直接使用 zookeeper 第三方库 Curator客户端,这个客户端中封装一个可重入的锁服务
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    try {
        return interProcessMutex.acquire(timeout, unit);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return true;
}

public boolean unlock() {
    try {
        interProcessMutex.release();
    } catch (Throwable e) {
        log.error(e.getMessage(), e);
    } finally {
       executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
    }
    return true;
}

Curator 提供的 InterProcessMutex 是分布式锁的实现。acquire方法 用户获取锁,release方法用于释放锁。
使用 Zookeeper实现的分布式锁好像完全符合对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。Zookeeper 中创建和删除节点只能通过 Leader服务器来执行,然后将数据同不到所有的 Follower 机器上。其实,使用 Zookeeper 也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可 Zookeeper集群的 session连接断了,那么 Zookeeper以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为 Zookeeper有重试机制,一旦 Zookeeper集群检测不到客户端的心跳,就会重试,Curator客户端 支持多种重试策略。多次重试之后还不行的话才会删除临时节点 (所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡)

使用 Zookeeper 实现分布式锁的优点 : 1> 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题,实现起来较为简单
使用 Zookeeper 实现分布式锁的缺点 : 2> 性能上不如使用缓存实现分布式锁,需要对 Zookeeper的原理有所了解


三种方案的比较
上面几种方式,哪种方式都无法做到完美。就像 CAP 一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合
从理解的难易程度角度 (从低到高) : 数据库 > 缓存 > Zookeeper
从实现的复杂性角度 (从低到高) : Zookeeper >= 缓存 > 数据库
从性能角度 (从高到低) : 缓存 > Zookeeper >= 数据库
从可靠性角度 (从高到低) : Zookeeper > 缓存 > 数据库


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值