分布式锁
约束
-
基本要求: 保证在分布式环境下,同一时间只能有一个方法拿到锁
-
性能: 获得锁和释放锁的性能要好
-
可用性: 获得锁和释放锁要高可用
锁的特性
-
避免活锁: 希望是可重入锁
-
公平锁(业务考虑)
-
阻塞锁(业务考虑)
数据库
优点: 简单,易于理解 缺点: 会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)
基于表主键唯一做分布式锁
利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。
问题
-
这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
-
这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
-
这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
-
这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
-
这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
-
在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。
解决方案
-
数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
-
没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
-
非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
-
非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
-
非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
-
比较好的办法是在程序中生产主键进行防重。
基于表字段版本号做分布式锁
- 这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
基于数据库排他锁做分布式锁
-
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意: InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
-
我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。
-
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
-
阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
-
锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
-
-
但是还是无法直接解决数据库单点和可重入问题。
-
还可能存在另外一个问题,虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。
-
还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
缓存实现(Redis)
优点: 高性能 缺点: 存在一些可靠性问题。超时时间设置多长合适? 短了需要定期延长,长了会浪费等待时间
基于 redis 的 setnx()
参考:Redis
基于 Redlock 做分布式锁
Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)。
算法的步骤
- 客户端获取当前时间,以毫秒为单位;
- 跟上面类似,轮流尝试在每个 master 节点上创建锁,超时时间较短,一般就几十毫秒(客户端为了获取锁而使用的超时时间比自动释放锁的总时间要小。例如:如果自动释放时间是 10 秒,那么超时时间可能在
5~50
毫秒范围内); - 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点
n / 2 + 1
; - 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
- 如果客户端获取锁失败了,客户端会依次删除所有的锁。 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。使用 Redlock 算法,可以保证在挂掉最多 2 个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和缓存锁大大提高了可用性,由于 redis 的高效性能,分布式缓存锁性能并不比数据库锁差。
基于 Redisson 做分布式锁
- redis 官方的分布式锁组件,支持上面的分布式锁实现
分布式协调(zookeeper)
优点: 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点: 性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。
基于 ZooKeeper 做分布式锁
zk 基本锁
-
原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
-
缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
zk 锁优化
- 原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。
步骤:
-
在 /lock 节点下创建一个有序临时节点(EPHEMERAL_SEQUENTIAL)。
-
判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。
-
当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小。
-
取锁成功则执行代码,最后释放锁(删除该节点)。