一.分布式锁简介

什么是分布式锁?
分布式锁:满足分布式系统或者集群模式下多进程可见并且互斥的锁。

分布式锁,一般会依托第三方组件来实现,而利用Redis实现则是工作中应用最多的一种。今天,就让我们从最基础的步骤开始,依照分布式锁的特性,层层递进,步步完善,将它优化到最优,让大家完整地了解如何用Redis来实现一个分布式锁。
1.1分布式锁实现方式
最简单的版本
首先,当然是搭建一个最简单的实现方式,直接用Redis的setnx命令,这个命令的语法是: setnxkey value如果key不存在,则会将key设置为value,并返回1;如果key存在,不会有任务影响,返回0。
实现分布式锁时需要实现的两个基本方法:
- 获取锁:
互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回true,失败返回false;
bash
# 添加锁,利用setnx的互斥特性
SETNX lock thread1
- 释放锁:
手动释放
bash
#释放锁,删除即可
DEL key
超时释放:获取锁时添加一个超时时间
bash
# 添加锁过期时间,避免服务宕机引起的死锁
SET lock thread1 EX 10 NX

基于Redis实现分布式锁初级版本
案例需求: 定义一个类,实现下面接口,利用Redis实现分布式锁功能:

java
package org.example.redis.config;
import org.example.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
//1.获取Redis模板对象
private StringRedisTemplate redisTemplate;
//锁的名称(我们希望不同的业务获取不同的锁,所以不能把锁的名称写死)
private String name;
private static final String LOCK_PREFIX = "lock:";
public SimpleRedisLock(StringRedisTemplate redisTemplate, String name) {
this.redisTemplate = redisTemplate;
this.name = name;
}
//2.获取锁
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
//获取锁
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(LOCK_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
//3.释放锁
@Override
public void unlock() {
redisTemplate.delete(LOCK_PREFIX + name);
}
}
上述代码解决了synchronized的问题,可以实现分布式的锁,但是仍有缺陷。当线程1拿到锁之后,因为业务有阻塞大,导致线程1的业务执行实践超出了Redis的EX释放锁。此时线程2拿到锁开始执行,在执行到一半的时候,线程1业务完成要执行DEL锁的操作,导致线程2的锁被删除。此时线程3又拿到锁,就出现线程2与3同时执行的并行情况。

就像是下课去取自行车,发现自己的自行车的锁解不开,一气之下,拿来了钳子把锁剪断,结果剪断才发现不是自己的自行车,锁也不是自己的锁。因此在DEL锁的时候要判断是否为自己的锁。
加owner
分布式锁需要满足谁申请谁释放原则,不能释放别人的锁,也就是说,分布式锁,是要有归属的。

改进Redis的分布式锁--解决误删问题
需求: 修改之前的分布式锁实现,满足:
- 在获取锁时存入线程标示(可以用UUID表示)
- 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
java
public class SimpleRedisLock implements ILock {
...
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 和 unlock 保持一致,都用 ID_PREFIX + 线程ID
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(LOCK_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁标示
String id = redisTemplate.opsForValue().get(LOCK_PREFIX + name);
if (threadId.equals(id)) {
//释放锁
redisTemplate.delete(LOCK_PREFIX + name);
}
}
}
上述情况在正常情况下可以解决误删锁的问题,但是还是存在一种极端情况,即发生业务阻塞,如下图所示,就会再一次因为误删锁导致并发:

所以我必须确保判断锁与释放具有原子性:
二.Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基本语法可以参考https://www.runoob.com/lua/lua-tutorial.html
在进行了Redis+Lua之后,Redis才真正在分布式锁和秒杀等场景有了用武之地,流程由:

变成了:

这里重点介绍Redis提供的调用函数,语法如下:
Lua
# 执行redis命令
redis.call('命令名称','key','其它参数',...)
例如,我们要执行set name jack,则脚本是这样:
Lua
# 执行 set name jack
redis.call('set', 'name','jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
Lua
# 先执行 set name jock
redis.call('set','name', 'jack')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本之后,需要用Redis命令来调用脚本,调用脚本的常见命令如下EVAL:

例如,我们要执行redis.call('set','name','jack')这个脚本,语法如下:

Lua
> EVAL "return redis.call('SET','hua','leilei')" 0
OK
> get hua
leilei
如果脚本中的key与value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

Lua
> EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 1 hua beibei
OK
> get hua
beibei
那我们知道,释放锁的业务流程是这样的:
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
如果用Lua脚本来标示则是这样的:
Lua
-- 得到锁的key
local key=KEYS[1]
-- 当前线程标示
local threadId =ARGV[1]
-- 获取锁中的线程标示 get key
local id=redis.call('get',key)
-- 比较线程标示与锁中的标示是否一致
if(id==threadId) then
-- 释放锁 del key
return redis.call('del',key)
end
return 0
再次改进Redis分布式锁
需求:基于Lua脚本实现分布式锁的释放锁逻辑
提示:RedisTemplate调用Lua脚本的API如下:

- 在Resources下新建Lua脚本
2.改写unlock调用脚本
此处就不演示了
上述基于setnx实现的分布式锁存在下面的问题

由此引出一个框架Redisson,在此之前我们先介绍一下Redis分布式锁的保证:
三.可靠性保证
我们发现前面的介绍当Redis挂掉了,那么锁就不能获取了。那这个问题如何解决呢?
一般来说有两种方式:主从容灾和多级部署:
3.1 主从容灾
最简单的一种方式,就是为Redis配置从节点,当主节点挂了,就用从节点顶包;

但是主从切换,需要人工参与,会提高人力成本。不过Redis已经有成熟的解决方案,也就是哨兵模式,可以灵活自动切换,不再需要人工介入。日后会再出 一篇文件来记录学习该模式的详细笔记;
但此时可能会出现下述RedLock前描述的问题,此处不赘述(在下文),因此有没有更可靠的方法呢?
3.2 多机部署
如果对可靠性的要求高一些,可以尝试多机部署,比如Redis的RedLock,此处不再介绍,下文详细介绍;
四.Redisson
Redisson是一个在Redis基础上实现的Java驻内存数据网格。它不仅提供了一系列的分布式和可扩展的Java数据结构,还提供了许多分布式服务。Redisson作为Redis的Java客户端,不仅仅是一个简单的Redis连接池,而是一个功能丰富的分布式和可扩展的Java数据结构集合。
Redisson快速入门
1.引入Redisson依赖
XML
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2.配置Redisson
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 创建配置
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
注:集群模式使用 useClusterServers() ,哨兵模式使用 useSentinelServers()。
- 使用Redisson的分布式锁
java
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断获取锁是否成功
if(isLock){
try {
System.out.println("执行业务");
}finally {
// 释放锁
lock.unlock();
}
}
}
可重入锁原理
为了实现锁的重入式,借助了ReentantLock,有一个计数count。当锁是自己的,获取锁+1。释放锁-1,当count为0的时候再释放锁。所以需要用到的结构是Hash(存2个值),如下所示

所以我们需要写Lua脚本
获取锁的Lua脚本
Lua
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在,获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 不存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁的Lua脚本:
Lua
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end;
上述为我们猜想的版本,事实上,Redisson中Lua脚本也是类似的实现思路

锁重试机制
总结一下流程如下:

源码分析:
入口:
java
lock.tryLock(1, 10, TimeUnit.SECONDS);
// ↑ ↑
// 等待时间 释放时间
源码流程:
java
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) {
long time = unit.toMillis(waitTime); // 等待时间转毫秒,比如1000ms
long current = System.currentTimeMillis();
// 第一次尝试获取锁
long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// ttl == null 说明获取锁成功,直接返回
if (ttl == null) return true;
// 判断等待时间是否已经超时
time -= System.currentTimeMillis() - current;
if (time <= 0) return false; // 超时,获取失败
// ========== 核心重试逻辑 ==========
// 订阅锁释放消息(别人释放锁时会收到通知)
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 在剩余等待时间内等待订阅结果
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
// 等待超时,取消订阅,返回失败
return false;
}
try {
// 循环重试
while (true) {
time -= System.currentTimeMillis() - current;
if (time <= 0) return false; // 等待超时
// 再次尝试获取锁
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) return true; // 获取成功
// 没拿到锁,等待锁释放信号
if (time > 0) {
// 利用信号量等待,收到释放通知才继续重试
// 不是无脑循环,而是等通知!
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
}
} finally {
unsubscribe(subscribeFuture, threadId); // 取消订阅
}
}
```
*
WatchDog机制
看门狗触发条件:
java
// 不指定释放时间时,触发看门狗
lock.lock(); // leaseTime = -1 触发
lock.tryLock(1, -1, SECONDS); // leaseTime = -1 触发
lock.tryLock(1, 10, SECONDS); // leaseTime = 10 不触发

我们现在解决了上述的不可重入,不可重试,超时释放问题,现在还剩下主从一致性的问题,现在来接着介绍Redisson如何解决它的主从一致性问题。

上述问题场景的后果是:

RedLock算法
为解决上述的问题场景以及后果,为此引入RedLock算法:
它的核心思想是:
- 不依赖单个Redis节点
- 而是同时向多个独立的Redis节点加锁
- 超过半数成功才算加锁成功
前提条件为:
多个 Redis 节点必须是完全独立的,不能是主从关系,也不能是集群关系;
RedLock加锁流程:
- 节点部署

2.加锁步骤:
