锁是开发过程中一个十分重要也很常见的工具,当出现竞争条件时,我们需要锁来保证我们数据的安全。
当应用是集群的时候,可能相同的情况会发到不同的机器处理,这个时候java提供的单机锁已经不能满足我们的要求,这个时候我们就需要一个全局锁来保证数据安全,也就是分布式锁。

应用场景

保证数据库可靠性(转账)
分布式缓存更新问题
分布式中任务领取问题

实现方式

实现分布式锁有三种实现方式:1)基于数据库;2)基于缓存(redis、memcached、tair);3)基于zookepper
对于分布式锁的要求:
1)保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行
2)锁必须是可重入的(避免死锁)
3)锁的获取和释放必须是高可用的
4)获取和释放锁的性能要好

基于数据库实现

1)利用唯一主键:
数据库表有唯一主键的规则,可以利用这个规则,通过插入数据的方式去获取锁,如果插入失败则获取锁失败,这样会保证只要一个线程能够获取到锁,但是存在一下问题:

  • 锁依赖数据库的可用性,如果是单点,一旦数据库挂掉,会导致整个依赖的业务系统不可用

  • 锁没有失效时间,一旦解锁失败(如在解锁之前出现突然断电等不可知突发情况),会导致死锁

  • 锁是非阻塞的,insert失败后会直接报错,需要代码实现重试

  • 锁是非重入的,同一个线程无法获取已获得的锁。

  • 对于mysql数据库有个自动优化机制,当数据量小的时候会忽略索引,那么就会锁整个表,进而引起死锁。

对于上面的几个问题,也都可以进一步解决:

  • 数据库是单点?搞两个数据库,保证数据同步即可

  • 没有失效时间?做一个定时任务,固定的清理超过预计时间的数据

  • 非阻塞?搞个while循环

  • 非重入的?可以用一个字段标记当前线程信息和锁的获取次数,获取锁之前先查询,如果存在数据,修改获取次数后返回即可。

2)基于数据库排它锁
使用for update ,这样在查询数据的过程中给数据库增加排它锁,一旦获取在没有提交之前,其他线程无法再次查询,这里还有个问题,如果排它锁长时间不提交,当连接过多时,就会报错,无法获取数据库连接的错误

优点:便于理解
缺点:由于需要操作数据库,会影响性能

基于缓存实现分布式锁

相对于使用数据库实现,使用缓存来实现,在性能方面会好很多,现在常见的缓存产品,包括redis,memcached、tair
以redis为例,说明如何实现。
锁的获取使用set命令实现,需要使用一条命令实现生成和设置过期时间来保证原子性。

  1. private static final String SET_IF_NOT_EXIST = "NX";
  2. private static final String SET_WITH_EXPIRE_TIME = "PX";
  3. @param jedis Redis客户端
  4. * @param lockKey
  5. * @param requestId 请求标识
  6. * @param expireTime 超期时间
  7. jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
  • 死锁的问题?由于设置了过期时间,到期键会自动删除,避免死锁问题

锁的释放,还是一个重点,需要一条命令完成保证原子性,但是需要先判断是否是当前线程持有的锁,然后才能执行删除,直接使用redis的命令,达不到这种效果,就是使用事务,虽然是原子执行,但由于第二条命令的执行以来第一条命令的结果,而redis的事务是将一系列命令一次性提交然后依次执行,这显然不能满足判断的要求,因为此时还没有提交命令,也就没有返回值。

  1. //lua脚本
  2. String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  3. Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

这样之所以能达到原子性的效果,是因为eval命令执行lua代码的时候,lua代码将被当成一个命令去执行,直到eval命令执行完成,redis才会执行其他命令。这样就达到了原子性的要求,完成锁的释放。
如果redis是多机部署的,可以使用redisson实现分布式锁,这是redis官方实现的java组件,里面所的获取也是采用使用lua脚本这种方式实现的。

  1. <!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
  2. <dependency>
  3. <groupId>org.redisson</groupId>
  4. <artifactId>redisson</artifactId>
  5. <version>3.7.5</version>
  6. </dependency>

基于zookepper实现分布式锁

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

  • 锁无法释放?使用zookepper可以有效解决锁无法释放的问题,因为在创建锁的时候,客户端会在zookepper中创建一个临时节点,一旦客户端获取到锁之后突然挂掉,那么这个临时节点就会自动删除,其他客户端可以再次获取锁。

  • 非阻塞锁?使用zookepper可以实现阻塞的锁,客户端同在zookepper中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,zookepper会通知客户端,客户端可以检查自己创建的节点是否是当前节点中序号最小的,如果是,则获得锁

  • 不可重入?使用zookepper也可以有效解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小节点汇总的数据对比一下就可以了,如果一致,则获取锁。

  • 单点问题?使用zookepper可以有效解决单点问题,zookepper是集群部署的,只有集群中有半数以上的机器存货,就可以对外提供服务。

使用zookepper实现的分布式锁好像完全符合开头提到的几个期望,但是zookepper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务高,因为每次创建锁和释放锁的过程中,都要动态创建、销毁临时节点,zookepper中检查和删除节点只能通过leader服务器来执行,然后将数据同步到所有的follow机器上。
其实,使用zookepper也有可能带来并发问题,只是不常见而已。假如由于网络抖动,客户端和zookepper集群的session连接断了,那么zookepper以为客户端挂了,就会删除临时节点,这时其他客户端就可以获取到分布式锁了,产生并发问题。这个问题不常见是因为zookepper有重试机制,一旦zookepper集群检测不到客户端心跳,就会重试,多次重试之后还是不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡)
优点:有效解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题,实现起来较为简单。
缺点:性能上不如缓存,还需要对zookepper原理有一定了解。

三种方案比较:
没一种方案都做不到完美,根据不同的场景选择最合适的才是王道。
理解难易程度(从低到高):数据库>缓存>zookepper
从实现复杂性角度(从低到高):zookepper>=缓存>数据库
从性能角度(从低到高):数据库从可靠性角度(从低到高):数据库<缓存<zookepper