跳至主要內容

分布式锁

ChenSino大约 10 分钟

1、写在前面

以前一直没搞清楚分布式锁和分布式事务,对其概念以及使用场景很模糊,今天查查资料,好好总结一下分布式事务和分布式锁。另外提前说一句,使用redis来解决分布式、高并发问题存在一些困难,Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库的防并发手段。

2、锁和分布式锁解决了什么问题?

单机锁:解决单进程中多线程同时操作共有数据(比如java堆数据)带来的安全问题,强调的是单机服务中线程安全问题,这种场景很常见,比如单机多线程售票的例子。这种锁直接通过java的本地锁实现即可,可以使用java自带的synchroized和Lock

分布式锁:解决集群服务中,多个相同服务操作同一个资源数据的安全问题。比如淘宝双十一抢购,为了支持高并发,下订单的服务肯定是集群模式而非单机,比如有一个商品促销,数量共有1000个,下订单的集群服务是10个,如果抢购开始,这10个集群服务应该是共同拥有这1000个商品,应当避免在并发情况下销售的总量超过1000的情况,这种就是典型的分布式锁需要处理的问题。

3、分布式锁特性

  • 互斥性: 同一时刻只能有一个服务(进程)持有锁
  • 可重入性: 同一服务节点上的同一个线程如果获取了锁之后能够再次获取锁
  • 锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
  • 高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
  • 具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒

4、分布式锁的实现

4.1 redis实现

实现思路:利用redis处理网络请求是单线程(其他模块是多线程),并且其操作具有原子性。

4.1.1 单机redis实现(采用逐步升级的方式来进行分析)
  1. 采用redis 的SETNX(SET IF NOT EXIST),key和value我们可以随意指定,若key不存在此操作成功返回1,如果key已存在则set失败返回0。正常情况第一个抢占锁的服务设置肯定是返回1的,如果此时其他服务(JVM进程)再次来set相同的key就返回0,代表抢锁失败。

    SETNX lock_source_key lock_value # 加锁
    do something #获取锁以后的业务处理代码
    DEL lock_source_key #业务处理完以后释放锁
    

如果仅仅是设置key,是存在问题的,比如第一个服务抢到了锁,但是服务挂掉、或者带宽堵塞、GC等种种原因,导致其迟迟不能释放锁,那么其他服务就一直没有机会抢到锁,这样肯定是不合理的。

  1. 在1的基础上升级一下,给key加一个过期时间,如果出现意外情况,key到期以后可以自动释放

    SETNX lock_source_key lock_value # 加锁
    EXPIRE lock_source_key 10 #假设过期时间是10s
    do something #获取锁以后的业务处理代码
    DEL lock_source_key #业务处理完以后释放锁
    

经过以上升级后依然会存在问题,细心的读者可能已经发现,因为SETNX 和 EXPIRE是两步分开的操作,所以仍然会存在安全问题,因为redis的原子性指的是一个操作,这两是分开的两个操作,并不是原子的。比如,当服务SETNX成功后,在EXPIRE 这个操作执行前又挂了,那就又回到第1个那样的场景,导致抢到锁,但是却一直不释放锁。

  1. 在2的基础上再次升级,为了解决 SETNX 和 EXPIRE 两个操作非原子性的问题,可以使用 Redis 的 SET 指令的扩展参数,使得 SETNX 和 EXPIRE 这两个操作可以原子执行

    SET lock_source_key lock_value NX EX 10 #加锁
    do something
    DEL lock_source_key #释放锁
    
    • NX 表示只有当 lock_resource_id 对应的 key 值不存在的时候才能 SET 成功。保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
    • EX 10 表示这个锁 10 秒钟后会自动过期,业务可以根据实际情况设置这个时间的大小。

再次升级后,看似完美,实则不然:

  • 锁被提前释放。假如线程 A 在加锁和释放锁之间的逻辑执行的时间过长(或者线程 A 执行过程中被堵塞),以至于超出了锁的过期时间后进行了释放,但线程 A 在临界区的逻辑还没有执行完,那么这时候线程 B 就可以提前重新获取这把锁,导致临界区代码不能严格的串行执行。
  • 锁被误删。假如以上情形中的线程 A 执行完后,它并不知道此时的锁持有者是线程 B,线程 A 会继续执行 DEL 指令来释放锁,如果线程 B 在临界区的逻辑还没有执行完,线程 A 实际上释放了线程 B 的锁。

为了避免以上情况,建议不要在执行时间过长的场景中使用 Redis 分布式锁,同时一个比较安全的做法是在执行 DEL 释放锁之前对锁进行判断,验证当前锁的持有者是否是自己。

具体实现就是在加锁时将 value 设置为一个唯一的随机数(或者线程 ID ),释放锁时先判断随机数是否一致,然后再执行释放操作,确保不会错误地释放其它线程持有的锁,除非是锁过期了被服务器自动释放,整个过程如下:

# 先产生一个随机数,可以用uuid等
SET lock_source_key random_value NX EX 10 #加锁
do something
if random_value == get(lock_source_key)   #释放锁前,先判断当前服务是否是锁的持有者
DEL lock_source_key #释放锁

但判断 value 和删除 key 是两个独立的操作,并不是原子性的,所以这个地方需要使用 Lua 脚本进行处理,因为 Lua 脚本可以保证连续多个指令的原子性执行。

if redis.call("get",KEYS[1] == ARGV[1]) then  #java代码中调用lua脚本判断
	return redis.call("DEL",KEYS[1])          #如果判断相等则用lua调用del释放锁
else
	return 0;                                 #如果当前服务不是锁拥有者直接返回0,即不释放锁,因为此时锁的所有者不是当前服务

基于 Redis 单节点的分布式锁基本完成了,但是这并不是一个完美的方案,只是相对完全一点,因为它并没有完全解决当前线程执行超时锁被提前释放后,其它线程乘虚而入的问题。要完美解决此问题需要使用 Redisson 的分布式锁,具体实现比较复杂以后补充。

4.1.2 多机redis实现

在4.1.1中,是单机部署,如果用在多机redis部署又会产生新的问题。加锁时只作用在一个 Redis 节点上,即使 Redis 通过 Sentinel 保证了高可用,但由于 Redis 的复制是异步的,Master 节点获取到锁后在未完成数据同步的情况下发生故障转移,此时其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。

整个过程如下:

  • 客户端 A 从 Master 节点获取锁。
  • Master 节点出现故障,主从复制过程中,锁对应的 key 没有同步到 Slave 节点。
  • Slave 升 级为 Master 节点,但此时的 Master 中没有锁数据。
  • 客户端 B 请求新的 Master 节点,并获取到了对应同一个资源的锁。
  • 出现多个客户端同时持有同一个资源的锁,不满足锁的互斥性。

正因为如此,在 Redis 的分布式环境中,Redis 的作者 antirez 提供了 RedLock 的算法来实现一个分布式锁,该算法大概是这样的:

假设有 N(N>=5)个 Redis 节点,这些节点完全互相独立,不存在主从复制或者其他集群协调机制,确保在这 N 个节点上使用与在 Redis 单实例下相同的方法获取和释放锁。

获取锁的过程,客户端应执行如下操作:

  • 获取当前 Unix 时间,以毫秒为单位。
  • 按顺序依次尝试从 5 个实例使用相同的 key 和具有唯一性的 value(例如 UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在一直等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(使用 Redis Lua 脚本)。

释放锁的过程相对比较简单:客户端向所有 Redis 节点发起释放锁的操作,包括加锁失败的节点,也需要执行释放锁的操作,antirez 在算法描述中特别强调这一点,这是为什么呢?

原因是可能存在某个节点加锁成功后返回客户端的响应包丢失了,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。虽然对客户端而言,由于响应超时导致加锁失败,但是对 Redis 节点而言,SET 指令执行成功,意味着加锁成功。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些 Redis 节点同样发起请求。

除此之外,为了避免 Redis 节点发生崩溃重启后造成锁丢失,从而影响锁的安全性,antirez 还提出了延时重启的概念,即一个节点崩溃后不要立即重启,而是等待一段时间后再进行重启,这段时间应该大于锁的有效时间。

4.2 zookeeper实现

TODO

4.3 基于数据库实现

TODO

4.4 MemCached 实现

TODO

5、参考博客

  1. 浅析分布式锁redis解决方案open in new window

  2. 分布式锁的实现之 redis 篇open in new window