• 使用redis实现分布式锁


    为什么需要分布式锁

    在一个分布式系统中,也会涉及多个节点访问同一个公共资源的情况,此时就需要通过锁来做互斥控制,避免出现类似于“线程安全”的问题,而java的synchronized这样的锁只能在当前进程中生效,在分布式的这种多个进程多个主机的场景无能为力,此时就需要分布式锁。

    分布式锁的基础实现

    例如:买票场景,现在车站提供了若干车次,每个车次的票数都是固定的。现在又多个服务器节点,都可能需要处理这个买票逻辑,先查询指定车次的余票,如果余票>0,则设置余票值-=1.

    image.png
    客户端1先查询余票,发现剩余1张,在即将执行1->0过程之前;客户端2也执行查询余票,发现也是剩余1张,也会执行1->0过程。这就造成1张票卖了给两个人,即超卖。
    我们可以在上述架构中引入redis,作为分布式锁的管理器。
    image.png
    所谓的分布式锁,也是一个/一组单独的服务器程序(如redis),给其他服务器提供“加锁”服务。
    买票服务器,在进行买票操作的时候,需要先加锁。往redis上设置一个特殊的键值对key-value,完成上述买票操作,再把这个key-value删除掉。其他服务器也想去买票的时候,也去redis上尝试设置key-value,如果发现key-value已经存在,就认为“加锁失败”(是放弃/阻塞等待,就看具体实现)。这样就可以保证,第一个服务器在执行“查询->更新"的过程中,第二个服务器不会执行”查询“,也就解决了”超卖“问题。
    :::success
    redis中提供的setnx操作,正好适合上述场景。即key不存在就设置,存在则设置失败
    :::

    引入过期时间

    某个服务器中加锁成功后(setnx成功),如果该服务器意外发生宕机,就会导致解锁操作(删除该key)不能执行,就可能引起其他服务器始终无法获取到锁的情况。

    在java的多线程编程中,可以把解锁操作放到finally中,保证解锁操作一定会被执行到。但是这种做法只是针对进程内的锁有用(进程异常退出,锁也就随之销毁)。而分布式锁是无效的,服务器宕机以后会导致redis上设置的key无人删除,也就导致其他服务器无法获取到锁

    :::info
    引入过期时间,使用set ex nx的方式,在设置锁的同时把过期时间设置进去,一但时间到了,key就会自动被删除掉。
    :::
    注意!此处设置过期时间只能使用一个命令的方式设置。

    如果分开设置,比如setnx之后,再来个expire。redis多个指令之间,无法保证原子性(redis的原子性是只能保证执行,不能保证成功)。此时就可能出现这两个命令,一个执行成功,一个执行失败情况

    引入校验id

    对于redis中写入的加锁键值对,其他节点也是可以删除的。

    比如 服务器1写入一个001:1这样的键值对,服务器2是完全可以把001:1给删除掉。当然,服务器2一般不会这样”恶意删除“操作,不过不能保证因为一些bug导致服务器2把锁给误删除

    为了解决上述问题,我们可以引入一个校验id。

    1. 给服务器编号,每个服务器都有一个自己的身份标识
    2. 进行加锁的时候,设置key-value。key是针对哪个资源加锁(比如车次),value就可以存储刚才服务器的编号,标识出当前这个锁是哪个服务器加上的。
    3. 解锁的时候,先查询一下这个锁对应的服务器编号,然后判定一下value是否和当前执行解锁的服务器编号一致,如果一致,才能真正执行del,如果不是,就失败。

    伪代码如下:

    String key = [要加锁的资源 id]; 
    String serverId = [服务器的编号]; 
    // 加锁, 设置过期时间为 10s 
    redis.set(key, serverId, "NX", "EX", "10s");  
    // 执⾏各种业务逻辑, ⽐如修改数据库数据. 
    doSomeThing();
    // 解锁, 删除 key. 但是删除前要检验下 serverId 是否匹配.
    if (redis.get(key) == serverId) { 
    	redis.del(key); 
    }  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    但是很明显,在解锁的时候,getdel是两步操作,不是原子的。

    引入lua

    在服务器内部,可能是多线程的。例如服务器1中有两个线程都在执行上述解锁操作。
    image.png
    在服务器1中,看起来只是重复执行del操作,问题不大???但是当服务器2,执行加锁时,就可能出现问题了。
    线程A执行完del操作后,线程B执行del操作之前,服务器2的线程C正好要执行加锁操作。此时线程A已经把锁删除了,线程C是能够加锁成功的。但是紧接着,线程B就会执行del操作,就会把服务器2的加锁操作给解锁了。虽然del操作中有引入校验id,但是线程B在get操作中已经通过id校验,可以执行del操作,虽然线程C这把锁的id不同,也能够解锁。
    使用redis是事务,能够避免命令之间的插队。但是实践中往往是使用lua脚本。由于lua语言非常轻量,因此可以内嵌到redis中。我们可以使用lua编写一些逻辑,把这个脚本上传到redis服务器上,然后就可以让客服端来控制redis执行上述脚本。redis执行lua脚本的过程,是原子的。并且redis官方也明确说明,lua属于事务的替代方案。
    使用lua脚本实现上述解锁功能:

    if redis.call('get',KEYS[1]) == ARGV[1] then
    	return redis.call('del',KEYS[1])
    else
    	return 0
    end;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    image.png

    引入看门狗(watch dog)

    上述方案中仍然存在一个重要问题,在加锁的时候,需要给key设置过期时间。过期时间,设置多少合适呢?

    • 设置太短,就可能业务逻辑还没执行完,就释放锁
    • 设置太长,会导致”锁释放不及时“问题

    因此更好的方式是”动态续约“,这就需要服务器这边有一个专门的线程,负责续约这件事。我们把这个负责的线程,叫做”看门狗“(watch dog).

    举个具体的例子:
    初始情况下设置过期时间10s,同时设定看门狗线程每隔3s检测一次。
    当3s时间到的时候,看门狗就会判定当前任务是否完成。

    • 如果任务已经完成,直接通过lua脚本的方式,释放锁(删除key)
    • 如果任务未完成,则把过期时间重新设置为10s,即续约

    这样就不用担心锁提前释放的问题了,而且另外一方面,如果服务器挂了,看门狗线程也会被销毁,此时无人续约,这个key自然就可以迅速过期,让其他服务器获取到锁

    引入redlock算法

    实践中的redis一般使用集群的方式部署的,那么就可能出现以下比较极端的情况。

    服务器1向master节点进行加锁操作,这个写入key的过程刚完成,master挂了;slave节点升级成新的master节点,但是由于刚才写入的这个key未来得及同步给slave,此时就相当于服务器1的加锁操作形同虚设。服务器2仍然可以进行加锁,即给新的master写入key,因为新的master不包含刚才的key。

    为了解决这个问题,redis作者提出了redlock算法。本质上是使用冗余解决可用性问题
    image.png
    此处加锁,就是按照一定的顺序,针对redis集群的所有分片都进行加锁操作。如果某个节点挂了(加不上锁了)继续给下一个节点加锁即可。如果写入key成功的节点个数超过总数的一半,就视为加锁成功。同理,进行解锁的时候,也就会把上述节点都设置一遍解锁。

  • 相关阅读:
    汽车租赁管理系统的设计与实现(JSP+SqlServer在线租车网站)
    工地木模板多少钱一张?
    探索ClickHouse——使用MaterializedView存储kafka传递的数据
    nginx(六十八)http_proxy模块 nginx与上游的ssl握手
    案例 | 美创助力镇海区大数据发展管理中心构建“数改”安全防护力
    Python顺序表
    致敬逆行者网页设计作品 大学生抗疫感动专题网页设计作业模板 疫情感动人物静态HTML网页模板下载
    从宏观到微观——泽攸科技ZEM系列台式扫描电子显微镜在岩石分析中的应用
    【并发与多线程】Java多线程程序设计(一)
    mybatis中resultMap与resultType的区别
  • 原文地址:https://blog.csdn.net/weixin_61427900/article/details/133277211