Redis系列第七章
发表于:2023-12-18 | 分类: 中间件
字数统计: 3k | 阅读时长: 11分钟 | 阅读量:

简介

分布式锁是控制分布式系统间同步访问共享资源的一种方式,其可以保证共享资源在并发场景下的数据一致性。

工作原理:

为了达到同步访问的目的,规定,让这些线程在访问共享资源之前先要获取到一个令牌 token,只有具有令牌的线程才可以访问共享资源。这个令牌就是通过各种技术实现的分布式锁。而这个分布锁是一种“互斥资源”,即只有一个。只要有线程抢到了锁,那么其它线程只能等待,直到锁被释放或等待超时。

实现:像 Redis 中的 setnx,数据库中创建相同主键都可以达到目的,删除即可释放

场景引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class Seckill {
@Autowired(required = false)
private StringRedisTemplate srt;
@GetMapping("/sk")
public String sk(){
String stock=srt.opsForValue().get("commodity");
int amount=stock==null?0:Integer.parseInt(stock);
if(amount>0){
srt.opsForValue().set("commodity",--amount+"");
return "库存:"+amount;
}
return "抱歉,您没抢到";
}
}

在商品秒杀背景下,一定会存在很多用户同时读取 Redis 缓存中的 commodity 这个 key,那么大家读取到的 value 很可能是相同的,均可购买,此时就会出现“超卖”

我们可以使用 synchronized () 上锁,但如果服务器不止一台,锁便会失效(每个服务器都会带一个锁)

此时我们便可以使用分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping("/sk1")
public String sk1(){
try {
Boolean lock = srt.opsForValue().setIfAbsent("Lock", "lock");
if(!lock)
return "没抢到锁";
String stock=srt.opsForValue().get("commodity");
int amount=stock==null?0:Integer.parseInt(stock);
if(amount>0){
srt.opsForValue().set("commodity",--amount+"");
return "库存:"+amount;
}
} finally {
srt.delete("Lock");
}
return "抱歉,您没抢到";
}

但此时又会产生新的问题:如果添加完锁后宕机,那么服务器会因为锁的存在永久阻塞

我们可以为锁添加过期时间,同时为了避免业务没执行完锁就过期导致最终误删其他请求的锁,我们可以为锁添加标识

为了实现这个效果,为每个申请锁的客户端随机生成一个 UUID,使用这个 UUID 作为该客户端标识,然后将该 UUID 作为该客户端申请到的锁的 value。在删除锁时,只有在发起当前删除操作的客户端的 UUID 与锁的 value 相同时才可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@GetMapping("/sk2")
public String sk2(){
String uuid= UUID.randomUUID().toString();
try {
Boolean lock = srt.opsForValue().setIfAbsent("Lock", uuid,5, TimeUnit.SECONDS);
if(!lock)
return "没抢到锁";
String stock=srt.opsForValue().get("commodity");
int amount=stock==null?0:Integer.parseInt(stock);
if(amount>0){
srt.opsForValue().set("commodity",--amount+"");
return "库存:"+amount;
}
} finally {
if(srt.opsForValue().get("Lock").equals(uuid))
srt.delete("Lock");
}
return "抱歉,您没抢到";
}

但删除与判断操作不具备原子性,有可能判断成功时恰好key过期,也会导致误删,此时我们可以使用Lua脚本来实现原子性的操作

Redis 中提供了执行Lua脚本的命令 eval,eval 命令在 RedisTemplate 中没有对应的方法,我们需要使用 Jedis 客户端

eval 语法格式:

1
eval script numkeys [key [key...]] [arg [arg...]]

d9e9181ba7826ad5b8013023fd4b30d9.png

Lua 中使用redis.call可以调用 redis 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@value("${spring.redis.host}")
private String redisHost;
@value("${spring.redis.port}")
private String redisPort;
...
finally{
JedisPool jedisPool=new JedisPool(redisHost,redisPort);
try(Jedis jedis=jedisPool.getResource()){
String script="if redis.call('get',KEYS[1])==ARGV[1] "+
"then return redis.call('del',KEYS[1]) "+
"return 0 "+
"end";
Object eval=jedis.eval(script,Collections.singletonList("Lock"),Collections.singletonList(uuid));
if("1".equals(eval.toString())){
System.out.println("释放锁成功");
}else{
System.out.println("释放锁失败");
}
}
}

以上代码仍然是存在问题的:请求 a 的锁过期,但其业务还未执行完毕;请求 b 申请到了锁,其也正在处理业务。如果此时两个请求都同时修改了共享的库存数据,那么就又会出现数据不一致的问题。

我们可以采用 “锁续约” 方式解决:在当前业务进程开始执行时,fork 出 一个子进程,用于启动一个定时任务。该定时任务的定时时间小于锁的过期时间,其会定时查看处理当前请求的业务进程的锁是否已被删除。

Redisson 可重入锁

Redisson 内部使用 Lua 脚本实现了对可重入锁的添加、重入、续约(续命)、释放。Redisson 需要用户为锁指定一个 key,但无需为锁指定过期时间,因为它有默认过期时间(也可指定)。由于该锁具有“可重入”功能,所以 Redisson 会为该锁生成一个计数器,记录一个线程重入锁的次数。

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.6</version>
</dependency>

添加一个由单 Redis 节点构建的 Redisson 的 Bean

1
2
3
4
5
6
7
8
9
10
11
12
@value("${spring.redis.host}")
private String redisHost;
@value("${spring.redis.port}")
private String redisPort;
@Bean
public Redisson redisson(){
Config config=new Config();
config.useSingleServer()
.setAddress(redisHost+":"+redisPort)
.setDataBase(0);
return (Redission) Redisson.create(config);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Autowired
private Redisson redisson;
@GetMapping("/sk3")
public String sk3(){
RLock rLock=redisson.getLock("Lock");
try {
Boolean lock = rLock.tryLock();
if(!lock)
return "没抢到锁";
String stock=srt.opsForValue().get("commodity");
int amount=stock==null?0:Integer.parseInt(stock);
if(amount>0){
srt.opsForValue().set("commodity",--amount+"");
return "库存:"+amount;
}
} finally {
rLock.unLock();
}
return "抱歉,您没抢到";
}

在 Redis 单机情况下,以上代码是没有问题的。但如果是在 Redis 主从集群中,那么其还存在锁丢失问题:master响应了应用服务器,但在同步前宕机,新master中没有key会再释放一个请求造成并发问题

因为主从集群丢失了一次锁申请,该问题称为主从集群的锁丢失问题。

Redisson 红锁

Redisson 红锁可以防止主从集群锁丢失问题。Redisson 红锁要求,必须要构建出至少三个 Redis 主从集群。若一个请求要申请锁,必须向所有主从集群中提交 key 写入请求,只有当大多数集群锁写入成功后,该锁才算申请成功。

da32ce04a44155b619f02568d88918e9.png

无论前面使用的是哪种锁,都是将所有请求通过锁实现串行化。而串行化在高并发场景下势必会引发性能问题。

分段锁

无论前面使用的是哪种锁,它们解决并发问题的思路都是相同的,那就将所有请求通过锁实现串行化。而串行化在高并发场景下势必会引发性能问题。

例如秒杀商品1000件,分成10份,分别为 sk:01,sk:02,sk:03 … sk:0008:10。 这样的话,就需要 10 把锁来控制所有请求的并发。

Redisson 详解

简介

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。

Redisson 底层采用的是 Netty 框架。Redisson 官网:https://redisson.org/

在生产中,对于 Redisson 使用最多的场景就是其分布式锁 RLock。

为了避免锁到期但业务逻辑没有执行完毕而引发的多个线程同时访问共享资源的情况发生,Redisson 内部为锁提供了一个监控锁的看门狗 watch dog,其会在锁到期前不断延长锁的到期时间,直到锁被主动释放。即会自动完成“锁续命”。

可重入锁

Redisson 的分布式锁 RLock 是一种可重入锁。当一个线程获取到锁之后,这个线程可以再次获取本对象上的锁,而其他的线程不可以。

  • JDK 中的 ReentrantLock 是可重入锁,其是通过 AQS(抽象队列同步器)实现的锁机制
  • JDK 中的 ReentrantLock 是可重入锁,其是通过 AQS(抽象队列同步器)实现的锁机制

公平锁

当有多个线程同时申请锁时,这些线程会进入到一个 FIFO 队列,只有队首元素才会获取到锁,其它元素等待。只有当锁被释放后,才会再将锁分配给当前的队首元素。

Redisson 的可重入锁 RLock 默认是一种非公平锁,但也支持可重入公平锁 FailLock。

联锁

Redisson 分布式锁可以实现联锁 MultiLock。当一个线程需要同时处理多个共享资源时,可使用联锁。即一次性申请多个锁,同时锁定多个共享资源。联锁可预防死锁。相当于对共享资源的申请实现了原子性:要么都申请到,只要缺少一个资源,则将申请到的所有资源全部释放。其是 OS 底层原理中 AND 型信号量机制的典型应用。

红锁

Redisson 分布式锁可以实现红锁 RedLock。红锁由多个锁构成,只有当这些锁中的大部分锁申请成功时,红锁才申请成功。红锁一般用于解决 Redis 主从集群锁丢失问题。

红锁与联锁的区别是,红锁实现的是对一个共享资源的同步访问控制,而联锁实现的是多个共享资源的同步访问控制。

读写锁

通过 Redisson 可以获取到读写锁 RReadWriteLock。通过 RReadWriteLock 实例可分别获取到读锁 RedissonReadLock 与写锁 RedissonWriteLock。读锁与写锁分别是实现了 RLock 的可重入锁。

一个共享资源,在没有写锁的情况下,允许同时添加多个读锁。只要添加了写锁,任何读锁与写锁都不能再次添加。即读锁是共享锁,写锁为排他锁。

这个和操作系统知识关联较大

信号量

通过Redisson可以获取到信号量RSemaphore。RSemaphore的常用场景有两种:无论谁添加的锁,任何其它线程都可以解锁,就可以使用 RSemaphore。另外,当一个线程需要一次申请多个资源时,可使用 RSemaphore。RSemaphore 是信号量机制的典型应用。

和操作系统知识关联较大

d251cb4dd1c55f1a135fef16ff3ee728.png

可过期信号量

通过 Redisson 可以获取到可过期信号量 PermitExpirableSemaphore。该信号量是在 RSemaphore 基础上,为每个信号增加了一个过期时间,且每个信号都可以通过独立的 ID 来辨识。释放时也只能通过提交该 ID 才能释放。

与 RSemaphore 不同:一个线程每次只能申请一个信号量

该信号量为互斥信号量时,其就等同于可重入锁。或者说,可重入锁就相当于信号量为 1 的可过期信号量。

可过期信号量与可重入锁的区别:

  • 可重入锁:相当于用户每次只能申请 1 个信号量,且只有一个用户可以申请成功
  • 可过期信号量:用户每次只能申请 1 个信号量,但可以有多个用户申请成功

17e83f62847f9eeb8510b81ea932e907.png

分布式闭锁

通过 Redisson 可以获取到分布式闭锁 RCountDownLatch,其与 JDK 的 JUC 中的闭锁 CountDownLatch 原理相同,用法类似。其常用于一个或者多个线程的执行必须在其它某些任务执行完毕的场景。

闭锁中定义了一个计数器和一个阻塞队列。阻塞队列中存放着待执行的线程。每当一个并行任务执行完毕,计数器就减 1。当计数器递减到 0 时就会唤醒阻塞队列的所有线程。

通常使用 Barrier 队列解决该问题,而 Barrier 队列通常使用 Zookeeper 实现

137bbecb7c0004c331b7878d1554cb74.png

上一篇:
Zookeeper
下一篇:
Redis系列第六章