Redis分布式锁

一.分布式锁简介

什么是分布式锁?

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

分布式锁,一般会依托第三方组件来实现,而利用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的分布式锁--解决误删问题

需求: 修改之前的分布式锁实现,满足:

  1. 在获取锁时存入线程标示(可以用UUID表示)
  2. 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
  • 如果一致则释放锁
  • 如果不一致则不释放锁
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

那我们知道,释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指定的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做

如果用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如下:

  1. 在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()。

  1. 使用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加锁流程:

  1. 节点部署

2.加锁步骤:

相关推荐
星火开发设计1 小时前
模板特化:为特定类型定制模板实现
java·开发语言·前端·c++·知识
番茄去哪了1 小时前
Redis零基础入门
数据库·redis·缓存
wzqllwy1 小时前
Java实战-性能
java
愿你天黑有灯下雨有伞2 小时前
Java 集合详解:ArrayList、LinkedList、HashMap、TreeMap、HashSet 等核心类对比分析
java·开发语言
知识即是力量ol2 小时前
口语八股——Redis 面试实战指南(二):缓存篇、分布式锁篇
java·redis·缓存·面试·分布式锁·八股
金銀銅鐵2 小时前
浅解 Junit 4 第四篇:类上的 @Ignore 注解
java·junit·单元测试
三水不滴2 小时前
SpringBoot + Redis 滑动窗口计数:打造高可靠接口防刷体系
spring boot·redis·后端
若水不如远方2 小时前
分布式一致性原理(四):工程化共识 —— Raft 算法
分布式·后端·算法
西门吹雪分身2 小时前
K8S之Pod生命周期
java·kubernetes·k8s