Redis——优惠券秒杀问题(分布式id、一人多单超卖、乐悲锁、CAS、分布式锁、Redisson)

#想cry 好想cry

目录

1 全局唯一id

1.1 自增ID存在的问题

1.2 分布式ID的需求

1.3 分布式ID的实现方式

1.4 自定义分布式ID生成器(示例)

1.5 总结

2 优惠券秒杀接口实现

3 单体系统下一人多单超卖问题及解决方案

3.1 问题背景

3.2 超卖问题的原因(并发查询)

3.3 解决方案

方案一:悲观锁

方案二:乐观锁

3.4  悲观锁和乐观锁的比较

3.4.1 性能

3.4.2 冲突处理

3.4.3 并发度

3.4.4 应用场景

3.4.5 总结对比

3.4.6 选择建议

3.5 乐观锁的实现(CAS法)

3.6 CAS的优缺点

3.7 总结

4 单体下的一人一单超卖问题

4.1 问题描述

4.2 原因

4.3 解决方案——悲观锁

4.3.1 实现流程

4.3.2 代码实现

4.3.3 实现细节(重要)

4.3.4 让代理对象生效的步骤

5 集群下的一人一单超卖问题

6 分布式锁

6.1 简要原理

6.2  分布式锁的特点

6.3 分布式锁的常见实现方式

6.4 Redis分布式锁的实现

6.5 分布式锁解决超卖问题

(1)创建分布式锁

(2)使用分布式锁

(3)实现细节

6.6 分布式锁优化 

(1)优化1 解决锁超时释放出现的超卖问题

(2)优化2 解决释放锁时的原子性问题

1 问题背景

2 问题的根本原因

3 解决方案

4 Lua脚本的优势

5 实现步骤

5.1 编写Lua脚本

5.2 在Java中加载Lua脚本

5.3 实现释放锁的逻辑

6.7  手写分布式锁的各种问题与Redission引入

6.8 Redisson分布式锁

6.8.1 使用步骤

tryLock 方法详解

6.8.2 Redisson 可重入锁原理

6.8.3 Redisson 可重入锁原理

可重入问题解决

可重试问题解决

超时续约问题解决

主从一致性问题解决

 6.9 看门狗机制的详细解剖

 6.10 主从一致性问题的深入探讨——MultiLock


1 全局唯一id

1.1 自增ID存在的问题

  1. 规律性太明显

    • 容易被猜测,导致信息泄露或伪造请求。

    • 攻击者可能通过规律推测其他用户的ID,造成安全风险。

  2. 分库分表限制

    • MySQL单表存储量有限(约500万行或2GB),超过后需分库分表。

    • 自增ID在分库分表后无法保证全局唯一性。

  3. 扩展性差

    • 高并发场景下,自增ID可能导致性能瓶颈。

    • 维护复杂,需额外机制保证ID的唯一性和安全性。

1.2 分布式ID的需求

分布式ID需满足以下特点:

  1. 全局唯一性:整个系统中ID不重复。

  2. 高可用性:支持水平扩展和冗余备份。

  3. 安全性:ID生成独立于业务逻辑,避免规律性。

  4. 高性能:低延迟生成ID。

  5. 递增性:ID可按时间顺序排序,便于索引和检索。

1.3 分布式ID的实现方式

  1. UUID

    • 优点:简单,全局唯一。

    • 缺点:无序,存储空间大,不适合索引。

  2. Redis自增

    • 优点:高性能,支持分布式。

    • 缺点:依赖Redis,需考虑Redis的高可用性。

  3. 数据库自增

    • 优点:简单易用。

    • 缺点:性能瓶颈,扩展性差。

  4. Snowflake算法

    • 优点:高性能,ID有序。

    • 缺点:依赖系统时钟,时钟回拨可能导致ID重复。

  5. 自定义实现

    • 结合时间戳、序列号和数据库自增,生成高安全性ID。

1.4 自定义分布式ID生成器(示例)

核心逻辑

        时间戳:31bit,表示秒级时间,支持69年。

        序列号:32bit,表示每秒内的计数器,支持每秒生成2^32个ID。

        拼接方式:时间戳左移32位后与序列号按位或运算。

代码实现: 

@Component
public class RedisIdWorker {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final long BEGIN_TIMESTAMP = 1640995200; // 起始时间戳
    private static final int COUNT_BITS = 32; // 序列号位数

    public long nextId(String keyPrefix) {
        // 1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2. 生成序列号(以当天日期为key,防止序列号溢出)
        String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        Long count = stringRedisTemplate.opsForValue().increment("id:" + keyPrefix + ":" + date);

        // 3. 拼接并返回ID
        return timestamp << COUNT_BITS | count;
    }
}

1.5 总结

  1. 自增ID的局限性

    • 规律性明显,安全性差。

    • 扩展性受限,不适合高并发和分库分表场景。

  2. 分布式ID的优势

    • 全局唯一、高性能、高可用。

    • 支持复杂业务场景,如高并发、分库分表。

  3. 实现建议

    • 优先选择Snowflake算法或自定义实现。

    • 结合时间戳和序列号,确保ID的唯一性和递增性。

    • 测试高并发场景下的性能和稳定性。

2 优惠券秒杀接口实现

    /**
     * 抢购秒杀券
     *
     * @param voucherId
     * @return
     */
    @Transactional
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1、查询秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2、判断秒杀券是否合法
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 秒杀券的开始时间在当前时间之后
            return Result.fail("秒杀尚未开始");
        }
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 秒杀券的结束时间在当前时间之前
            return Result.fail("秒杀已结束");
        }
        if (voucher.getStock() < 1) {
            return Result.fail("秒杀券已抢空");
        }
        // 5、秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
        boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
                .eq(SeckillVoucher::getVoucherId, voucherId)
                .setSql("stock = stock -1"));
        if (!flag){
            throw new RuntimeException("秒杀券扣减失败");
        }
        // 6、秒杀成功,创建对应的订单,并保存到数据库
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());
        voucherOrder.setVoucherId(voucherOrder.getId());
        flag = this.save(voucherOrder);
        if (!flag){
            throw new RuntimeException("创建秒杀券订单失败");
        }
        // 返回订单id
        return Result.ok(orderId);
    }

3 单体系统下一人多单超卖问题及解决方案

3.1 问题背景

在高并发场景下,优惠券秒杀功能可能出现超卖问题

        现象:库存为负数,订单数量超过实际库存。

        原因:多个线程同时查询库存,发现库存充足后同时扣减库存,导致库存被多次扣减。

3.2 超卖问题的原因(并发查询)

        线程1查询库存,发现库存充足,准备扣减。

        线程2和线程3同时查询库存,也发现库存充足。

        线程1扣减库存后,库存变为0,但线程2和线程3继续扣减,导致库存为负数。

3.3 解决方案

方案一:悲观锁

  • 原理:认为线程安全问题一定会发生,操作前先加锁,确保线程串行执行。

  • 实现方式

    • synchronizedLock等。

  • 优点:简单直接,保证数据安全。

  • 缺点

    • 性能低,加锁会导致线程阻塞。

    • 并发度低,锁粒度大时影响系统性能。

  • 适用场景:写入操作多、冲突频繁的场景。

方案二:乐观锁

  • 原理:认为线程安全问题不一定发生,更新时判断数据是否被修改。

  • 实现方式

    1. 版本号法

      • 添加version字段,更新时检查版本号是否一致。

      • 不一致则重试或抛异常。

    2. CAS法

      • 使用库存字段代替版本号,更新时检查库存是否与查询时一致。

      • 不一致则重试或抛异常。

  • 优点

    • 性能高,无锁操作。

    • 并发度高,适合读多写少的场景。

  • 缺点

    • 冲突时需重试,可能增加CPU开销。

    • 需处理ABA问题(版本号法)。

  • 适用场景:读多写少、冲突较少的场景。

3.4  悲观锁和乐观锁的比较

3.4.1 性能

  • 悲观锁

    • 需要先加锁再操作,加锁过程会消耗资源。

    • 性能较低,尤其是在高并发场景下,锁竞争会导致线程阻塞。

  • 乐观锁

    • 不加锁,只在提交时检查冲突。

    • 性能较高,适合读多写少的场景。

3.4.2 冲突处理

  • 悲观锁

    • 冲突发生时直接阻塞其他线程,确保数据安全。

    • 冲突处理能力较低,可能导致大量线程等待。

  • 乐观锁

    • 冲突发生时通过重试机制解决(如版本号法、CAS)。

    • 冲突处理能力较高,适合低冲突场景。

3.4.3 并发度

  • 悲观锁

    • 锁粒度较大,可能限制并发性能。

    • 并发度较低,尤其是在锁竞争激烈时。

  • 乐观锁

    • 无锁操作,支持高并发。

    • 并发度较高,适合高并发场景。

3.4.4 应用场景

  • 悲观锁

    • 适合写入操作多、冲突频繁的场景。

    • 例如:银行转账、库存扣减等强一致性要求的场景。

  • 乐观锁

    • 适合读取操作多、冲突较少的场景。

    • 例如:秒杀系统、评论系统等高并发读场景。

3.4.5 总结对比

特性悲观锁乐观锁
性能较低(加锁开销大)较高(无锁操作)
冲突处理直接阻塞线程通过重试机制解决冲突
并发度较低(锁粒度大)较高(无锁,支持高并发)
适用场景写多读少、冲突频繁读多写少、冲突较少
实现复杂度简单(直接加锁)较复杂(需处理重试、ABA问题)

3.4.6 选择建议

  • 如果需要强一致性且冲突频繁,选择悲观锁

  • 如果需要高并发且冲突较少,选择乐观锁

    3.5 乐观锁的实现(CAS法)

    CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于解决多线程环境下的数据竞争问题。它是乐观锁算法的一种实现方式。

    CAS操作包含三个参数:内存地址V、旧的预期值A和新的值B。CAS的执行过程如下:

    比较(Compare):将内存地址V中的值与预期值A进行比较。
    判断(Judgment):如果相等,则说明当前值和预期值相等,表示没有发生其他线程的修改。
    交换(Swap):使用新的值B来更新内存地址V中的值。
    CAS操作是一个原子操作,意味着在执行过程中不会被其他线程中断,保证了线程安全性。如果CAS操作失败(即当前值与预期值不相等),通常会进行重试,直到CAS操作成功为止。

    业务核心逻辑

    • 更新库存时,检查库存是否大于0。

    • 如果库存大于0,则扣减库存;否则,操作失败。

    代码示例

    boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
        .eq(SeckillVoucher::getVoucherId, voucherId)
        .gt(SeckillVoucher::getStock, 0) // 检查库存是否大于0
        .setSql("stock = stock - 1")); // 扣减库存

    优化

    • 初始实现:库存不一致时直接终止操作,导致异常率高。

    • 优化后:只要库存大于0就允许扣减,降低异常率。

    3.6 CAS的优缺点

    优点

    • 无锁操作,性能高。

    • 适合高并发场景。

    缺点

    (1)ABA问题:CAS操作无法感知到对象值从A变为B又变回A的情况,可能会导致数据不一致。为了解决ABA问题,可以引入版本号或标记位等机制。
    (2)自旋开销:当CAS操作失败时,需要不断地进行重试,会占用CPU资源。如果重试次数过多或者线程争用激烈,可能会引起性能问题。
    (3)并发性限制:如果多个线程同时对同一内存地址进行CAS操作,只有一个线程的CAS操作会成功,其他线程需要重试或放弃操作。

    3.7 总结

    1. 超卖问题的本质

      • 高并发下,多个线程同时操作共享资源(库存),导致数据不一致。

    2. 解决方案对比

      • 悲观锁:简单但性能低,适合写多读少的场景。

      • 乐观锁:性能高但需处理冲突,适合读多写少的场景。

    3. 推荐方案

      • 使用CAS法实现乐观锁,避免额外字段开销。

      • 优化判断条件(库存>0),降低异常率。

    4 单体下的一人一单超卖问题

    4.1 问题描述

    • 一个用户多次下单,导致超卖问题。

    4.2 原因

    • 多个线程同时查询用户订单状态,发现用户未下单后同时创建订单。

    4.3 解决方案——悲观锁

    使用synchronized锁住用户ID,确保同一用户串行执行。

    4.3.1 实现流程

    4.3.2 代码实现

        /**
         * 抢购秒杀券
         *
         * @param voucherId
         * @return
         */
        @Transactional
        @Override
        public Result seckillVoucher(Long voucherId) {
            // 1、查询秒杀券
            SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
            // 2、判断秒杀券是否合法
            if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
                // 秒杀券的开始时间在当前时间之后
                return Result.fail("秒杀尚未开始");
            }
            if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
                // 秒杀券的结束时间在当前时间之前
                return Result.fail("秒杀已结束");
            }
            if (voucher.getStock() < 1) {
                return Result.fail("秒杀券已抢空");
            }
            // 3、创建订单
            Long userId = ThreadLocalUtls.getUser().getId();
            synchronized (userId.toString().intern()) {
                // 创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                return proxy.createVoucherOrder(userId, voucherId);
            }
        }
    
        /**
         * 创建订单
         *
         * @param userId
         * @param voucherId
         * @return
         */
        @Transactional
        public Result createVoucherOrder(Long userId, Long voucherId) {
    //        synchronized (userId.toString().intern()) {
            // 1、判断当前用户是否是第一单
            int count = this.count(new LambdaQueryWrapper<VoucherOrder>()
                    .eq(VoucherOrder::getUserId, userId));
            if (count >= 1) {
                // 当前用户不是第一单
                return Result.fail("用户已购买");
            }
            // 2、用户是第一单,可以下单,秒杀券库存数量减一
            boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
                    .eq(SeckillVoucher::getVoucherId, voucherId)
                    .gt(SeckillVoucher::getStock, 0)
                    .setSql("stock = stock -1"));
            if (!flag) {
                throw new RuntimeException("秒杀券扣减失败");
            }
            // 3、创建对应的订单,并保存到数据库
            VoucherOrder voucherOrder = new VoucherOrder();
            long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);
            voucherOrder.setId(orderId);
            voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());
            voucherOrder.setVoucherId(voucherOrder.getId());
            flag = this.save(voucherOrder);
            if (!flag) {
                throw new RuntimeException("创建秒杀券订单失败");
            }
            // 4、返回订单id
            return Result.ok(orderId);
    //        }
        }
    

    4.3.3 实现细节(重要)

    (1)锁的范围尽量小。synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低

    (2)锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)

    (3)我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题

    (4)Spring的@Transactional注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。

    4.3.4 让代理对象生效的步骤

    ①引入AOP依赖,动态代理是AOP的常见实现之一

    <dependency>
               <groupId>org.aspectj</groupId>
               <artifactId>aspectjweaver</artifactId>
    </dependency>

    ②暴露动态代理对象,默认是关闭的,在启动类上开启

    @EnableAspectJAutoProxy(exposeProxy = true)

    5 集群下的一人一单超卖问题

    在集群部署的情况下,请求访问到不同的服务器,这个synchronized锁形同虚设,这是由于synchronized是本地锁,只能提供线程级别的同步,每个JVM中都有一把synchronized锁,不能跨 JVM 进行上锁,当一个线程进入被 synchronized 关键字修饰的方法或代码块时,它会尝试获取对象的内置锁(也称为监视器锁)。如果该锁没有被其他线程占用,则当前线程获得锁,可以继续执行代码;否则,当前线程将进入阻塞状态,直到获取到锁为止。而现在我们是多台服务器,也就意味着有多个JVM,所以synchronized会失效!

    从而会出现超卖问题!

    6 分布式锁

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

    6.1 简要原理

    前面sychronized锁失效的原因是由于每一个JVM都有一个独立的锁监视器,用于监视当前JVM中的sychronized锁,所以无法保障多个集群下只有一个线程访问一个代码块。所以我们直接将使用一个分布锁,在整个系统的全局中设置一个锁监视器,从而保障不同节点的JVM都能够识别,从而实现集群下只允许一个线程访问一个代码块

     

    6.2  分布式锁的特点

    1. 多线程可见:分布式锁存储在共享存储(如Redis)中,所有线程和节点都能看到锁的状态。

    2. 互斥性:任何时候只有一个线程或节点能持有锁,其他线程或节点必须等待。

    3. 高可用性

      • 即使部分节点故障,锁服务仍能正常工作。

      • 具备容错性,锁持有者故障时能自动释放锁。

    4. 高性能

      • 锁的获取和释放操作要快,减少对共享资源的等待时间。

      • 减少锁竞争带来的开销。

    5. 安全性

      • 可重入性:同一线程可多次获取同一把锁。

      • 锁超时机制:避免锁被长时间占用,设置超时时间自动释放锁。

    6.3 分布式锁的常见实现方式

    1. 基于关系数据库

      • 利用数据库的唯一约束和事务特性实现锁。通过向数据库插入一条具有唯一约束的记录作为锁,其他进程在获取锁时会受到数据库的并发控制机制限制。

      • 优点:简单易实现。

      • 缺点:性能较低,不适合高并发场景。

    2. 基于缓存(如Redis)

      • 使用Redis的setnx指令实现锁。通过将锁信息存储在缓存中,其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。

      • 优点:性能高,适合高并发场景。

      • 缺点:需处理锁超时、可重入等问题。

    3. 基于ZooKeeper

      • ZooKeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时有序节点,每个请求都会尝试创建一个唯一的节点,并检查自己是否是最小节点,如果是,则表示获取到了锁。

      • 优点:高可用,支持可重入锁。

      • 缺点:性能较低,实现复杂。

    4. 基于分布式算法

      • 使用Chubby、DLM等分布式算法实现锁。这些算法通过在分布式系统中协调进程之间的通信和状态变化,实现分布式锁的功能。

      • 优点:适用于复杂分布式系统。

      • 缺点:实现复杂,运维成本高。

    • setnx指令的特点setnx只能设置key不存在的值,值不存在设置成功,返回 1 ;值存在设置失败,返回 0

     6.4 Redis分布式锁的实现

    1. 获取锁

      • 使用setnx指令设置锁,确保锁的唯一性。

      • 为锁设置超时时间,避免死锁。

      • #保障指令的原子性
        # 添加锁
        set [key] [value] ex [time] nx
        
      • 代码示例

        Boolean result = stringRedisTemplate.opsForValue()
            .setIfAbsent("lock:" + name, threadId, timeoutSec, TimeUnit.SECONDS);
    2. 释放锁

      • 使用del指令删除锁。

      • 代码示例

        stringRedisTemplate.delete("lock:" + name);

    6.5 分布式锁解决超卖问题

     (1)创建分布式锁

    public class SimpleRedisLock implements Lock {
    
        /**
         * RedisTemplate
         */
        private StringRedisTemplate stringRedisTemplate;
    
        /**
         * 锁的名称
         */
        private String name;
    
        public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.name = name;
        }
    
    
        /**
         * 获取锁
         *
         * @param timeoutSec 超时时间
         * @return
         */
        @Override
        public boolean tryLock(long timeoutSec) {
            String id = Thread.currentThread().getId() + "";
            // SET lock:name id EX timeoutSec NX
            Boolean result = stringRedisTemplate.opsForValue()
                    .setIfAbsent("lock:" + name, id, timeoutSec, TimeUnit.SECONDS);
            return Boolean.TRUE.equals(result);
        }
    
        /**
         * 释放锁
         */
        @Override
        public void unlock() {
            stringRedisTemplate.delete("lock:" + name);
        }
    }
    

    (2)使用分布式锁

    改造前面VoucherOrderServiceImpl中的代码,将之前使用sychronized锁的地方,改成我们自己实现的分布式锁:

            // 3、创建订单(使用分布式锁)
            Long userId = ThreadLocalUtls.getUser().getId();
            SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
            boolean isLock = lock.tryLock(1200);
            if (!isLock) {
                // 索取锁失败,重试或者直接抛异常(这个业务是一人一单,所以直接返回失败信息)
                return Result.fail("一人只能下一单");
            }
            try {
                // 索取锁成功,创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                return proxy.createVoucherOrder(userId, voucherId);
            } finally {
                lock.unlock();
            }
    

    (3)实现细节

    try...finally...确保发生异常时锁能够释放,注意这给地方不要使用catch,A事务方法内部调用B事务方法,A事务方法不能够直接catch,否则会导致事务失效。

    6.6 分布式锁优化 

    (1)优化1 解决锁超时释放出现的超卖问题

    问题

    当线程1获取锁后,由于业务阻塞,线程1的锁超时释放了,这时候线程2趁虚而入拿到了锁,然后此时线程1业务完成了,然后把线程2刚刚获取的锁给释放了,这时候线程3又趁虚而入拿到了锁,这就导致又出现了超卖问题!(但是这个在小项目(并发数不高)中出现的概率比较低,在大型项目(并发数高)情况下是有一定概率的)

    如何解决呢?

    我们为分布式锁添加一个线程标识,在释放锁时判断当前锁是否是自己的锁,是自己的就直接释放,不是自己的就不释放锁,从而解决多个线程同时获得锁的情况导致出现超卖 

     

     只需要改一下锁的实现:

    package com.hmdp.utils.lock.impl;
    
    import cn.hutool.core.lang.UUID;
    import com.hmdp.utils.lock.Lock;
    import org.springframework.data.redis.core.StringRedisTemplate;
    
    import java.util.concurrent.TimeUnit;
    
    /**
     * @author ghp
     * @title
     * @description
     */
    public class SimpleRedisLock implements Lock {
    
        /**
         * RedisTemplate
         */
        private StringRedisTemplate stringRedisTemplate;
    
        /**
         * 锁的名称
         */
        private String name;
        /**
         * key前缀
         */
        public static final String KEY_PREFIX = "lock:";
        /**
         * ID前缀
         */
        public static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    
        public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.name = name;
        }
    
    
        /**
         * 获取锁
         *
         * @param timeoutSec 超时时间
         * @return
         */
        @Override
        public boolean tryLock(long timeoutSec) {
            String threadId = ID_PREFIX + Thread.currentThread().getId() + "";
            // SET lock:name id EX timeoutSec NX
            Boolean result = stringRedisTemplate.opsForValue()
                    .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
            return Boolean.TRUE.equals(result);
        }
    
        /**
         * 释放锁
         */
        @Override
        public void unlock() {
            // 判断 锁的线程标识 是否与 当前线程一致
            String currentThreadFlag = ID_PREFIX + Thread.currentThread().getId();
            String redisThreadFlag = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
            if (currentThreadFlag != null || currentThreadFlag.equals(redisThreadFlag)) {
                // 一致,说明当前的锁就是当前线程的锁,可以直接释放
                stringRedisTemplate.delete(KEY_PREFIX + name);
            }
            // 不一致,不能释放
        }
    }
    

     (2)优化2 解决释放锁时的原子性问题

    1 问题背景

    在高并发场景下,分布式锁可能会出现以下问题:

    • 锁超时释放:线程1获取锁后,因业务阻塞导致锁超时释放,线程2趁机获取锁并执行业务。此时线程1恢复执行,误删线程2的锁,导致线程3也能获取锁,从而引发超卖问题。


    2 问题的根本原因
    1. 锁超时机制

      • 锁设置了超时时间,防止死锁。

      • 但业务执行时间可能超过锁的超时时间,导致锁被提前释放。

    2. 非原子操作

      • 判断锁和释放锁是两个独立的操作,中间可能被其他线程插入。


    3 解决方案

    使用Lua脚本确保判断锁释放锁的原子性。

    4 Lua脚本的优势
    1. 原子性

      • Redis执行Lua脚本时,会阻塞其他命令和脚本,确保脚本内的操作是原子的。

      • 类似于事务的MULTI/EXEC,但Lua脚本更轻量。

    2. 高性能

      • Lua脚本在Redis中执行,避免了多次网络通信的开销。

    3. 简单易用

      • Lua脚本可以直接嵌入Java代码中,通过Redis执行。

    5 实现步骤
    5.1 编写Lua脚本
    1. 释放锁的Lua脚本

      • 检查锁的线程标识是否与当前线程一致。

      • 如果一致,则删除锁;否则,不做任何操作。

      • 脚本内容

        -- 比较缓存中的线程标识与当前线程标识是否一致
        if (redis.call('get', KEYS[1]) == ARGV[1]) then
            -- 一致,直接删除
            return redis.call('del', KEYS[1])
        end
        -- 不一致,返回0
        return 0
    2. 脚本说明

      • KEYS[1]:锁的Key(如lock:order:1)。

      • ARGV[1]:当前线程的标识(如UUID-线程ID)。

    5.2 在Java中加载Lua脚本
    1. 定义Lua脚本

      • 将Lua脚本保存为文件(如unlock.lua),并放在resources/lua目录下。

    2. 加载Lua脚本

      • 使用DefaultRedisScript加载Lua脚本。

      • 代码示例

        private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
        
        static {
            UNLOCK_SCRIPT = new DefaultRedisScript<>();
            UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua"));
            UNLOCK_SCRIPT.setResultType(Long.class);
        }

    5.3 实现释放锁的逻辑
    1. 释放锁的Java代码

      • 使用stringRedisTemplate.execute执行Lua脚本。

      • 代码示例

        @Override
        public void unlock() {
            // 执行Lua脚本
            stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name), // KEYS[1]
                ID_PREFIX + Thread.currentThread().getId()    // ARGV[1]
            );
        }
    2. 关键点

      • 线程标识:使用UUID + 线程ID作为线程的唯一标识,确保不同线程的锁不会冲突。

      • 原子性:Lua脚本确保判断锁和释放锁的操作是原子的。

    6.7  手写分布式锁的各种问题与Redission引入

    在分布式系统中,为保证数据一致性和线程安全,常需要使用分布式锁。但自己实现的分布式锁存在诸多问题,难以达到生产可用级别:

    • 不可重入:同一线程无法重复获取同一把锁,易造成死锁。例如在嵌套方法调用中,若方法 A 和方法 B 都需获取同一把锁,线程 1 在方法 A 获取锁后,进入方法 B 再次获取时会失败,导致死锁。
    • 不可重试:获取锁仅尝试一次,失败即返回 false,无重试机制。若线程 1 获取锁失败后直接结束,会导致数据丢失,比如线程 1 要将数据写入数据库,因锁被线程 2 占用而放弃,数据无法正常写入。
    • 超时释放问题:虽超时释放机制能降低死锁概率,但有效期设置困难。有效期过短,业务未执行完锁就释放,存在安全隐患;有效期过长,易出现死锁。
    • 主从一致性问题:在 Redis 主从集群中,主从同步存在延迟。若线程 1 在主节点获取锁后,主节点故障,从节点未及时同步该锁信息,其他线程可能在从节点再次获取到该锁,导致数据不一致。

     Redisson 是成熟的 Redis 框架,提供分布式锁和同步器、分布式对象、分布式集合、分布式服务等多种分布式解决方案,可有效解决上述问题,因此可直接使用 Redisson 优化分布式锁。

    6.8 Redisson分布式锁

    6.8.1 使用步骤

    (1)引入依赖

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.13.6</version>
    </dependency>

    (2)配置 Redisson 客户端

    @Configuration
    public class RedissonConfig {
    
        @Value("${spring.redis.host}")
        private String host;
        @Value("${spring.redis.port}")
        private String port;
        @Value("${spring.redis.password}")
        private String password;
    
        @Bean
        public RedissonClient redissonClient() {
            Config config = new Config();
            config.useSingleServer().setAddress("redis://" + this.host + ":" + this.port)
                  .setPassword(this.password);
            return Redisson.create(config);
        }
    }

    注:也可引入 Redisson 的 starter 依赖并在 yml 文件中配置,但不推荐,因其会替换 Spring 官方提供的 Redisson 配置。

    (3)修改使用锁的代码

    在业务代码中,使用 Redisson 客户端获取锁并尝试加锁: 

    Long userId = ThreadLocalUtls.getUser().getId();
    RLock lock = redissonClient.getLock(RedisConstants.LOCK_ORDER_KEY + userId);
    boolean isLock = lock.tryLock();

    tryLock 方法详解

    • tryLock():使用默认的超时时间和等待机制,具体超时时间由 Redisson 配置文件或自定义配置决定。
    • tryLock(long time, TimeUnit unit):在指定的 time 时间内尝试获取锁,若成功则返回 true;若在指定时间内未获取到锁,则返回 false
    • tryLock(long waitTime, long leaseTime, TimeUnit unit):指定等待时间 waitTime,若超过 leaseTime 仍未获取到锁,则直接返回失败。 无参的 tryLock 方法中,waitTime 默认值为 -1,表示不等待;leaseTime 默认值为 30 秒,即锁超过 30 秒未释放会自动释放。自上而下,tryLock 方法的灵活性逐渐提高。

     6.8.2 Redisson 可重入锁原理

    Redisson 内部将锁以 hash 数据结构存储在 Redis 中,每次获取锁时,将对应线程的 value 值加 1;每次释放锁时,将 value 值减 1;只有当 value 值归 0 时,才真正释放锁,以此确保锁的可重入性。

    6.8.3 Redisson 可重入锁原理

    可重入问题解决

    利用 hash 结构记录线程 ID 和重入次数。每次线程获取锁时,检查 hash 结构中该线程 ID 对应的重入次数,若不存在则初始化重入次数为 1,若已存在则将重入次数加 1。

    可重试问题解决

    利用信号量和 PubSub(发布 - 订阅)功能实现等待、唤醒机制。当线程获取锁失败时,将其放入等待队列,通过 PubSub 监听锁释放的消息,一旦锁释放,唤醒等待队列中的线程重试获取锁。

    超时续约问题解决

    利用看门狗(WatchDog)机制,每隔一段时间(releaseTime / 3)重置锁的超时时间。若线程持有锁的时间超过预设的有效时间,看门狗会自动延长锁的有效期,确保业务执行完毕后再释放锁。

    主从一致性问题解决

    利用 Redisson 的 MultiLock 机制,多个独立的 Redis 节点必须都获取到重入锁,才算获取锁成功。这样即使主从节点同步存在延迟,也能保证锁的一致性。但此方法存在运维成本高、实现复杂的缺陷。

     6.9 看门狗机制的详细解剖

    • 工作原理:看门狗机制是 Redisson 解决锁超时释放问题的关键。当一个线程成功获取锁后,看门狗会启动一个定时任务,每隔 releaseTime / 3 的时间就会去重置锁的过期时间。例如,如果锁的初始有效期是 30 秒,那么看门狗会每隔 10 秒就去将锁的有效期重新设置为 30 秒,直到线程主动释放锁。
    • 取消任务的情况:虽然看门狗机制可以确保业务执行过程中锁不会过期,但也不能让锁永不过期。当线程调用 unlock() 方法释放锁时,看门狗的定时任务会被取消。另外,如果在获取锁时指定了 leaseTime(锁的有效期),那么当到达 leaseTime 时,锁会自动释放,看门狗也不会再去续约。

     6.10 主从一致性问题的深入探讨——MultiLock

    • MultiLock 机制的工作流程:当使用 Redisson 的 MultiLock 时,它会尝试在多个独立的 Redis 节点上同时获取锁。只有当所有节点都成功获取到锁时,才认为整个锁获取成功。例如,假设有三个 Redis 节点 A、B、C,线程尝试获取锁时,会依次向这三个节点发送获取锁的请求。如果三个节点都返回获取锁成功,那么线程才真正获得了锁;只要有一个节点获取锁失败,整个获取锁的操作就失败。
    • 运维成本和复杂度分析:使用 MultiLock 虽然可以解决主从一致性问题,但会带来较高的运维成本和实现复杂度。在运维方面,需要管理多个独立的 Redis 节点,包括节点的部署、监控、故障处理等。在实现方面,代码逻辑会变得更加复杂,需要考虑多个节点的状态和交互。而且,由于要在多个节点上获取锁,会增加锁获取的时间开销,降低系统的性能。

    本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/969776.html

    如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

    相关文章

    easyexcel快速使用

    1.easyexcel EasyExcel是一个基于ava的简单、省内存的读写Excel的开源项目。在尽可能节约内存的情况下支持读写百M的Excel 即通过java完成对excel的读写操作&#xff0c; 上传下载 2.easyexcel写操作 把java类中的对象写入到excel表格中 步骤 1.引入依赖 <depen…

    数据结构 04

    4. 栈 4.2. 链式栈 4.2.1. 特性 逻辑结构&#xff1a;线性结构 存储结构&#xff1a;链式存储结构 操作&#xff1a;创建&#xff0c;入栈&#xff0c;出栈&#xff0c;清空&#xff0c;获取 4.2.2. 代码实现 头文件 LinkStack.h #ifndef __LINKSTACK_H__ #define __LINKST…

    LeetCode刷题第7题【整数反转】---解题思路及源码注释

    LeetCode刷题第7题【整数反转】—解题思路及源码注释 结果预览 目录 LeetCode刷题第7题【整数反转】---解题思路及源码注释结果预览一、题目描述二、解题思路1、问题理解2、解题思路 三、代码实现及注释1、源码实现2、代码解释 四、执行效果1、时间和空间复杂度分析 一、题目描…

    相机闪光灯拍照流程分析

    和你一起终身学习&#xff0c;这里是程序员Android 经典好文推荐&#xff0c;通过阅读本文&#xff0c;您将收获以下知识点: 一、Flash 基础知识二、MTK 闪光灯拍照log分析 一、Flash 基础知识 1.1 Flash HAL 场景枚举值 Flash HAL 场景枚举值 1.2 AE AF mode State 枚举值 AE …

    给本地模型“投喂“数据

    如何训练本地Deepseek-r1:7b模型 在前面两篇文章中&#xff0c;我在自己的电脑的本地部署了Deepseek的7b的模型&#xff0c;并接入到我Chrome浏览器的插件中&#xff0c;使用起来更方便了。在使用的过程中发现7b的推理能力确实没有671满血版本的能力强&#xff0c;很多问题回答…

    大脑网络与智力:基于图神经网络的静息态fMRI数据分析方法|文献速递-医学影像人工智能进展

    Title 题目 Brain networks and intelligence: A graph neural network based approach toresting state fMRI data 大脑网络与智力&#xff1a;基于图神经网络的静息态fMRI数据分析方法 01 文献速递介绍 智力是一个复杂的构念&#xff0c;包含了多种认知过程。研究人员通…

    原生Three.js 和 Cesium.js 案例 。 智慧城市 数字孪生常用功能列表

    对于大多数的开发者来言&#xff0c;看了很多文档可能遇见不到什么有用的&#xff0c;就算有用从文档上看&#xff0c;把代码复制到自己的本地大多数也是不能用的&#xff0c;非常浪费时间和学习成本&#xff0c; 尤其是three.js &#xff0c; cesium.js 这种难度较高&#xff…

    学习总结三十二

    map #include<iostream> #include<map> using namespace std;int main() {//首先创建一个map对象map<int, char>oneMap;//插入数据oneMap.insert(pair<int, char>(1, A));oneMap.insert(make_pair(2,B));oneMap.insert(map<int,char>::value_ty…

    AI如何与DevOps集成,提升软件质量效能

    随着技术的不断演进&#xff0c;DevOps和AI的融合成为推动软件开发质量提升的重要力量。传统的DevOps已经为软件交付速度和可靠性打下了坚实的基础&#xff0c;而随着AI技术的加入&#xff0c;DevOps流程不仅能提升效率&#xff0c;还能在质量保障、缺陷预测、自动化测试等方面…

    ESP学习-1(MicroPython VSCode开发环境搭建)

    下载ESP8266固件&#xff1a;https://micropython.org/download/ESP8266_GENERIC/win电脑&#xff1a;pip install esptools python.exe -m pip install --upgrade pip esptooo.py --port COM5 erase_flash //清除之前的固件 esptool --port COM5 --baud 115200 write_fla…

    解决DeepSeek服务器繁忙问题

    目录 解决DeepSeek服务器繁忙问题 一、用户端即时优化方案 二、高级技术方案 三、替代方案与平替工具&#xff08;最推荐简单好用&#xff09; 四、系统层建议与官方动态 用加速器本地部署DeepSeek 使用加速器本地部署DeepSeek的完整指南 一、核心原理与工具选择 二、…

    在WPS中通过JavaScript宏(JSA)调用本地DeepSeek API优化文档教程

    既然我们已经在本地部署了DeepSeek,肯定希望能够利用本地的模型对自己软件开发、办公文档进行优化使用,接下来就先在WPS中通过JavaScript宏(JSA)调用本地DeepSeek API优化文档的教程奉上。 前提: (1)已经部署好了DeepSeek,可以看我的文章:个人windows电脑上安装DeepSe…

    CentOS-Stream 9安装

    文章目录 1 CentOS9安装引导界面2 CentOS9安装过程2.1 语言选择2.2 安装项选择2.2.1 安装目标位置2.2.2 软件选择2.2.3 网络和主机名2.2.4 root密码2.2.5 创建用户 2.3 开始安装2.4 等待安装成功 3 安装成功 1 CentOS9安装引导界面 选择Install CentOS Stream 9后按Enter键&…

    【神经网络框架】非局部神经网络

    一、非局部操作的数学定义与理论框架 1.1 非局部操作的通用公式 非局部操作(Non-local Operation)是该研究的核心创新点,其数学定义源自经典计算机视觉中的非局部均值算法(Non-local Means)。在深度神经网络中,非局部操作被形式化为: 其中: 1.2 与传统操作的对比分析…

    RAG科普文!检索增强生成的技术全景解析

    RAG 相关技术的八个主题&#xff1a;https://pub.towardsai.net/a-taxonomy-of-retrieval-augmented-generation-a39eb2c4e2ab 增强生成 (RAG) 是塑造应用生成式 AI 格局的关键技术。Lewis 等人在其开创性论文中提出了一个新概念面向知识密集型 NLP 任务的检索增强生成之后&…

    【做一个微信小程序】校园地图页面实现

    前言 上一个教程我们实现了小程序的一些的功能&#xff0c;有背景渐变色&#xff0c;发布功能有的呢&#xff0c;已支持图片上传功能&#xff0c;表情和投票功能开发中&#xff08;请期待&#xff09;。下面是一个更高级的微信小程序实现&#xff0c;包含以下功能&#xff1a;…

    STM32G474--Linpack程序移植笔记

    1 获取测试程序 直接将该页面的测试程序复制到新建的linpack.c文件中即可。 Linpack测试程序 2 移植程序 2.1 准备基本工程 参考这篇笔记从我的仓库中选择合适的基本工程,进行程序移植。这里我用的是stm32g474的基本工程。 使用git clone一个指定文件或者目录 2.2 在基本…

    【2025深度学习系列专栏大纲:深入探索与实践深度学习】

    第一部分:深度学习基础篇 第1章:深度学习概览 1.1 深度学习的历史背景与发展轨迹 1.2 深度学习与机器学习、传统人工智能的区别与联系 1.3 深度学习的核心组件与概念解析 神经网络基础 激活函数的作用与类型 损失函数与优化算法的选择 1.4 深度学习框架简介与选择建议 第2…

    对PosWiseFFN的改进: MoE、PKM、UltraMem

    先从PosWiseFFN说起 class PoswiseFeedForwardNet(nn.Module):def __init__(self):super(PoswiseFeedForwardNet, self).__init__()self.fc nn.Sequential(nn.Linear(d_model, d_ff, biasFalse),nn.GeLU(),nn.Linear(d_ff, d_model, biasFalse))def forward(self, inputs): …

    web第三次作业

    弹窗案例 1.首页代码 <!DOCTYPE html><html lang"en"><head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>综合案例</title><st…