黑马redis实战-优惠券秒杀

视频地址

1. 全局唯一 ID

1. 1 问题:针对优惠券秒杀模块全局唯一 ID 的作用是什么?

作为优惠券订单 id
在这里插入图片描述

1.2 问题:为什么不使用数据库自增 ID ?

考虑分布式场景下ID的全局唯一性

分库分表后,id主键如何处理

① 数据库自增 id:先从一个数据库表中获取自增id,再根据该 id 往对应的分库分表中写入
问题:生成id的数据库表高并发瓶颈
适用场景:并发不高,数据量太大导致的分库分表

② uuid:UUID.randomUUID()
问题:不适用于实际的业务需求,生成的订单号UUID字符串看不出与订单相关的有用信息。长字符串【存储性能差,查询耗时】

③ 获取系统当前时间:系统时间作为主键
问题:秒级并发时,会有重复情况
适用场景:业务字段与当前时间拼接,组成全局唯一编号

④ redis:redis 的 incr 实现 ID 原子性自增
问题:redis 持久化过程中出现宕机时,RDB持久化会出现重复 id 的情况,AOF 持久化会导致重启恢复数据时间过长 【问题:什么原因导致了两者的差异?】

⑤ 雪花算法:时间戳 + 机器 id + 序列号
问题:强依赖机器时钟

视频中采用 redis 生成全局唯一 id

1.3 如何利用 redis 生成全局唯一 id

① 业务名称前缀 + 当前日期 作为 redis 自增长参数 key

//2.2 自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

② 全局唯一id = 当前时间戳【高 32 位】+ redis 自增长序列【低 32 位】

//3.拼接并返回
private static final int COUNT_BITS = 32;
return timestamp << COUNT_BITS | count;

完整代码:

@Component
public class RedisIdWorker {
    // 开始时间戳
    private static final long BEGIN_TIMESTAMP = 1648857600L;
	// 序列号的位数
    private static final int COUNT_BITS = 32;
    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

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

        //2.生成序列号
        //2.1 获取当前日期
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));

        //2.2 自增长:redis 自增长key: 前缀 + 当前日期
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        //3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

实现效果:
在这里插入图片描述
实现效果分析:高 32 位为 时间戳,低 32 位为 redis 中精确到 天的记录。同一天的记录可以根据时间戳前缀唯一标识,同时在redis 中可以直观看到与相关业务逻辑以及日期相关的信息

2. 优惠券秒杀下单

流程说明:
① 判断秒杀是否开始或结束,若尚未开始或已经结束则无法下单

② 库存是否充足,不足则无法下单

2.1 问题:如何实现优惠券下单

① 扣减库存

// 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId).update();

② 创建订单并保存到数据库

// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1. 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2. 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3. 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
2.2 如何添加优惠券?

使用 postman添加优惠券:如下图所示,在 2 号商铺中添加优惠券
在这里插入图片描述

2.3 添加的优惠券是如何保存到数据库的?

通过 postman 添加优惠券后
在这里插入图片描述
会将优惠券信息同时写入 tb_voucher 和 tb_seckill_voucher 表中
在这里插入图片描述
tb_voucher :记录优惠券的店铺,描述,面值等信息

tb_seckill_voucher :记录优惠券秒杀开始,结束时间以及库存

实现过程
① 通过 postman 发送请求到 /voucher/seckill
在这里插入图片描述
请求被分发到 Controller 层的 addSeckillVoucher(@RequestBody Voucher voucher)

@RestController
@RequestMapping("/voucher")
public class VoucherController {
    /**
     * 新增秒杀券
     * @param voucher 优惠券信息,包含秒杀信息
     * @return 优惠券id
     */
    @PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }
}

② Service 层 VoucherServiceImpl.java 中的 addSeckillVouocher(Voucher voucher) 实现保存秒杀券信息到数据库中

@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
}

优惠券秒杀核心代码:

// VoucherOrderServiceImpl.java
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        //尚未开始
        return Result.fail("秒杀尚未开始!");
    }

    // 3.判断秒杀是否已经结束
    if(voucher.getEndTime().isBefore(LocalDateTime.now())){
        //秒杀已结束
        return Result.fail("秒杀已结束:");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        //库存不足
        return Result.fail("库存不足!");
    }
    // 5.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).update();
    if(!success){
        //扣减失败
        return Result.fail("库存不足!");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1. 订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2. 用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3. 代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    // 7.返回订单 id
    return Result.ok(orderId);
}

3. 超卖问题

视频地址

3.1 超卖?如何产生的?

100件库存,200左右人抢购,数据库中库存为负数
在这里插入图片描述
原因分析:

// VoucherOrderServiceImpl.java -- seckillVoucher(Long voucherId)
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
    //库存不足
    return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId).update();

库存超卖 流程演示:
在这里插入图片描述
对比可知:流程二出现异常主要是在 查询库存和 扣减库存期间有线程查询库存,取到了库存扣减之前的值。判断条件【库存是否充足】失效。而这在高并发场景下是不可避免的

3.2 如何解决超卖问题?

解决方案:
① 悲观锁:synchronized,Lock 操作数据前先获取锁,确保线程串行执行

② 乐观锁:CAS,修改前判断是否与预期相符,相符则更新数据

问题:如何用 CAS 解决超卖?

分析:CAS 的关键是比较当前值与预期值是否相符,如何设置预期值呢?

① 数据库中添加一个 version 字段,执行查询逻辑时,从数据库中取出 stock 和 version ;执行扣减逻辑时,判断当前 version 是否与从数据库查询到的一致。一致说明没有其他线程操作,可以扣减,否则已被其他线程修改,扣减失败

② 优化:将 version 的功能合并到 stock 上,比较时的预期值为从数据库中查询的 stock 值,扣减前 stock 没有变化,说明没有线程修改,可以执行扣减
代码实现:在操作数据库执行扣减时,添加判断条件,stock 是否与查询到的值一致。

// 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .eq("stock", voucher.getStock())
        .update();

测试:利用 Jmeter 创建 200 个线程,抢购库存为100的优惠券【id = 18】
测试结果:库存剩余 74 件,错误率高达90%
在这里插入图片描述
问题分析:多个线程查询到相同的库存,但只有一个执行CAS成功

改进:执行CAS时修改判断条件,库存大于0即可执行扣减操作
在这里插入图片描述

// 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)
        .update();

修改后的测试结果:
① 数据库中的数据扣减为0
在这里插入图片描述
② JMeter 失败率 50 【200人抢购库存为100的商品】
在这里插入图片描述

4. 一人一单

一人一单:同一优惠券,一个用户只能下一单

4.1 如何实现一人一单?

思路:下单之前判断秒杀券订单表中是否有该用户信息,若有则抢购失败
步骤:
① 根据ThreadLocal 查询当前线程的用户信息

② 根据用户 id 查询 tb_voucher_order 中的用户抢购的优惠券【正在被秒杀的优惠券】的数目

③ 若用户已有当前优惠券的抢购订单信息,则抢购失败,否则添加订单到数据库

代码实现

// 5. 一人一单
Long userId = UserHolder.getUser().getId();
// 5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2 判断是否存在
if(count > 0){
    // 用户已经购买过了
    return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)
        .update();

测试
测试说明 :JMeter 中创建200 线程,设置登录状态头,模拟同一用户下单200次场景
在这里插入图片描述
测试结果:stock 减为 90,即同一用户下了 10 单
在这里插入图片描述
问题分析:与库存超卖的问题相同,实现一人一单的逻辑也是:先查询订单,然后再创建订单。同一用户多次操作模拟并发场景,多次操作查询到用户下单为0,然后执行创建订单业务

4.2 如何解决并发情况下 一人多单问题?

解决方案:加锁

问题:能否用乐观锁执行?

不能,原因是乐观锁只能操作单个变量,而创建订单需要操作数据库

@Transactional
public Result createVoucherOrder(Long voucherId){
    // 5. 一人一单
    Long userId = UserHolder.getUser().getId();

    synchronized (userId.toString().intern()){
        // 5.1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2 判断是否存在
        if(count > 0){
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }
        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if(!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1. 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2. 用户id
        voucherOrder.setUserId(userId);
        // 7.3. 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 8.返回订单 id
        return Result.ok(orderId);
    }
}
4.3 问题:synchronized 代码块中的锁,为什么要将 Long 类型的 userId 转化为 String 类型

① 同一个线程每次请求获取的 userId 是否是一样的?

Long userId = UserHolder.getUser().getId();

返回值为 Long 类型,即属于对象类型

问题转化为:每次从 ThreadLocal 中获取存储的对象是否是同一个对象?

// ThreadLocal.java
public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

ThreadLocal 中通过 get() 方法找到对应的 Entry 后执行了 T result = (T) e.value最后将 result 作为结果返回

那么 result 和 map 中存储的 value 之间是什么关系呢?

① 由于 result 是新创建的变量,result 和 value 必然不是同一个对象

② result 对象引用,被赋值为 value 的引用地址

③ 故 result 和 value 不是同一个对象,但是内容相同,指向同一块地址

由于 synchronized 代码块的锁为( )内的对象,每次请求时得到的 userId 不是同一个对象,必然是不同的锁,因此需要比较其数值。

那么为什么不转为基本类型,而是转为 String 呢?

因为 synchronized 锁为对象,基本类型不属于引用类型

4.4 问题:转为 String 后还需要执行 intern( ) ?

String 类是不可变的,对String 操作都会返回新的 String 对象

// Long.java
public String toString() {
        return toString(value);
}
public static String toString(long i) {
    if (i == Long.MIN_VALUE)
        return "-9223372036854775808";
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    char[] buf = new char[size];
    getChars(i, size, buf);
    return new String(buf, true);
}

即:每次toString() 之后生成的是不同的 String 对象

那么 intern( ) 的作用是什么呢?

// String.java
public native String intern();

深入理解JVM 第 3 版 pg 61 - pg 632.4.3 方法区和运行时常量池溢出
① intern( ) :操作的是字符串字面量,即下面的 “abc”

String s1 = new String("abc");
s1 = s1.intern();
String s2 = "abc";
System.out.println(s1 == s2); // true

② JDK 7 之后,String::intern( ) 会返回 字符串字面量 “abc” 在字符串常量池中的引用,若没有就添加并返回

上述示例中:s2 指向字符串常量池中 “abc” 的引用,s1 执行 intern( ) 会返回字符串常量池中 “abc” 的引用,因此 s1 == s2

测试
测试说明: JMeter 下 同一用户创建 200 线程抢购库存为 100 的优惠券 【id = 18】
测试结果:正确,只扣减了一条记录
在这里插入图片描述

5. 分布式锁

集群下的线程并发安全问题

问题复现:
① 在IDEA 上开启两个 JVM 进程
在这里插入图片描述
② Nginx 中的 nginx.conf 文件中使用 upstream 配置服务组进行负载均衡
在这里插入图片描述
③ 在postman上使用同一用户发送两次秒杀请求
在这里插入图片描述
测试数据未保存,从视频上截取的结果
在这里插入图片描述
tb_voucher_order 表中同一用户 user_id,抢购了两次voucher_id = 7 的优惠券

5.1 问题:为什么要使用分布式锁

出现上述问题的原因:

① 不同的服务启动不同的 JVM,

② synchronzied 只能保证单个 JVM 内部的多个线程互斥

即 synchronized 作用于跨进程的 线程时是失效的

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

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

① MySQL 自带的互斥锁机制 ???【暂未了解】

② Redis 利用 setnex 互斥命令

③ Zookeeper 利用节点的唯一性和有序性 【不了解】

视频:综合来说 使用 redis 效果最佳

so so,后续再了解

5.2 问题:如何基于redis实现分布式锁

核心代码实现逻辑:

① redis 实现分布式锁的两个基本方法

# 添加锁,利用 setnx 的互斥特性
SETNX lock thread1
# 添加锁过期时间,避免服务宕机引起死锁
EXPIRE lock 10
# 释放锁,删除 key
DEL key

② 利用 StringRedisTemplate中提供的 setIfAbsent(…) 和 delete(…) 获取锁和释放锁

@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标识
    long threadId = Thread.currentThread().getId();
    // 非阻塞获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
    // 释放锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
} 

完整代码实现:
① 定义 ILock 接口声明 tryLock(time) ,unlock() 方法

public interface ILock {
    boolean tryLock(long timeoutSec);
    void unlock();
}

② 定义 SimpleRedisLock 实现类,实现 tryLock(time),unlock() 方法

public class SimpleRedisLock implements ILock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        long threadId = Thread.currentThread().getId();
        // 非阻塞获取锁
        // key--lock:order    value--threadId
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

③ 修改一人一单逻辑:使用自定义锁 替换 synchronized

@Resource
private StringRedisTemplate stringRedisTemplate;

@Transactional
public Result createVoucherOrder(Long voucherId){
    // 5. 一人一单
    Long userId = UserHolder.getUser().getId();

    //创建锁对象
    SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
    //尝试获得锁
    boolean isLock = redisLock.tryLock(1200);
    //判断
    if(!isLock){
        //获取锁失败,直接返回失败或重试
        return Result.fail("不允许重复下单");
    }
    //获取锁成功,执行一人一单的逻辑
    try {
//一人一单/
        // 5.1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2 判断是否存在
        if(count > 0){
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }
        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if(!success){
            //扣减失败
            return Result.fail("库存不足!");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1. 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2. 用户id
        voucherOrder.setUserId(userId);
        // 7.3. 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 8.返回订单 id
        return Result.ok(orderId);
    } finally {
// 释放锁
        redisLock.unlock();
    }
}
5.3 redis 分布式锁的原理?可能存在的问题

Redis深度历险 pg 18
① setnx(set if not exists) 只会有一个线程获取锁

② del 释放锁

③ expire :给锁设置过期时间,即使 del 指令因故障没有执行,锁仍然会被释放

④ set lock ex time nx :将 setnx 和 expire 组合成原子指令,避免expire 得不到执行时,造成死锁

上述是redis 分布式锁的演化过程,那么进行到第④步,将加锁操作和设置过期时间的操作打包成原子指令后,还会出现什么问题?

当业务阻塞时,锁会被超时释放。在高并发场景下,会出现锁误删问题

什么是分布式锁误删?
在这里插入图片描述
① 线程1 由于业务阻塞,线程1持有的锁因超时被释放

② 线程2 获取到线程1 超时释放的锁

③ 线程1 阻塞恢复,执行释放锁的逻辑,释放掉线程2 持有的锁

④ 线程3 获取被线程1 释放的锁

最终导致的结果:线程2 线程3 同时进入临界区执行任务

问题根源:线程的锁被超时释放,线程在执行释放锁的逻辑时删除了其他线程持有的锁

5.4 问题:如何解决分布式锁误删问题?

问题分析:误删的根源是删除锁的时候没有判断条件,任意线程都可能删除别的线程的锁,因此需要设定条件线程只能删除自己获得的锁

核心问题:在释放线程的时候,区分释放锁的线程

问题:如何区分线程?注意是在分布式存在多个JVM的情况下

不仅要区分不同的线程,还要区分不同 JVM上的线程

解决方案:使用 uuid + threadid 拼接字符串标识不同JVM上的不同线程

① 使用 UUID 区分不同的服务 JVM

② 使用 线程ID 区分JVM 的不同线程

** 代码实现**:

① 获取锁时存入线程标识

// 锁 key 前缀
private static final String KEY_PREFIX = "lock:";
// 线程标识前缀
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "_";
@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标识
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 非阻塞获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

② 释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致

@Override
public void unlock() {
   // 获取线程标识
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 获取锁中的标识
   String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
   // 判断标识是否一致
   if(threadId.equals(id)){
       // 释放锁
       stringRedisTemplate.delete(KEY_PREFIX + name);
   }
}
5.5 分布式锁原子性问题

分布式锁误删的原因是:删除锁的时候若不进行线程判断,会删除其他线程持有的锁,导致多个线程并行

但是上述判断 锁标识 和释放锁的操作不是原子的,根据分布式锁组合 setnx 和 expire 指令的背景知识可知。当判断锁标识和释放锁不是原子操作时,会出现问题

会出现什么问题呢?
在这里插入图片描述
本图加了锁标识判断后的流程和没加之前基本一致,最后都导致了线程2,3并行

问题分析:
① 线程1 判断锁标识通过后,在删除锁的过程中发生阻塞

② 线程1 的锁再次出现超时释放,线程2获取锁

③ 线程2 持有锁期间,线程1 解除阻塞,由于经过了锁标识判断的检查,线程1 直接执行删除锁的逻辑,再次删除了线程2 的锁

④ 线程3 获得被线程1 释放的锁,线程2,3并行

问题的根源:判断锁标识和释放锁不是原子操作

解决方案:使用 lua 脚本将两条指令打包成原子操作

5.6 lua 脚本解决多条命令原子性问题

问题:使用 lua 脚本的原因?
将 redis 操作中判断锁标识的操作,和释放锁的操作整合册成原子操作

-- 比较线程标示与锁中的标示是否一致

if(redis.call('get', KEYS[1]) == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

上述代码可以拆分为两部分:

① lua 的流程控制语法

--[ 0 为 true ]
if(0)
then
    print("0 为 true")
end

② lua 脚本调用redis

-- 执行 redis 命令
redis.call('命令名称','key','参数'...)
-- exam: 执行 set name jack
redis.call('set','name','jack')

代码实现
① 在 resources 目录下创建 unlock.lua
上述 lua 脚本代码执行流程说明:

  • redis 指令 get key ----> value 通过 redis.call(‘get’, KEYS[1]) 得到的是在 redis 中保存的获得锁的线程的 uuid-threadid
  • ARGV 是传入的参数,用于比较要释放锁的线程的标识是否与redis中保存的标识一致
  • 一致则执行 redis.call(‘del’, KEYS[1]) 删除锁

② 修改 unlock() 代码逻辑,使用 stringRedisTemplate.execute 执行 lua 脚本

// SimpleRedisLock.java
//借助 DefaultRedisScript 使得 lua 脚本在启动时加载,且仅加载一次即可
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
    // 构造函数初始化 UNLOCK_SCRIPT 对象
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    // lua 脚本路径
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    // 返回值类型【lua脚本的返回值】
    UNLOCK_SCRIPT.setResultType(Long.class);
}
// 使用 lua 脚本 判断锁标识 和释放锁
@Override
public void unlock(){
    // 调用 lua 脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT, // lua 脚本
            Collections.singletonList(KEY_PREFIX + name), // KEYS[1]  锁
            ID_PREFIX + Thread.currentThread().getId()); // ARGV[1]  线程标识
}

6. redis 优化秒杀

6.1 redisson 实现分布式锁

上述基于 setnx 实现的分布式锁存在的问题:
① 不可重入

② 不可重试

③ 超时释放:锁超时释放可避免死锁,但若是耗时业务也会导致锁释放

④ 主从一致性:从节点还未和主节点同步,主节点发生宕机

解决方案:redisson 实现分布式锁

使用 redisson 实现分布式锁的流程

① 配置 pom.xml 文件

<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.13.6</version>
</dependency>

② config 目录下添加 RedissonConfig.java 配置类

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        //配置
        Config config = new Config();  config.useSingleServer().setAddress("redis://xxx.xxx.xxx.xxx:6379").setPassword("password");
        //创建 RedissonClient 对象
        return Redisson.create(config);
    }
}

③ createVoucherOrder(…) 方法中使用 redissonClient 提供的 getLock(),unlock() 方法,替代自定义接口实现类实现的 tryLock(),unlock() 方法

// VoucherOrderServiceImpl.java
@Resource
private RedissonClient redissonClient;

@Transactional
public Result createVoucherOrder(Long voucherId){
    // 5. 一人一单
    Long userId = UserHolder.getUser().getId();

    //创建锁对象
    RLock redisLock = redissonClient.getLock("lock:order:" + userId);
    //尝试获得锁
    boolean isLock = redisLock.tryLock();
    //判断
    if(!isLock){
        //获取锁失败,直接返回失败或重试
        return Result.fail("不允许重复下单");
    }
    try {
        // 5.1 查询订单
		...
        // 6.扣减库存
		...
        // 7.创建订单
        ...
    } finally {
        redisLock.unlock();
    }
}
6.2 异步秒杀

异步秒杀思路:将判断秒杀库存及校验一人一单的工作放到 redis 中,
与操作数据库耗时较久的 扣减库存和创建订单操作分开,中间借助 消息队列,异步读取队列中的信息,完成下单

6.2.1 新增优惠券时将优惠券信息保存到 redis 中
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券到 tb_voucher 
    save(voucher);
    // 保存秒杀信息到 tb_seckill_voucher
    ...
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到 Redis 中
    stringRedisTemplate.opsForValue()
            .set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
6.2.2 lua 脚本实现,扣减秒杀库存,一人一单

在这里插入图片描述
lua 脚本的执行逻辑:
① 获取:优惠券id 【voucherId】,用户id【userId】,订单id【orderId】

-- 1.1 优惠券 id
local voucherId = ARGV[1]
-- 1.2 用户 id
local userId = ARGV[2]

② 拼接:库存key【stockKey】,订单key【orderKey】通过 voucherId拼接

-- 2. 数据 key
-- 2.1 库存 key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单 key
local orderKey = 'seckill:order:' .. voucherId

③ 判断库存是否充足 get stockKey

if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2 库存不足,返回 1
    return 1
end

④ 判断用户是否下单 sismember orderkey userId【订单列表中是否有用户id】

if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3 存在,说明重复下单,返回 2
    return 2
end

⑤ 扣减库存 incrby stockKey -1

redis.call('incrby', stockKey, -1)

⑥ 下单(保存用户) sadd orderKey userId

redis.call('sadd', orderKey, userId)
return 0
6.2.3 使用 ArrayBlockingQueue 创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//使用 @PostConstruct 注解方法,在对象加载完依赖注入后执行,会在服务器加载 Servlet 的时候运行,且只运行一次
@PostConstruct
private void init(){
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
    @Override
    public void run() {
        while(true){
            try {
                // 1.获取队列中的订单信息
                VoucherOrder voucherOrder = orderTasks.take();
                // 2.创建订单
                createVoucherOrder(voucherOrder);
            } catch (Exception e) {
                log.error("处理订单异常", e);
            }
        }
    }
}

阻塞队列执行逻辑:
① 创建阻塞队列用户存放订单信息

② 创建线程池执行阻塞队列中的任务

  • 循环获取队列中的订单信息
  • 创建订单
6.2.4 执行 lua 脚本并将订单信息添加到阻塞队列
// VoucherOrderServiceImpl.java
@Override
public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();

    // 1.执行 lua 脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(),
            userId.toString()
    );
    int r = result.intValue();
    // 2.判断结果是否为 0
    if(r != 0){
        // 2.1不为0,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 2.2为0,有购买资格,把下单信息保存到阻塞队列
    VoucherOrder voucherOrder = new VoucherOrder();
    // 2.3 订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 2.4 用户id
    voucherOrder.setUserId(userId);
    // 2.5 代金券id
    voucherOrder.setVoucherId(voucherId);
    // 2.6 放入阻塞队列
    orderTasks.add(voucherOrder);
    // 3.返回订单 id
    return Result.ok(orderId);
}
  • 4
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值