【Redis】3.详解分布式锁

news2024/12/19 10:49:05

文章目录

  • 1. 什么是分布式锁
  • 2. 分布式锁的特点
  • 3. 常见的分布式锁
  • 4. 实现分布式锁
  • 5.解决分布式锁中的原子性问题
    • 5.1 Lua脚本
    • 5.2 使用Java代码调用Lua脚本实现原子性

1. 什么是分布式锁

分布式锁是指分布式系统或者不同系统之间共同访问共享资源的一种锁实现,其是互斥的,多个线程均可见。

分布式锁的核心是大家都用同一个锁,不同服务之间锁是一样的,那么就能锁得住进程,不让进程进入锁住的代码块中。

在这里插入图片描述

为什么会使用分布式锁呢?使用ReentrantLock或synchronized不行吗?

ReentrantLock和synchronized在分布式情况下,每个服务的锁对象都不一样,因为每个服务的锁对象都是不一样的,所以无法锁住不同服务的线程。


2. 分布式锁的特点

那么分布式锁有什么特点呢?

  1. 可见性:指所有线程都可见,无论是同一个服务还是不同服务,都可以感知到锁的变化
  2. 互斥:可以使得城西串行化
  3. 高可用:不容易崩溃,时时可用
  4. 高性能:由于加锁本身就让性能降低,所以对于分布式锁本身需要他就较高的加锁性能和释放锁性能
  5. 安全性:安全也是程序中必不可少的一环

在这里插入图片描述


3. 常见的分布式锁

常见的分布式锁有以下三种:

  1. Mysql:mysql本身就有锁机制,但是由于mysql性能一般,因此很少使用mysql作为分布式锁
  2. Redis:redis作为分布式锁是企业里面很常用的方式。主要是使用sentnx方法,如果插入key成功,也就代表获得了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
  3. Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,具体可看作者【单曲循环的寂寞】——基于zookeeper实现分布式锁
MYSQLredisZookeeper
互斥利用mysql本身的互斥锁机制利用sentnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁的超时时间,到期释放临时节点,断开连接自动释放

4. 实现分布式锁

分布式锁实现的有以下两个很重要的步骤:

  • 获取锁:

    • 互斥:只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
  • 释放锁:

    • 手动释放
    • 超时释放:获取锁时添加一个超时时间

核心思路

  • 利用redis的setNx方法,当很多线程进入抢夺锁的时候,线程1进入redis插入key,返回1,如果结果是1,证明获取锁成功,它就可以执行锁内的业务。当其他线程尝试获取锁的时候,无法获取成功,便会一定时间后重试。只有当线程1执行完业务,删掉该key之后,其他线程才能获取锁。

在这里插入图片描述

具体实现分布式锁的步骤如下:

  • 定义一个锁接口,接口里面有两个重要的方法

    • tryLock( long timeoutSec ):尝试获取锁,参数为锁持有的过期时间,过期后自动释放

    • unlock( ):释放锁

    • public interface ILock {
      
          /**
           * 尝试获取锁
           * @param timeoutSec 锁持有的超时时间,过期后自动释放
           * @return true代表获取锁成功; false代表获取锁失败
           */
          boolean tryLock(long timeoutSec);
      
          /**
           * 释放锁
           */
          void unlock();
      }
      
  • 定义一个类,实现该接口

    • public class SimpleRedisLock implements ILock {
      
          private String name;
          private StringRedisTemplate stringRedisTemplate;
      
          public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
              this.name = name;
              this.stringRedisTemplate = stringRedisTemplate;
          }
      
          private static final String KEY_PREFIX = "lock:";
          //ture可以去掉uuid的横线
          private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
      
          @Override
          public boolean tryLock(long timeoutSec) {
              // 获取线程标示
              String threadId = ID_PREFIX + Thread.currentThread().getId();
              // 获取锁
              Boolean success = stringRedisTemplate.opsForValue()
                      .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
              //这里这样返回是因为防止拆箱的时候success为null,导致空指针异常
              return Boolean.TRUE.equals(success);
          }
      
      
          @Override
          public void unlock() {
              // 获取线程标示
              String threadId = ID_PREFIX + Thread.currentThread().getId();
              // 获取锁中的标示
              String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
              // 判断标示是否一致
              if(threadId.equals(id)) {
                  // 释放锁
                  stringRedisTemplate.delete(KEY_PREFIX + name);
              }
          }
      }
      

这里的分布式锁,是为了解决一人一单问题,也就是一个人只能下一单,因此key就是key前置+用户id。

而value,则是uuid+线程id。为什么这样做呢?

是因为如果单单用线程id作为value的话,在分布式情况下,用户发送多个请求过来,会出现分布式锁误删情况。当有一个线程1获取到锁(这把锁后面称为锁A),由于某种情况,线程1获取锁A之后出现了阻塞,导致锁A超时被释放。这时候线程2进来获取锁(这把锁后面称为锁B),这时候线程1反应过来,进行释放锁的操作,因为锁A已经超时释放了,这时候线程1释放的锁将会是线程2获取的锁B,因此出现了误删的情况。

在这里插入图片描述

因此,需要在每个线程释放锁之前,判断这把锁是否属于自己,如果属于自己则释放,如果不属于自己那么就不释放,防止误删。因为程序可能位于分布式的系统中,那么多个服务之间线程ID可能会出现一样,因此value不能单单使用线程ID,应该用uuid拼上线程ID,这样保证了分布式情况下value的唯一性。

总而言之,解决分布式锁误删问题的解决方案如下:

  1. value使用UUID+线程ID。
  2. 在释放锁的时候,先判断该锁是否是自己的,也就是判断value。
    1. 如果该key对应的value是自己存进去的那个value,则释放锁。
    2. 否则不释放锁,防止锁误删。

在这里插入图片描述

一人一单的业务逻辑:

  • 首先判断秒杀是否开始

    • @Override
      public Result seckillVoucher(Long voucherId) {
          // 1.查询优惠券
          SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
          // 2.判断秒杀是否开始
          if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
              // 尚未开始
              return Result.fail("秒杀尚未开始!");
          }
          // 3.判断秒杀是否已经结束
          if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
              // 尚未开始
              return Result.fail("秒杀已经结束!");
          }
          // 4.判断库存是否充足
          if (voucher.getStock() < 1) {
              // 库存不足
              return Result.fail("库存不足!");
          }
      
          return createVoucherOrder(voucherId);
      }
      
  • 秒杀开始之后创建订单

    • @Transactional
      public Result createVoucherOrder(Long voucherId) {
          // 5.一人一单
          Long userId = UserHolder.getUser().getId();
      
          // 创建锁对象
          SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
          // 尝试获取锁
          boolean isLock = redisLock.tryLock(1200);
          // 判断
          if (!isLock) {
              // 获取锁失败,直接返回失败或者重试
              return Result.fail("不允许重复下单!");
          }
      
          try {
              // 5.1.查询订单
              int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
              // 5.2.判断是否存在
              if (count > 0) {
                  // 用户已经购买过了
                  return Result.fail("用户已经购买过一次!");
              }
      
              // 6.扣减库存
              boolean success = seckillVoucherService.update()
                      .setSql("stock = stock - 1") // set stock = stock - 1
                      .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                      .update();
              if (!success) {
                  // 扣减失败
                  return Result.fail("库存不足!");
              }
      
              // 7.创建订单
              VoucherOrder voucherOrder = new VoucherOrder();
              // 7.1.订单id
              long orderId = redisIdWorker.nextId("order");
              voucherOrder.setId(orderId);
              // 7.2.用户id
              voucherOrder.setUserId(userId);
              // 7.3.代金券id
              voucherOrder.setVoucherId(voucherId);
              save(voucherOrder);
      
              // 7.返回订单id
              return Result.ok(orderId);
          } finally {
              // 释放锁
              redisLock.unlock();
          }
      
      }
      

这里分布式锁的作用,主要是保证程序在创建订单,将订单数据插入数据库这一过程中,只有一个线程在代码块中执行,防止超买超卖的情况,有效解决一人一单。


5.解决分布式锁中的原子性问题

虽然上面解决的分布式锁误删的情况,但是会出现分布式锁的原子性问题。

线程1尝试获取锁,获取锁成功(这把锁下面称为锁A),线程1执行完业务逻辑,准备释放锁。这时候其他线程进不来,所以这把锁肯定是线程1自己的,当线程1判断完标识为一致后,准备执行释放锁操作,这时候由于某种情况,线程1阻塞了,由于阻塞时间太长,锁A超时释放了。这时候线程2获取锁成功(这把锁后面称为锁B),这时候线程1不再阻塞,执行释放锁操作,因为锁A超时释放,因此线程1执行的释放锁操作释放的是线程2获取的锁B,因此依然出现了锁误删情况**,出现这一情况的原因是因为判断锁是否是自己的和释放锁这是两个操作**,不存在原子性。

在这里插入图片描述

解决方法也很简单,只需要保证判断锁和释放锁这两个操作是原子的就可以了。


5.1 Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了。

Redis提供的调用函数,语法如下:

redis.call('命令名称', 'key', '其它参数', ...)

比如执行set name jack,则脚本如下:

# 执行 set name jack
redis.call('set', 'name', 'jack')

使用redis命令执行脚本

在这里插入图片描述

上面是写死的情况,如果不想写死,可以使用参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数(注意数组是从1开始):

在这里插入图片描述

因此,上面释放锁的操作写成Lua脚本就可以这样写

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

5.2 使用Java代码调用Lua脚本实现原子性

在RedisTemplate已经为我们封装好了一个execute方法用来执行Lua脚本

public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
    return this.scriptExecutor.execute(script, keys, args);
}
  1. script:Lua脚本
  2. keys:KEYS数组
  3. args:ARGV数组

在这里插入图片描述

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
        UNLOCK_SCRIPT,
        Collections.singletonList(KEY_PREFIX + name),
        ID_PREFIX + Thread.currentThread().getId());
}

这里可以将Lua脚本写在一个Lua文件中,从而实现解耦,可以使用Spring的ClassPathResource读取Class类下的文件。


参考:黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1832.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Django框架】——20 Django视图 02 路由命名和反向解析

文章目录一、 路由命名二、reverse反向解析三、通过URL模板页面进行传参四、namespace1.reverse反向解析2.url模板标签在 Django 项⽬中&#xff0c;⼀个常⻅需求是获取最终形式的 URL&#xff0c;⽐如⽤于嵌⼊⽣成的内容中&#xff08;视图和资源⽹址&#xff0c;给⽤户展示⽹…

《网络安全笔记》第七章:注册表基础

一、注册表基础 1、概述 注册表是windows操作系统、硬件设备以及客户应用程序得以正常运行和保存设置的核心“数据库”&#xff0c;也可以说是一个非常巨大的树桩分层结构的数据库系统注册表记录了用户安装在计算机上的软件和每个程序的相互关联信息&#xff0c;它包括了计算…

【UDS】ISO14229之0x2F服务

文章目录前言一、理论描述二、使用步骤1.请求2.响应总结->返回总目录<- 前言 简称&#xff1a; “InputOutputControlByIdentifier”&#xff0c;根据标识符控制输入输出 功能&#xff1a; 根据标识符控制输入输出服务用于替换输入信号的值、电控单元内部参数或控制电子…

Telnet连接

❤️人生没有白走的路&#xff0c;每一步都算数❤️ 你是否安装Telnet没毛病&#xff0c;但登录总报错&#xff1f; 巧了&#xff0c; 我也遇到了。 于是我打开浏览器尝试搜索&#xff0c;有许多说的并不详细。 所以呢就有了这篇文章&#xff01; 首先我们准备实验环境&#xf…

oracle中替换字符串的不同写法

replace函数 replace(原字段&#xff0c;“原字段旧内容“,“原字段新内容“) 例如将DEPTNO字段值中的0替换为1&#xff1a; TRANSLATE TRANSLATE(expr, from_string, to_string) from_string 与 to_string 以字符为单位&#xff0c;对应字符一一替换。 用法示例&#xf…

详解数据结构——二叉排序树

目录 二叉排序树 二叉排序树的查找 二叉排序树的插入 二叉排序树的删除 查找时间效率分析 二叉排序树 二叉排序树&#xff0c;又称二叉查找树&#xff08;BST&#xff0c;Binary Search Tree)一棵二叉树或者是空二叉树&#xff0c;或者是具有如下性质的二叉树: 左子树上所有结…

SpringBoot - SpringBoot整合i18n实现消息国际化

文章目录1. MessageSource源码2. 项目环境搭建1. 创建项目服务auth2. 工具类 I18nUtils3. 自定义异常 CommonException4. 统一异常处理 GlobalExceptionHandler3. 业务实现1. 实体类 UserEntity2. 请求实体 UserQo3. 控制层 UserController4. 业务逻辑层 UserService5. 将异常信…

144. 授人以渔 - 如何查找 SAP UI5 官网上没有提到的控件属性的使用明细

本教程第 113 步骤, SAP UI5 应用开发教程之一百一十三 - 授人以渔 - 如何自行查询任意 SAP UI5 控件属性的文档和技术实现细节我用一整篇文章的篇幅,解答了一位学习者这个疑问: 想请教一下 sap.m.Input 控件中,value里设置的内容,比如path,type,constraints,在哪里可以查…

C++ 多态

目录 一、多态的定义和实现 1.1 多态的构成条件&#xff1a; 1.2 虚函数的重写&#xff08;覆盖&#xff09;&#xff1a; 1.3 多态的两个特殊点&#xff1a; 1.4 析构函数的重写&#xff1a; 1.5 override和final 1.6 重载&#xff0c;重定义&#xff08;隐藏&#xff…

Linux【进程地址空间】

进程地址空间&#x1f4d6;1. 地址空间概念&#x1f4d6;2. 写时拷贝&#x1f4d6;3. 虚拟地址空间的优点&#x1f4d6;1. 地址空间概念 在学习C/C内存管理时&#xff0c;我们可能见过这样一幅图&#xff1a; 但是我们可能不是很理解它&#xff0c;首先有一个问题&#xff1a;…

OpenTCS客户端开发之Web客户端(一)

越来越多人私信我关于OpenTCS的问题。可以感觉到很多人对OpenTCS的研究的人多了很多&#xff0c;很好。这些问题很多是关于算法方面的&#xff0c;也有一部分是关于UI方面的&#xff0c;毕竟OpenTCS本质上是一个算法项目&#xff0c;但是如果希望把它进行商业化&#xff0c;那免…

【微服务】服务拆分和远程调用

2.1 服务拆分原则 这里总结了微服务拆分时的几个原则&#xff1a; 不同微服务&#xff0c;不要重复开发相同业务微服务数据独立&#xff0c;不要访问其它微服务的数据库微服务可以将自己的业务暴露为接口&#xff0c;供其它微服务调用 2.2 服务拆分示例 以微服务cloud-demo为…

第三节:运算符【java】

目录 &#x1f392;运算符 &#x1f4c3;1. 什么是运算符 &#x1f4d7;2. 算术运算符 2.1 基本四则运算符&#xff1a;加减乘除模( - * / %) 2.2 增量运算符 - * % 2.3 自增/自减运算符 -- &#x1f4d9;3. 关系运算符 &#x1f4d5;4.逻辑运算符(重点) 4.1 逻辑与…

隔离出来的“陋室铭”

被隔离了 日常锻炼身体就是去公司旁边的酒店游泳&#xff0c;结果酒店里除了小阳人&#xff0c;我就喜提次密称号&#xff0c;7天隔离走起&#xff1b;又因为不想耽误家里孩子上学&#xff0c;老人外出&#xff0c;就选择了单独隔离&#xff0c;结果就拉到了单独的隔离点&…

精通Git(三)——Git分支机制

文章目录前言分支机制简述创建分支切换分支基本的分支与合并操作基本的分支操作基本的合并操作基本的合并冲突处理分支管理与分支有关的工作流长期分支主题分支远程分支推送跟踪分支拉取删除远程分支变基基本的变基操作变基操作的潜在危害只在需要的时候执行变基操作变基操作与…

C++——vector容器的基本使用和模拟实现

1、vector的介绍 vector是表示可变大小数组的序列容器。 就像数组一样&#xff0c;vector也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素 进行访问&#xff0c;和数组一样高效。但是又不像数组&#xff0c;它的大小是可以动态改变的&#xff0c;而且…

【动手学深度学习PyTorch版】18 使用块的网络 VGG

上一篇请移步【动手学深度学习PyTorch版】17 深度卷积神经网络 AlexNet_水w的博客-CSDN博客 目录 一、使用块的网络 VGG 1.1 AlexNet--->VGG ◼ VGG网络简介 1.2 VGG架构 1.3 总结 二、VGG网络的代码实现 2.1 VGG网络&#xff08;使用自定义&#xff09; 一、使用块的…

软件测试基本概念

目录本章要点什么是软件测试?软件测试的特定?软件测试和开发的区别?软件测试和软件开发中的调试有什么区别?软件测试在不同公司的定位?一个优秀的测试人员应该具备的素质(你为啥要选择测试开发)需求是衡量软件测试的依据从软件测试人员角度看需求为啥需求对软件测试人员如…

SpringBoot 面试题总结 (JavaGuide)

SpringBoot 面试题总结 &#xff08;JavaGuide&#xff09; 用 JavaGuide 复习 SpringBoot 时&#xff0c;找到一些面试题&#xff0c;没有答案&#xff0c;自己花了一天时间在网上找资料总结了一些&#xff0c;有些答案的来源比较杂忘了没有标注&#xff0c;望见谅。 1. 简单…

Visual Studio 2022开发Arduino详述

目录&#xff1a; 一、概述 二、软件的下载与安装 1、前言 2、Visual Studio 2022的下载与安装 3、Visual Micro扩展插件的导入 4、Visual Micro的使用 1&#xff09;安装修改插件 2&#xff09;搜索 : Visual.Micro.Processing.Sketch.dll 3&#xff09;打开Visual.…