分布式锁+数据库和Redis以及Zookeeper实现方式详解

351 阅读13分钟

1. 为什么要使用分布式锁

超卖问题,这里是指多人抢购同⼀商品的时候,多⼈同时判断是否有库存,如果只剩⼀个,则都会判断有库存,此时会导致超卖现象产生,也就是⼀个商品下了多个订单的现象,解决超卖问题可以使⽤分布式锁得方案。
我们在开发应用的时候,如果需要对某⼀个共享变量进行多线程同步访问的时候,可以使⽤我们学到的Java多线程的18般武艺进行处理,并且可以完美的运营,毫无Bug!
注意这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM内部的⼀块内存空间!
后来业务发展,需要做集群或者微服务架构,⼀个应用需要部署到几台机器上然后做负载均衡,大致如下图:

image.png

2. 分布式锁应该具备哪些条件

在分析分布式锁的三种实现⽅式之前,先了解⼀下分布式锁应该具备哪些条件:

  1. 在分布式系统环境下,⼀个⽅法在同⼀时间只能被⼀个机器的⼀个线程执⾏;
  2. 高可用的获取锁与释放锁;
  3. 高性能的获取锁与释放锁;
  4. 具备可重入特性;
  5. 具备锁失效机制,防止死锁;
  6. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

3. 基于数据库的实现方式

3.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='锁定中的⽅法';

method_name一定要是唯一索引

(2)想要执行某个⽅法,就使⽤这个⽅法名向表中插⼊数据: 因为我们对 method_name 做了唯⼀性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有⼀个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的 methodName');

(3)成功插⼊则获取锁,执行完成后删除对应的行数据释放锁:

delete from method_lock where method_name ='methodName';

上⾯这种简单的实现有以下几个问题:

  • 这把锁强依赖数据库的可用性,数据库是⼀个单点,⼀旦数据库挂掉,会导致业务系统不可⽤。
  • 这把锁没有失效时间,⼀旦解锁操作失败,就会导致锁记录⼀直在数据库中,其他线程⽆法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的insert操作,⼀旦插入失败就会直接报错。没有获得锁的线程 并不会进⼊排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同⼀个线程在没有释放锁之前⽆法再次获得该锁。因为数据中数据已经存在了。
    当然,我们也可以有其他方式解决上面的问题。
  • 数据库是单点?搞两个数据库,数据之前双向同步。⼀旦挂掉快速切换到备库上。
  • 没有失效时间?只要做⼀个定时任务,每隔⼀定时间把数据库中的超时数据清理⼀遍。
  • 非阻塞的?搞⼀个while循环,直到insert成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获 取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

3.2 基于数据库排他锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据库中⾃带的锁来实现分布式的锁。
我们还⽤刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使⽤以下⽅法来实现加锁操作

image.png 在查询语句后面增加 for update ,数据库会在查询过程中给数据库表增加排他锁

这里再多提⼀句, InnoDB引擎在加锁的时候,只有通过索引进⾏检索的时候才会使⽤⾏级锁,否则会使⽤表级锁。这⾥我们希望使用⾏级锁,就要给method_name添加索引,值得注意的是,这个索引⼀定要创建成唯⼀索引,否则会出现多个重载方法之间⽆法同时被访问的问题。重载⽅法的话建议把参数类型也加上。)

当某条记录被加上排他锁之后,其他线程⽆法再在该行记录上增加排他锁。 我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁: image.png 通过 connection.commit() 操作来释放锁。

这种⽅法可以有效的解决上⾯提到的⽆法释放锁和阻塞锁的问题。

  • 阻塞锁? for update 语句会在执⾏成功后立即返回,在执行失败时⼀直处于阻塞状态,直到成功。 锁定之后服务宕机,
  • 无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是⽆法直接解决数据库单点问题。 这⾥还可能存在另外⼀个问题,虽然我们对 method_name 使⽤了唯⼀索引,并且显示使用 for update 来使⽤⾏级锁。但是,MySql会对查询进⾏优化,即便在条件中使⽤了索引字段,但是否使⽤索引来检索数据是由 MySQL 通过判断不同执⾏计划的代价来决定的,如果 MySQL 认为全表扫效率更⾼, 比如一些很小的表,它就不会使⽤索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。

还有⼀个问题,就是我们要使⽤排他锁来进行分布式锁的lock,那么⼀个排他锁⻓时间不提交,就会占用数据库连接。⼀旦类似的连接变得多了,就可能把数据库连接池撑爆。

3.3 总结

总结⼀下使用数据库来实现分布式锁的⽅式,这两种方式都是依赖数据库的⼀张表,⼀种是通过表中的记录的存在情况确定当前是否有锁存在,另外⼀种是通过数据库的排他锁来实现分布式锁。

数据库实现分布式锁的优点

  • 直接借助数据库,容易理解。

数据库实现分布式锁的缺点

  • 会有各种各样的问题,在解决问题的过程中会使整个⽅案变得越来越复杂。
  • 操作数据库需要⼀定的开销,性能问题需要考虑。
  • 使用数据库的⾏级锁并不⼀定靠谱,尤其是当我们的锁表并不⼤的时候。

4. 基于Redis的实现方式

4.1 Redis实现分布式的特点

(1)Redis有很⾼的性能;
(2)Redis命令对此支持持较好,实现起来比较方便;
(3)Redis同时还有相关客户端,提供了封装好能直接使用的分布式锁;

4.2 命令介绍

(1)SETNX

SETNX key val:
当且仅当key不存在时,set⼀个key为val的字符串,返回1;若key存在,则什么都不做,返回0。

(2)expire

expire key timeout:
为key设置⼀个超时时间,单位为second,超过这个时间锁会⾃动释放,避免死锁。

(3)delete

delete key:删除key

在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。

4.3 实现思想

(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加⼀个超时时间,超过该时间则自动释放锁,锁的value值为⼀个随机生成的UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置⼀个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

4.4 总结

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

使用缓存实现分布式锁的优点

  • 性能好,实现起来较为方便。 使用缓存实现分布式锁的缺点

  • 通过超时时间来控制锁的失效时间并不是⼗分的靠谱。

  • 在一些项目中采用的Redisson,就是采用redis实现分布式锁 Redisson是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大,此处我们只⽤它的分布式锁功能。

5. 基于ZooKeeper的实现方式

5.1 实现分析

ZooKeeper是⼀个为分布式应用提供⼀致性服务的开源组件,它内部是⼀个分层的文件系统目录树结构,规定同⼀个目录下文件名不能重复。基于ZooKeeper实现分布式锁的步骤如下:

  1. 创建⼀个目录mylock;
  2. 线程A想获取锁就在mylock目录下创建临时顺序节点;
  3. 获取mylock⽬录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 线程B获取所有节点,判断自己不是最小节点,设置监听比自己小的节点;
  5. 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

来看下Zookeeper能不能解决前面提到的问题。

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

可以直接使⽤zookeeper第三⽅库Curator客户端,这个客户端中封装了⼀个可重入的锁服务。

image.png Curator提供的InterProcessMutex是分布式锁的实现。acquire⽅法⽤户获取锁,release⽅法⽤于释放锁。

使用ZK实现的分布式锁好像完全符合了本⽂开头我们对⼀个分布式锁的所有期望。但是,其实并不是, Zookeeper实现的分布式锁其实存在⼀个缺点,那就是性能上可能并没有缓存服务那么⾼。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

其实,使用Zookeeper也有可能带来并发问题,只是并不常⻅⽽已。考虑这样的情况,由于网络抖动, 客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常⻅是因为zk有重试机制,⼀旦zk集群检测不到客户端的⼼跳,就会重试,Curator客户端⽀持多种重试策略。多次重试之后还不行的话才会删除临时节点。

(所以,选择⼀个合适的重试策略也比较重要,要在锁的粒度和并发之间找⼀个平衡。)

5.2 总结

使⽤Zookeeper实现分布式锁的优点

  • 有效的解决单点问题,不可重⼊问题,⾮阻塞问题以及锁无法释放的问题。实现起来较为简单。 使⽤Zookeeper实现分布式锁的缺点
  • 性能上不如使用缓存实现分布式锁。
  • 需要对ZK的原理有所了解,比较复杂。

6. 分布式锁总结

上面几种方式,哪种方式都无法做到完美。就像CAP⼀样,在复杂性、可靠性、性能等⽅⾯⽆法同时满足,所以,根据不同的应⽤场景选择最适合自己的才是王道。 从理解的难易程度角度(从低到高)

  • 数据库 > Redis缓存 > Zookeeper 从实现的复杂性角度(从低到高)
  • Zookeeper >= Redis缓存 > 数据库 从性能角度(从高到低)
  • Redis缓存 > Zookeeper >= 数据库 从可靠性角度(从高到低)
  • Zookeeper > Redis缓存 > 数据库