一、基于数据库实现分布式锁
大致思路:需要在数据库中建一张锁表,当我们需要锁住某个资源或者方法时,就向该表中添加一条数据,如果需要释放锁则删除数据即可。
以方法锁为例
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,在原文基础上理解并重组,其中基于缓存实现的分布式锁有较大改变。