1 什么是分布式锁?
对于单机多线程,在 Java 中,我们通常使用 ReetrantLock
这类 JDK 自带的 本地锁 来控制本地多个线程对本地共享资源的访问。对于分布式系统,我们通常使用 分布式锁 来控制多个服务对共享资源的访问。
一个最基本的分布式锁需要满足:
- 互斥 :任意一个时刻,锁只能被一个线程持有;
- 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。
通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点。
2 分布式锁的应用场景
业务:10个人来抢2部iphone手机
业务实现:
3 高并发场景下秒杀超卖Bug复现
现有两种bug:
- 10个请求同时访问一台服务器。
- 10个请求同时访问两台服务器。
4 高并发场景下JVM锁现场压测实战
通过设置JVM锁,可以解决bug1,但是无法解决bug2,因为jvm锁只局限于处理该请求的代码运行环境,故两台服务器之间还是会发生并发事故。因此需要分布式锁来解决这个问题。
5 高并发场景下分布式锁思路分析
6 高并发秒杀场景下mysql分布式锁实战
- 在数据库中创建一个抢占锁表,假如只有两个字段,id,value
- 当多个用户抢商品时,每一个试图抢单的进程都会忘数据库表中增加一行记录,记录id就是本订单id
- 设计抢占锁表的id主键不可重复,那么谁在数据库插入成功了,就是抢占锁成功
- 其他因为主键约束插入失败的,视为抢占锁失败
- 抢锁成功的,执行完业务后调用释放锁即删除哪行记录;
7 高并发场景下redis分布式锁实战
7.1 简单的redis分布式锁
不论是实现锁还是分布式锁,核心都在于互斥。在 Redis 中, SETNX
命令是可以帮助我们实现互斥。SETNX
即 SET if Not eXists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX
啥也不做。
> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0
释放锁的话,直接通过 DEL
命令删除对应的 key 即可。
> DEL lockKey
(integer) 1
为了误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,现在思考这样一个问题,如果在业务代码的实现逻辑中,已经加锁成功,但是在删除锁之前代码出现了问题,就会导致该锁无法被释放。这种情况该如何解决呢?答案是给锁设置一个过期时间
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
- lockKey :加锁的锁名;
- uniqueValue :能够唯一标示锁的随机字符串;
- NX :只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
- EX :过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。如果不是一个原子操作,伪代码如下所示
1.setnx //加锁代码
2.xxx //业务代码1
3.expire //设置过期时间
4.xxx //业务代码2
如果在执行的2步骤的代码的时候出现了问题,则依然不会设置过期时间,从而导致锁无法释放,因此一定要保证设置指定 key 的值和过期时间是一个原子操作!!!但是即使成功设置了锁的过期时间,也会出现一些问题。如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
你或许在想: 如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。
Redisson 中的分布式锁自带自动续期机制,它提供了一个专门用来监控锁的 Watch Dog( 看门狗),如果操作共享资源的还未完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
7.2 redis锁性能如何提升
假设现在有1000个人抢1号商品,1号商品有100个,其中一个人获得了锁,则剩余的999人都会等待锁的释放才会执行自己的业务逻辑。
7.2.1 分段锁
解决方案:将100个商品分成十组,也就是说可以同时加10个锁,每一组都会有一个请求在进行处理。效率提升10倍。分段锁的概念。
7.2.2 redis集群方案-解决redis单点故障
如果redis宕机了呢?
这个时候就得引入redis集群了。
但是涉及到redis集群,就会有新的问题出现,假设是主从集群,且主从数据并不是强一致性。当主节点宕机后,主节点的数据还未来得及同步到从节点,进行主从切换后,新的主节点并没有老的主节点的全部数据,这就会导致刚写入到老的主节点的锁在新的主节点并没有,其他服务来获取锁时还是会加锁成功。此时则会有2个服务都可以操作公共资源,此时的分布式锁则是不安全的。
redis的作者也想到这个问题,于是他发明了RedLock。
什么是RedLock?
要实现RedLock,需要至少5个实例(官方推荐),且每个实例都是master,不需要从库和哨兵。
实现流程
- 客户端先获取当前时间戳T1
- 客户端依次向5个master实例发起加锁命令,且每个请求都会设置超时时间(毫秒级,注意:不是锁的超时时间),如果某一个master实例由于网络等原因导致加锁失败,则立即想下一个master实例申请加锁。
- 当客户端加锁成功的请求大于等于3个时,且再次获取当前时间戳T2,当时间戳T2 - 时间戳T1 < 锁的过期时间。则客户端加锁成功,否则失败。
- 加锁成功,开始操作公共资源,进行后续业务操作。
- 加锁失败,向所有redis节点发送锁释放命令。
即当客户端在大多数redis实例上申请加锁成功后,且加锁总耗时小于锁过期时间,则认为加锁成功。
释放锁需要向全部节点发送锁释放命令。
第3步为啥要计算申请锁前后的总耗时与锁释放时间进行对比呢?
因为如果申请锁的总耗时已经超过了锁释放时间,那么可能前面申请redis的锁已经被释放掉了,保证不了大于等于3个实例都有锁存在了,锁也就没有意义了
这样的话分布式锁就真的没问题了嘛?
- 得5个redis实例,成本大大增加
- 可以通过上面的流程感受到,这个RedLock锁太重了
- 主从切换这种场景绝大多数的时候不会碰到,偶尔碰到的话,保证最终的兜底操作我觉得也没啥问题。
- 分布式系统中的NPC问题
分布式系统中的NPC问题
(可不是游戏里的NPC提问哦)
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
举个例子吧:
- 客户端 1 请求锁定节点 A、B、C、D、E
- 客户端 1 的拿到锁后,进入 GC(时间比较久)
- 所有 Redis 节点上的锁都过期了
- 客户端 2 获取到了 A、B、C、D、E 上的锁
- 客户端 1 GC 结束,认为成功获取锁
- 客户端 2 也认为获取到了锁,发生【冲突】
在第2步已经成功获取到锁后,由于GC时间超过锁过期时间,导致GC完成后其他客户端也能够获取到锁,此时2个客户端都会持有锁。就会有问题。
这个问题无论是redlock还是zookeeper都会有这种问题。不做业务上的兜底操作就没得解。
时钟漂移问题也只能是尽量避免吧。无法做到根本解决。
8 高并发场景下zookeeper分布式锁实现方案
8.1 什么是zookeeper(zk)?
zk是一个分布式协调服务,功能包括:配置维护、域名服务、分布式同步、组服务等。
zk的数据结构跟Unix文件系统类似。是一颗树形结构,这里不做详细介绍。
8.2 zookeeper节点介绍
zk的节点称之为znode节点,znode节点分两种类型:
- 临时节点(Ephemeral):当客户端与服务器断开连接后,临时znode节点就会被自动删除
- 持久节点(Persistent):当客户端与服务器断开连接后,持久znode节点不会被自动删除
znode节点还有一些特性:
- 节点有序:在一个父节点下创建子节点,zk提供了一个可选的有序性,创建子节点时会根据当前子节点数量给节点名添加序号。例:/root下创建/java,生成的节点名称则为java0001,/root/java0001。
- 临时节点:当会话结束或超时,自动删除节点
- 事件监听:当节点有创建,删除,数据修改,子节点变更的时候,zk会通知客户端的。
8.3 zookeeper分布式锁的实现
zookeeper就是通过临时节点和节点有序来实现分布式锁的。
- 每个获取锁的线程会在zk的某一个目录下创建一个临时有序的节点。
- 节点创建成功后,判断当前线程创建的节点的序号是否是最小的。
- 如果序号是最小的,那么获取锁成功。
- 如果序号不是最小的,则对他序号的前一个节点添加事件监听。如果前一个节点被删了(锁被释放了),那么就会唤醒当前节点,则成功获取到锁。
zookeepe和redisr两者的优缺
zookeeper
优点:
- 不用设置过期时间
- 事件监听机制,加锁失败后,可以等待锁释放
缺点:
- 性能不如redis
- 当网络不稳定时,可能会有多个节点同时获取锁问题。例:node1由于网络波动,导致zk将其删除,刚好node2获取到锁,那么此时node1和node2两者都会获取到锁。
Redis
优点:性能上比较好,天然的支持高并发
缺点:
- 获取锁失败后,得轮询的去获取锁
- 大多数情况下redis无法保证数据强一致性