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

news2025/2/15 13:43:06

#想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 节点,包括节点的部署、监控、故障处理等。在实现方面,代码逻辑会变得更加复杂,需要考虑多个节点的状态和交互。而且,由于要在多个节点上获取锁,会增加锁获取的时间开销,降低系统的性能。

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

    如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

    相关文章

    【现代深度学习技术】深度学习计算 | GPU

    【作者主页】Francek Chen 【专栏介绍】 ⌈ ⌈ ⌈PyTorch深度学习 ⌋ ⌋ ⌋ 深度学习 (DL, Deep Learning) 特指基于深层神经网络模型和方法的机器学习。它是在统计机器学习、人工神经网络等算法模型基础上&#xff0c;结合当代大数据和大算力的发展而发展出来的。深度学习最重…

    USB Flash闪存驱动器安全分析(第一部分)

    翻译原文链接&#xff1a;Hacking Some More Secure USB Flash Drives (Part I) | SySS Tech Blog 文章翻译总结&#xff1a;文章对一些具有AES硬件加密的USB闪存驱动器的网络安全分析研究。研究由SySS的IT安全专家Matthias Deeg进行&#xff0c;他在2022年初发现了几个安全漏…

    【Linux】--- 基础开发工具之yum/apt、vim、gcc/g++的使用

    Welcome to 9ilks Code World (๑•́ ₃ •̀๑) 个人主页: 9ilk (๑•́ ₃ •̀๑) 文章专栏&#xff1a; Linux网络编程 本篇博客我们来认识一下Linux中的一些基础开发工具 --- yum,vim,gcc/g。 &#x1f3e0; yum &#x1f3b8; 什么是yum 当用户想下载软…

    Python + WhisperX:解锁语音识别的高效新姿势

    大家好&#xff0c;我是烤鸭&#xff1a; 最近在尝试做视频的质量分析&#xff0c;打算利用asr针对声音判断是否有人声&#xff0c;以及识别出来的文本进行进一步操作。asr看了几个开源的&#xff0c;最终选择了openai的whisper&#xff0c;后来发现性能不行&#xff0c;又换了…

    redis 缓存击穿问题与解决方案

    前言1. 什么是缓存击穿?2. 如何解决缓存击穿?怎么做?方案1: 定时刷新方案2: 自动续期方案3: 定时续期 如何选? 前言 当我们使用redis做缓存的时候,查询流程一般是先查询redis,如果redis未命中,再查询MySQL,将MySQL查询的数据同步到redis(回源),最后返回数据 流程图 为什…

    SAP HCM 批量核算工资报错如何定位错误 (SAT分析错误)

    导读 簇目录 (表 RGDIR) 不包含任何记录:今天遇到一个很奇怪的问题&#xff0c;簇目录 (表 RGDIR) 不包含任何记录&#xff0c;而且出现的问题没有具体到员工编号&#xff0c;所以处理问题非常棘手。今天分享下我的处理方式&#xff0c;以便大家遇到这类的问题不知道如何下手。…

    服务器被暴力破解的一次小记录

    1. 网络架构 家里三台主机&#xff0c;其他一台macmini 启用ollama运行大模型的服务&#xff0c;主机1用来部署一些常用的服务如&#xff1a;mysql, photoprism等&#xff0c;服务器作为网关部署docker, 并且和腾讯云做了内网穿透。服务器部署了1panel用来管理服务并且监控&…

    3. 导入官方dashboard

    官方dashboard&#xff1a;https://grafana.com/grafana/dashboards 1. 点击仪表板 - 新建 - 导入 注&#xff1a;有网络的情况想可以使用ID&#xff0c;无网络情况下使用仪表板josn文件 2. 在官方dashboard网页上选择符合你现在数据源的dashboard - 点击进入 3. 下拉网页选…

    国家队出手!DeepSeek上线国家超算互联网平台!

    目前,国家超算互联网平台已推出 DeepSeek – R1 模型的 1.5B、7B、8B、14B 版本,后续还会在近期更新 32B、70B 等版本。 DeepSeek太火爆了!在这个春节档,直接成了全民热议的话题。 DeepSeek也毫无悬念地干到了全球增速最快的AI应用。这几天,国内的云计算厂家都在支持Dee…

    第6章 6.4 ASP.NET Core Web API各种技术及选择

    6.4.1 控制器父类用哪个 6.2小节和6.3小节所演示的ASP.NET Core Web API 的控制器类都继承自ControllerBase&#xff0c;而6.1中MVC的控制器继承自Controller&#xff0c;Controller又继承自ControllerBase。 所以&#xff0c;一般情况下&#xff0c;编写的WebAPI控制器类继承…

    【Linux】Ubuntu Linux 系统——Node.js 开发环境

    ℹ️大家好&#xff0c;我是练小杰&#xff0c;今天星期五了&#xff0c;同时也是2025年的情人节&#xff0c;今晚又是一个人的举个爪子&#xff01;&#xff01; &#x1f642; 本文是有关Linux 操作系统中 Node.js 开发环境基础知识&#xff0c;后续我将添加更多相关知识噢&a…

    使用pyCharm创建Django项目

    使用pyCharm创建Django项目 1. 创建Django项目虚拟环境&#xff08;最新版版本的Django) 使用pyCharm的创建项目功能&#xff0c;选择Django,直接创建。 2. 创建Django项目虚拟环境&#xff08;安装特定版本&#xff09; 2.1创建一个基础的python项目 2.2 安装指定版本的D…

    【前端框架】深入Vue 3组件开发:构建高效灵活的前端应用

    一、引言 Vue 3作为一款流行的前端框架&#xff0c;其组件化系统是构建大型应用的核心。通过将应用拆分为多个可复用的组件&#xff0c;不仅能提高代码的可维护性与复用性&#xff0c;还能让开发团队进行高效的协作。本文将深入探讨Vue 3组件开发的各个方面&#xff0c;帮助开…

    基于Python flask-sqlalchemy的SQLServer数据库管理平台

    适应场景&#xff1a; 主要用于帮助DBA自动化很多日常工作&#xff0c;包括&#xff1a; 数据库状态监控 性能问题诊断 日志分析 自动巡检 问题告警 系统截图&#xff1a; main.py from flask import Blueprint, render_template, request, flash, redirect, url_for f…

    npm运行Vue项目报错 error:0308010c:digital envelope routines::unsupported

    大家好&#xff0c;我是 程序员码递夫。 问题 VSCode 运行Vue项目&#xff0c;提示错误&#xff1a; building 2/2 modules 0 activeError: error:0308010c:digital envelope routines::unsupported 解决方法 原因是 npm 高版本(大于17)&#xff0c;对ssl的处理做了改进&…

    计数排序

    目录 计数排序原理和步骤&#xff1a; 完整代码实现&#xff1a; 计数排序原理和步骤&#xff1a; 当一段数据比较集中在一个范围&#xff0c;比如 98&#xff0c;95&#xff0c;98&#xff0c;91&#xff0c;90&#xff0c;93&#xff0c;94&#xff0c;97&#xff0c;93&…

    Pythong 解决Pycharm 运行太慢

    Pythong 解决Pycharm 运行太慢 官方给Pycharm自身占用的最大内存设低估了限制,我的Pycharm刚开始默认是256mb。 首先找到自己的Pycharm安装目录 根据合适自己的改 保存&#xff0c;重启Pycharm

    fastadmin 接口请求提示跨域

    问题描述 小程序项目&#xff0c;内嵌h5页面&#xff0c;在h5页面调用后端php接口&#xff0c;提示跨域。网上查找解决方案如下&#xff1a; 1&#xff0c;设置header // 在入口文件index.php直接写入直接写入 header("Access-Control-Allow-Origin:*"); header(&q…

    NHANES指标推荐:DDA!

    文章题目&#xff1a;Association of dietary decanoic acid intake with diabetes or prediabetes: an analysis from NHANES 2005-2016 DOI&#xff1a;10.3389/fnut.2024.1483045 中文标题&#xff1a;饮食中癸酸摄入量与糖尿病或糖尿病前期的关系&#xff1a;2005-2016 年 …

    用大模型学大模型04-模型与网络

    目前已经学完深度学习的数学基础&#xff0c;开始学习各种 模型和网络阶段&#xff0c;给出一个从简单到入门的&#xff0c;层层递进的学习路线。并给出学习每种模型需要的前置知识。增加注意力机制&#xff0c;bert, 大模型&#xff0c;gpt, transformer&#xff0c; MOE等流行…