Redis 分布式锁|从青铜到钻石的五种演进方案

回复 PDF 领取资料 html

这是悟空的第 99 篇原创文章前端

做者 | 悟空聊架构java

来源 | 悟空聊架构(ID:PassJava666)git

转载请联系受权(微信ID:PassJava)github

上篇咱们讲到如何用本地内存:《缓存实战(上篇)》 来作缓存从而加强系统的性能,另外探讨了加锁解决缓存击穿的问题。可是本地加锁的方式在分布式的场景下就不适用了,因此本文咱们来探讨下如何引入分布式锁解决本地锁的问题。本篇全部代码和业务基于个人开源项目 PassJava。redis

本篇主要内容以下:docker

1、本地锁的问题

首先咱们来回顾下本地锁的问题:数据库

目前题目微服务被拆分红了四个微服务。前端请求进来时,会被转发到不一样的微服务。假如前端接收了 10 W 个请求,每一个微服务接收 2.5 W 个请求,假如缓存失效了,每一个微服务在访问数据库时加锁,经过锁(synchronziedlock)来锁住本身的线程资源,从而防止缓存击穿小程序

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

2、什么是分布式锁

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

生活中的案例:能够把锁当作房门外的一把,全部并发线程比做,他们都想进入房间,房间内只能有一我的进入。当有人进入后,将门反锁,其余人必须等待,直到进去的人出来。

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

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

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

  • 2.每一个微服务处理 2.5 W 个请求。

  • 3.每一个处理请求的线程在执行业务以前,须要先抢占锁。能够理解为“占坑”。

  • 4.获取到锁的线程在执行完业务后,释放锁。能够理解为“释放坑位”。

  • 5.未获取到的线程须要等待锁释放。

  • 6.释放锁后,其余线程抢占锁。

  • 7.重复执行步骤 四、五、6。

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

3、Redis 的 SETNX

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

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

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

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

set <key> <value> NX

咱们能够进到 redis 容器中来试下 SETNX 命令。

先进入容器:

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

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

set wukong 1111 NX

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

4、青铜方案

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

3.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();
}

一个小问题:那为何须要休眠一段时间?

由于该程序存在递归调用,可能会致使栈空间溢出。

3.2 青铜方案的缺陷

青铜之因此叫青铜,是由于它是最初级的,确定会带来不少问题。

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

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

那如何规避这个风险呢?

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

4、白银方案

4.1 生活中的例子

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

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

4.2 技术原理图

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

4.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.4 白银方案的缺陷

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

由于占锁和设置过时时间是分两步执行的,因此若是在这两步之间发生了异常,则锁的过时时间根本就没有设置成功。

因此和青铜方案有同样的问题:锁永远不能过时

5、黄金方案

5.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(已过时)。

5.2 技术原理图

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

5.3 示例代码

设置 lock 的值等于 123,过时时间为 10 秒。若是 10 秒 之后,lock 还存在,则清理 lock。

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

5.4 黄金方案的缺陷

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

5.4.1 用户 A 抢占锁

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

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

5.4.2 用户 B 抢占锁

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

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

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

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

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

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

5.4.3 用户 C 抢占锁

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

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

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

从上面的案例中咱们能够知道,由于用户 A 处理任务所须要的时间大于锁自动清理(开锁)的时间,因此在自动开锁后,又有其余用户抢占到了锁。当用户 A 完成任务后,会把其余用户抢占到的锁给主动打开。

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

6、铂金方案

6.1 生活中的例子

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

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

作了个动图,方便理解:

动图演示

静态图更高清,能够看看:

6.2 技术原理图

与黄金方案的不一样之处:

  • 设置锁的过时时间时,还须要设置惟一编号。

  • 主动删除锁的时候,须要判断锁的编号是否和设置的一致,若是一致,则认为是本身设置的锁,能够进行主动删除。

6.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();
}
  • 1.生成随机惟一 id,给锁加上惟一值。

  • 2.抢占锁,并设置过时时间为 10 s,且锁具备随机惟一 id。

  • 3.抢占成功,执行业务。

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

  • 5.若是锁的值和设置的值相等,则清理本身的锁。

6.4 铂金方案的缺陷

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

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

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

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

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

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

  • 时刻:13s。线程 A 仍是拿本身设置的锁的值和返回的值进行比较,值是相等的,清理锁,可是这个锁实际上是线程 B 抢占的锁。

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

7、钻石方案

上面的线程 A 查询锁和删除锁的逻辑不是原子性的,因此将查询锁和删除锁这两步做为原子指令操做就能够了。

7.1 技术原理图

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

7.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 脚本。

那钻石方案是否是就完美了呢?有没有更好的方案呢?

下篇,咱们再来介绍另一种分布式锁的王者方案:Redisson。

8、总结

本篇经过本地锁的问题引伸出分布式锁的问题。而后介绍了五种分布式锁的方案,由浅入深讲解了不一样方案的改进之处。

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

触类旁通,这种不断演进的思惟模式也能够运用到其余技术中。

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

青铜方案

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

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

白银方案

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

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

黄金方案

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

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

铂金方案

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

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

钻石方案

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

  • 改进: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

- END -

写了两本 PDF, 回复  分布式  或  PDF  载。

个人 JVM 专栏已上架,回复  JVM  领取

我是悟空,努力变强,变身超级赛亚人!

本文分享自微信公众号 - 悟空聊架构(PassJava666)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索