点评day04 一人一单集群

首先一人一单已经在单机模式下实现,原理就是利用悲观锁(syn锁)将对象锁起来,但这种方法在集群模式下是有严重问题的,因为不同的服务器内部的jvm是不同的,你同一台机器子所以能够达到这种效果完全是因为一个jvm内部使用一个锁监视器,不同线程去运行监视同一个锁监视器,但在集群模式下,不共用一个锁监视器,这样我们做了nginx负载均衡后就会出问题,继续使用syn锁是达不到这种效果的,这时就需要使用分布式锁,

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。 分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路而redis就是一个天然的工具,它可以在多个集群之间共享。

1.分布式锁

复制代码
那么分布式锁他应该满足条件:

可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

互斥:互斥是分布式锁的最基本的条件,使得程序串行执行

高可用:程序不易崩溃,时时刻刻都保证较高的可用性

高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能

安全性:安全也是程序中必不可少的一环
复制代码
常见的分布式锁有三种

Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见

Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁

Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,后续可以学一下。

2.redis实现分布式锁

复制代码
实现分布式锁时需要实现的两个基本方法:

* 获取锁:

  * 互斥:确保只能有一个线程获取锁
  * 非阻塞(就是失败的时候直接返回false,不做什么休眠呀,然后重试这种东西):尝试一次,成功返回true,失败返回false

* 释放锁:

  * 手动释放
  * 超时释放:获取锁时添加一个超时时间

这是基本的流程

2.1实现分布式锁基本代码

这是实现锁的工具类,有几个细节:

java 复制代码
public class SimpleRedisLock {
    /**
     * 锁名称,针对每个业务名字不同, 锁名称也不同,因为不可能每个业务的锁都是同一把
     */
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    private long userId;
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate, long userId) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
        this.userId = userId;
    }
    /*
    * 统一前缀
    * */
    public static final String KEY_PREFIX = "lock:";
    public boolean tryLock(long timeoutSec) {
        String name1 = Thread.currentThread().getName();
        String key = KEY_PREFIX + name + userId;
        Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, name1, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(b);
    }
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name + userId);
    }
}

首先 Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, name1, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(b);其实以前讲过,因为函数要求返回boolean,但是我们得到的Boolean是执行那个redis命令返回的,可能是True,False,Null,自动拆包的时候就会产生问题,所以使用Boolean.TRUE.equals(b),只有当b是True的时候才返回true,不然才是false。

其次要给业务一个名字,因为不同业务是不同的锁,你不可能所有操作都是一个锁乱套了,所以要用name去限制业务名字,然后就是因为要实现一人一单,应该把一个人锁住,所以就要加入userid这个,一起合成key,然后就是要存入当前的线程,方便后续操作。

这是修改的业务逻辑,以前是单机的。

改进一:误删他人锁情况:

复制代码
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明,而要解决这种情况的发生,应该给每一个线程一个标识(可以用UUID表示),存在锁里,删的时候看一下是不是自己的锁,不是就不能删。

我们之前存入的是线程名,在不同集群下是完全又可能一样的,我们如果使用uuid拼接这个,每次初始化的uuid都不一样,这样就可以区分了。

于是有了改进代码,就是加上了uuid,释放锁的逻辑哪里判断了一下。

java 复制代码
public class SimpleRedisLock {
    /**
     * 锁名称,针对每个业务名字不同, 锁名称也不同,因为不可能每个业务的锁都是同一把
     */
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    private long userId;
    private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate, long userId) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
        this.userId = userId;
    }
    /*
    * 统一前缀
    * */
    public static final String KEY_PREFIX = "lock:";
    public boolean tryLock(long timeoutSec) {
        String name1 = Thread.currentThread().getName();
        String key = KEY_PREFIX + name + ':'+ userId;
        Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, ID_PREFIX+name1, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(b);
    }
    public void unlock() {
        //获取锁中标识
        String s = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name + ':' + userId);
        //判断是否一致
        if (s.equals(ID_PREFIX+Thread.currentThread().getName())) {
            //一致则释放即可。
            stringRedisTemplate.delete(KEY_PREFIX + name + ':'+ userId);
        }

    }

改进二(这是自己发现的问题,课上没讲):不是自己的锁失败情况补充,其实这样还是不优化,比如说还是上面的情况,线程一发现释放的时候锁不是自己的,就不会删除了,但是此时它还是执行了自己的逻辑,这时当然线程二恰好也执行完了自己的逻辑,这不就发生了一人多单了吗,所以我的理解是应该判断锁不是自己的就让这个业务集体回滚,

所以改进释放锁的代码:

java 复制代码
    public void unlock() {
        //获取锁中标识
        String s = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name + ':' + userId);
        //判断是否一致
        if (!s.equals(ID_PREFIX+Thread.currentThread().getName())) {
            //如果不一致,需要回滚,即以前做的操作都不行
            throw new IllegalStateException("锁不属于当前线程,触发回滚");
        }
        //一致则释放即可。
        stringRedisTemplate.delete(KEY_PREFIX + name + ':'+ userId);
    }

unlock() 方法检测到锁不属于当前线程时,会抛出 IllegalStateException。这个异常会从 finally 块中抛出,被spring层捕获,由于 createVoucherOrder 方法上有 @Transactional 注解,Spring 会检测到这个 RuntimeException 子类,并自动触发事务回滚,撤销在 createVoucherOrder 中执行的所有数据库操作(如扣库存、创建订单)。

注意因为我想让spring捕获,所以就不能被try-catch的catch层捕获,正因为没有 catchunlock() 抛出的异常才能向上传递,触发事务回滚,所以一定不能让catch层捕获这个异常,索性我们就不要catch层了,当然如果先写catch层,一定不能包含这个异常

java 复制代码
        //获取成功执行下面的函数
        try {
            IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
            Result voucherOrder = o.createVoucherOrder(voucherId);
            return voucherOrder;
        }finally {
            simpleRedisLock.unlock();
        }

为什么不需要写 catch?

  • 异常的作用是触发回滚unlock() 抛出的 IllegalStateExceptionRuntimeException 子类,只要这个异常能传递到 Spring 事务管理器,就会自动触发回滚。
  • catch 会吞掉异常 :如果在 finally 外层加 catch,并且没有重新抛出异常,事务管理器就感知不到异常,回滚会失效。

改进三,锁的原子性问题:

复制代码
更为极端的误删逻辑说明:线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的(释放锁的逻辑不是原子性的),我们要防止刚才的情况发生,

lua脚本解决一致性问题:

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁和删锁是一个原子性动作了

复制代码
这里重点介绍Redis提供的调用函数,基本语法:redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
# 执行 set name jack
redis.call('set', 'name', 'jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
这是一个整体,写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
复制代码
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

我们要判断当前锁标识和我们现在的一样不一样,这里的 KEYS[1] 就是锁的key(用来查锁标识),这里的ARGV[1] 就是当前线程标示 -- 获取锁中的标示,判断是否与当前线程标示一致,利用ai让它生成我们原来业务逻辑的lua脚本:

Lua 复制代码
-- KEYS[1]: 锁的 key,即 KEY_PREFIX + name + ':' + userId
-- ARGV[1]: 预期的锁 value,即 ID_PREFIX + Thread.currentThread().getName()

-- 1. 获取当前锁的 value
local currentValue = redis.call('GET', KEYS[1])

-- 2. 判断锁是否存在且归属一致
if currentValue == ARGV[1] then
    -- 一致则删除锁
    return redis.call('DEL', KEYS[1])
else
    -- 不一致或锁不存在,返回 0
    return 0
end

然后利用Java在unlock逻辑中调用lua脚本

java 复制代码
    public void unlock() {
        // Lua 脚本字符串
        String script = """
        local currentValue = redis.call('GET', KEYS[1])
        if currentValue == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0
        end
        """;
        // 执行脚本
        Long result = stringRedisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(KEY_PREFIX + name + ':'+ userId),
                ID_PREFIX + Thread.currentThread().getName()
        );
        // 脚本返回 0,说明锁不属于当前线程,抛出异常触发回滚
        if (result == null || result == 0) {
            throw new IllegalStateException("锁不属于当前线程,触发回滚");
        }
    }

关于execute的第一个参数解释:

DefaultRedisScript 是 Spring Data Redis 提供的Lua 脚本执行器,它的两个构造参数作用是:

  • 第一个参数 script:你要执行的 Lua 脚本字符串。
  • 第二个参数 Long.class :指定 Lua 脚本返回值的类型。(默认Object)

至此完成redis实现分布式锁,我们摒弃了单机模式下的syn锁用户id,然后利用事务进行的业务实现一人一单。使用redis实现分布式锁,key是业务名+用户id,存储的是当前线程标识+线程名字,并且利用Lua脚本实现了拿锁,比锁,删锁的原子性。并且进行了如果锁不是自己的就抛出异常实现事务回滚操作。自此大概已经完成一人一单集群模式的代码,大部分情况下可用

相关推荐
Boxsc_midnight2 小时前
【MCP服务器的配置和使用】Cherry Studio应用更多更好的MCP工具来完成更多工作
服务器·人工智能·windows
哈哈浩丶2 小时前
LK(little kernel)-3:LK的启动流程-作为Android的bootloarder
android·linux·服务器
圥忈&&丅佽&&扗虖3 小时前
linux 安装docker和docker-compose
linux·运维·docker
微风起皱10 小时前
Keepalived 高可用集群
运维·服务器
盖头盖10 小时前
【无参数_Rce】
服务器
LateFrames12 小时前
IIS 窗口不显示,但是任务栏状态正常
服务器·windows·microsoft
feng68_12 小时前
HAProxy算法实践
linux·运维·haproxy
志栋智能13 小时前
AI驱动的系统自动化巡检:重塑IT基石的智慧“守护神”
大数据·运维·人工智能·云原生·自动化
DeeplyMind13 小时前
第23章 ROCm虚拟化栈Docker化实战
运维·docker·容器