为什么需要分布式锁
在单机部署的项目中,多线程间的并发控制可以由Java相关的并发处理API来控制线程间的通信和互斥。但是在分布式集群的系统中,单机部署情况下的并发控制策略就会失效了,单纯的Java API是不具备分布式环境下的并发控制能力的;所以这就需要一种跨JVM的互斥机制来控制对共享资源的访问,这就是分布式锁要解决的问题了
在分布式场景下,CAP理论已经证明了任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项;所以为了保证在分布式环境下的数据最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等
分布式锁的特性
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
- 高可用、高性能的获取锁与释放锁
- 具备可重入特性
- 具备锁失效机制,防止死锁
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
分布式锁的三种实现方案
数据库实现
数据库实现分布式锁主要是依赖唯一索引
(唯一索引:不允许具有索引值相同的行,从而禁止重复的索引或键值。数据库会在创建该索引时检查是否有重复的键值,并在每次使用 INSERT 或 UPDATE 语句时进行检查)
实现的思路:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,因为做了唯一索引,所以即使多个请求同时提交到数据库,都只会保证只有一个操作能够成功,插入成功则获取到该方法的锁,执行完成后删除对应的行数据释放锁
1 | CREATE TABLE `distributed_lock` ( |
数据库实现分布式锁的增强
- 该分布式锁依赖数据库的可用性,如果数据库是单点且挂掉,那么分布式锁功能失效
- 解决方案:
- 多机部署,数据同步,数据库主备切换
- 解决方案:
- 同一个线程在释放锁之前,行数据一直存在,无法再次插入数据;这种情况下该分布式锁不具备可重入性
- 解决方案:在表中新增一列用于记录当前获取到锁的机器和线程信息,在该线程再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁
- 没有锁失效的机制可能会出现在获取锁之后,数据库宕机,对应的行数据没有被删除,等到数据库服务器恢复后,表中的数据仍然存在,从而无法再获取到锁;或者释放锁失败
- 解决方案:
- 在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;此时也需要根据业务需求考虑定时任务的执行时间,不能过长或者过短
- 多机部署,数据同步,数据库主备切换
- 解决方案:
- 阻塞锁特性,在代码逻辑中增加失败重试机制(while循环),根据业务需求多次去获取锁直到成功或者达到失败次数后返回等等
数据库实现分布式锁的问题
虽然我们对method_name 使用了唯一索引,并且显示使用for update来使用行级锁。
但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁
Redis实现分布式锁
实现思路:- setnx:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0
1. 获取锁的时候,使用setnx加锁,锁的value值可以是一个随机生成的UUID,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁
2. 获取锁的时候设置一个获取锁的超时时间,若超过这个时间则放弃获取锁
3. 释放锁的时候,通过随机生成的UUID去匹对锁的键值对是否对应,若是则执行delete释放锁
1 | /** |
#### Redis分布式锁的增强
1. 锁失效时间
- 锁失效的时间需要根据实际业务需求来设置一个合适的值
- 如果设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题
- 如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间
2. 可利用while循环去获取锁,可以设置重试间隔时间和最大重试时间来实现锁阻塞特性
3. 不可重入
- 解决方案:
- 线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者;释放锁的时候将这些信息删除
4. 单点故障
- 解决方案:
- Redis集群,Redis主从
#### Redis实现分布式锁存在的问题
这类最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
1. 在Redis的master节点上拿到了锁
2. 但是这个加锁的key还没有同步到slave节点
3. master故障,发生故障转移,slave节点升级为master节点
4. 导致锁丢失
### Zookeeper实现分布式锁
实现思路:
每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点(EPHEMERAL_SEQUENTIAL)
使用Zookeeper可以实现的分布式锁是阻塞的,客户端可以通过在ZK中创建瞬时有序节点,并且在节点上绑定监听器,一旦节点发生变化,ZK会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是那么自己就获取到锁,反之则继续等待
当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题,因为瞬时节点在会话断开后就会自动删除
1 | /** |
Curator的分布式锁
1 | /** |
ZK实现分布式锁的问题
使用Zookeeper也有可能带来并发问题:由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了,就可能产生并发问题。
这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点(所以选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡)
三者的比较
- 从性能角度(从高到低)
- 缓存 > Zookeeper >= 数据库
- 从可靠性角度(从高到低)
- Zookeeper > 缓存 > 数据库