分布式锁实现:数据库、redis、zookeeper、memcache
实现分布式锁现在主流的方式大致有以下三种
1. 基于数据库的索引和行锁
2. 基于Redis的单线程原子操作:setNX(过时),jedis.set(lockName, "", "NX", "PX", expireTime)
3. 基于Zookeeper的临时有序节点
4. 基于memcache的add
一、基于 数据库 实现分布式锁
1.1、基于数据库的索引和行锁
重点:并发通过单实例的唯一索引解决
基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
DROP TABLE IF EXISTS `method_lock`; CREATE TABLE `method_lock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名', `desc` varchar(255) NOT NULL COMMENT '备注信息', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
想要执行某个方法获取得到锁,就使用这个方法名向表中插入数据:
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete from method_lock where method_name ='methodName';
注意:这只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的玩法!
使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:
- 因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
- 不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
- 没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
- 不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。
当然,我们也可以有其它方式解决上面的问题:
1、数据库是单点?那就搞两个数据库,数据库之前双向同步,一旦挂掉快速切换到备库上。
2、没有失效时间?可以做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
3、非阻塞?可以写一个while循环,直到insert成功再返回成功。
4、非重入?可以在数据库表中加一个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库中可以查到的话,就直接把锁分配给它即可。
1.2、基于数据库表做乐观锁
重点:CAS,乐观锁
在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。
数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。
实现数据版本有两种方式,第一种是使用版本号,第二种是使用时间戳。
1、使用版本号实现乐观锁
使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。
查询出商品信
select (status,status,version) from t_goods where id=#{id} 2.根据商品信息生成订单 3.修改商品status为2 update t_goods set status=2,version=version+1 where id=#{id} and version=#{version};
需要注意的是,乐观锁机制往往基于系统中数据存储逻辑,因此也具备一定的局限性。由于乐观锁机制是在我们的系统中实现的,对于来自外部系统的用户数据更新操作不受我们系统的控制,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,我们应该充分考虑到这些情况,并进行相应的调整(如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开)。
hibernate在其数据访问引擎中内置了乐观锁的实现。如果不用考虑外部系统对数据库的更新操作,利用hibernate提供的透明化乐观锁实现,将大大提升我们的效率。
User.hbm.xml:
<?xml version="1.0"?> <!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd"> <hibernate-mapping package="com.xiaohao.test"> <class name="User" table="user" optimistic-lock="version" > <id name="id"> <generator class="native" /> </id> <!--version标签必须跟在id标签后面--> <version column="version" name="version" /> <property name="userName"/> <property name="password"/> </class> </hibernate-mapping>
注意:version节点必须出现在ID节点之后。
这里我们声明了一个version属性,用于存放用户的版本信息,保存在User表的version中,optimistic-lock属性有如下可选取值:
1、none 无乐观锁
2、version 通过版本机制实现乐观锁
3、dirty 通过检查发生变动过的属性实现乐观锁
4、all 通过检查所有属性实现乐观锁
其中通过version实现的乐观锁机制是hibernate官方推荐的乐观锁实现,同时也是hibernate中,目前唯一一种在数据对象脱离session发生修改的情况下依然有效的锁机制。因此,一般情况下,我们都选择version方式作为hibernate乐观锁的实现机制。
2、优点与不足
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题。
1.3、基于数据库表做悲观锁(排它锁)
除了可以通过增删操作数据库表中的记录以外,还可以借助数据库中自带的锁来实现分布式锁。
我们还用上面创建的数据库表,可以通过数据库的排它锁来实现分布式锁。基于MySQL的InnoDB引擎,可以使用以下方法来实现加锁操作:
public boolean lock(){ Connection.setAutoCommit(false); while (true) { try { result = select * from MethodLock where methodName = 'xxxx' for update; if (result == null) { return false; } } catch (Exception e) { } sleep(1000); } return false; }
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
public void unlock(){ connection.commit(); }
通过connection.commit()操作来释放锁。
这里还可能存在另外一个问题,虽然我们对method_name 使用了唯一索引,并且显示使用for update来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。
还有一个问题,就是我们要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题:
1、阻塞锁?for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
2、锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法解决数据库单点和可重入的问题。
优点:直接借助数据库容易理解;
缺点:
1、会有各种各样的问题,在解决问题的过程中会使整个方案变的越来越复杂。
2、操作数据库需要一定的开销,性能问题需要考虑。
总结:
使用数据库来实现分布式锁,这三种方式都是依赖数据库中的一张表,一种是通过表中的记录存在情况确定当前是否有锁存在,另外两种是通过数据库的乐观锁和排它锁/悲观锁来实现分布式锁。
二、基于 Redis 实现分布式锁
2.1、通过setNx和getSet来实现
这是现在网上大部分版本的实现方式,笔者之前项目里面用到分布式锁也是通过这样的方式实现
public boolean lock(Jedis jedis, String lockName, Integer expire) { // 返回是否设置成功 // setNx加锁 px
long now = System.currentTimeMillis(); boolean result = jedis.setnx(lockName, String.valueOf(now + expire * 1000)) == 1; if (!result) { // 防止死锁的容错 String timestamp = jedis.get(lockName); if (timestamp != null && Long.parseLong(timestamp) < now) { // 不通过del方法来删除锁。而是通过同步的getSet String oldValue = jedis.getSet(lockName, String.valueOf(now + expire)); if (oldValue != null && oldValue.equals(timestamp)) { result = true; jedis.expire(lockName, expire); } } } if (result) { jedis.expire(lockName, expire); } return result; }
代码分析:
- 通过setNx命令保证操作的原子性,获取到锁,并且把过期时间设置到value里面。
- 通过expire方法设置过期时间,如果设置过期时间失败的话,再通过value的时间戳来和当前时间戳比较,防止出现死锁。
- 通过getSet命令在发现锁过期未被释放的情况下,避免删除了在这个过程中有可能被其余的线程获取到了锁
存在问题
- 防止死锁的解决方案是通过系统当前时间决定的,不过线上服务器系统时间一般来说都是一致的,这个不算是严重的问题
- 锁过期的时候可能会有多个线程执行getSet命令,在竞争的情况下,会修改value的时间戳,理论上来说会有误差
- 锁无法具备客户端标识,在解锁的时候可能被其余的客户端删除同一个key
- 虽然有小问题,不过大体上来说这种分布式锁的实现方案基本上是符合要求的,能够做到锁的互斥和避免死锁
2.2、 通过Redis高版本的原子命令
jedis的set命令可以自带复杂参数,通过这些参数可以实现原子的分布式锁命令
jedis.set(lockName, "", "NX", "PX", expireTime); 合并普通的setNx()和expire()操作,使其具有原子性。
说明:redis的set命令可以携带复杂参数,第一个是锁的key,第二个是value,可以存放获取锁的客户端ID,通过这个校验是否当前客户端获取到了锁,第三个参数取值NX/XX,第四个参数 EX|PX,第五个就是时间
- NX:如果不存在就设置这个key,XX:如果存在就设置这个key
- EX:如果为EX时,第5个参数的单位为秒,如果为PX时:第5个参数的单位为毫秒
这个命令实质上就是把我们之前的setNx和expire命令合并成一个原子操作命令,不需要我们考虑set失败或者expire失败的情况
/** * Set the string value as value of the key. The string can't be longer than 1073741824 bytes (1 * GB). * @param key * @param value * @param nxxx NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key * if it already exist. * @param expx EX|PX, expire time units: EX = seconds; PX = milliseconds * @param time expire time in the units of <code>expx</code> * @return Status code reply */ public String set(final String key, final String value, final String nxxx, final String expx, final long time) { checkIsInMultiOrPipeline(); client.set(key, value, nxxx, expx, time); return client.getStatusCodeReply(); }
加锁
通过set(key,value,NX,EX,timeout)方法,我们就可以轻松实现分布式锁。值得注意的是这里的value作为客户端锁的唯一标识,不能重复。
public boolean lock1(KeyPrefix prefix, String key, String value, Long lockExpireTimeOut, Long lockWaitTimeOut) { Jedis jedis = null; try { jedis = jedisPool.getResource(); String realKey = prefix.getPrefix() + key; Long deadTimeLine = System.currentTimeMillis() + lockWaitTimeOut; for (;;) { String result = jedis.set(realKey, value, "NX", "PX", lockExpireTimeOut); if ("OK".equals(result)) { return true; } lockWaitTimeOut = deadTimeLine - System.currentTimeMillis(); if (lockWaitTimeOut <= 0L) { return false; } } } catch (Exception ex) { log.info("lock error"); } finally { returnToPool(jedis); } return false; }
解锁
我们可以使用lua脚本合并get()和del()操作,使其具有原子性。一切大功告成。
public boolean unlock1(KeyPrefix prefix, String key, String value) { Jedis jedis = null; try { jedis = jedisPool.getResource(); String realKey = prefix.getPrefix() + key; String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(luaScript, Collections.singletonList(realKey), Collections.singletonList(value)); if ("1".equals(result)) { return true; } } catch (Exception ex) { log.info("unlock error"); } finally { returnToPool(jedis); } return false; }
具体lua代码是这样的:
// 1获取锁 // NX是指如果key不存在就成功,key存在返回false,PX可以指定过期时间 SET anyLock unique_value NX PX 30000
// 2释放锁:通过执行一段lua脚本 // 释放锁涉及到两条指令[call("get")、call("del")],这两条指令不是原子性的 // 需要用到redis的lua脚本支持特性,redis执行lua脚本是原子性的 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0
这种方式有几大要点:
一定要用SET key value NX PX milliseconds 命令如果不用,先设置了值,再设置过期时间,这个不是原子性操作,有可能在设置过期时间之前宕机,会造成死锁(key永久存在)
value要具有唯一性,这个是为了在解锁的时候,需要验证value是和加锁的一致才删除key。
这是避免了一种情况:假设A获取了锁,过期时间30s,此时35s之后,锁已经自动释放了,A去释放锁,但是此时可能B获取了锁。A客户端就不能删除B的锁了。
除了要考虑客户端要怎么实现分布式锁之外,还需要考虑redis的部署问题。
2.3、redis 有3种部署方式:
- 单机模式
- master-slave + sentinel选举模式
- redis cluster模式
使用redis做分布式锁的缺点在于:如果采用单机部署模式,会存在单点问题,只要redis故障了。加锁就不行了。
采用master-slave模式,加锁的时候只对一个节点加锁,即便通过sentinel做了高可用,但是如果master节点故障了,发生主从切换,此时就会有可能出现锁丢失的问题。
基于以上的考虑,其实redis的作者也考虑到这个问题,他提出了一个RedLock的算法,这个算法的意思大概是这样的:
假设redis的部署模式是redis cluster,总共有5个master节点,通过以下步骤获取一把锁:
- 获取当前时间戳,单位是毫秒
- 轮流尝试在每个master节点上创建锁,过期时间设置较短,一般就几十毫秒
- 尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1)
- 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了
- 要是锁建立失败了,那么就依次删除这个锁
- 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁
但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。
另一种方式:Redisson
此外,实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,还可以使用开源框架:Redission
Redisson 是一个企业级的开源 Redis Client,也提供了分布式锁的支持。我也非常推荐大家使用,为什么呢?
SET anyLock unique_value NX PX 30000
这里设置的超时时间是30s,假如我超过30s都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。
这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,太麻烦了~
我们来看看redisson是怎么实现的?先感受一下使用redission的爽:
1、引用redisson包
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.5.0</version> </dependency>
lock和unlock示例:
Config config = new Config(); config.useClusterServers() .setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒 //可以用"rediss://"来启用SSL连接 .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001") .addNodeAddress("redis://127.0.0.1:7002"); RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("anyLock"); lock.lock(); lock.unlock();
封装的java实现类:
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.util.concurrent.TimeUnit; public class RedissonDistributedLocker implements DistributedLocker { private RedissonClient redissonClient; @Override public RLock lock(String lockKey) { RLock lock = redissonClient.getLock(lockKey); lock.lock(); return lock; } @Override public RLock lock(String lockKey, int leaseTime) { RLock lock = redissonClient.getLock(lockKey); lock.lock(leaseTime, TimeUnit.SECONDS); return lock; } @Override public RLock lock(String lockKey, TimeUnit unit ,int timeout) { RLock lock = redissonClient.getLock(lockKey); lock.lock(timeout, unit); return lock; } @Override public boolean tryLock(String lockKey, TimeUnit unit, int waitTime, int leaseTime) { RLock lock = redissonClient.getLock(lockKey); try { return lock.tryLock(waitTime, leaseTime, unit); } catch (InterruptedException e) { return false; } } @Override public void unlock(String lockKey) { RLock lock = redissonClient.getLock(lockKey); lock.unlock(); } @Override public void unlock(RLock lock) { lock.unlock(); } public void setRedissonClient(RedissonClient redissonClient) { this.redissonClient = redissonClient; } }
就是这么简单,我们只需要通过它的api中的lock和unlock即可完成分布式锁,他帮我们考虑了很多细节:
- redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
- redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办?
- redisson中有一个watchdog的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s,这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。
- redisson的“看门狗”逻辑保证了没有死锁发生。(如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)
lua脚本
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);
对于redisson更多的用法请参考官方文档:https://github.com/redisson/redisson/wiki/
小结:本节分析了使用redis作为分布式锁的具体落地方案,以及其一些局限性,然后介绍了一个redis的客户端框架redisson,这也是我推荐大家使用的,比自己写代码实现会少care很多细节。
三、基于Zookeeper的临时有序节点
常见的分布式锁实现方案里面,除了使用redis来实现之外,使用zookeeper也可以实现分布式锁。在介绍zookeeper(下文用zk代替)实现分布式锁的机制之前,先粗略介绍一下zk是什么东西:
Zookeeper是一种提供配置管理、分布式协同以及命名的中心化服务。
zk的模型是这样的:zk包含一系列的节点,叫做znode,就好像文件系统一样每个znode表示一个目录,然后znode有一些特性:
- 有序节点:假如当前有一个父节点为/lock,我们可以在这个父节点下面创建子节点;zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号也就是说,如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推。
- 临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点。
节点操作:
- 节点创建
- 节点删除
- 节点数据修改
- 子节点变更
基于以上的一些zk的特性,我们很容易得出使用zk实现分布式锁的落地方案:
/** * ZooKeeperSession * @author Administrator * */ public class ZooKeeperSession { private static CountDownLatch connectedSemaphore = new CountDownLatch(1); private ZooKeeper zookeeper; private CountDownLatch latch; public ZooKeeperSession() { try { this.zookeeper = new ZooKeeper( "192.168.31.187:2181,192.168.31.19:2181,192.168.31.227:2181", 50000, new ZooKeeperWatcher()); try { connectedSemaphore.await(); } catch(InterruptedException e) { e.printStackTrace(); } System.out.println("ZooKeeper session established......"); } catch (Exception e) { e.printStackTrace(); } } /** * 获取分布式锁 * @param productId */ public Boolean acquireDistributedLock(Long productId) { String path = "/product-lock-" + productId; try { zookeeper.create(path, "".getBytes(), Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL); return true; } catch (Exception e) { while(true) { try { Stat stat = zk.exists(path, true); // 相当于是给node注册一个监听器,去看看这个监听器是否存在。stat=null时,节点不存在 if(stat != null) { this.latch = new CountDownLatch(1); //这里会阻塞,唤醒是下面的watch方法中的this.latch.countDown(),而整个唤醒方法是由zk的监听器回来的 this.latch.await(waitTime, TimeUnit.MILLISECONDS); this.latch = null; } //尝试创建一把锁,这是还是有可能出错,别人抢占了,下面的catch里的continue zookeeper.create(path, "".getBytes(), Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL); return true; } catch(Exception e) { continue; } } // 很不优雅,我呢就是给大家来演示这么一个思路,比较通用的. } return true; } /** * 释放掉一个分布式锁 * @param productId */ public void releaseDistributedLock(Long productId) { String path = "/product-lock-" + productId; try { zookeeper.delete(path, -1); System.out.println("release the lock for product[id=" + productId + "]......"); } catch (Exception e) { e.printStackTrace(); } } /** * 建立zk session的watcher * @author Administrator * */ private class ZooKeeperWatcher implements Watcher { public void process(WatchedEvent event) { System.out.println("Receive watched event: " + event.getState()); if(KeeperState.SyncConnected == event.getState()) { connectedSemaphore.countDown(); } if(this.latch != null) { this.latch.countDown(); } } } /** * 封装单例的静态内部类 * @author Administrator * */ private static class Singleton { private static ZooKeeperSession instance; static { instance = new ZooKeeperSession(); } public static ZooKeeperSession getInstance() { return instance; } } /** * 获取单例 * @return */ public static ZooKeeperSession getInstance() { return Singleton.getInstance(); } /** * 初始化单例的便捷方法 */ public static void init() { getInstance(); } }
zookeeper集群的每个节点的数据都是一致的, 那么我们可以通过这些节点来作为锁的标志.
首先给锁设置一下API, 至少要包含:lock(锁住), unlock(解锁), isLocked(是否锁住)三个方法,然后我们可以创建一个工厂(LockFactory), 用来专门生产锁.锁的创建过程如下描述:
前提:每个锁都需要一个路径来指定(如:/lock/)
- 使用zk的临时节点和有序节点,每个线程获取锁就是在zk创建一个临时有序的节点,比如在/lock/目录下。
- 创建节点成功后,获取/lock目录下的所有临时节点,再判断当前线程创建的节点是否是所有的节点的序号最小的节点
- 如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功。
- 如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。比如当前线程获取到的节点序号为/lock/003,然后所有的节点列表为[/lock/001,/lock/002,/lock/003],则对/lock/002这个节点添加一个事件监听器。如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。
比如/lock/001释放了,/lock/002监听到时间,此时节点集合为[/lock/002,/lock/003],则/lock/002为最小序号节点,获取到锁。
整个过程如下:
具体的实现思路就是这样,简易代码:
public class ZooKeeperDistributedLock implements Watcher{ private ZooKeeper zk; private String locksRoot= "/locks"; private String productId; private String waitNode; private String lockNode; private CountDownLatch latch; private CountDownLatch connectedLatch = new CountDownLatch(1); private int sessionTimeout = 30000; public ZooKeeperDistributedLock(String productId){ this.productId = productId; try { String address = "192.168.31.187:2181,192.168.31.19:2181,192.168.31.227:2181"; zk = new ZooKeeper(address, sessionTimeout, this); connectedLatch.await(); } catch (IOException e) { throw new LockException(e); } catch (KeeperException e) { throw new LockException(e); } catch (InterruptedException e) { throw new LockException(e); } } public void process(WatchedEvent event) { if(event.getState()==KeeperState.SyncConnected){ connectedLatch.countDown(); return; } if(this.latch != null) { this.latch.countDown(); } } public void acquireDistributedLock() { try { if(this.tryLock()){ return; } else{ waitForLock(waitNode, sessionTimeout); } } catch (KeeperException e) { throw new LockException(e); } catch (InterruptedException e) { throw new LockException(e); } } public boolean tryLock() { try { // 传入进去的locksRoot + “/” + productId // 假设productId代表了一个商品id,比如说1 // locksRoot = locks // /locks/10000000000,/locks/10000000001,/locks/10000000002 lockNode = zk.create(locksRoot + "/" + productId, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 看看刚创建的节点是不是最小的节点 // locks:10000000000,10000000001,10000000002 List<String> locks = zk.getChildren(locksRoot, false); Collections.sort(locks); if(lockNode.equals(locksRoot+"/"+ locks.get(0))){ //如果是最小的节点,则表示取得锁 return true; } //如果不是最小的节点,找到比自己小1的节点 int previousLockIndex = -1; for(int i = 0; i < locks.size(); i++) { if(lockNode.equals(locksRoot + “/” + locks.get(i))) { previousLockIndex = i - 1; break; } } this.waitNode = locks.get(previousLockIndex); } catch (KeeperException e) { throw new LockException(e); } catch (InterruptedException e) { throw new LockException(e); } return false; } private boolean waitForLock(String waitNode, long waitTime) throws InterruptedException, KeeperException { Stat stat = zk.exists(locksRoot + "/" + waitNode, true); if(stat != null){ this.latch = new CountDownLatch(1); this.latch.await(waitTime, TimeUnit.MILLISECONDS); this.latch = null; } return true; } public void unlock() { try { // 删除/locks/10000000000节点 // 删除/locks/10000000001节点 System.out.println("unlock " + lockNode); zk.delete(lockNode,-1); lockNode = null; zk.close(); } catch (InterruptedException e) { e.printStackTrace(); } catch (KeeperException e) { e.printStackTrace(); } } public class LockException extends RuntimeException { private static final long serialVersionUID = 1L; public LockException(String e){ super(e); } public LockException(Exception e){ super(e); } } }
小结:学完了两种分布式锁的实现方案之后,本节需要讨论的是redis和zk的实现方案中各自的优缺点。
对于redis的分布式锁而言,它有以下缺点:
- redis分布式锁获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
- 另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮
- 即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题,关于redlock的讨论可以看How to do distributed locking
- redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
- 但是另一方面使用redis实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”
所以使用redis作为分布式锁也不失为一种好的方案,最重要的一点是redis的性能很高,可以支撑高并发的获取、释放锁操作。
对于 zk 分布式锁而言:
- zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
- 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
- 但是zk也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。
小结:综上所述,redis和zookeeper都有其优缺点。我们在做技术选型的时候可以根据这些问题作为参考因素。
四、memcache实现的分布式互斥锁
实现原理:
memcached带有add函数,利用add函数的特性即可实现分布式锁。add和set的区别在于:如果多线程并发set,则每个set都会成功,但最后存储的值以最后的set的线程为准。而add的话则相反,add会添加第一个到达的值,并返回true,后续的添加则都会返回false。利用该点即可很轻松地实现分布式锁。
if (memcache.get(key) == null) { // 3 min timeout to avoid mutex holder crash if (memcache.add(key_mutex, 3 * 60 * 1000) == true) { value = db.get(key); memcache.set(key, value); memcache.delete(key_mutex); } else { sleep(50); retry(); } }
memcache 缺点:
(1)memcached采用列入LRU置换策略,所以如果内存不够,可能导致缓存中的锁信息丢失。
(2)memcached无法持久化,一旦重启,将导致信息丢失。