文章目录
1. 产生原因
分布式应用通常需要考虑数据一致性问题,分布式锁的目的就是保证数据的最终一致性。
分布式的CAP理论认为 “任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项”。在大多数的场景中,牺牲强一致性来换取系统的高可用性是可以接受的,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内
2. 分布式锁的属性
分布式锁应该具有以下属性:
- 可以保证在分布式部署的应用集群中不同节点的不同线程的互斥
- 为避免线程自己阻塞自己造成死锁, 分布式锁需具有可重入性
- 根据业务需求考虑要不要将锁设置为阻塞锁
- 要保证获取锁和释放锁功能的可用性
- 获取锁和释放锁的性能要好
3. 分布式锁的实现
3.1. 基于数据库方式
使用数据库来实现分布式锁的方式都是依赖数据库的一张锁记录表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。基于数据库的方式对数据库可用性依赖很高,另外操作数据库的性能开销也需要考虑
3.1.1. 基于数据库表
原理
创建一张专门的锁记录表,当要锁住资源或方法的时候,在表中增加记录, 可通过唯一性约束来保证记录的唯一性。 这样当多个线程调用同一个方法时,只有一条线程可在表中成功创建记录,也就意味着只有一条线程获取锁成功。线程释放锁的时候删除该记录, 其他线程就可以重新尝试写入记录获取锁。
缺点
- 这把锁强依赖数据库的可用性, 数据库是一个单点,如果数据库挂掉会导致业务系统不可用
- 这把锁没有失效时间, 一旦释放锁操作失败,就会导致锁记录一直在数据库里,其他线程无法再获得锁
- 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程不会进入排队队列,要想再次获得锁就要再次触发获得锁操作
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为表中数据已经存在,尚未删除
解决方法
数据库是单点
: 使用两个数据库,数据之间双向同步,一旦主库挂掉快速切换到备库没有失效时间
: 做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍非阻塞的
: 使用while
循环,直到insert
成功再返回成功非重入的
: 在数据库表中加字段用以记录当前获得锁的机器的主机信息和线程信息,下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接分配锁就可以了
3.1.2. 基于数据库排他锁
原理
基于MySql的InnoDB引擎,可以通过数据库的排他锁来实现分布式锁。
主要步骤为在查询语句后面增加 for update,数据库会在查询过程中给数据库表增加排他锁,当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。这样获得排它锁的线程即获得分布式锁,可以通过commit()操作来释放锁
- NOTE
InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。如果希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上
可能的问题
- 虽然对 method_name 使用了唯一索引,并且显式使用
for update
来使用行级锁,但MySql会对查询进行优化,即便在条件中使用了索引字段,最终是否使用索引检索数据是由 MySQL 查询优化器通过判断不同执行计划的代价来决定的。如果 MySQL 认为全表扫效率更高,例如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁- 使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。 如果类似的连接变得多了,就可能把数据库连接池撑爆
解决的问题
这种方式可以有效的解决上面提到的无法释放锁和阻塞锁的问题,但是还是无法直接解决数据库单点和可重入问题
- 阻塞锁: for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功
- 锁定之后服务宕机,无法释放锁: 使用这种方式,服务宕机之后连接断开,数据库会自己把锁释放掉
3.2. 基于缓存方式
3.2.1. Redis 指令简单实现
原理
Redis 的
setnx
指令规则为如果不存在以指定的 key 为键的键值对数据则新建,返回1 表示获取锁。若给定的 key 已经存在,则setnx
不做任何动作,返回0 表示获取锁失败,做完操作以后 del key,表示释放锁
- NOTE
加锁之后如果机器宕机,那么这个锁就不会得到释放,所以需要加入过期时间,加入过期时间必须和setnx同一个原子操作(使用组合参数,setnx和expire合成一条指令), 因为在setnx之后执行expire之前Redis有可能意外挂掉
可能的问题
- 业务服务线程执行代码时由于 GC 或者网络异常执行慢,导致锁超时后其它线程拿到锁
- Redis 服务器时钟跳跃,导致锁过期
- Redis 服务器主从部署,从节点尚未同步数据时主节点挂了,从升主导致锁丢失
应对措施
- 设置合理的锁过期时间
根据业务处理的时间复杂度,设置一个合理的锁过期时间,尽量避免因为GC等原因导致锁过期- 锁续期
如果这个锁非常重要,可以启动一个后台线程,当业务逻辑没有执行完但锁快要过期时,对锁进行续期操作。这样可以确保在业务逻辑执行的过程中,锁一直是有效的- 使用 RedLock 算法
RedLock 是 Redis 官方推荐的分布式锁实现方案,它在多个 Redis 节点上同时加锁,只有当大部分Redis节点上加锁成功时,才认为获取锁成功。这样可以避免单个 Redis 节点出问题等原因导致锁过期- 业务内部处理做幂等
如果业务很重要,建议使用数据库唯一键做幂等兜底- 根据业务重要性做好告警
3.2.2. Redis 的 RedLock
使用多个 Redis 实例来实现分布式锁,这是为了保证单点故障时锁仍然可用
原理
- 尝试从 N 个相互独立的 Redis 实例获取锁
- 计算获取锁消耗的时间,当该时间小于锁过期时间并且至少(N/2 +1)个实例获取了锁,认为锁获取成功
- 如果锁获取失败,到每个Redis 实例上释放锁
争议问题
- 系统时钟跳跃或者GC 导致的STW 会造成锁过期时间不准的问题
- 例如: 集群
A B C D E
中节点A B C
获取到锁, 集群锁获取成功。此时因为系统时钟跳跃,A节点过期时间提前到达,释放锁后另一条线程在A D E
节点上拿到了锁,造成锁互斥失效- 回答: 锁过期强依赖于时间的分布式锁都会有这个问题
- 多个 Redis 实例会影响效率
Reference: Redis分布式锁续期
获取锁成功会开启一个定时任务,也就是 watchdog,定时任务会定期检查去续期。该定时调度每次调用的时间差是 internalLockLeaseTime / 3,也就是10秒。默认情况下加锁的时间是30秒,如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候就会进行一次续期,把锁重置成30秒
3.3. 基于ZooKeeper
原理
获取锁流程:
- 首先进行可重入的判定。可重入锁记录在
ConcurrentMap<Thread, LockData> threadData
这个Map里,如果threadData.get(currentThread)
是有值的那么就证明是可重入锁,记录就会加1- 资源目录下创建一个节点,这个节点需要设置为
EPHEMERAL_SEQUENTIAL
也就是临时节点并且有序- 获取当前目录下所有子节点,判断自己的节点是否位于子节点第一个
- 如果是第一个,则获取到锁,那么可以返回
- 如果不是第一个,证明已经有线程获取到锁,那么需要获取自己节点的前一个节点,在上面注册Watcher监听(这个watcher其实调用的是object.notifyAll(),用来解除阻塞)
解锁流程:
- 首先进行可重入锁判定。如果有可重入锁只需要次数减1即可,减1之后加锁次数为0的话继续下面步骤,不为0直接返回
- 删除当前节点。(服务宕机后临时节点自动删除,不存在锁无法释放而产生的死锁问题)
- 删除
threadDataMap
里面的可重入锁的数据