分布式锁的三种实现方式

一、基于数据库实现分布式锁

大致思路:需要在数据库中建一张锁表,当我们需要锁住某个资源或者方法时,就向该表中添加一条数据,如果需要释放锁则删除数据即可。
以方法锁为例

 create table method_lock
(
   id                   int not null auto_increment,
   method_name          varchar(100) not null comment '锁定的方法名',
   desc                 varchar(100) comment '描述',
   update_time          datetime not null default CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   holder               varchar(50) comment '锁持有者',
   primary key (id),
   unique key `idx_method_name` (`method_name`) using BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 comment='方法锁表';

如果需要锁定某个方法,则向method_lock表中插一条数据

insert into method_lock(method_name, desc, holder) values ('function1', 'desc1', 'holder1');

如果需要释放锁,则从method_lock表中删除数据

delete from method_lock where method_name = 'function1';

由于method_name列有唯一索引,若多个应用同时获取function1的锁,数据库会保证只有一条数据插入成功,我们可以认为插入成功的那个应用获得了该方法锁,就可以执行方法体。

以上的实现有如下的问题:

这把锁强依赖于数据库,数据库单点,一旦数据库挂掉,会导致所有需要获取方法锁的业务不可用;
锁没有加有效时间,一旦释放锁失败了,则导致锁数据一直存在数据库中,其他应用无法再获得该锁;
这把锁是非阻塞式的,一旦insert失败就会直接报错,尝试获得锁的线程无法进入排队队列,如果想重新获取锁,只能再次触发获得锁操作。

针对以上问题,有如下的解决方案:

数据库单点?搞两个数据库,主主同步,挂了一个另一个也可以顶上;
锁没有有效时间?搞一个定时任务,隔一段时间就扫描一次表,将超时的数据删除(需要设置一个合理的超时时间);
锁非阻塞?如果希望获取锁失败的应用进入排队队列,搞一个while循环反复尝试insert,直到insert成功(但大部分的业务场景是不需要这样的)。

二、基于缓存实现分布式锁

相较于基于数据库实现分布式锁,基于缓存实现分布式锁的性能会好一些(毕竟操作缓存的开销小于操作数据库的开销),而且缓存可以实现分布式部署,解决了基于数据库实现分布式锁的单点问题。
常用的分布式缓存有memcached和redis,下面分开讲一下。

memcached:

可以利用memcached的add命令,这个命令在key不存在的时候才会执行成功,并发下多个应用同时add同一个key,只有一个可以add成功,add成功的那个应用就获取了锁。处理完业务后需要将锁释放掉,用delete命令将key删除掉即可(有些文章会先判断一下缓存是否失效,没失效的情况下删除,失效了就不管它了,我个人觉得多此一举,直接删除没那么多麻烦事)
伪码如下:

String key = 'method_lock_' + methodName;
try {
    String value = holder;//锁持有者的信息
    long expireTime = xxx;//锁的有效时长
    if(mc.add(key, value, expireTime)) {
        //do business things

        //业务处理结束后,将缓存删除
        mc.delete(key);
    }
} catch(Exception e) {
    mc.delete(key); 
}
redis:

redis没有add这个命令,但它有一个SETNX(SET IF NOT EXISTS)可以实现一样的效果,只有当key不存在时才会设置成功并返回1,设置不成功时返回0。
但遗憾的是,SETNX这个命令不能像mc.add那样直接设置失效时间,需要在设置成功后再通过EXPIRE命令追加设置失效时间。

伪码如下:

String key = 'method_lock_' + methodName;
String value = holder;//锁持有者的信息
long expireTime = xxx;//锁的有效时长
int lockedRes = redis.SETNX(key,value);
if(lockedRes == 1) {
    //[1]获取锁成功

    //[2]追加设置失效时间
    redis.EXPIRE(key, expireTime);

    try {
        //[3]do business things

        //[4]业务处理结束后,将缓存删除
        redis.DEL(key);
    } catch(Exception e) {
        //业务处理过程中发生了异常,也需要将缓存删除
        redis.DEL(key);
    }
}

以上伪码基本上实现了分布式锁的功能,但是有一个问题,在[1]和[2]之间(即已经成功获取了锁但是还没来得及设置失效时间时)应用挂掉了,那么这个缓存将永久存在,其他应用再也无法获取这个锁。虽说这种场景是很少见的,但是也得处理啊,改进思路如下:
1.把value从holder变成expiredDate,即令value=expiredDate.toString(),尝试通过SETNX(key,value)获取锁。若返回1,获取锁成功;返回0,获取锁失败,进入下一步;
2.通过GET(key)命令,拿到已有的value(即expiredDate),判断是否已经超时,若未超时,说明某个应用正在持有锁;若已经超时,则进入下一步;
3.通过GETSET(key,newValue)命令(将给定key的值设为newValue ,并返回key的旧值(oldValue)),拿到oldValue,再次解析出oldValue中包含的失效时间,判断是否已经超时,若已经超时,说明获取锁成功。

为什么第三步中要再一次判断是否超时呢?第二步里不是判断过了吗?

原因是,可能有多个应用并发执行到第三步,比如应用A和应用B都执行到第三步了,应用A早于应用B一点点的时间执行了GETSET方法,对应用A来说,拿到的oldValue是第二步中的value,肯定是超时的,那么应用A实际上获得了这个锁;对应用B来说,因为晚了一步,GETSET拿到的oldValue实际上是应用A设置的newValue,理论上它此刻是不会超时的,那么应用B获取锁就失败了。

有人要问了,应用B通过GETSET再次更新了失效时间(覆盖了已经获取锁的应用A设置的失效时间),有影响吗?不影响,因为无论是应用A还是应用B,它们的失效时长都是固定的,应用B的expiredDate会晚于应用A的expiredDate,应用B的GETSET不会影响应用A已经获取锁的这个事实,它只是将失效时间延长了一点点。

伪码如下:

long expireTime = 300 * 1000; //失效时长(单位ms) 300秒即5分钟
String expiredDateStr = //当前时间加上expireTime时长计算得到的时间并转成String类型;
String key = 'method_lock_' + methodName;
int lockResult = redis.SETNX(key, expiredDateStr);
bool getLock = false;
if (lockResult == 1) {
    //得到锁
    getLock = true;
} else {
    String oldExpiredDateStr = redis.GET(key);

    //检查锁是否超时
    if (CheckedLockTimeOut(oldExpiredDateStr)) {
        String newExpiredDateStr = //当前时间加上expireTime时长计算得到的时间并转成String类型;
        string oldValue = redis.GETSET(key, newExpiredDateStr);
        if (CheckedLockTimeOut(oldValue)) {
            //得到锁
            getLock = true;
        }
    }
}
//[1]获得锁
if (getLock)
{
    try {
        //[2]do business  function

        //[3]释放锁
        redis.DEL(key);
    } catch (Exception e) {
        //业务处理过程中发生了异常,需要释放锁
        redis.DEL(key);
    }
}

改进后的代码解决了死锁的问题,但是,这种方式实现的锁无法重入,即获得锁的应用再重新进入的时候,无法判断出锁的持有者是自己。改进前的代码是可以的,因为改进前存储的value是holder,可以GET(key)后判断holder是不是自己。有人要说了,是不是可以把改进后代码中的value中加进holder信息?答案是,不可以。因为改进后的代码用到了GETSET,如果应用A和应用B几乎同时进入第三步,A先GETSET,B后GETSET,最终结果是A拿到了锁B没拿到,但是存储的信息却被B更新了(锁的holder变成了B但实际上A拿到了锁),这就尴尬了。

所以,改进后的方法也不是完美的。

三、基于zookeeper实现分布式锁

zookeeper临时有序节点可以实现分布式锁
大致思路:每个客户端尝试获取某个方法锁时,就在zookeeper的该方法对应的节点目录下,生成一个临时有序节点。如果生成的有序节点是该目录下最小的,即认为获得了该方法锁,可以执行方法体。如果需要释放某个方法锁,则只要删除之前生成的节点就可以。

这种实现方式的优点:

如果某个应用挂掉,与zookeeper断连,则该应用生成的临时节点全部自动删除掉,即它所拥有的锁会自动释放。这样避免了基于数据库和基于缓存实现分布式锁时,单个应用挂掉无法自动释放其持有锁的问题;
可以实现阻塞式的锁(如果有这个需要的话)。具体实现方式是:如果某个客户端尝试获取锁失败,那么它可以在当前目录下的最小节点上绑定一个监听器,如果该节点发生了变化(被删除),监听器可以通知到之前尝试获取锁的客户端,该客户端可以再次检查自己生成的节点是否是当前最小的节点,如果是的话,即获取了方法锁;
不存在单点问题。zookeeper是集群部署的,只要集群中有半数以上机器存活,就可以对外提供服务;
生成节点的时候,将锁持有者的信息保存下来,同一客户端重入时,判断持有者是否是自己,即可重入。

基于zookeeper临时有序节点的方式实现分布式锁优点很多,缺点只有一个,效率不如基于缓存实现分布式锁,因为zookeeper在生成节点、销毁节点时的消耗大于缓存操作的消耗。

四、三种方式的比较

理解难易程度(从易到难):

数据库>缓存>zookeeper

实现难易程度(从易到难):

zookeeper≈缓存>数据库(数据库需要建表,其他两个相对简单)

性能(从高到低):

缓存>zookeeper>数据库(数据库最低毫无疑问,zookeeper居中,缓存最高)

可靠性(从高到低):

zookeeper>缓存>数据库

本文参考了http://www.hollischuang.com/archives/1716,在原文基础上理解并重组,其中基于缓存实现的分布式锁有较大改变。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值