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

#想cry 好想cry

目录

[1 全局唯一id](#1 全局唯一id)

[1.1 自增ID存在的问题](#1.1 自增ID存在的问题)

[1.2 分布式ID的需求](#1.2 分布式ID的需求)

[1.3 分布式ID的实现方式](#1.3 分布式ID的实现方式)

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

[1.5 总结](#1.5 总结)

[2 优惠券秒杀接口实现](#2 优惠券秒杀接口实现)

[3 单体系统下一人多单超卖问题及解决方案](#3 单体系统下一人多单超卖问题及解决方案)

[3.1 问题背景](#3.1 问题背景)

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

[3.3 解决方案](#3.3 解决方案)

方案一:悲观锁

方案二:乐观锁

[3.4 悲观锁和乐观锁的比较](#3.4 悲观锁和乐观锁的比较)

[3.4.1 性能](#3.4.1 性能)

[3.4.2 冲突处理](#3.4.2 冲突处理)

[3.4.3 并发度](#3.4.3 并发度)

[3.4.4 应用场景](#3.4.4 应用场景)

[3.4.5 总结对比](#3.4.5 总结对比)

[3.4.6 选择建议](#3.4.6 选择建议)

[3.5 乐观锁的实现(CAS法)](#3.5 乐观锁的实现(CAS法))

[3.6 CAS的优缺点](#3.6 CAS的优缺点)

[3.7 总结](#3.7 总结)

[4 单体下的一人一单超卖问题](#4 单体下的一人一单超卖问题)

[4.1 问题描述](#4.1 问题描述)

[4.2 原因](#4.2 原因)

[4.3 解决方案------悲观锁](#4.3 解决方案——悲观锁)

[4.3.1 实现流程](#4.3.1 实现流程)

[4.3.2 代码实现](#4.3.2 代码实现)

[4.3.3 实现细节(重要)](#4.3.3 实现细节(重要))

[4.3.4 让代理对象生效的步骤](#4.3.4 让代理对象生效的步骤)

[5 集群下的一人一单超卖问题](#5 集群下的一人一单超卖问题)

[6 分布式锁](#6 分布式锁)

[6.1 简要原理](#6.1 简要原理)

[6.2 分布式锁的特点](#6.2 分布式锁的特点)

[6.3 分布式锁的常见实现方式](#6.3 分布式锁的常见实现方式)

[6.4 Redis分布式锁的实现](#6.4 Redis分布式锁的实现)

[6.5 分布式锁解决超卖问题](#6.5 分布式锁解决超卖问题)

(1)创建分布式锁

(2)使用分布式锁

(3)实现细节

[6.6 分布式锁优化](#6.6 分布式锁优化)

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

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

[1 问题背景](#1 问题背景)

[2 问题的根本原因](#2 问题的根本原因)

[3 解决方案](#3 解决方案)

[4 Lua脚本的优势](#4 Lua脚本的优势)

[5 实现步骤](#5 实现步骤)

[5.1 编写Lua脚本](#5.1 编写Lua脚本)

[5.2 在Java中加载Lua脚本](#5.2 在Java中加载Lua脚本)

[5.3 实现释放锁的逻辑](#5.3 实现释放锁的逻辑)

[6.7 手写分布式锁的各种问题与Redission引入](#6.7 手写分布式锁的各种问题与Redission引入)

[6.8 Redisson分布式锁](#6.8 Redisson分布式锁)

[6.8.1 使用步骤](#6.8.1 使用步骤)

[tryLock 方法详解](#tryLock 方法详解)

[6.8.2 Redisson 可重入锁原理](#6.8.2 Redisson 可重入锁原理)

[6.8.3 Redisson 可重入锁原理](#6.8.3 Redisson 可重入锁原理)

可重入问题解决

可重试问题解决

超时续约问题解决

主从一致性问题解决

[6.9 看门狗机制的详细解剖](#6.9 看门狗机制的详细解剖)

[6.10 主从一致性问题的深入探讨------MultiLock](#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指令设置锁,确保锁的唯一性。

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

    java 复制代码
    #保障指令的原子性
    # 添加锁
    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)创建分布式锁

java 复制代码
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锁的地方,改成我们自己实现的分布式锁:

java 复制代码
        // 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又趁虚而入拿到了锁,这就导致又出现了超卖问题!(但是这个在小项目(并发数不高)中出现的概率比较低,在大型项目(并发数高)情况下是有一定概率的)

如何解决呢?

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

只需要改一下锁的实现:

java 复制代码
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脚本

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

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

    • 脚本内容

      java 复制代码
      -- 比较缓存中的线程标识与当前线程标识是否一致
      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脚本。

    • 代码示例

      java 复制代码
      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脚本。

    • 代码示例

      java 复制代码
      @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)引入依赖

java 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

(2)配置 Redisson 客户端

java 复制代码
@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 客户端获取锁并尝试加锁:

java 复制代码
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 节点,包括节点的部署、监控、故障处理等。在实现方面,代码逻辑会变得更加复杂,需要考虑多个节点的状态和交互。而且,由于要在多个节点上获取锁,会增加锁获取的时间开销,降低系统的性能。
相关推荐
早起的年轻人39 分钟前
Docket Desktop 安装redis 并设置密码
数据库·redis·缓存
xlxxy_1 小时前
ABAP数据库表的增改查
开发语言·前端·数据库·sql·oracle·excel
清水加冰1 小时前
【MySQL】索引
数据库·mysql
qw9491 小时前
Redis(高阶篇)03章——缓存双写一致性之更新策略探讨
数据库·redis·缓存
IT猿手1 小时前
2025最新智能优化算法:鲸鱼迁徙算法(Whale Migration Algorithm,WMA)求解23个经典函数测试集,MATLAB
android·数据库·人工智能·算法·机器学习·matlab·无人机
m0_748234082 小时前
SQL Server 导入Excel数据
数据库
Ciderw2 小时前
MySQL日志undo log、redo log和binlog详解
数据库·c++·redis·后端·mysql·面试·golang
CT随3 小时前
Redis 存在线程安全问题吗?为什么?
数据库·redis·安全
TravisBytes3 小时前
Redis如何解决热Key问题
数据库·redis·缓存