一文讲透分布锁
type
status
date
slug
summary
tags
category
icon
password
解决的问题
解决分布式系统、异步和并发操作的一致性问题
- 买家和卖家的数据一致性问题
- 数据库锁
悲观锁
- 共享锁
SELECT … LOCK IN SHARE MODE
- 排他锁
SELECT … FOR UPDTAE
乐观锁
{ Business Code }
IF {UPDATE} Failed, go to step 1 else Done !
- 分布式锁管理器
Lock Manager
- 提供分布式锁租约协议
- 无状态服务
- 高可用服务
- 没有单点故障
- 锁的状态要被持久化
- 死锁检测
- 锁过期
- 锁心跳
锁模型
- 无锁模型
- 并发读
- 并发写
- 保护读
- 保护写
- 排他锁
- Redis Redlock
- Lock
SET resource_name my_random_value NX PX 30000
my_random_value
Unique across all clients
NX
Not Exist
PX 30000
Expired of 30s
- Unlock
算法:
- 客户端连接器获取当前毫秒级的时间戳
- 客户端尝试对多个节点用相同的Key和Random Value 进行加锁
- 成功锁住了多数节点并且 加锁时间 < 锁的有效时间
- 为了要让所有的锁都在指定的时间失效 实际上锁的时间 = 锁的有效时间 - 当前加锁的时间
- 如果加锁失败,就解锁所有实例的锁
这个算法就是扯蛋
Redis 这个算法是基于系统时间的是非常危险的
- 有问题的Case
- 解决方法
- Zookeeper
- 创建一个有序的,临时的 标签
- 获得子节点检查自己是否是最小的序号
- 是,获得锁
- 不是,Watch 最小的序列号
Redis分布式锁实现
- SETNX + EXPIRE
先使用setnx来抢锁,如果抢到之后再用expire给锁设置一个过期时间,防止忘记释放
问题:
SETNX 和 EXPIRE两个命令分开了,不是原子操作。如果执行完SETNX加锁正要执行EXPIRE设置过期时间时,服务挂了或者出现了未捕获的异常,别的线程就无法获取到锁了
- SETNX + Value值是(系统时间 + 过期时间)
为了解决方案一,发生异常锁得不到释放的场景,可以把过期时间放到SETNX的Value值里面,如果加锁失败,再拿出Value值校验一下即可
优点:
巧妙的溢出EXPIRE单独设置过期时间的操作,把过期时间放到SETNX的Value值里面。解决了方案一发生异常,锁得不到释放的问题
缺点:
- 过期时间是自己生成的,必须保证分布式环境下,每个客户端的时间是同步的
- 如果锁过期的时候,并发多个客户端请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端的过期时间,可能被别的客户端覆盖
- 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
- 使用Lua脚本(SETNX + EXPIRE指令)
- SET的扩展命令(SET EX PX NX)
SET key value[EX seconds][PX milliseconds][NX|XX]
这条命令也可以保证原子性
- NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
- EX seconds :设定key的过期时间,时间单位是秒。
- PX milliseconds: 设定key的过期时间,单位为毫秒
- XX: 仅当key存在时设置值
问题:
- 锁过期释放了,业务还没执行完,导致业务并行执行了
- 由于GC或者网络原因,锁被别的线程误删了
SET EX PX NX + 校验唯一随机值,在删除
给锁key设置一个标记当前线程唯一的随机数,在删除的时候,校验一下
这里解锁,判断是不是当前线程加的锁和释放锁不是一个原子性操作。如果调用jedis.del释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
为了更严谨,一般也是用lua脚本代替
- Redisson框架
锁过期释放,业务没执行完的问题
只要线程一加锁成功,就会启动一个watch dog 看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁Key的生存时间。
- Redis集群部署使用RedLock
- 按顺序向5个master节点请求加锁
- 根据设置的超时时间来判断,是不是要跳过该master节点。
- 如果大于等于三个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
- 如果获取锁失败,解锁!
分布式锁需要考虑的问题
Redis命令或实现的原子性
如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。
锁误解除导致锁失效
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线程持有。可生成一个 UUID 标识当前线程,使用 lua 脚本做验证标识和解锁操作。
超时解锁导致并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:
- 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
- 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
锁的可重入性
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
在本地记录记录重入次数,如 Java 中使用 ThreadLocal 进行重入次数统计,简单示例代码:
本地记录重入次数虽然高效,但如果考虑到过期时间和本地、Redis 一致性的问题,就会增加代码的复杂性。另一种方式是 Redis Map 数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。Redission 加锁示例:
无法等待锁释放
上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。
- 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
- 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。如下:
集群
- 主备切换
为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。
在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。
- 集群脑裂
集群脑裂指因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。Redis Cluster 集群部署方式同理。
当不同的客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁。如下:
Loading...