草庐IT

Redis分布式锁的五大演进攻略

轻风博客 2023-03-28 原文

 

本文我们来探讨下如何引入分布式锁解决本地锁的问题。本篇所有代码和业务基于我的开源项目 PassJava。

 

本篇主要内容如下:

 

 

一、本地锁的问题

 

首先我们来回顾下本地锁的问题:

 

目前题目微服务被拆分成了四个微服务。前端请求进来时,会被转发到不同的微服务。假如前端接收了 10 W 个请求,每个微服务接收 2.5 W 个请求,假如缓存失效了,每个微服务在访问数据库时加锁,通过锁(synchronzied 或 lock)来锁住自己的线程资源,从而防止缓存击穿

 

这是一种本地加锁的方式,在分布式情况下会带来数据不一致的问题:比如服务 A 获取数据后,更新缓存 key =100,服务 B 不受服务 A 的锁限制,并发去更新缓存 key = 99,最后的结果可能是 99 或 100,但这是一种未知的状态,与期望结果不一致。流程图如下所示:

 

 

二、什么是分布式锁

 

基于上面本地锁的问题,我们需要一种支持分布式集群环境下的锁:查询 DB 时,只有一个线程能访问,其他线程都需要等待第一个线程释放锁资源后,才能继续执行。

 

生活中的案例:可以把锁看成房门外的一把锁,所有并发线程比作人,他们都想进入房间,房间内只能有一个人进入。当有人进入后,将门反锁,其他人必须等待,直到进去的人出来。

 

 

我们来看下分布式锁的基本原理,如下图所示:

 

 

我们来分析下上图的分布式锁:

 

1)前端将 10W 的高并发请求转发给四个题目微服务。

 

2)每个微服务处理 2.5 W 个请求。

 

3)每个处理请求的线程在执行业务之前,需要先抢占锁。可以理解为“占坑”。

 

4)获取到锁的线程在执行完业务后,释放锁。可以理解为“释放坑位”。

 

5)未获取到的线程需要等待锁释放。

 

6)释放锁后,其他线程抢占锁。

 

7)重复执行步骤 4、5、6。

 

大白话解释:所有请求的线程都去同一个地方“占坑”,如果有坑位,就执行业务逻辑,没有坑位,就需要其他线程释放“坑位”。这个坑位是所有线程可见的,可以把这个坑位放到 Redis 缓存或者数据库,这篇讲的就是如何用 Redis 做“分布式坑位”。

 

三、Redis 的 SETNX

 

Redis 作为一个公共可访问的地方,正好可以作为“占坑”的地方。

 

用 Redis 实现分布式锁的几种方案,我们都是用 SETNX 命令(设置 key 等于某 value)。只是高阶方案传的参数个数不一样,以及考虑了异常情况。

 

我们来看下这个命令,SETNXset If not exist的简写。意思就是当 key 不存在时,设置 key 的值,存在时,什么都不做。

 

在 Redis 命令行中是这样执行的:

set <key> <value> NX

 

我们可以进到 redis 容器中来试下 SETNX 命令。

 

先进入容器:

docker exec -it <容器 id> redis-cli

 

然后执行 SETNX 命令:将 wukong 这个 key 对应的 value 设置成 1111

set wukong 1111 NX

 

返回 OK,表示设置成功。重复执行该命令,返回 nil表示设置失败。

 

 

四、青铜方案

 

我们先用 Redis 的 SETNX 命令来实现最简单的分布式锁。

 

1、青铜原理

 

我们来看下流程图:

 

 

  • 多个并发线程都去 Redis 中申请锁,也就是执行 setnx 命令,假设线程 A 执行成功,说明当前线程 A 获得了。

 

  • 其他线程执行 setnx 命令都会是失败的,所以需要等待线程 A 释放锁。

 

  • 线程 A 执行完自己的业务后,删除锁。

 

  • 其他线程继续抢占锁,也就是执行 setnx 命令。因为线程 A 已经删除了锁,所以又有其他线程可以抢占到锁了。

 

代码示例如下,Java 中 setnx 命令对应的代码为 setIfAbsent

 

setIfAbsent 方法的第一个参数代表 key,第二个参数代表值。

// 1.先抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if(lock) {
  // 2.抢占成功,执行业务
  List<TypeEntity> typeEntityListFromDb = getDataFromDB();
  // 3.解锁
  redisTemplate.delete("lock");
  return typeEntityListFromDb;
} else {
  // 4.休眠一段时间
  sleep(100);
  // 5.抢占失败,等待锁释放
  return getTypeEntityListByRedisDistributedLock();
}

 

一个小问题:那为什么需要休眠一段时间?

 

因为该程序存在递归调用,可能会导致栈空间溢出。

 

2、青铜方案的缺陷

 

青铜之所以叫青铜,是因为它是最初级的,肯定会带来很多问题。

 

设想一种家庭场景:晚上小空一个人开锁进入了房间,打开了电灯,然后突然断电了,小空想开门出去,但是找不到门锁位置,那小明就进不去了,外面的人也进不来。

 

 

从技术的角度看:setnx 占锁成功,业务代码出现异常或者服务器宕机,没有执行删除锁的逻辑,就造成了死锁。

 

那如何规避这个风险呢?

 

设置锁的自动过期时间,过一段时间后,自动删除锁,这样其他线程就能获取到锁了。

 

五、白银方案

 

1、生活中的例子

 

上面提到的青铜方案会有死锁问题,那我们就用上面的规避风险的方案来设计下,也就是我们的白银方案。

 

 

还是生活中的例子:小空开锁成功后,给这款智能锁设置了一个沙漏倒计时,沙漏完后,门锁自动打开。即使房间突然断电,过一段时间后,锁会自动打开,其他人就可以进来了。

 

2、技术原理图

 

和青铜方案不同的地方在于,在占锁成功后,设置锁的过期时间,这两步是分步执行的。如下图所示:

 

 

3、示例代码

 

清理 redis key 的代码如下:

// 在 10s 以后,自动清理 lock
redisTemplate.expire("lock", 10, TimeUnit.SECONDS);

完整代码如下:


// 1.先抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if(lock) {
    // 2.在 10s 以后,自动清理 lock
    redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
    // 3.抢占成功,执行业务
    List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    // 4.解锁
    redisTemplate.delete("lock");
    return typeEntityListFromDb;
}

 

4、白银方案的缺陷

 

白银方案看似解决了线程异常或服务器宕机造成的锁未释放的问题,但还是存在其他问题:

 

因为占锁和设置过期时间是分两步执行的,所以如果在这两步之间发生了异常,则锁的过期时间根本就没有设置成功。

 

所以和青铜方案有一样的问题:锁永远不能过期。

 

六、黄金方案

 

1、原子指令

 

上面的白银方案中,占锁和设置锁过期时间是分步两步执行的,这个时候,我们可以联想到什么:事务的原子性(Atom)。

 

原子性:多条命令要么都成功执行,要么都不执行。

 

将两步放在一步中执行:占锁+设置锁过期时间。

 

Redis 正好支持这种操作:


# 设置某个 key 的值并设置多少毫秒或秒 过期。
set <key> <value> PX <多少毫秒> NX
或
set <key> <value> EX <多少秒> NX

 

然后可以通过如下命令查看 key 的变化。

ttl <key>

 

下面演示下如何设置 key 并设置过期时间。注意:执行命令之前需要先删除 key,可以通过客户端或命令删除。


# 设置 key=wukong,value=1111,过期时间=5000ms
set wukong 1111 PX 5000 NX
# 查看 key 的状态
ttl wukong

 

执行结果如下图所示:每运行一次 ttl 命令,就可以看到 wukong 的过期时间就会减少。最后会变为 -2(已过期)。

 

 

2、技术原理图

 

黄金方案和白银方案的不同之处:获取锁的时候,也需要设置锁的过期时间,这是一个原子操作,要么都成功执行,要么都不执行。如下图所示:

 

 

3、示例代码

 

设置 lock 的值等于 123,过期时间为 10 秒。如果 10 秒 以后,lock 还存在,则清理 lock。

setIfAbsent("lock", "123", 10, TimeUnit.SECONDS);

 

4、黄金方案的缺陷

 

我们还是举生活中的例子来看下黄金方案的缺陷。

 

1)用户 A 抢占锁

 

 

  • 用户 A 先抢占到了锁,并设置了这个锁 10 秒以后自动开锁,锁的编号为 123。

 

  • 10 秒以后,A 还在执行任务,此时锁被自动打开了。

 

2)用户 B 抢占锁

 

 

  • 用户 B 看到房间的锁打开了,于是抢占到了锁,设置锁的编号为 123,并设置了过期时间 10 秒。

 

  • 因房间内只允许一个用户执行任务,所以用户 A 和 用户 B 执行任务产生了冲突。

 

  • 用户 A 在 15 s 后,完成了任务,此时 用户 B 还在执行任务。

 

  • 用户 A 主动打开了编号为 123的锁。

 

  • 用户 B 还在执行任务,发现锁已经被打开了。

 

  • 用户 B 非常生气:我还没执行完任务呢,锁怎么开了?

 

3)用户 C 抢占锁

 

 

  • 用户 B 的锁被 A 主动打开后,A 离开房间,B 还在执行任务。

 

  • 用户 C 抢占到锁,C 开始执行任务。

 

  • 因房间内只允许一个用户执行任务,所以用户 B 和 用户 C 执行任务产生了冲突。

 

从上面的案例中我们可以知道,因为用户 A 处理任务所需要的时间大于锁自动清理(开锁)的时间,所以在自动开锁后,又有其他用户抢占到了锁。当用户 A 完成任务后,会把其他用户抢占到的锁给主动打开。

 

这里为什么会打开别人的锁?因为锁的编号都叫做 “123”,用户 A 只认锁编号,看见编号为 “123”的锁就开,结果把用户 B 的锁打开了,此时用户 B 还未执行完任务,当然生气了。

 

七、铂金方案

 

1、生活中的例子

 

上面的黄金方案的缺陷也很好解决,给每个锁设置不同的编号不就好了~

 

如下图所示,B 抢占的锁是蓝色的,和 A 抢占到绿色锁不一样。这样就不会被 A 打开了。

 

做了个动图,方便理解:

 

动图演示

 

静态图更高清,可以看看:

 

 

2、技术原理图

 

与黄金方案的不同之处:

 

  • 设置锁的过期时间时,还需要设置唯一编号。

 

  • 主动删除锁的时候,需要判断锁的编号是否和设置的一致,如果一致,则认为是自己设置的锁,可以进行主动删除。

 

 

3、代码示例

 

// 1.生成唯一 id
String uuid = UUID.randomUUID().toString();
// 2. 抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
if(lock) {
    System.out.println("抢占成功:" + uuid);
    // 3.抢占成功,执行业务
    List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    // 4.获取当前锁的值
    String lockValue = redisTemplate.opsForValue().get("lock");
    // 5.如果锁的值和设置的值相等,则清理自己的锁
    if(uuid.equals(lockValue)) {
        System.out.println("清理锁:" + lockValue);
        redisTemplate.delete("lock");
    }
    return typeEntityListFromDb;
} else {
    System.out.println("抢占失败,等待锁释放");
    // 4.休眠一段时间
    sleep(100);
    // 5.抢占失败,等待锁释放
    return getTypeEntityListByRedisDistributedLock();
}

 

  • 生成随机唯一 id,给锁加上唯一值。

 

  • 抢占锁,并设置过期时间为 10 s,且锁具有随机唯一 id。

 

  • 抢占成功,执行业务。

 

  • 执行完业务后,获取当前锁的值。

 

  • 如果锁的值和设置的值相等,则清理自己的锁。

 

4、铂金方案的缺陷

 

上面的方案看似很完美,但还是存在问题:第 4 步和第 5 步并不是原子性的。

 

 

  • 时刻:0s。线程 A 抢占到了锁。

 

  • 时刻:9.5s。线程 A 向 Redis 查询当前 key 的值。

 

  • 时刻:10s。锁自动过期。

 

  • 时刻:11s。线程 B 抢占到锁。

 

  • 时刻:12s。线程 A 在查询途中耗时长,终于拿多锁的值。

 

  • 时刻:13s。线程 A 还是拿自己设置的锁的值和返回的值进行比较,值是相等的,清理锁,但是这个锁其实是线程 B 抢占的锁。

 

那如何规避这个风险呢?钻石方案登场。

 

八、钻石方案

 

上面的线程 A 查询锁和删除锁的逻辑不是原子性的,所以将查询锁和删除锁这两步作为原子指令操作就可以了。

 

1、技术原理图

 

如下图所示,红色圈出来的部分是钻石方案的不同之处。用脚本进行删除,达到原子操作。

 

 

2、代码示例

 

那如何用脚本进行删除呢?

 

我们先来看一下这段 Redis 专属脚本:


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

 

这段脚本和铂金方案的获取key,删除key的方式很像。先获取 KEYS[1] 的 value,判断 KEYS[1] 的 value 是否和 ARGV[1] 的值相等,如果相等,则删除 KEYS[1]。

 

那么这段脚本怎么在 Java 项目中执行呢?

 

分两步:先定义脚本;用 redisTemplate.execute 方法执行脚本。

// 脚本解锁
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);

 

上面的代码中,KEYS[1] 对应“lock”,ARGV[1] 对应 “uuid”,含义就是如果 lock 的 value 等于 uuid 则删除 lock。

 

而这段 Redis 脚本是由 Redis 内嵌的 Lua 环境执行的,所以又称作 Lua 脚本。

 

九、总结

 

本篇通过本地锁的问题引申出分布式锁的问题。然后介绍了五种分布式锁的方案,由浅入深讲解了不同方案的改进之处。

 

从上面几种方案的不断演进的过程中,知道了系统中哪些地方可能存在异常情况,以及该如何更好地进行处理。

 

举一反三,这种不断演进的思维模式也可以运用到其他技术中。

 

下面总结下上面五种方案的缺陷和改进之处。

 

1)青铜方案

 

  • 缺陷:业务代码出现异常或者服务器宕机,没有执行主动删除锁的逻辑,就造成了死锁。

 

  • 改进:设置锁的自动过期时间,过一段时间后,自动删除锁,这样其他线程就能获取到锁了。

 

2)白银方案

 

  • 缺陷:占锁和设置锁过期时间是分步两步执行的,不是原子操作。

 

  • 改进:占锁和设置锁过期时间保证原子操作。

 

3)黄金方案

 

  • 缺陷:主动删除锁时,因锁的值都是相同的,将其他客户端占用的锁删除了。

 

  • 改进:每次占用的锁,随机设为较大的值,主动删除锁时,比较锁的值和自己设置的值是否相等。

 

4)铂金方案

 

  • 缺陷:获取锁、比较锁的值、删除锁,这三步是非原子性的。中途又可能锁自动过期了,又被其他客户端抢占了锁,导致删锁时把其他客户端占用的锁删了。

 

  • 改进:使用 Lua 脚本进行获取锁、比较锁、删除锁的原子操作。

 

5)钻石方案

 

  • 缺陷:非专业的分布式锁方案。

 

  • 改进:Redission 分布式锁。

 

上述所有代码都基于 PassJava 开源项目,后端、前端、小程序都上传到同一个仓库里面了,大家可以通过 github 或 码云访问。地址如下:

 

Github:https://github.com/Jackson0714/PassJava-Platform

 

码云:https://gitee.com/jayh2018/PassJava-Platform

 

配套教程:www.passjava.cn

 

>>>>

参考资料

 

  • http://redis.cn/commands/set.html

  • https://www.bilibili.com/video/BV1np4y1C7Yf

 

作者丨悟空聊架构

有关Redis分布式锁的五大演进攻略的更多相关文章

  1. ruby-on-rails - 带 Spring 锁的 Rails 4 控制台 - 2

    我正在使用Ruby2.1.1和Rails4.1.0.rc1。当执行railsc时,它被锁定了。使用Ctrl-C停止,我得到以下错误日志:~/.rvm/gems/ruby-2.1.1/gems/spring-1.1.2/lib/spring/client/run.rb:47:in`gets':Interruptfrom~/.rvm/gems/ruby-2.1.1/gems/spring-1.1.2/lib/spring/client/run.rb:47:in`verify_server_version'from~/.rvm/gems/ruby-2.1.1/gems/spring-1.1.

  2. ruby - 分布式事务和队列,ruby,erlang,scala - 2

    我有一个涉及多台机器、消息队列和事务的问题。因此,例如用户点击网页,点击将消息发送到另一台机器,该机器将付款添加到用户的帐户。每秒可能有数千次点击。事务的所有方面都应该是容错的。我以前从未遇到过这样的事情,但一些阅读表明这是一个众所周知的问题。所以我的问题。我假设安全的方法是使用两阶段提交,但协议(protocol)是阻塞的,所以我不会获得所需的性能,我是否正确?我通常写Ruby,但似乎Redis之类的数据库和Rescue、RabbitMQ等消息队列系统对我的帮助不大——即使我实现某种两阶段提交,如果Redis崩溃,数据也会丢失,因为它本质上只是内存。所有这些让我开始关注erlang和

  3. Hive SQL 五大经典面试题 - 2

    目录第1题连续问题分析:解法:第2题分组问题分析:解法:第3题间隔连续问题分析:解法:第4题打折日期交叉问题分析:解法:第5题同时在线问题分析:解法:第1题连续问题如下数据为蚂蚁森林中用户领取的减少碳排放量iddtlowcarbon10012021-12-1212310022021-12-124510012021-12-134310012021-12-134510012021-12-132310022021-12-144510012021-12-1423010022021-12-154510012021-12-1523.......找出连续3天及以上减少碳排放量在100以上的用户分析:遇到这类

  4. ruby - 停止分布式 Ruby 服务 - 2

    我有一个启动DRb服务的脚本,然后生成处理程序对象并通过DRb.thread.join等待。我希望脚本一直运行直到被明确杀死,所以我添加了trap"INT"doDRb.stop_serviceend在Ruby1.8下成功停止DRb服务并退出,但在1.9下似乎死锁(在OSX10.6.7上)。对该进程进行采样显示在semaphore_wait_signal_trap中有几个线程在旋转。我假设我在调用stop_service时做错了什么,但我不确定是什么。谁能给我任何关于如何正确处理它的指示? 最佳答案 好的,我想我已经找到了解决方案。如

  5. BigData/Cloud Computing:基于阿里云技术产品的人工智能与大数据/云计算/分布式引擎的综合应用案例目录来理解技术交互流程 - 2

    BigData/CloudComputing:基于阿里云技术产品的人工智能与大数据/云计算/分布式引擎的综合应用案例目录来理解技术交互流程目录一、云计算网站建设:部署与发布网站建设:简单动态网站搭建云服务器管理维护云数据库管理与数据迁移云存储:对象存储管理与安全超大流量网站的负载均衡二、大数据MOOC网站日志分析搭建企业级数据分析平台基于LBS的热点店铺搜索基于机器学习PAI实现精细化营销基于机器学习的客户流失预警分析使用DataV制作实时销售数据可视化大屏使用MaxCompute进行数据质量核查使用Quick BI制作图形化报表使用时间序列分解模型预测商品销量三、云安全云平台使用安全云上服务

  6. ruby - 数组的所有可能分布,来自一个数字 - 2

    我不太确定如何表达这一点,所以我只是举个例子。如果我写:some_method(["a","b"],3)我希望它返回某种形式的[{"a"=>0,"b"=>3},{"a"=>1,"b"=>2},{"a"=>2,"b"=>1},{"a"=>3,"b"=>0}]如果我传入some_method(%w(abc),2)期望的返回值应该是[{"a"=>2,"b"=>0,"c"=>0},{"a"=>1,"b"=>1,"c"=>0},{"a"=>1,"b"=>0,"c"=>1},{"a"=>0,"b"=>2,"c"=>0},{"a"=>0,"b"=>1,"c"=>1},{"a"=>0,"b"=>0,"

  7. 开发人员使用Klocwork进行软件安全的五大原因 - 2

    Klocwork专为企业DevOps和DevSecOps而构建,是首选的静态分析和SAST工具,用于保持高开发速度,同时还强制实施安全性和质量的持续合规性。在这里,我们分享了开发人员选择Klocwork的五大原因。为什么安全性对软件开发至关重要?安全性对于软件开发至关重要,因为黑客和网络犯罪分子一直在寻找将漏洞转化为利益的方法。强大的软件安全防御的一个关键部分是使用安全编码标准,这些标准是用于防止安全漏洞的规则和准则。如果使用得当,安全编码标准可以检测、预防和消除可能危及安全性的漏洞。行业标准工具(特别是SAST工具)可以有效地实施标准,以帮助确保您的软件免受安全漏洞的侵害。开发人员使用Klo

  8. Seatunnel超高性能分布式数据集成平台使用体会 - 2

    文章目录概述定义使用场景特点工作流程连接器转换为何选择SeaTunnel安装下载配置文件部署模式入门示例启动脚本配置文件使用参数示例Kafka进Kafka出的ETL示例FlinkRun传递参数概述定义SeaTunnel官网http://seatunnel.incubator.apache.org/SeaTunnel最新版本官网文档http://seatunnel.incubator.apache.org/docs/2.1.3/intro/aboutSeaTunnelGitHub地址https://github.com/apache/incubator-seatunnelSeaTunnel是一个

  9. ruby - 在 Ruby 中生成高斯(正态分布)随机数的代码 - 2

    用ruby​​生成正态分布随机数的代码是什么?(注意:我回答了我自己的问题,但我会等几天再接受,看看是否有人有更好的答案。)编辑:为此,我查看了两次搜索产生的SO上的所有页面:+“正态分布”ruby和+高斯+随机ruby 最佳答案 Python的random.gauss()和Boost的normal_distribution都使用Box-Mullertransform,所以这对Ruby来说也应该足够好了。defgaussian(mean,stddev,rand)theta=2*Math::PI*rand.callrho=Math.s

  10. 《统计学》第八版贾俊平第六章统计量及抽样分布知识点总结及课后习题答案 - 2

    一、知识框架二、练习题调节一个装瓶机使其对每个瓶子的灌装量均值为μ盎司,通过观察这台装瓶机对每个瓶子的灌装量服从标准差σ=1.0盎司的正态分布。随机抽取这台机器灌装的9个瓶子组成一个样本,并测定每个瓶子的灌装量。试确定样本均值偏离总体均值不超过0.3盎司的概率。解:设每个瓶子的灌装量为X,X为样本均值,样本容量为n。由于总体X服从正态分布,样本均值X也服从正态分布,且均值相同,标准差为所以三、简述题1什么是统计量?为什么要引进统计量?统计量中为什么不含任何未知参数?答:(1)统计量的定义:设X1,X2,…,Xn是从总体X中抽取的容量为n的一个样本,如果由此样本构造一个函数T(X1,X2,…,X

随机推荐