[Redis]分布式锁

为什么需要分布式锁#

现在大多数的项目都是由分布式架构所搭建起来的,常常会采用集群以及部署在多台服务器上等方式运行,想必现在nginx的反向代理、负载均衡已经无人不知无人不晓了吧。由于通过用户不同的电脑已经不同的浏览器进行访问项目,就会由nginx转发给集群中的项目实例,此时常用的锁由于跨服务器的原因导致失效。但当请求过来时,例如有三台服务器收到了请求,都去查询数据库中是否还有一个商品,当发现还有10件商品的时候,三个台服务器同时进行,都买了4件,到最后的时候发现库存成负数了,这就导致了超卖问题。现在就必须要用到分布式锁,来解决这个问题了。

业务场景#

库存超卖问题 系统A是一个电商系统,目前是一台机器部署,系统中有一个用户下订单的接口,但是用户下订单之前一定要去检查一下库存,确保库存足够了才会给用户下单。 由于系统有一定的并发,所以会预先将商品的库存保存在redis中,用户下单的时候会更新redis的库存。此时系统架构如下:

image

但是这样一来会产生一个问题:假如某个时刻,redis里面的某个商品库存为1,此时两个请求同时到来,其中一个请求执行到上图的第3步,更新数据库的库存为0,但是第4步还没有执行。而另外一个请求执行到了第2步,发现库存还是1,就继续执行第3步。这样的结果,是导致卖出了2个商品,然而其实库存只有1个。
很明显不对啊!这就是典型的库存超卖问题
此时,我们很容易想到解决方案:用锁把2、3、4步锁住,让他们执行完之后,另一个线程才能进来执行第2步。

image

按照上面的图,在执行第2步时,使用Java提供的synchronized或者ReentrantLock来锁住,然后在第4步执行完之后才释放锁。这样一来,2、3、4 这3个步骤就被“锁”住了,多个线程之间只能串行化执行。
但是好景不长,整个系统的并发飙升,一台机器扛不住了。现在要增加一台机器,如下图:

image

假设此时两个用户的请求同时到来,但是落在了不同的机器上,那么这两个请求是可以同时执行了,还是会出现库存超卖的问题。为什么呢?因为上图中的两个A系统,运行在两个不同的JVM里面,他们加的锁只对属于自己JVM里面的线程有效,对于其他JVM的线程是无效的。
因此,这里的问题是Java提供的原生锁机制在多机部署场景下失效了这是因为两台机器加的锁不是同一个锁(两个锁在不同的JVM里面)。
那么,我们只要保证两台机器加的锁是同一个锁,问题不就解决了吗?此时,就该分布式锁隆重登场了,分布式锁的思路是:在整个系统提供一个全局、唯一的获取锁的“东西”,然后每个系统在需要加锁时,都去问这个“东西”拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁

分布式应用进行逻辑处理时经常会遇到并发问题。
如图所示,一个操作要修改用户的状态。修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。

image

如果这样的操作同时进行,就会出现并发问题,因为“读取”和“保存状态”这两个操作不是原子操作。 原子操作是指不会被线程调度机制打断的操作。这种操作一旦开始,就会一直运行到结束,中间不会有任何线程切换。这个时候就要使用到分布式锁来限制程序的并发执行。 Redis 分布式锁使用得非常广泛,它是面试的重要考点之一,很多同学都知道这个知识,也大致知道分布式锁的原理,但是具体到细节的掌握上,往往并不完全正确。

分布式锁的奥义

分布式锁本质上要实现的目标就是在 Redis 里面占一个,当别的进程也要来占坑时,发现那里已经有一根“大萝卜”了,就只好放弃或者稍后再试。占坑一般使用 setnx(set if not exists)指令,只允许被一个客户端占坑。先来先占,用完了,再调用 del 指令释放“坑”。

//这里的冒号 :就是一个普通的字符,没特别含义,它可以是其他任意字符,别误解
> setnx lock:codehole true
OK
do something critical
> del lock:codehole
(integer)1

执行异常,无法释放锁#

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del指令没有被调用,这样就会陷入死锁,锁永远得不到释放。于是我们在拿到锁之后,再给锁加上一个过期时间,比如5s,这样即使中间出现异常也可以保证 5s 之后锁会自动释放。

> setnx lock:codehole true
OK
> expire lock:codehole 5
do something critical
> del lock:codehole
(integer) 1

上锁和设置过期不是原子操作#

但是以上逻辑还有问题。如图 1-14 所示,如果在 setnx expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是人为造成的,就会导致 expire 得不到执行,也会造成死锁。

image

这种问题的根源就在于 setexpire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决,但在这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁, expire 是不应该执行的。事务里没有 if else 分支逻辑,事务的特点是一口气执行,要么全部执行,
要么个都不执行。
为了解决这个疑难, Redis 开源社区涌现了许多分布式锁的 library ,专门用来解决这个问题,实现方法极为复杂,小白用户一般要费很大的精力才可以弄懂 如果你需要使用分布式锁,意昧着你不能仅仅使用 Jeclis 或者 redis-py ,还得引人分布式锁的 library为了治理这个乱象,在 Redis 2.8 版本中,作者加入了 set 指令的扩展参数,使
setnx expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁 library 都可以休息了

> set lock:codehole true ex 5 nx
OK
do something critical
> del lock:codehole

上面这个指令就是 setnx expire 组合在一起的原子指令,它就是分布式锁的奥义所在。

这条命令是一个 Redis 命令,用于在 Redis 中设置一个分布式锁。下面是对该命令的详细解释:

命令:`SET lock:codehole true EX 5 NX`

- `SET`:Redis 的键值对存储命令,用于设置键的值。
- `lock:codehole`:锁的名称,这里命名为 "lock:codehole"。锁的名称通常是一个唯一标识符,用于在 Redis 中标识不同的锁。
- `true`:锁的值。在这种情况下,我们将锁的值设置为布尔值 `true`。实际上,锁的值可以是任何字符串。
- `EX 5`:设置键的过期时间为 5 秒。`EX` 是一个选项,表示过期时间的单位是秒。在这里,我们将锁设置为在 5 秒后自动过期。
- `NX`:设置键的条件。`NX` 是一个选项,表示只有在键不存在时才设置键。这确保了只有一个客户端能够成功地设置该锁。如果键已经存在,命令将不会设置锁并返回失败。

综合起来,该命令的含义是:尝试在 Redis 中设置一个键为 "lock:codehole" 的分布式锁,其值为 `true`,过期时间为 5 秒,只有当该键不存在时才设置成功。
该命令的使用场景是在分布式系统中实现互斥访问。多个客户端可以通过执行这个命令来竞争获取同一个锁。只有一个客户端能够成功地设置该锁,其他客户端则会失败。这样,成功获取锁的客户端可以执行需要互斥访问的操作,而其他客户端则需要等待或执行其他逻辑。

超时问题(锁的时间设置的太短)#

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,就会出现问题 因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,而同时第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格串行执行。

这里有一个解决方案就是开启一个延时线程,比如说我们给锁设置了3s的过期时间,那么在设置这个锁的同时,我们开启一个线程,在2s之后查看这个锁是不是还在,如果锁还在,那么把锁的过期时间重新设置为3s。这样在锁没有释放之前我们一直延期,不会出现锁提前释放的情况,只能被程序逻辑本身del。

为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了问题,造成的数据小错乱可能需要人工介入解决。

锁被其他线程释放#

如果锁的过期时间设置的太短,线程A还没执行完成,锁就超时释放了,这时另一个线程B过来占据了锁,在B的执行过程中,A执行完成主动释放锁。这样线程A释放了线程B的锁,这样肯定是不行的,为了避免这种情况,我们在设置锁的时候,不把锁的值设置成简单的true,而是设置成复杂的随机数,并且要把这个随机数记下来,当我们主动去释放锁的时候,要看一下锁的值是不是还是之前设置锁的时候设置的那个随机数,如果不是那个随机数,说明所被另一个线程重新设置了,我们不能释放别人的锁。

tag = random.nextint() #随机数
if redis.set(key, tag, nx=True, ex=5):
	do_something()
	redis.delifequals(key, tag) #假想的 delifequals 指令

检查锁的值和删除锁不是原子操作#

一个稍微安全一点的方案是将 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key ,这是为了确保当前线程占有的锁不会被其他线程释放,除非这个锁是因为过期了而被服务器自动释放的,但是匹配 value 和删除 key 不是一个原子操作, Redis 也没有提供类似于delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。

# delifequals
if redis.call("get", KEYS[1]) == ARGV[l] then
	return redis.call("del", KEYS[1])
else
	return 0
end

但是这也不是一个完美的方案 它只是相对安全一点 因为如果真的超时了,当前线程的逻辑没有执行完,其他线程也会乘虚而入。

可重入性#

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程多次加锁,那么这个锁就是可重入的。 比如 Java 语言里有个 ReentrantLock 就是 可重人锁。 redis 分布式锁如果要支持可重入 需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。

# -*- coding : utf-8
import redis
import threading

locks = threading.local()
locks.redis = {}


def key_for(user_id):
    return "account_{}".format(user_id)


def _lock(client, key):
    print("尝试通过set指令获得一个锁")
    return bool(client.set(key, True, nx=True, ex=5))


def _unlock(client, key):
    print("尝试通过del指令删掉锁")
    client.delete(key)


def lock(client, user_id):
    key = key_for(user_id)
    if key in locks.redis:
        locks.redis[key] += 1
        return True
    ok = _lock(client, key)
    if not ok:
        return False
    locks.redis[key] = 1
    return True


def unlock(client, user_id):
    key = key_for(user_id)
    if key in locks.redis:
        locks.redis[key] -= 1
        if locks.redis[key] <= 0:
            del locks.redis[key]
        return True
    return False


client = redis.StrictRedis()
print("lock", lock(client, "codehole"))
print("lock", lock(client, "codehole"))
print("unlock", unlock(client, "codehole"))
print("unlock", unlock(client, "codehole"))

以上还不是可重入锁的全部,精确一点还需要考虑内存锁计数的过期时间,代码复杂度将会继续升高。老钱不推荐使用可重人锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不使用可重入锁。下面是 Java版本的可重入锁。


其与 Python 版本区别不大 也是基于 Threadlocal 和引用计数。

下面介绍一个实现可重入分布式锁的思路:
不要把锁的值简单的设置为true或者false,而是设置成客户端IP+客户端线程ID,以及上锁的数量,这样就可以唯一标识一条线程,下面来看加锁和释放锁的流程:

  • 加锁
    • 查看当前是否已经有锁
    • 如果已经有锁,查看当前锁的值是不是我的客户端IP+客户端线程ID
      • 如果是,说明这个锁当前被我获得,把锁的数量值+1,成功获得锁
      • 如果不是,说明这个锁已经被其他线程获得,加锁失败
    • 如果还没有这个锁,创建一个锁,并初始化锁的值为客户端IP+客户端线程ID,以及上锁的数量1
  • 释放锁
    • 对锁的数量减一,如果等于零了,删掉这个锁

主节点的锁没有及时同步到从节点#

老钱细致讲解了分布式锁的原理,它的使用非常简单,一条指令就可以完成加锁操作。不过在集群环境下,这种方式是有缺陷的,它不是绝对安全的。

image

如图 4-6 所示, Sentinel 集群中,当主节点挂掉时,从节点会取而代之,但客户端上却并没有明显感知。比如,原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了,然后从节点变成了主节点,这个新的主节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。

不过这种不安全也仅在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。

Redlock 算法#

为了解决这个问题, Antirez 发明了 Redlock 算法,它的流程比较复杂,不过已经有了很多开源的 library 做了良好的封装,用户可以拿来即用,比如 redlock-py

import redlock
addrs=[
    {
        "host":"localhost",
        "port":6379,
        "db":0
    },
    {
        "host":"localhost",
        "port":6479,
        "db":0
    },
    {
        "host":"localhost",
        "port":6579,
        "db":0
    }
]
dlm = redlock.Redlock(addrs)
success = dlm.lock("user-lck-laoqian",5000)
if success:
    print('lock success')
    dlm.unlock('user-lck-laoqian')
else:
    print('lockfailed')


导入 redlock 模块。
定义了一个包含三个 Redis 节点地址的列表 addrs。
创建 redlock.Redlock 实例 dlm,并传入 Redis 节点地址列表。
调用 dlm.lock() 方法尝试获取名为 "user-lck-laoqian" 的锁,有效期为 5000 毫秒。
如果成功获取锁,则打印 "lock success",并执行需要互斥访问的操作。然后调用 dlm.unlock() 方法释放锁。
如果获取锁失败,则打印 "lock failed"

为了使用Redlock需要提供多个Redis实例,这些实例之前相互独立,没有主从关系。同很多分布式算法一样,Redlock也使用大多数机制

加锁时,它会向过半节点发送set(key,value,nx=True,ex=xxx)指令,只要过半节点set成功,就认为加锁成功。
释放锁时,需要向所有节点发送del指令。不过Redlock算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为Redlock要向多个节点进行读写,意昧着其相比单实例Redis性能会下降一些。

原理#

背景:解决基于单 Redis 节点的单点故障问题;以及哨兵模式下基于异步的主从复制(replication)可能带来的数据不一致问题。

因此 antirez 提出了新的分布式锁的算法 Redlock,它基于 N 个完全独立的 Redis 节点(通常情况下N可以设置成5)。

获取锁#

运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

  • 获取当前时间(毫秒数)。
  • 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
  • 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  • 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  • 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

释放锁#

上面描述的只是获取锁的过程,而释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。为什么?

设想这样一种情况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET操作,但是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。实际上,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。

Failover#

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。我们前面讨论的单Redis节点的分布式锁在failover的时候锁失效的问题,在Redlock中不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。具体的影响程度跟 Redis 对数据的持久化程度有关。

假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:

客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源)。

在默认情况下,Redis 的 AOF 持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。所以,上面分析的由于节点重启引发的锁失效问题,总是有可能出现的。为了应对这一问题,antirez又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。

Redlock使用场景#

如果你很在乎高可用性,希望即使挂了一台Redis也完全不受影响,就应该考虑Redlock。不过代价也是有的,需要更多的Redis实例,性能也下降了,代码上还需要引入额外的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌。

参考资料

作者:Esofar

出处:https://www.cnblogs.com/DCFV/p/18300484

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   Duancf  阅读(9)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示