分布式锁,怎么个事?

发布时间 2023-11-09 09:43:44作者: VayneBeSelf

平时的工作中,由于生产环境中的项目是需要部署在多台服务器中的,所以经常会面临解决分布式场景下数据一致性的问题,那么就需要引入分布式锁来解决这一问题。

本文参考文章:
https://www.cnblogs.com/niceyoo/p/13711149.html
https://cloud.tencent.com/developer/article/1595817
https://www.ghosind.com/2020/06/22/redis-string
https://zhuanlan.zhihu.com/p/77484377

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

针对分布式锁的实现,目前比较常用的就如下几种方案:

  1. 基于数据库实现分布式锁
  2. 基于Redis实现分布式锁
  3. 基于Zookeeper实现分布式锁

听起来B格很高,我们一起静下心来研究一下体会,相信对大家工作中会有所帮助

本文章则已基于Redis实现分布式锁的实现方案、实现细节和实现原理入手,也会涉及到Zookeeper实现分布式锁的原理以及实现,我们一起来看看这是怎么个事?

Redis分布式锁

  1. setNX + Lua脚本
  2. Redisson + RLock可重入锁

文章会带大家详解Redisson + RLock可重入锁这套被青睐的实现方案

setNX + Lua脚本

setNX

完整语法:SET key value [EX seconds|PX milliseconds] [EXAT timestamp|PXAT milliseconds-timestamp] [NX|XX] [KEEPTTL]

SET命令有EX、PX、NX、XX以及KEEPTTL五个可选参数,其中KEEPTTL为6.0版本添加的可选参数,其它为2.6.12版本添加的可选参数。

EX seconds:以秒为单位设置过期时间

PX milliseconds:以毫秒为单位设置过期时间

EXAT timestamp:设置以秒为单位的UNIX时间戳所对应的时间为过期时间

PXAT milliseconds-timestamp:设置以毫秒为单位的UNIX时间戳所对应的时间为过期时间

NX:键不存在的时候设置键值

XX:键存在的时候设置键值

KEEPTTL:保留设置前指定键的生存时间

GET:返回指定键原本的值,若键不存在时返回nil

SET命令使用EX、PX、NX参数,其效果等同于SETEX、PSETEX、SETNX命令。根据官方文档的描述,未来版本中SETEX、PSETEX、SETNX命令可能会被淘汰。

EXAT、PXAT以及GET为Redis 6.2新增的可选参数。

注意:其实我们常说的通过 Redis 的 setnx 命令来实现分布式锁,并不是直接使用 Redis 的 setnx 命令,因为在老版本之前 setnx 命令语法为setnx key value,并不支持同时设置过期时间的操作,那么就需要再执行 expire 过期时间的命令,这样的话加锁就成了两个命令,原子性就得不到保障,所以通常需要配合 Lua 脚本使用,而从 Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

那么较低版本如果想使用setnx+expire完成原子抢锁应该怎么办呢?

借助lua脚本

大致说一下用 setnx 命令实现分布式锁的流程:

在 Redis 2.6.12 版本之后,Redis 支持原子命令加锁,我们可以通过向 Redis 发送 set key value NX 过期时间 命令,实现原子的加锁操作。比如某个客户端想要获取一个 key 为lock 的锁,此时需要执行 set lock random_value NX PX 30000 ,在这我们设置了 30 秒的锁自动过期时间,超过 30 秒自动释放。

如果 setnx 命令返回 ok,说明拿到了锁,此时我们就可以做一些业务逻辑处理,业务处理完之后,需要释放锁,释放锁一般就是执行 Redis 的 del 删除指令,del lock

如果 setnx 命令返回 nil,说明拿锁失败,被其他线程占用,如下:

为什么我们往往要将锁加一个时间呢?

这是因为如果redis宕机,那么再次恢复之后,由于之前的代码还未执行到删除锁的时候,redis就宕机了,那么此时获取的锁无法释放,导致后续的请求获取不到锁,业务因此崩盘。

注意,这里在设置值的时候,value 应该是随机字符串,比如 UUID,而不是随便用一个固定的字符串进去,为什么这样做呢?

为了防止锁的误删

value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

感觉这样说还是不清晰,举个例子:例如进程 A,通过 setnx 指令获取锁成功(命令中设置了加锁自动过期时间30 秒),既然拿到锁了就开始执行业务吧,但是进程 A 在接下来的执行业务逻辑期间,程序响应时间竟然超过30秒了,不管是线程阻塞还是业务执行时间的原因吧,锁自动释放了,而此时进程 B 进来了,由于进程 A 设置的过期时间一到,让进程 B 拿到锁了,然后进程 B 又开始执行业务逻辑,但是呢,这时候进程 A 执行到了释放锁的逻辑(代码层面),进行删除锁,然后把进程 B 的锁得释放了。

总之,有了随机数的 value 后,可以通过判断 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

在使用UUID的情况下,且在删除KEY前对其value进行一致性校验,可以99%避免这个情况的发生

点击查看maven依赖
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    @PostMapping(value = "/addUser")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
        String value = UUID.randomUUID().toString().replace("-","");
        // String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(10000 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            /** 判断是否是key对应的value **/
            String lockValue = (String) redisTemplate.opsForValue().get(key);
            if (lockValue != null && lockValue.equals(value)) {
                redisTemplate.delete(key);
                log.info("{} 解锁成功,结束处理业务", key);
            }
            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

我们模拟三个并发新增用户数据,发现:

只有一个会抢到锁并进行删除。

但是如果是固定的字符串的话:

可以看到刚拿到锁就被删除,我们这里演示了如何解决分布式锁使用场景中锁的误删

但随机字符串就真的安全了吗?

。。哈哈相信看到这里有点无语,但是我们还是需要不断深入,前人栽树就是这么伟大啊!

由于无法保证 redisTemplate.delete(key); 的原子操作,在多进程下还是会有进程安全问题。

举个例子,比如进程 A 执行完业务逻辑,在 redisTemplate.opsForValue().get(key); 获得 key 这一步执行没问题,同时也进入了 if 判断中,但是恰好这时候进程 A 的锁自动过期时间到了(别问为啥,就是这么巧,就是会发生这种情况),而另一个进程 B 获得锁成功,然后还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁,那么进程B锁了个寂寞。。。

那么我们接下来的目标就是将删除前的判断(防止误删),和删除key的操作合并,让它成为一个原子性的操作,那我就需要使用到lua脚本

lua脚本

又是个听着很有B格的词汇,听哥们给你逐步深入

先简单介绍一下 Lua 脚本:

Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua有哪些优势?

  1. 减少网络开销:原先多次请求的逻辑放在 redis 服务器上完成。使用脚本,减少了网络往返时延
  2. 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入(想象为事务)
  3. 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑

那么我们使用它最为重要和核心的就是原子操作,Redis会将整个脚本作为一个整体执行,这个就为我们使用redis实现分布式锁的时候带来的极大的便捷,我们可以将删除前的判断(防止误删),和删除key的操作合并到lua脚本中去,实现原子操作,同时,如果我们使用的是低版本的redis,那么其实setnx是不能设置过期时间的,还需要一个expire命令来设置,那这两步宏观上来看也不是原子的,我们仍然可以借助lua脚本来实现

这里就不在赘述lua脚本的使用以及编写,大家看可以看一下 https://zhuanlan.zhihu.com/p/77484377 相关文章,我帮大家找的这篇文章若简单看完eval命令即可编写简单的lua脚本,相信大家一眼就能看懂

如下是Lua脚本,通过 Redis 的 eval/evalsha 命令来运行:

-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的key和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1] 
    then 
 -- 执行删除操作
        return redis.call('del', KEYS[1]) 
    else 
 -- 不成功,返回0
        return 0 
end

那么项目中如何使用lua脚本呢?

配置如下:

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

完整的controller如下:

点击查看代码
package com.itvayne.distributedlock.controller;

import com.itvayne.distributedlock.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;


    private DefaultRedisScript<Long> script;

    @PostConstruct
    void init() {
        script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
    }

    @PostMapping(value = "/addUser")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
//        String value = UUID.randomUUID().toString().replace("-","");
        String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 10000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
             finally {
                ArrayList<String> arrayList = new ArrayList<>();
                arrayList.add(key);
                /** 判断是否是key对应的value **/
                Long execute = redisTemplate.execute(script, arrayList, value);
                System.out.println("execute执行结果,1表示执行del,0表示未执行 ===== " + execute);
                log.info("{} 解锁成功,结束处理业务", key);
            }


            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

可以看到lua脚本已经执行成功

我们先来对setnx+lua脚本做简单的总结,再继续向下学习:

  1. 所谓的 setnx 命令来实现分布式锁,其实不是直接使用 Redis 的 setnx 命令,因为 setnx 不支持设置自动释放锁的时间(至于为什么要设置自动释放锁,是因为防止被某个进程不释放锁而造成死锁的情况),不支持设置过期时间,就得分两步命令进行操作,一步是 setnx key value,一步是设置过期时间,这种情况的弊端很显然,无原子性操作。

  2. Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

  3. 经过分析,在使用 set key value nx px xxx 命令时,value 最好是随机字符串,这样可以防止业务代码执行时间超过设置的锁自动过期时间,而导致再次释放锁时出现释放其他进程锁的情况(锁的误删

  4. 尽管使用随机字符串的 value,但是在释放锁时(delete方法),还是无法做到原子操作,比如进程 A 执行完业务逻辑,在准备释放锁时,恰好这时候进程 A 的锁自动过期时间到了,而另一个进程 B 获得锁成功,然后 B 还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁.... ,因此需要配合 Lua 脚本释放锁,文章也给出了 SpringBoot 的使用示例。

Redisson + RLock可重入锁

施工建设中