首页 > 其他分享 >分布式锁,怎么个事?

分布式锁,怎么个事?

时间:2023-11-08 22:34:57浏览次数:38  
标签:怎么 过期 setnx Redis value key 个事 分布式

平时的工作中,由于生产环境中的项目是需要部署在多台服务器中的,所以经常会面临解决分布式场景下数据一致性的问题,那么就需要引入分布式锁来解决这一问题。

本文参考文章:
https://www.cnblogs.com/niceyoo/p/13711149.html
https://cloud.tencent.com/developer/article/1595817
https://www.ghosind.com/2020/06/22/redis-string
https://zhuanlan.zhihu.com/p/77484377

一句话:分布式锁就是满足分布式系统或集群模式下多进程可见并且互斥的锁。

针对分布式锁的实现,目前比较常用的就如下几种方案:

  1. 基于数据库实现分布式锁
  2. 基于Redis实现分布式锁
  3. 基于Zookeeper实现分布式锁

听起来B格很高,我们一起静下心来研究一下体会,相信对大家工作中会有所帮助

本文章则已基于Redis实现分布式锁的实现方案、实现细节和实现原理入手,也会涉及到Zookeeper实现分布式锁的原理以及实现,我们一起来看看这是怎么个事?

Redis分布式锁

  1. setNX + Lua脚本
  2. Redisson + RLock可重入锁

文章会带大家详解Redisson + RLock可重入锁这套被青睐的实现方案

setNX + Lua脚本

setNX

完整语法:SET key value [EX seconds|PX milliseconds] [EXAT timestamp|PXAT milliseconds-timestamp] [NX|XX] [KEEPTTL]

SET命令有EX、PX、NX、XX以及KEEPTTL五个可选参数,其中KEEPTTL为6.0版本添加的可选参数,其它为2.6.12版本添加的可选参数。

EX seconds:以秒为单位设置过期时间

PX milliseconds:以毫秒为单位设置过期时间

EXAT timestamp:设置以秒为单位的UNIX时间戳所对应的时间为过期时间

PXAT milliseconds-timestamp:设置以毫秒为单位的UNIX时间戳所对应的时间为过期时间

NX:键不存在的时候设置键值

XX:键存在的时候设置键值

KEEPTTL:保留设置前指定键的生存时间

GET:返回指定键原本的值,若键不存在时返回nil

SET命令使用EX、PX、NX参数,其效果等同于SETEX、PSETEX、SETNX命令。根据官方文档的描述,未来版本中SETEX、PSETEX、SETNX命令可能会被淘汰。

EXAT、PXAT以及GET为Redis 6.2新增的可选参数。

注意:其实我们常说的通过 Redis 的 setnx 命令来实现分布式锁,并不是直接使用 Redis 的 setnx 命令,因为在老版本之前 setnx 命令语法为setnx key value,并不支持同时设置过期时间的操作,那么就需要再执行 expire 过期时间的命令,这样的话加锁就成了两个命令,原子性就得不到保障,所以通常需要配合 Lua 脚本使用,而从 Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

那么较低版本如果想使用setnx+expire完成原子抢锁应该怎么办呢?

借助lua脚本

大致说一下用 setnx 命令实现分布式锁的流程:

在 Redis 2.6.12 版本之后,Redis 支持原子命令加锁,我们可以通过向 Redis 发送 set key value NX 过期时间 命令,实现原子的加锁操作。比如某个客户端想要获取一个 key 为lock 的锁,此时需要执行 set lock random_value NX PX 30000 ,在这我们设置了 30 秒的锁自动过期时间,超过 30 秒自动释放。

如果 setnx 命令返回 ok,说明拿到了锁,此时我们就可以做一些业务逻辑处理,业务处理完之后,需要释放锁,释放锁一般就是执行 Redis 的 del 删除指令,del lock

如果 setnx 命令返回 nil,说明拿锁失败,被其他线程占用,如下:

为什么我们往往要将锁加一个时间呢?

这是因为如果redis宕机,那么再次恢复之后,由于之前的代码还未执行到删除锁的时候,redis就宕机了,那么此时获取的锁无法释放,导致后续的请求获取不到锁,业务因此崩盘。

注意,这里在设置值的时候,value 应该是随机字符串,比如 UUID,而不是随便用一个固定的字符串进去,为什么这样做呢?

为了防止锁的误删

value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

感觉这样说还是不清晰,举个例子:例如进程 A,通过 setnx 指令获取锁成功(命令中设置了加锁自动过期时间30 秒),既然拿到锁了就开始执行业务吧,但是进程 A 在接下来的执行业务逻辑期间,程序响应时间竟然超过30秒了,不管是线程阻塞还是业务执行时间的原因吧,锁自动释放了,而此时进程 B 进来了,由于进程 A 设置的过期时间一到,让进程 B 拿到锁了,然后进程 B 又开始执行业务逻辑,但是呢,这时候进程 A 执行到了释放锁的逻辑(代码层面),进行删除锁,然后把进程 B 的锁得释放了。

总之,有了随机数的 value 后,可以通过判断 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

在使用UUID的情况下,且在删除KEY前对其value进行一致性校验,可以99%避免这个情况的发生

点击查看maven依赖
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    @PostMapping(value = "/addUser")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
        String value = UUID.randomUUID().toString().replace("-","");
        // String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(10000 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            /** 判断是否是key对应的value **/
            String lockValue = (String) redisTemplate.opsForValue().get(key);
            if (lockValue != null && lockValue.equals(value)) {
                redisTemplate.delete(key);
                log.info("{} 解锁成功,结束处理业务", key);
            }
            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

我们模拟三个并发新增用户数据,发现:

只有一个会抢到锁并进行删除。

但是如果是固定的字符串的话:

可以看到刚拿到锁就被删除,我们这里演示了如何解决分布式锁使用场景中锁的误删

但随机字符串就真的安全了吗?

。。哈哈相信看到这里有点无语,但是我们还是需要不断深入,前人栽树就是这么伟大啊!

由于无法保证 redisTemplate.delete(key); 的原子操作,在多进程下还是会有进程安全问题。

举个例子,比如进程 A 执行完业务逻辑,在 redisTemplate.opsForValue().get(key); 获得 key 这一步执行没问题,同时也进入了 if 判断中,但是恰好这时候进程 A 的锁自动过期时间到了(别问为啥,就是这么巧,就是会发生这种情况),而另一个进程 B 获得锁成功,然后还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁,那么进程B锁了个寂寞。。。

那么我们接下来的目标就是将删除前的判断(防止误删),和删除key的操作合并,让它成为一个原子性的操作,那我就需要使用到lua脚本

lua脚本

又是个听着很有B格的词汇,听哥们给你逐步深入

先简单介绍一下 Lua 脚本:

Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua有哪些优势?

  1. 减少网络开销:原先多次请求的逻辑放在 redis 服务器上完成。使用脚本,减少了网络往返时延
  2. 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入(想象为事务)
  3. 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑

那么我们使用它最为重要和核心的就是原子操作,Redis会将整个脚本作为一个整体执行,这个就为我们使用redis实现分布式锁的时候带来的极大的便捷,我们可以将删除前的判断(防止误删),和删除key的操作合并到lua脚本中去,实现原子操作,同时,如果我们使用的是低版本的redis,那么其实setnx是不能设置过期时间的,还需要一个expire命令来设置,那这两步宏观上来看也不是原子的,我们仍然可以借助lua脚本来实现

这里就不在赘述lua脚本的使用以及编写,大家看可以看一下 https://zhuanlan.zhihu.com/p/77484377 相关文章,我帮大家找的这篇文章若简单看完eval命令即可编写简单的lua脚本,相信大家一眼就能看懂

如下是Lua脚本,通过 Redis 的 eval/evalsha 命令来运行:

-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的key和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1] 
    then 
 -- 执行删除操作
        return redis.call('del', KEYS[1]) 
    else 
 -- 不成功,返回0
        return 0 
end

那么项目中如何使用lua脚本呢?

配置如下:

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

完整的controller如下:

点击查看代码
package com.itvayne.distributedlock.controller;

import com.itvayne.distributedlock.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;


    private DefaultRedisScript<Long> script;

    @PostConstruct
    void init() {
        script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
    }

    @PostMapping(value = "/addUser")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
//        String value = UUID.randomUUID().toString().replace("-","");
        String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 10000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
             finally {
                ArrayList<String> arrayList = new ArrayList<>();
                arrayList.add(key);
                /** 判断是否是key对应的value **/
                Long execute = redisTemplate.execute(script, arrayList, value);
                System.out.println("execute执行结果,1表示执行del,0表示未执行 ===== " + execute);
                log.info("{} 解锁成功,结束处理业务", key);
            }


            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

可以看到lua脚本已经执行成功

我们先来对setnx+lua脚本做简单的总结,再继续向下学习:

  1. 所谓的 setnx 命令来实现分布式锁,其实不是直接使用 Redis 的 setnx 命令,因为 setnx 不支持设置自动释放锁的时间(至于为什么要设置自动释放锁,是因为防止被某个进程不释放锁而造成死锁的情况),不支持设置过期时间,就得分两步命令进行操作,一步是 setnx key value,一步是设置过期时间,这种情况的弊端很显然,无原子性操作。

  2. Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

  3. 经过分析,在使用 set key value nx px xxx 命令时,value 最好是随机字符串,这样可以防止业务代码执行时间超过设置的锁自动过期时间,而导致再次释放锁时出现释放其他进程锁的情况(锁的误删

  4. 尽管使用随机字符串的 value,但是在释放锁时(delete方法),还是无法做到原子操作,比如进程 A 执行完业务逻辑,在准备释放锁时,恰好这时候进程 A 的锁自动过期时间到了,而另一个进程 B 获得锁成功,然后 B 还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁.... ,因此需要配合 Lua 脚本释放锁,文章也给出了 SpringBoot 的使用示例。

setnx 琐最大的缺点就是它加锁时只作用在一个 Redis 节点上,即使 Redis 通过 Sentinel(哨岗、哨兵机制) 保证高可用,如果这个 master 节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况,下面是个例子:

  1. 在 Redis 的 master 节点上拿到了锁;

  2. 但是这个加锁的 key 还没有同步到 slave 节点;

  3. master 故障,发生故障转移,slave 节点升级为 master节点;

  4. 上边 master 节点上的锁丢失。

有的时候不单单是锁丢失,新选出来的 master 节点可以重新获取同样的锁,出现一把锁被拿两次的场景。

锁被拿两次,也就不能满足安全性了...

缺陷看完了,怎么解决嘛~

我们继续深入

Redisson + RLock可重入锁

加急施工建设中

标签:怎么,过期,setnx,Redis,value,key,个事,分布式
From: https://www.cnblogs.com/vaynebeself/p/17817366.html

相关文章

  • 分布式任务调度(03)--中心化设计
    把调度和任务执行,隔离成两个部分:调度中心只需要负责任务调度属性,触发调度命令执行器执行器接收调度命令,去执行具体的业务逻辑两者都可以进行横向扩容。1MQ调度中心依赖Quartz集群模式,当任务调度时,发送消息到RabbitMQ。业务应用收到任务消息后,消费任务信息。充分利......
  • 应用内测分发平台怎么直观的分辨苹果ios签名分发平台的产品好坏
    当今移动应用市场竞争激烈,许多开发者都希望在应用上线之前进行内测,确保应用的质量和用户体验。而应用内测分发平台成为了一个不可或缺的工具,帮助开发者方便地进行内测分发。然而,如何直观地分辨一个好的内测分发平台呢?首先,一个好的内测分发平台应该有简洁、直观和易于使用的用户界面......
  • 邮件怎么发送?蜂邮外贸版发信效果如何
    在当今的商业环境中,电子邮件通信仍然是一种重要的工具,特别是对于外贸行业。本文将介绍如何发送邮件以及蜂邮外贸版的发信效果如何,帮助外贸企业更好地利用电子邮件来联系客户和合作伙伴。1.如何发送邮件邮件的发送是一个简单而重要的过程,适用于各种商业交流。以下是如何发送邮件的......
  • 电子公章怎么制作?1分钟免费在线生成
    电子公章已经成为很多企业日常运营中不可或缺的一部分。那么,电子公章怎么制作呢?是否需要专业的电子公章制作工具?是否存在免费在线生成电子公章的选项?本文将为你揭示如何一分钟免费在线生成电子公章,解答你对电子公章如何制作的种种疑惑。首先,电子公章的制作需要依托于专业正规的电子......
  • yarn在vscode运行报错怎么解决
    修改powerShell策略,需要修改权限(电脑脚本不信任,所以阻止了脚本的运行) 输入set-ExcutionPolicyRemoteSigned更改策略,选择A全是,执行更改然后再使用yarn–version验证版本   使用yarnserve在vscode中验证项目是否能正常执行......
  • 深圳/成都/武汉/西安CSPM-3项目管理中级认证怎么考?
    CSPM-3中级项目管理专业人员评价,是中国标准化协会(全国项目管理标准化技术委员会秘书处),面向社会开展项目管理专业人员能力的等级证书。旨在构建多层次从业人员培养培训体系,建立健全人才职业能力评价和激励机制的要求,培养我国项目管理领域复合型人才。  【证书含金量】 ·竞聘优先......
  • 虚拟机如何连网以及Xshell怎么连接虚拟机?
    问题1:虚拟机如何联网?在linux中,虚拟机联网需要进行以下步骤:在虚拟机中设置网络适配器为桥接模式,这样虚拟机就可以直接连接到物理网络上。在虚拟机中配置IP地址、子网掩码、网关和DNS服务器等网络参数,确保虚拟机能够正确地访问网络。在主机中关闭防火墙或者开放相应的端口,以允......
  • Seata分布式事务框架-AT模式与TCC模式介绍
    SeataAT事务方案Seata的AT模式(AutomaticTransaction)是一种无侵入的分布式事务解决方案。下面结合具体业务场景来分析其执行的原理。业务场景订单系统当用户下订单时,执行以下三步流程:订单系统保存订单订单系统调用库存服务,减少商品库存订单系统调用账户服务,扣减用户金额......
  • 关于关于怎么样让自己的虚拟机连上网络,以及Xshell怎么连上虚拟机
    当你使用虚拟机来模拟不同的操作系统环境或进行开发和测试时,连接虚拟机到网络以及使用远程终端工具如Xshell是非常重要的。在本篇博客中,我将向你介绍如何使你的虚拟机连接到网络,以及如何使用Xshell来连接到虚拟机。连接虚拟机到网络在开始之前,确保你已经安装了虚拟机软件,比如VMwar......
  • xposed是怎么hook的,举个简单详细的例子
    Xposed框架通过替换Android系统中的一些关键组件来实现其功能,具体而言,它修改了/system/bin/app_process程序,这是在启动Zygote时用来启动应用程序的系统进程。Xposed的核心组件在系统启动时加载,然后它提供了一个运行时可插拔的基础设施,允许Xposed模块在运行时修改应用程序和系统服......