分布式锁(Redis)

news2024/12/25 12:56:28

一、序言

本文和大家聊聊分布式锁以及常见的解决方案。

二、什么是分布式锁

未命名文件 (4).png
假设一个场景:一个库存服务部署在上面三台机器上,数据库里有 100 件库存,现有 300 个客户同时下单。并且这 300 个客户均摊到上面的三台机器上(即三台机器上分别有 100 个客户)。如果库存服务采取的是传统的进程锁或线程锁,我们会发现三台机器上在检测库存时都能满足(因为每台机器有 100 个客户,刚好满足 100 件库存)。此时会出现只有 100 件库存,却卖出了 300 件的现象(即超卖现象)。
未命名文件 (6).png
为了解决上述在分布式环境中存在的问题,我们需要使用分布式锁。分布式锁是一种在分布式系统中实现线程或进程同步访问共享资源的机制。它的主要目标是在分布式环境下,确保在同一时间只有一个线程或进程可以访问特定的资源

三、分布式锁方案

分布式锁的实现方式主要有三种:

  1. 基于数据库的分布式锁:这种方案主要是在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引。想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。这种方案的缺点包括数据库单点问题、没有锁超时机制、不可重入、非公平锁、非阻塞锁等。
  2. 基于 Redis 的分布式锁:Redis 的分布式锁主要是通过 SETNX 和 EXPIRE 命令来实现的。SETNX 可以用来尝试获取锁。EXPIRE 命令用来设置锁的超时时间,防止死锁。此外,还有基于 Redlock 算法的 Redisson 分布式锁。
  3. 基于 Zookeeper 的分布式锁:Zookeeper 是一个开源的分布式协调服务,它提供了一种高效且可靠的分布式锁实现机制。Zookeeper 的分布式锁主要是通过临时顺序节点和使用 watch 机制来实现。

四、Redis 分布式锁

4.1 Redis 分布式锁实现方式

Redis 分布式锁的实现通常基于 Redis 的原子性操作(比如 SETNX、EXPIRE、DEL 等),主要思想是通过在Redis 中设置一个特定的键值对来表示锁的状态,当某个节点需要获取锁时,会尝试在 Redis 中设置这个键值对,如果设置成功,则获取到锁,可以执行相应的操作;如果设置失败,则表示锁已经被其他节点持有,当前节点需要等待或重试。

public class RedisLock {

    private Jedis jedis;
    private String lockKey;

    // 构造器
    public RedisLock(Jedis jedis) {
        this.jedis = jedis;
        this.lockKey = "lock";
    }

    // 获取锁
    public boolean tryLock() {
        // 使用 set key value NX 命令尝试获取锁
        String result = jedis.set(lockKey, "1", SetParams.setParams().nx());
        return "OK".equals(result);
    }

    // 释放锁
    public void unLock() {
        // 释放锁,即删除对应的键
        jedis.del(lockKey);
    }
}

4.2 Redis 分布式锁过期

在之前,我们利用 set key value nx 这个互斥命令实现了最基本的分布式锁。但是,现在有一个问题:如果有一个业务在获取锁之后,由于未知原因发生了业务阻塞或者在业务完成之后忘记了释放锁,这将会导致当前业务会永久性的持有该锁。为了解决 Redis 分布式锁无法释放的问题,我们采用给锁设置超时时间:

public class RedisLock {

    private Jedis jedis;
    private String lockKey;

    // 构造器
    public RedisLock(Jedis jedis) {
        this.jedis = jedis;
        this.lockKey = "lock";
    }

    // 获取锁
    public boolean tryLock() {
        // 使用 set key value NX EX seconds 命令尝试获取锁, 并设置过期时间
        String result = jedis.set(lockKey, "1", SetParams.setParams().nx().ex(5));
        return "OK".equals(result);
    }

    // 释放锁
    public void unLock() {
        // 释放锁,即删除对应的键
        jedis.del(lockKey);
    }
}

在上面的代码中,我们利用 set key value nx ex seconds 命令给锁设定了超时时间解决了 Redis 分布式锁被占用而无法释放的问题(设定了超时时间,就算发生了业务阻塞,锁最终也会被释放)。

4.3 Redis 分布式锁误解锁

上面的 Redis 分布式锁引入了超时机制后会带来一个问题。我们先假设一个场景:

  1. 业务 A 获取到锁之后发生了业务阻塞,锁被超时释放了。
  2. 业务 B 正常获取到锁执行业务。此时,业务 A 恢复执行,并在执行完成后释放掉了锁(此时锁是属于业务 B 的)
  3. 业务 C 争抢到锁,但是业务 B 与业务 C 是互斥的此时就会导致并发问题(业务 B 与业务 C 是互斥的,但是同时在执行)。
public class RedisLock {

    private Jedis jedis;
    private String lockKey;

    // 构造器
    public RedisLock(Jedis jedis, String lockKey) {
        this.jedis = jedis;
        // lockKey 不再为固定值
        this.lockKey = lockKey;
    }

    // 获取锁
    public boolean tryLock() {
        // 使用 set key value NX EX seconds 命令尝试获取锁, 并设置过期时间
        String result = jedis.set(lockKey, "1", SetParams.setParams().nx().ex(5));
        return "OK".equals(result);
    }

    // 释放锁
    public void unLock() {
        // 释放锁,即删除对应的键
        jedis.del(lockKey);
    }
}

为了解决分布式锁误解锁的问题,Redis 分布式锁的 key 不再为一个固定值。业务 A 有自己的 lockKey,业务 B 与业务 C 有相同的 lockKey。此时,业务 A 只能释放自己的锁,业务 B 与业务 C 拥有相同的 lockKey,当业务 B 没有释放锁时,业务 C 是无法获取到锁的,从而保证了业务 B 与业务 C 的互斥。

4.4 Redis 分布式锁续约

在 Redis 分布式锁误解锁的例子中,我们似乎使用不同的 lockKey 解决了误解锁的问题。但是当我们再深入思考一下会发现还有一个问题。我们现假设:

  1. 首先,业务 B 获取到锁之后发生了业务阻塞,锁被超时释放了。
  2. 然后,业务 C 争抢到锁,由于业务 B 与业务 C 是互斥的此时就会导致并发问题。

每一个业务的执行时间大抵是不尽相同的。在之前的例子中我们使用 jedis.set(lockKey, "1", SetParams.setParams().nx().ex(5))将锁的的释放时间设置成了 5 秒,如果业务能够在 5 秒内执行完成倒是没什么问题,若发生了业务阻塞或业务执行时间大于我们设定的过期时间呢?
针对以上的问题,我们通常采用一种称为 Watch Dog(看门狗)的机制去解决。

public class RedisLock {

    private final Jedis jedis;
    private final String lockKey;
    private final ScheduledExecutorService executorService;

    // 构造器
    public RedisLock(Jedis jedis, String lockKey) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.executorService = Executors.newSingleThreadScheduledExecutor();
    }

    // 尝试获取锁
    @SneakyThrows
    public boolean tryLock(long leaseTime, TimeUnit timeUnit)  {
        String result = jedis.set(lockKey, "1", SetParams.setParams().nx().px(timeUnit.toMillis(leaseTime)));
        if ("OK".equals(result)) {
            // 获取锁成功时自动启动 watch dog
            startWatchdog(leaseTime, timeUnit);
            return true;
        }
        return false;
    }

    // 释放锁
    public void unlock() {
        jedis.del(lockKey);
        stopWatchdog(); // 释放锁时停止 watch dog
    }

    // 启动 Watchdog
    public void startWatchdog(long leaseTime, TimeUnit timeUnit) {
        long leaseTimeMillis = timeUnit.toMillis(leaseTime);
        // 续期检测时间间隔为租约时间的 1/3
        long checkIntervalMillis = leaseTimeMillis / 3;
        executorService.scheduleAtFixedRate(() -> {
            long ttl = jedis.pttl(lockKey);
            if (ttl > 0) {
                // 续约锁
                jedis.pexpire(lockKey, leaseTimeMillis);
            } else {
                // 锁过期后停止 watch dog
                stopWatchdog();
            }
            // 周期性执行任务
        }, checkIntervalMillis, checkIntervalMillis, TimeUnit.MILLISECONDS);
    }

    // 停止 Watchdog
    public void stopWatchdog() {
        executorService.shutdown();
    }
}

在上面的代码中采用了 Watch Dog 机制周期性的去给锁续期,在业务完成之后,调用 unlock() 方法便可释放锁,并且停止 Watch Dog。

4.5 Redis 分布式锁重试

现在的 Redis 分布式锁已经解决了一部分问题,但是我们假设一个场景:

  1. 有三个业务(业务 A,业务 B,业务 C)同时争抢锁,业务 A 首先抢到了锁
  2. 业务 A 的执行时间很短,业务 B 与业务 C 此时应该如何处理

现有两种处理方式:

  1. 业务 B 与业务 C 直接返回失败信息
  2. 业务 B 与业务 C 自动重试争抢锁

在高并发的场景下,第一种方式业务 B 与业务 C 获取到锁的成功率会很小(因为第一次没抢到就返回失败信息了),第二种方式显然会更高(业务 B 与业务 C 会重试获取锁,如果在重试时锁空闲了便能获取到)。为了系统的稳定性和可靠性我们通常会采用第二种方式。

public class RedisLock {

    private final Jedis jedis;
    private final String lockKey;
    private final ScheduledExecutorService executorService;

    // 构造器
    public RedisLock(Jedis jedis, String lockKey) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.executorService = Executors.newSingleThreadScheduledExecutor();
    }

    // 获取锁
    @SneakyThrows
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit timeUnit)  {
        long start = System.currentTimeMillis();
        long end = start + timeUnit.toMillis(waitTime);
        // while 循环进行锁重试
        while (System.currentTimeMillis() < end) {
            String result = jedis.set(lockKey, "1", SetParams.setParams().nx().px(timeUnit.toMillis(leaseTime)));
            if ("OK".equals(result)) {
                // 获取锁成功时自动启动 watch dog
                startWatchdog(leaseTime, timeUnit);
                return true;
            }
            // 尝试等待一段时间再重试
            TimeUnit.MILLISECONDS.sleep(100);
        }
        return false;
    }

    // 释放锁
    public void unlock() {
        jedis.del(lockKey);
        stopWatchdog(); // 释放锁时停止 watch dog
    }

    // 启动 Watchdog
    public void startWatchdog(long leaseTime, TimeUnit timeUnit) {
        long leaseTimeMillis = timeUnit.toMillis(leaseTime);
        // 续期检测时间间隔为租约时间的 1/3
        long checkIntervalMillis = leaseTimeMillis / 3;
        executorService.scheduleAtFixedRate(() -> {
            long ttl = jedis.pttl(lockKey);
            if (ttl > 0) {
                // 续约锁
                jedis.pexpire(lockKey, leaseTimeMillis);
            } else {
                // 锁过期后停止 watch dog
                stopWatchdog();
            }
        }, checkIntervalMillis, checkIntervalMillis, TimeUnit.MILLISECONDS);
    }

    // 停止 Watchdog
    public void stopWatchdog() {
        executorService.shutdown();
    }
}

五、Redis 分布式锁的问题

5.1 如何实现可重入分布式锁

之前我们基于 set key value 实现的分布式锁,但是这样的锁是不可重入的。如果我们想实现可重入的分布式锁可以基于 Hash 类型,采用 hset key field value 这样的命令实现(重入一次 value 自增 +1)。

5.2 锁过期与锁续约的冲突

锁过期是为了防止锁一直被占用无法释放,锁续约是为了防止锁被提前释放。如果锁无限续约那么锁设置过期时间就无意义了,所以锁在续约时需要一些兜底方案(例如:有一个最大的续约时间)。除此之外,应该在设置锁过期时间和锁续约时间时充分考虑业务的执行时间,从而尽可能提前避免一些问题。

5.3 锁重试为什么需要等待

在我们设计锁重试时有这么一行代码 TimeUnit.MILLISECONDS.sleep(100)(即休眠 100 ms)。为什么需要休眠呢?现假设一个场景:

  1. 业务 A 与业务 B 同时争抢锁
  2. 业务 A 先抢到了锁,执行业务需要 1s

业务 A 既然执行时间需要 1s,如果业务 B 在重试的时候不休眠就会白白浪费系统资源。如果休眠 100 ms,最多会重试 10 次,这样很大程度上节省了系统资源。

往期推荐

  1. JDK 动态代理
  2. ThreadLocal
  3. HashMap 源码分析(三)
  4. Spring 三级缓存
  5. RBAC 权限设计(二)

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

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

相关文章

4套java智慧型管理系统源码-智慧校园-智慧工地-智慧城管-智慧3D导诊

第一套&#xff1a;Java智慧校园系统源码 智慧学校源码 微信小程序电子班牌 智慧校园系统简介&#xff1a; 智慧校园的建设逐渐被师生、家长认可接受&#xff0c;智慧校园通过对在校师生、教务等所有人员的信息以及各种信息搜集与储存&#xff0c;进行数据优化与管理&#xf…

信息系统项目管理师0057:运维管理(4信息系统管理—4.2管理要点—4.2.2运维管理)

点击查看专栏目录 文章目录 4.2.2运维管理1.能力模型2.智能运维4.2.2运维管理 IT运维是组织IT服务中关键的一种类型。随着组织IT建设的不断深入和完善,信息系统运维已经成为了各行各业各组织管理者和IT团队普遍关注的问题。IT运维是指采用IT手段及方法,依据服务对象提出的服务…

代码学习记录49---单调栈

随想录日记part49 t i m e &#xff1a; time&#xff1a; time&#xff1a; 2024.04.20 主要内容&#xff1a;今天开始要学习单调栈的相关知识了&#xff0c;今天的内容主要涉及&#xff1a;柱状图中最大的矩形 84.柱状图中最大的矩形 Topic184.柱状图中最大的矩形 题目&…

一线实战:国产数据库Mogdb双网卡同步最佳实践

前言 大家都知道Oracle数据库无论是单机还是RAC集群在进行生产部署实施时&#xff0c;我们都会对网卡做冗余考虑&#xff0c;使用双网卡&#xff0c;比如public、心跳网络。这样的目的主要是为了安全&#xff0c;避免单点故障。当然双网卡Bond不仅是可以做主备还可以支持负载均…

问题总结笔记

1.向量旋转 问题&#xff1a; 将一个向量旋转90 方法&#xff1a;旋转矩阵 FVector FrontDir EndMousePoint - Point; FrontDir.Normalize(); FVector Left FVector(-FrontDir.Y, FrontDir.X, 0); Verties.Add(Point Left * (WallWedith / 2)); Verties.Add(FVector(Vertie…

告别数据丢失,轻松掌握文件自动备份秘籍

在这个数字化高速发展的时代&#xff0c;我们的工作和生活都离不开电脑&#xff0c;而电脑中存储的文件和数据更是至关重要。然而&#xff0c;数据丢失的风险无处不在&#xff0c;可能因为硬件故障、软件崩溃、病毒攻击等原因而导致重要文件丢失。因此&#xff0c;文件自动备份…

漫谈HAMR硬盘的可靠性-2

很显然&#xff0c;HAMR已经成为业内用于提升HDD硬盘容量硬盘的技术手段。三家机械硬盘HDD厂商&#xff0c;希捷、西数、东芝都已对HAMR硬盘进行了十多年的研究&#xff0c;但只有希捷大胆押注HAMR。相反&#xff0c;东芝和西部数据在采用HAMR之前选择了能量辅助垂直磁记录&…

iOS 在OC旧项目中使用Swift进行混编

iOS 在OC旧项目中使用Swift进行混编 1、创建桥接文件 ​ 第一次在Swift创建OC文件&#xff0c;或者第一次OC创建Swift时&#xff0c;xcode会提示桥接&#xff0c;Creat Bridging Header即可,这个文件用于Swift调用OC文件&#xff0c;与OC调用Swift无关。 2、在TARGETS中设置D…

机器学习(三)之监督学习2

前言&#xff1a; 本专栏一直在更新机器学习的内容&#xff0c;欢迎点赞收藏哦&#xff01; 笔者水平有限&#xff0c;文中掺杂着自己的理解和感悟&#xff0c;如果有错误之处还请指出&#xff0c;可以在评论区一起探讨&#xff01; 1.支持向量机&#xff08;Support Vector Ma…

第22天:安全开发-PHP应用留言板功能超全局变量数据库操作第三方插件引用

第二十二天 一、PHP留言板前后端功能实现 开发环境&#xff1a; DW PHPStorm PhpStudy Navicat Premium DW : HTML&JS&CSS开发 PHPStorm : 专业PHP开发IDE PhpStudy &#xff1a;Apache MYSQL环境 Navicat Premium: 全能数据库管理工具 二、数据库创建&架…

【Flutter】One or more plugins require a higher Android SDK version.

问题描述 项目里多个组件需要更高版本的Android SDK One or more plugins require a higher Android SDK version.解决方案&#xff1a; 报错提示requires Android SDK version 34 按提示修改android项目app里build.gradle的compileSdkVersion 为34 android {compileSdkVe…

线程池 ThreadPoolExecutor 参数详解

一、引言 提到 Java 线程池&#xff0c;就不得不说 ThreadPoolExecutor&#xff0c;它是 Java 并发包 java.util.concurrent 中的一个类&#xff0c;提供一个高效、稳定、灵活的线程池实现&#xff0c;用于实现多线程并发执行任务&#xff0c;提高应用程序的执行效率。 在《任…

JVM与GC原理

JVM运行流程 Java 虚拟机&#xff08;Java Virtual Machine&#xff0c;JVM&#xff09;是 Java 平台的核心组件之一&#xff0c;它是一个在实际硬件和操作系统上模拟运行 Java 字节码的虚拟计算机 Java 程序被执行的顺序通常包括以下几个步骤&#xff1a; 编辑&#xff08;E…

Midjourney 中文文档

快速使用 学习如何在Discord上使用Midjourney Bot从简单的文本提示中创建自定义图像。 行为准则 不要表现出不良行为。不要使用我们的工具制作可能引起煽动&#xff0c;不安或引起争议的图像。这包括血腥和成人内容。尊重其他人和团队。 1&#xff1a;加入Discord 访问Midj…

如何让AI生成自己喜欢的歌曲-AI音乐创作的正确方式 - 第507篇

历史文章 AI音乐&#xff0c;8大变现方式——Suno&#xff1a;音乐版的ChatGPT - 第505篇 日赚800&#xff0c;利用淘宝/闲鱼进行AI音乐售卖实操 - 第506篇 导读 在使用AI生成音乐&#xff08;AI写歌&#xff09;的时候&#xff0c;你是不是有这样的困惑&#xff1a; &…

Docker 部署网页版 vscode (code-server)

什么是 code-server code-server 是一个基于 Visual Studio Code 的开源项目&#xff0c;它允许你通过 Web 浏览器来使用 Visual Studio Code 的编辑功能。这意味着你可以在任何设备上&#xff0c;只要有浏览器和网络连接&#xff0c;就可以访问和使用 Visual Studio Code&…

如果备份了oradata文件,该如何还原Oracle数据呢?

程序员的公众号&#xff1a;源1024&#xff0c;获取更多资料&#xff0c;无加密无套路&#xff01; 最近整理了一波电子书籍资料&#xff0c;包含《Effective Java中文版 第2版》《深入JAVA虚拟机》&#xff0c;《重构改善既有代码设计》&#xff0c;《MySQL高性能-第3版》&…

(mac)性能监控平台搭建JMeter+Grafana+Influxdb

【实现原理】 通过influxdb数据库存储jmeter的结果&#xff0c;再通过grafana采集influxdb数据库数据&#xff0c;完成监控平台展示 一、时间序列数据InfluxDB 1.InfluxDB下载安装 官网下载 https://portal.influxdata.com/downloads/ 官网最新版&#xff1a; &#xff0…

AI预测体彩排列3第2套算法实战化测试第1弹2024年4月22日第1次测试

从今天开始&#xff0c;开始新一轮的测试&#xff0c;本轮测试&#xff0c;以6码为基础&#xff0c;同步测试杀号情况&#xff0c;争取杀至4-5码。经过计算&#xff0c;假如5码命中&#xff0c;即每期125注&#xff0c;投入250元&#xff0c;十期共计2500元&#xff0c;则命中率…

06 JavaScript学习:语句

JavaScript 语句是用来执行特定任务或操作的一组指令。它可以包括变量声明、条件语句、循环语句、函数调用等。JavaScript 语句以分号结尾&#xff0c;每个语句都会被解释器执行。 分号 ; 在JavaScript中&#xff0c;分号&#xff08;;&#xff09;用于表示语句的结束。尽管在…