仿黑马点评项目(三、优惠卷秒杀 SET、String、Stream)

文章介绍了如何使用Redis生成全局唯一ID,包括利用Redis的自增命令和位运算保证ID的唯一性和安全性。接着,讨论了秒杀场景的实现,包括库存判断、下单逻辑以及防止超卖的乐观锁策略。此外,提到了Redis分布式锁的问题和解决方案,如使用Lua脚本确保原子性,并引出了Redisson作为更完善的分布式锁实现。最后,文章探讨了使用RedisStream进行异步秒杀,以解决阻塞队列的潜在问题。
摘要由CSDN通过智能技术生成

1.全局唯一ID

为什么不使用表的自增,因为表的自增在多表业务下,自增可能会导致ID的重复,无法唯一识别,因此不能使用自增。

​ 全局ID生成器:是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:唯一性、高可用、高性能、递增性、安全性。

​ 唯一性:可以利用Redis数据库来实现全局ID生成,因为Redis中String类型有一个方法INCR,让一个整型自增1

​ 高可用:Redis中的集群方案、主从方案、哨兵方案

​ 高性能:Redis读写性能比MySQL好太多

​ 递增性:可以使用Redis中的INCRBY、INCRBYFLOAT来自增长,减少规律性

​ 安全性:为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他的信息,让规律性不那么明显。为了提高数据库性能,ID会使用数值类型(转为Bit,编码使用二进制,可以存储更多的信息)

2.Redis实现全局唯一Id

全局唯一ID生成策略:

  • UUID:生成的是16进制的String字符串,而且不是自增,不符合全局唯一ID的递增性
  • Redis自增:下面有说明
  • snowflake算法:不依赖Redis,性能可能较高,但是对时钟依赖较高,如果时钟出错,容易导致生成出错
  • 数据库自增:单独创建一张自增表,所以其他表自增的ID均来自这张自增表,但数据库性能不如Redis

借助 redis 的 increment 命令实现 id 自增长:
ID = 时间戳 + 序列号

  • 生成时间戳,定义起始时间戳属性,时间戳=当前时间戳-起始时间戳;

  • 生成序列号,increment 命令从 redis 缓存获取序列号,key 为自定义前缀+自定义传入参数前缀+当前日期

  • 位运算拼接时间戳和序列号返回 id 给调用者;

  • 在Util工具包下定义一个RedisIDWorker类,加上@Component标签使他成为Spring容器中管理的Bean;

  • 开始时间戳的秒数生成及定义;

  • 定义一个nextId方法,参数为String keyPreFix,表示redis中不同业务的key对应的唯一Id,自增长可对对应的key进行操作

@Component
public class RedisIdWorker {

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

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public Long nextId(String keyPrefix){
        // 完整全局Id: 符号位1位,时间戳31位,序列号32位

        // 1.生成当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        Long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2.生成序列号(注意更新key,不能永远使用同一个key,虽然Redis的自增上限值为2^64,但实际用于记录序列号的只有32位,可以在key后面拼上时间戳)
        // 2.1.获取当前日期,精确到天,根据年月日分层,可以便于统计每天、月、年的销量
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长,如果key不存在,则会自动创建
        Long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":" + date);

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

    //如何生成时间戳的秒数,利用LocalDateTime
    public static void main(String[] args) {
        //利用LocalDateTime的静态方法,设置日期时间
        LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0);
        //获取日期时间对应的时间戳
        long second = time.toEpochSecond(ZoneOffset.UTC);
        //输出秒数
        System.out.println("second: " + second);
    }

}

3.实现优惠券秒杀下单

  • 数据库两张表的定义:

    1. tb_voucher:优惠券,分为普通券和秒杀券
    2. tb_seckill_voucher:秒杀券优惠券的一种,有数量限制,生效时间和失效时间,主键是跟优惠券的id一样(字段名),与优惠券是一对一关系
  • 数据库添加优惠券

    1. voucher:除了优惠券的字段,还包括秒杀券的字段
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher")
public class Voucher implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 商铺id
     */
    private Long shopId;

    /**
     * 代金券标题
     */
    private String title;

    /**
     * 副标题
     */
    private String subTitle;

    /**
     * 使用规则
     */
    private String rules;

    /**
     * 支付金额
     */
    private Long payValue;

    /**
     * 抵扣金额
     */
    private Long actualValue;

    /**
     * 优惠券类型
     */
    private Integer type;

    /**
     * 优惠券类型
     */
    private Integer status;
    /**
     * 库存
     */
    @TableField(exist = false)
    private Integer stock;

    /**
     * 生效时间
     */
    @TableField(exist = false)
    private LocalDateTime beginTime;

    /**
     * 失效时间
     */
    @TableField(exist = false)
    private LocalDateTime endTime;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;


    /**
     * 更新时间
     */
    private LocalDateTime updateTime;


}
  1. VoucherService:添加秒杀券的步骤,先将Voucher对象保存至数据库,然后再根据Voucher对象的秒杀基本信息新建秒杀对象存放至数据库
/**
 * 新增秒杀券
 * @param voucher 优惠券信息,包含秒杀信息
 * @return 优惠券id
 */
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    // Voucher对象里面还包含秒杀券的基本信息字段
    voucherService.addSeckillVoucher(voucher);
    // 返回秒杀券(优惠券)的id,传到前端页面,方便后续抢券操作
    return Result.ok(voucher.getId());
}
@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);
    // 特价优惠券保存到 redis 中
    stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY + voucher.getId(),
                                            voucher.getStock().toString());
}
  • 优惠券秒杀下单(购买秒杀券)代码实现:
    在实现用户下单(购买秒杀券)时需要判断两点:
    1. 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单;
    2. 库存是否充足,不足则无法下单

Controller 层,

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {

    @Autowired
    private IVoucherOrderService iVoucherOrderService;

    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return iVoucherOrderService.seckillVoucher(voucherId);
    }
}

Service 层,

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    //注入Id生成器
    @Resource
    private RedisIdWorker redisIdWorker;
    //注入秒杀券服务
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @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.判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock <= 0){
            // 库存不足
            return Result.fail("秒杀券已经被抢光!");
        }
        // 5.扣减库存
//        voucher.setStock(stock - 1);
//        seckillVoucherService.updateById(voucher);
        boolean flag = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).update();
        if(!flag){
            // 扣减失败
            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);

        // 7.将订单写入数据库
        this.save(voucherOrder);

        // 8.返回订单id
        return Result.ok(orderId);
    }
}

库存超卖问题分析:
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,
但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,
那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

锁的选择技巧:

  • 悲观锁:成本较高,将并行转换为串行,客户端响应时间延长,用户体验不好

  • 乐观锁:仍是并行,在更新时判断其他线程是在进行修改,但是成功率低,因为假如100个线程同时查到stock = 100,但是在第一个线程做完 更新后,stock = 99,那么其他线程就不会成功了。因此可以将stock = oldStock改成stock > 0即可。

// 5.扣减库存(对比版本号前后是否相同)
boolean flag = seckillVoucherService.update()
        .setSql("stock = stock - 1") // set stock = stock - 1
        .eq("voucher_id", voucherId).gt("stock",0) // where voucher_id = ? and stock > 0
        .update();

一人一单:

// 用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 判断是否存在
if (count > 0) {
    // 用户已经购买过了
    return Result.fail("用户已经购买过一次!");
}

问题一:存在多线程并发问题
所以需要加锁,问题是悲观锁还是乐观锁呢?分析:乐观锁,主要应用在数据更新方面,但这里是判断数据是否存在,与数据有无正在被更改无关系。因此这里需要使用悲观锁。

将synchronize修饰方法改成锁关键字(关键字转字符串)。为什么要将关键字userId转String().intern()呢,因为每次请求,即使是user的id相同,但是对象会不同,也就是内存地址不同,但装的id是相同的,可比较的是对象。因此需要toString().intern(0),当用户id的值一样时,锁就一样。

问题二:spring的事务和锁
方法被spring的事务控制,如果在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以需要选择将当前方法整体包裹起来,确保事务不会出现问题。

问题三:事务生效
调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,需要获得原始的事务对象, 来操作事务。

添加依赖,

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

启动类添加注解,

@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}

业务层,

@Override
/**
* 秒杀优惠券---判断能否生成订单
* 技术:悲观锁
* 说明:单机环境,未解决集群环境下的并发问题
* */
public Result addSeckillVoucher(long voucherId) {
   // 获取特价券信息
   SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);

   // 1.判断秒杀是否开始
   if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
       return Result.fail("秒杀暂未开始!");
   }
   if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
       return Result.fail("秒杀已经结束!");
   }

   // 2.判断库存是否充足
   if (seckillVoucher.getStock() < 1){
       return Result.fail("库存不足!");
   }

   // 创建订单
   Long userId = UserHolder.getUser().getId();
   // 锁的细粒度是同一个用户
   // 需要使用字符串常量池来保证是同一个锁
   synchronized (userId.toString().intern()){
       // 获取代理对象(事务)
       IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
       return proxy.createSeckillOrder(voucherId);
   }
}
@Transactional
/**
 * 秒杀优惠券---创建订单
 * 说明:乐观锁解决超卖问题,实际上借助数据库的加锁达到乐观锁的效果
 * */
public Result createSeckillOrder(long voucherId) {
    // 一用户一订单
    Long userId = UserHolder.getUser().getId();

    Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0){
        return Result.fail("用户已购买一次!");
    }

    // 3.创建订单
    // 3.1 生成订单 id
    long orderId = redisIdWorker.nextId("order");
    // 3.2 修改库存
    boolean isUpdateSuccess = iSeckillVoucherService.update()
            .setSql("stock = stock - 1")
            .gt("stock", 0)
            .eq("voucher_id", voucherId)    // where voucher_id = ? and stock > 0
            .update();
    if (!isUpdateSuccess){
        return Result.fail("库存不足!");
    }
    // 3.3 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(orderId);    // 设置订单 id
    voucherOrder.setVoucherId(voucherId);   // 设置特价券 id
    voucherOrder.setUserId(userId);   // 设置当前用户 id
    this.save(voucherOrder);

    // 4.返回订单 id
    return Result.ok(orderId);

}

4.Redis分布式锁

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

  • 分布式锁的功能性:可重入性(也就是获取锁是阻塞还是非阻塞的)

  • 分布式锁的实现:

    • 注意点一: 获取锁:SEXNX key value,中间为避免服务宕机,还需要加上TTL,EXPIRE key seconds,但是服务宕机要是发生在这两条指令中间,那么还是会进入死锁,所以两条指令需要确保原子性,用命令 SEX key value EX seconds NX。对应的StringRedisTemplate方法是opsForValue().setIfAbsent(key, value, time, timeUnit)
    • 注意点二: 获取锁失败时的两种处理办法:一种是阻塞,获取锁失败时,线程进入阻塞状态,直至有线程释放锁;另一种是非阻塞,获取锁失败时,直接返回一个结果。这里采用非阻塞,成功返回true,失败返回false。
    • 注意点三: 线程并发安全问题:在线程一处理业务时,锁因为超时而被释放,线程2拿到锁,在执行业务的时候,线程1业务完成,释放了线程2的锁。线程3因此也拿到了锁。以此类推…解决办法,在业务完成时,获取锁的标识(value: 线程Id),是否与获得锁时的线程Id前后一致,一致,则表示业务没有超时,正常删除时;不一致,证明业务超时,锁已经被自动释放。
    • 注意点四: 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一样,一样则释放锁,不一样则不释放锁
    • 注意点五: 在获取锁时存入线程标识(可用UUID表示),为什么不使用当前线程Id,因为当前线程Id是一个自增的数字,每个JVM内部都有一个,当出现集群时,多个服务器意味着存在有多个线程Id自增的数字冲突的风险,有可能两个自增的数字会一样而导致误删锁。
public class SimpleRedisLock implements ILock {

    // key 前缀
    private static final String KEY_PREFIX = "lock:";

    // value 前缀
    private static final String THREAD_PREFIX = UUID.randomUUID().toString(true) +"-";

    // key 自定义参数前缀
    private final String name;

    private final StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadID = THREAD_PREFIX + Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + this.name,
                threadID, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

	@Override
    /**
     * 当前线程标识与 redis 里锁的线程标识对比,属于自己线程的锁则释放锁,不是则忽略
     * 存在原子性问题
     * 判断和删除锁时两条命令,可能判断成功后,准备删除锁,却在 JVM 里阻塞导致锁过期,阻塞结束后将会误删别的线程的锁
     * */
    public void unLock() {
        String threadID = THREAD_PREFIX + Thread.currentThread().getId();
        String redisThreadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + this.name);
        if (threadID.equals(redisThreadId)){
            //通过del删除锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
    
}
  • 分布式锁的原子性问题
    • 在上一个流程中判断锁标识是否是自己线程的时候,这个GC垃圾回收机制发生,阻塞了线程,当阻塞时间足够长时,锁就会超时自动释放,线程2因此获得锁,而当线程1删除锁时,就会发生误删删除了线程2的锁,产生并发线程安全问题。因此必须保证判断锁标识和释放锁必须保证原子性。
    • Lua脚本解决多条命令原子性问题
      用Lua语言编写脚本去调用Redis,在Lua脚本里面可以使判断锁标识和释放锁保证原子性,要么都执行,要么都不执行。

在IDEA中创建Lua脚本文件,选择File->setting->plugins->EmmyLua
Lua脚本语言:
参考网站:https://www.runoob.com/lua/lua-tutorial.html

--- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
--- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
    --- 一致,则删除锁
    return redis.call('DEL', KEYS[1])
end
--- 不一致,直接返回
return 0
// 负责执行 lua 脚本,ctrl+h 查看类的继承结构
private static final DefaultRedisScript<Long> DEFAULT_REDIS_SCRIPT;

// 初始化 lua 脚本类
static {
    DEFAULT_REDIS_SCRIPT = new DefaultRedisScript<>();
    DEFAULT_REDIS_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    DEFAULT_REDIS_SCRIPT.setResultType(Long.class);
}

@Override
/**
 * Lua 脚本释放锁
 * */
public void LuaUnLock() {
    // 调用 lua 脚本
    stringRedisTemplate.execute(DEFAULT_REDIS_SCRIPT,
                                Collections.singletonList(KEY_PREFIX + name),   // 快捷创建单个元素的集合List
                                THREAD_PREFIX + Thread.currentThread().getId());
}

5.Redission

  • 关于SETNX实现分布式锁存在下面的问题:

    • 不可重入:同一个线程无法多次获取同一把锁
    • 不可重试:获取锁只尝试一次就返回false,没有重试机制
    • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患(虽然上文已经用了一种Lua脚本来判断线程ID是否相同来解决)
    • 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主的锁数据,则会出现死锁
  • Redisson入门:

1.引入依赖

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

2.配置Redisson客户端

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.1.113:6379").setPassword("abc123456");
        return Redisson.create(config);
    }
}

3.使用Redisson的分布式锁

@Resource
private RedissonClient redissonClient;
// 使用 redisson
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 如果选择空参,也就是非阻塞队列,等待时间默认为-1就是不等待,释放时间默认是30s
boolean success = lock.tryLock();
if (!success){
    return Result.fail("不允许重复下单!");
}
try {
    //获取代理对象(事务)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createSeckillOrder(voucherId);
}finally {
    // 释放锁
    lock.unlock();
}

6.Redis优化秒杀

  • 测试秒杀业务的性能:提前在数据库创建好1000个用户,然后登陆1000个用户(将redis中用户的TTL设置为-1永久化),然后在JMeter中的头信息中authorization添加redis中各用户的token(${token})

  • 异步秒杀思路(采用阻塞队列)

    • 版本1:两个线程分别对MySQL数据进行查询
    • 版本2:因为Redis读写的效率高于MySQL,因此可以将判断秒杀库存和校检一人一单放入Redis中缓存,线程1做判断,然后线程2减库存和创建订单写入数据库。
  • 关于判断秒杀库存和校检一人一单在redis中采用的数据结构

    • 秒杀库存:两个值,一是优惠券Id,二是优惠券库存量,因此直接使用String即可
    • 校检一人一单:首先需要记录优惠券Id,然后是抢购了该优惠券的用户Id,因此使用Set数据结构,对比用Hash数据结构,票根订单id信息应该存入数据库,需要用时再查,减少Redis的额外消耗。
  • 关于秒杀库存和校检一人一单两个步骤保证原子性

    • 使用Lua脚本
    • 执行完Lua脚本后,保存优惠券Id、用户Id、订单Id到阻塞队列
  • 需求分析

1.新增秒杀券的同时,首先将优惠券、秒杀券存入数据库,也要将秒杀券库存信息存入Redis
修改 VoucherServiceImpl 代码,

@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);
    // 特价优惠券保存到 redis 中
    stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY + voucher.getId(),
                                            voucher.getStock().toString());
}

2.基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

--- 优化秒杀优惠券
--- 功能:判断库存是否充足;判断用户是否下单;扣减库存
---
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1.库存key
local stockKey = "seckill:stock:" .. voucherId
-- 2.2.订单key 形成购买了该优惠券的用户列表,Set数据类型
local orderKey = "seckill:order:" .. voucherId

-- 3.脚本业务
-- 3.1判断库存是否充足 get stockKey,因为取出来的数据是字符串,因此要将他转为数字
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1)then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(不存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.返回0
return 0

3.如果抢购成功,将优惠券id和用户id封装后存入阻塞队列

  • 创建阻塞队列BlockingQueue和线程池ExecutorService以及执行创建订单信息的实现了Rnunable的类
  • 因为从阻塞队列取出订单信息是在在秒杀类初始化后就可以开始执行,因此利用Spring的注解@PostConstruct来实现
  • 因为处理订单信息的线程是异步执行,因为不会影响秒杀业务,当秒杀业务添加了秒杀信息入阻塞队列后,处理线程就可以从阻塞队列取到订单信息然后进行创建订单,否则阻塞队列阻塞。
/** 优化秒杀,负责执行 lua 脚本,ctrl+h 查看类的继承结构 */
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
/** 初始化 lua 脚本类 */
static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

@Override
/**
 * 优化秒杀---判断能否生成订单
 * 技术:阻塞队列
 * */
public Result seckillVoucherUpgrate(long voucherId){
    // 获取特价券信息
    SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);

    // 1.判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
        return Result.fail("秒杀暂未开始!");
    }
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
        return Result.fail("秒杀已经结束!");
    }

    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");

    // 1.执行 lua 脚本判断能否下单
    int res = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId, userId, orderId).intValue();

    if (res != 0){
        // 2.不允许下单的两种情况
        return Result.fail(res ==  1? "库存不足!":"不允许重复下单!");
    }

    // 3.订单加入阻塞队列
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(orderId);    // 设置订单 id
    voucherOrder.setVoucherId(voucherId);   // 设置特价券 id
    voucherOrder.setUserId(userId);   // 设置当前用户 id
    orderTasks.add(voucherOrder);
    proxy = (IVoucherOrderService)AopContext.currentProxy();

    // 4.返回订单 id
    return Result.ok(orderId);
}

4.开启线程任务,不断从阻塞队列中获取信息,实现异步下单,

/** 优化秒杀---阻塞队列,成员变量 */
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);

/** 优化秒杀---异步处理线程池 */
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

// PostConstruct注解是使该方法在当前类初始化完后就执行
@PostConstruct
private void init(){
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
/**当初始化完毕后,就会去从阻塞队列中去拿信息 **/
private class VoucherOrderHandler implements Runnable  {

    @Override
    public void run() {
        while (true) {
            try {
                // 有订单信息就取出,没有则阻塞(当secKillVoucher(voucherId)执行完即可以取到)
                // 1.获取队列中的订单信息
                VoucherOrder voucherOrder = orderTasks.take();
                // 2.创建订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
                log.error("处理订单异常", e);
            }
        }
    }

    // 此处其实可以不加分布式锁
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1.获取用户
        Long userId = voucherOrder.getUserId();
        // 2.创建锁对象
        RLock redisLock = redissonClient.getLock("lock:order:" + userId);
        // 3.尝试获取锁
        boolean isLock = redisLock.tryLock();
        // 4.判断是否获得锁成功
        if (!isLock) {
            // 获取锁失败,直接返回失败或者重试
            log.error("不允许重复下单!");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            // 释放锁
            redisLock.unlock();
        }
    }
}

@Transactional
/**
 * 优化秒杀---创建订单
 * 说明:与未优化时创建订单的步骤基本一致,这里无需主动创建订单类
 * */
public void createVoucherOrder(VoucherOrder voucherOrder) {
    // 一用户一订单
    Long userId = UserHolder.getUser().getId();

    Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
    if (count > 0){
        log.error("用户已经购买过了");
        return ;
    }

    // 3.创建订单
    // 3.1 修改库存
    boolean isUpdateSuccess = iSeckillVoucherService.update()
            .setSql("stock = stock - 1")
            .gt("stock", 0)
            .eq("voucher_id", voucherOrder.getVoucherId())
            .update();
    if (!isUpdateSuccess){
        // 扣减失败
        log.error("库存不足");
        return ;
    }
    // 3.3 创建订单
    this.save(voucherOrder);

}

  • 阻塞队列异步秒杀虽然提升了秒杀业务的性能,但也存在一些问题:

    • 内存限制问题:我们使用的是JDK里面的阻塞队列,它使用的是JVM的内存,如果不加以限制,在高并发的环境下可能会导致堆栈溢出,也有可能当阻塞队列中空间已经存满了,后面的订单信息就存放不进去了

    • 数据安全问题:如果服务突然宕机,则订单信息有可能全部丢失,用户已经付款了,但是订单信息没有生成;又或者在执行中间发生了一些异常,导致订单生成没有完成,即使修复异常,但是业务也不会再执行了,相当于任务丢失,导致Redis数据库和MySQL数据库数据前后不一致

7.Redis消息队列实现异步秒杀

  • 认识消息队列

    • 消息队列与阻塞队列的不同:消息队列是在JVM以外的独立服务,不受JVM内存的限制;消息队列不仅仅是做存储,还需要确保数据的安全,对数据做持久化,不管服务重启还是宕机,数据都不会丢失。而且消息队列的数据传到"消费者"那里会进行数据确认。如果确认失败,消息队列会再次发送消息,直到确认成功。

    • 消息队列:MQ–RabbitMQ、SpringAMQP

    • 基于Redis的消息队列:
      在这里插入图片描述

  • 基于Stream消息队列实现异步秒杀

1.创建消费者组(使用XGROUP,最后加上MKSTREAM,若是队列不存在就创建,因为队列和消费者组两个都可以创建成功)

command:XGROUP CREATE stream.orders g1 0 MKSTREAM

2.修改之前的秒杀下单Lua脚本,在认定有抢够资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId

--- 优化秒杀优惠券
--- 功能:判断库存是否充足;判断用户是否下单;扣减库存
---

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]

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

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
public Result seckillVoucherUpgrate(long voucherId){
    // 获取特价券信息
    SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);

    // 1.判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
        return Result.fail("秒杀暂未开始!");
    }
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
        return Result.fail("秒杀已经结束!");
    }

    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");

    // 1.执行 lua 脚本判断能否下单
    int res = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId, userId, orderId).intValue();

    if (res != 0){
        // 2.不允许下单的两种情况
        return Result.fail(res ==  1? "库存不足!":"不允许重复下单!");
    }

//        // 3.订单加入阻塞队列
//        VoucherOrder voucherOrder = new VoucherOrder();
//        voucherOrder.setId(orderId);    // 设置订单 id
//        voucherOrder.setVoucherId(voucherId);   // 设置特价券 id
//        voucherOrder.setUserId(userId);   // 设置当前用户 id
//        orderTasks.add(voucherOrder);
//        proxy = (IVoucherOrderService)AopContext.currentProxy();

    // 4.返回订单 id
    return Result.ok(orderId);
}

3.项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单

/** 优化秒杀---异步处理线程池 */
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

/** PostConstruct注解是使该方法在当前类初始化完后就执行,因为当这个类初始化好了之后,异步处理线程池随时都是有可能要执行的 */
@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandleWithMq());
}

/** 当初始化完毕后,就会去从消息队列 steam 队列中去拿信息 */
private class VoucherOrderHandleWithMq implements Runnable {
    private final String queueName = "stream.orders";
    
    @Override
    public void run() {
        while (true){
            try {
                // 0.初始化stream
                initStream();

                // 1.获取队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );

                // 2.判断订单信息是否为空
                if (list == null || list.isEmpty()){
                    // 为 null,说明无消息,继续监听
                    continue;
                }

                // 3.解析数据
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);

                // 4.创建订单
                createVoucherOrder(voucherOrder);

                // 5.确认消息 XACK
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

            }catch (Exception e){
                log.error("处理订单异常", e);
                // 6.处理异常消息
                handlePendingList();
            }
        }
    }

    // 创建stream,消费者组
    public void initStream(){
        Boolean exists = stringRedisTemplate.hasKey(queueName);
        if (BooleanUtil.isFalse(exists)) {
            log.info("stream不存在,开始创建stream");
            // 不存在,需要创建
            stringRedisTemplate.opsForStream().createGroup(queueName, ReadOffset.latest(), "g1");
            log.info("stream和group创建完毕");
            return;
        }
        // stream存在,判断group是否存在
        StreamInfo.XInfoGroups groups = stringRedisTemplate.opsForStream().groups(queueName);
        if(groups.isEmpty()){
            log.info("group不存在,开始创建group");
            // group不存在,创建group
            stringRedisTemplate.opsForStream().createGroup(queueName, ReadOffset.latest(), "g1");
            log.info("group创建完毕");
        }
    }

    // 处理异常消息
    private void handlePendingList(){
        while (true){
            try {
                // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1),
                        StreamOffset.create(queueName, ReadOffset.from("0"))
                );

                // 2.判断订单信息是否为空
                if (list == null || list.isEmpty()){
                    // 为 null,说明没有异常消息,结束循环
                    break;
                }

                // 3.解析数据
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);

                // 4.创建订单
                createVoucherOrder(voucherOrder);

                // 5.确认消息 XACK
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

            }catch (Exception e){
                log.error("处理pendding订单异常", e);
                try{
                    Thread.sleep(20);
                }catch(Exception ee){
                    ee.printStackTrace();
                }
            }
        }
    }

}
黑马rabbitMQ优惠卷秒杀是指利用RabbitMQ消息队列来实现优惠卷秒杀活动。RabbitMQ是一个开源的消息队列中间件,它可以实现高效的消息传递和异步通信。在秒杀活动中,由于瞬间会有大量用户同时请求抢购,传统的同步处理方式无法满足高并发的需求,而使用RabbitMQ可以将请求异步化,提高系统的并发处理能力。 具体实现过程如下: 1. 创建一个消息队列:首先需要创建一个RabbitMQ消息队列,用于存储用户的秒杀请求。 2. 生成优惠卷:在秒杀活动开始前,需要提前生成一定数量的优惠卷,并将其存储在数据库中。 3. 用户抢购请求:用户在秒杀活动开始时,发送抢购请求到消息队列中。 4. 消费者处理请求:创建多个消费者来监听消息队列中的请求,并进行处理。当有新的请求进入队列时,消费者会从队列中获取请求,并进行相应的处理逻辑。 5. 校验优惠卷:消费者在处理请求时,会先校验用户是否有资格参与秒杀活动,并检查优惠卷的库存情况。 6. 分发优惠卷:如果用户符合条件并且优惠卷有库存,消费者会将优惠卷分发给用户,并更新数据库中的库存信息。 7. 返回结果:消费者处理完请求后,将处理结果返回给用户,告知用户是否成功抢购。 通过使用RabbitMQ消息队列,可以有效地解决高并发场景下的请求处理问题,提高系统的性能和稳定性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值