分布式锁特性
分布式锁,顾名思义,就是分布式项目开发中用到的锁,可以用来控制分布式系统之间同步访问共享资 源。
- 互斥性:在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
- 高可用性:在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布 式锁的服务以集群的方式部署;
- 防止锁超时:如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕 机或者网络不可达时产生死锁;
- 独占性:加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了。
redis实现分布式锁:
优点:Redis 锁实现简单,理解逻辑简单,性能好,可以支撑高并发的获取、释放锁操作。
缺点:
- Redis 容易单点故障,集群部署,并不是强一致性的,锁的不够健壮;
- key 的过期时间设置多少不明确,只能根据实际情况调整;
- 需要自己不断去尝试获取锁,比较消耗性能。
mysql实现分布式锁:
缺点:
- 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程 并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。可解决。
zookeeper实现分布式锁:
优点:zookeeper 天生设计定位就是分布式协调,强一致性,锁很健壮。如果获取不到锁,只需要添加一 个监听器就可以了,不用一直轮询,性能消耗较小。
缺点:在高请求高并发下,系统疯狂的加锁释放锁,最后 zk 承受不住这么大的压力可能会存在宕机的风 险。
redis实现分布式锁
setnx
-
加锁:使用setnx来加锁。key是锁的唯一标识,按业务来决定命名
setx key test
value这里设置为test。当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回 0,说明key已经存在,该线程抢锁失败;
-
解锁:当得到的锁的线程执行完任务,需要释放锁,以便其他线程可以进入:
del key
-
锁超时:setnx的key必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一段时间后自动 释放:
expire key 30
存在的问题:
-
SETNX 和 EXPIRE 非原子性:
-
某一个线程刚执行 setnx,成功得到了锁。此时 setnx 刚执行成功,还未来得及执 行 expire 命令,节点就挂掉了。此时这把锁就没有设置过期时间,别的线程就再也无法获得该锁。
-
解决办法:可以使用 set 指令,后面加入参数 EX 和 NX 就可以实现加锁的同时,设置过期时间!(即把 EX 和 NX 变成了一个原子操作)
SET key value [EX seconds][PX milliseconds] [NX|XX]
:SET lockKey requestId NX PX 30000
- EX second: 设置过期时间,设置键的过期时间为 second 秒;
- PX millisecond:设置过期时间,设置键的过期时间为 millisecond 毫秒;
- NX:互斥,只在键不存在时,才对键进行设置操作;
- XX:非互斥,只在键已经存在时,才对键进行设置操作;
-
-
锁误删:
- 如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期 自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的是线程 B 加的锁。
- 解决方法:删除锁时获取锁标识并判断是否一致。在 del 释放锁之前加一个判断,验证当前的锁是不是自己加的锁。 具体在加锁的时候把当前线程的 id 当做 value,可生成一个 UUID 标识当前线程,在删除之前验证 key 对应的value 是不是自己线程的 id。还可以使用 lua 脚本做验证标识和解锁操作以保证原子性。 (不能使用默认的线程 id,因为在分布式情况下,可能多个线程访问不同的服务器,使得具有相同 的线程 id)
-
释放锁的问题:非原子操作
包含了【获取该锁的值】,【判断是否是自己加的锁】,【删除锁】这三个操作,万一这三个操作中间的某个时刻出现阻塞(业务处理、GC、操作系统等原因),就会导致问题。
正确的释放锁姿势——锁的判断和删除都在服务端(Redis),使用 lua 脚本保证原子性。
释放锁的操作为什么要使用 lua 脚本?
释放锁其实包含三步操作:‘GET’、判断和‘DEL’,用 Lua 脚本来实现能保证这三步的原子性。
基于setnx实现的分布式锁问题:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放。
- 主从一致性:如果Redis提供主从集群,主从同步存在延迟,当主机宕机时,如果从库未同步主库的数据,则会出现锁失效问题。
Redisson
-
解决不可重入问题:(类似于 ReentrantLock,用 hash 结构存储线程标识以及重入次数,使用 lua 脚本确保原子性)
-
解决不可重试问题:指定参数,该时间内重试获取。
-
解决锁超时问题:如果客户端 1 请求锁成功了,但是由于业务处理、GC、操作系统等原因导致它处理时间过长,超过了锁的时间,这时候 Redis 会自动释放锁,这种情况可能导致问题。通过续期解决
watchdog(看门狗):加锁时没有指定加锁时间时会启用 watchdog 机制,默认加锁 30 秒,每 10 秒钟检查一次,如果存在就重新设置 过期时间为 30 秒(即 30 秒之后它就不再续期了)。
- 默认值:30000,可以通过修改 Config.lockWatchdogTimeout 来另行指定。
- 如果负责储存这个分布式锁的 Redisson 节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现死锁的情况。为了避免这种情况的发生,Redisson 内部提供了一个监控锁的看门狗(WatchDog),它 的作用是在 Redisson 实例被关闭前,不断的延长锁的有效期。适用于分布式锁的加锁请求中未明确使用 leaseTimeout() 参数的情况。如果该看门狗未使用 lockWatchdogTimeout 去重新调整一个分布式锁的 lockWatchdogTimeout 超时,那么这个锁将变为失效状态。
RedLock
【为什么有RedLock?】
Redis 主从架构数据同步复制问题:如果线程一在
Redis
的master
节点上拿到了锁,但是加锁的key
还没同步到slave
节点。恰好这时,master
节点发生故障,一个slave
节点就会升级为master
节点。线程二就可以获取同个key
的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。【Redlock算法的基本思路】
让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和 半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁 失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
加锁:
- 客户端获取当前时间
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。
- 使用 SET 命令,带上 NX,EX/PX 选项
- 包含客户端的唯一标识(防止锁误删除),
- 为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。
- 如果客户端从大多数 Redis 节点(大于等于 N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功。
- 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算。等于最初有效时间减去获得锁消耗的时间。
- 如果最终获取锁失败了(可能由于获取到锁的 Redis 节点个数少于 N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有 Redis 节点发起释放锁的操作(使用 Redis Lua 脚本)。
解锁:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。
为什么要在多个实例上加锁?
本质上为了**容错,**部分实例异常宕机,剩余实例只要超过 N/2+1 依旧可用。
为什么加锁成功之后,还要计算加锁的累计耗时?
考虑网络延迟、丢包、超时等情况发生,如果加锁的累计耗时已经超过了锁的过期时间,那么此时的锁已经没有意义了。
为什么释放锁,要操作所有节点,对所有节点都释放锁?
因为当对某一个 Redis 节点加锁时,可能因为网络原因导致加锁“失败”。注意这个“失败”,指的是 Redis 节点实际已经加锁成功了,但是返回的结果因为网络延迟并没有传到加锁的线程,被加锁线程丢弃了,加锁线程误以为没有成功,于是加锁线程去尝试下一个节点了。所以释放锁的时候,不管以前有没有加锁成功,都要释放所有节点的锁,以保证清除节点上述发生的情况导致残留的锁。
崩溃恢复(AOF 持久化)对 Redlock 算法影响:
-
问题:假设 Rodlock 算法中的 Redis 发生了崩溃-恢复,那么锁的安全性将无法保证。假设加锁线程在 5 个实例中对其中 3 个加锁成功,获得了这把分布式锁,这个时候 3 个实例中有一个实例被重启了。重启后的实例将丢失其中的锁信息,这个时候另一个加锁线程可以对这个实例加锁成功,此时两个线程同时持有分布式锁。锁的安全性被破坏。
-
解决:持久化配置中设置
fsync=always
,但性能大大降低
Redlock 算法存在的问题:
- 节点重启:N 个 Redis 节点中如果有节点发生崩溃重启,会对锁的安全性有影响的。具体的影响程度跟 Redis 对数据的持久化程度有关。
- 解决:延迟重启:一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。
- 时钟变迁::Redis 的过期时间是依赖系统时钟的,如果时钟漂移过大时会影响到过期时间的计算。