Redis - 优惠卷秒杀

场景分析

为了避免对数据库造成压力,我们在新增优惠卷的时候,可以将优惠卷的信息储存在Redis中,这样用户抢购的时候访问优惠卷信息,通过Redis读取信息。

抢购流程

业务分析

既然在新增优惠卷的时候,我们只需要id和库存信息,那么这个时候我们需要说选择Redis中Stringt数据类型,以id作为键,此时我们在进行抢劵的时候,通过id可以直接查询到对应的库存信息,进行判断。
此时我们会面临一下场景:

  1. 出现超卖问题
  2. 不能保证一人一单

超卖问题

锁结构选型:


读多写少的场景:

选择乐观锁。因为读操作不会加锁,可以提高系统的并发性能。写操作通过版本号或条件更新来避免冲突。
写操作频繁且并发量高的场景

选择悲观锁。悲观锁通过加锁机制可以有效避免并发冲突,保证数据的一致性和正确性。
性能和一致性权衡:

在性能和一致性之间需要权衡。如果一致性要求非常高,且系统能够接受较低的并发性能,选择悲观锁。如果系统需要高性能,并且能够接受偶尔的冲突重试,选择乐观锁。

由于这个优惠卷抢票属于,读多写少这种场景,那么我们选型的话选择乐观锁会比较合适。

数据库乐观锁解决超卖问题
超卖问题分析

假设我们现在库存store = 1 ,那么此时线程一 判断之后 store > 0 那么这个时候线程一开始进行库存缩减,但是此时库存还没开始在数据库中扣减,此时线程二开始查询,发现 store > 0,那么线程二也认定自己抢票成功,开始扣减库存,那么此时就会出现store 减少了两次,那么库存就变成了-1,假设在线程二查询之后,线程一库存扣减之前,线程三又进行了判断,那么此时库存就又变成了-2。这是超卖问题的一个场景。
乐观锁,版本号法:

版本号法:也就是说引入一个新的字段用来检测当前我查询到的version,和我修改时的version是否是同一个,这样的话保证不会出现超卖问题,但是同样,会出现大量失败情况导致错误率极高。不推荐使用
CAS方案:

比较我查询到的库存值跟我更新后的库存值判断。但是这种情况也会出现上述的那种限制,所以我们只要判断扣减前的库存是否大于0即可,那么这种情况效率高,且不会出现大幅度失败。这个主要还是依靠mysql自身的行级锁机制进行的。属于悲观锁的一种范畴。

MySQL 中的行级锁可以确保同一行的数据在同一时间只能被一个事务修改,从而避免了并发更新导致的数据不一致问题。在代码中,当多个线程同时尝试更新同一行数据时,MySQL 会自动对该行数据加锁,以确保只有一个线程能够成功执行更新操作,其他线程会被阻塞直到锁释放。

这种方案解决较上一种方案会好一点,但依旧不是好的解决方案,因为对于这种场景下,阻塞依旧是不可避免的。

java 复制代码
 boolean voucherId1 = iSeckillVoucherService.update()
         .setSql("stock = stock - 1")
         .eq("voucher_id", voucherId)
         .gt("stock", 0)
         .update();

一人一单问题

我们只需要用户下单之后,把用户订单信息,存入Redis中,只需要到时候读取这个信息判断有多少次下单即可。

java 复制代码
// 这个位置采用优惠卷id 和 用户 id作为键,储存对应的信息,避免抢其他票时出现误判
String keyUserOrder = "user:order:" + voucherId + user.getId();
Integer count = 0;
try{
    count  = Integer.valueOf(stringRedisTemplate.opsForValue().get(keyUserOrder));
    if (count >= 1) {
        return Result.fail("用户已购买");
    }
}catch (Exception e){
    log.error("第一次抢票");
}
// 这个是抢票成功时,将订单信息保存。值自增用来判断是否是第一次购买
Long orderCount = stringRedisTemplate.opsForValue().increment(keyUserOrder, 1);
新的实现思路

既然是抢劵,那我确实无法避免这个内容。我可以说实现一个优惠卷池,将这个内容存入Redis中,抢到的话就生成订单,这样的话也没有超卖问题。只不过还得解决一人一单。效率较上面这种会更好一点。

实现步骤
  • 初始化优惠券池:将每张优惠券的信息预先存入Redis。
  • 抢券逻辑:用户请求时,从Redis中获取一张优惠券。
  • 生成订单:抢到优惠券后,生成订单并将订单信息和优惠券信息关联起来。
  • 一人一单检查:确保同一用户不能重复抢券。
代码示例

以下是一个简单的代码实现示例:

初始化优惠券池:

java 复制代码
// 初始化优惠券池,将100张优惠券信息存入Redis
String voucherPoolKey = "voucher:pool";
for (int i = 1; i <= 100; i++) {
    // 假设优惠券信息是一个简单的字符串,这里可以是更复杂的对象
    stringRedisTemplate.opsForList().leftPush(voucherPoolKey, "voucher_" + i);
}

抢券逻辑

java 复制代码
public Result grabVoucher(Long voucherId, Long userId) {
    // Redis中的键
    String voucherPoolKey = "voucher:pool";
    String userOrderKey = "user:order:" + voucherId + ":" + userId;

    // 检查用户是否已经抢过
    if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(userOrderKey))) {
        return Result.fail("用户已购买");
    }

    // 从Redis中弹出一张优惠券
    String voucher = stringRedisTemplate.opsForList().rightPop(voucherPoolKey);
    if (voucher == null) {
        return Result.fail("优惠券已抢完");
    }

    // 抢券成功,生成订单
    // 这里假设订单生成成功,实际应用中需要加入订单生成逻辑
    String orderInfo = "order_for_" + voucher + "_by_user_" + userId;

    // 记录用户抢券信息
    stringRedisTemplate.opsForValue().set(userOrderKey, orderInfo);

    return Result.ok("抢券成功", orderInfo);
}

订单生成

实际的订单生成过程可能涉及复杂的业务逻辑和数据库操作,这里简化为成功后记录用户抢券信息。

一人一单的实现

在用户抢券前,检查用户是否已经抢过,通过Redis键的存在性判断。

java 复制代码
// 检查用户是否已经抢过
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(userOrderKey))) {
    return Result.fail("用户已购买");
}
优点
  • 高效处理并发:Redis的高吞吐量和低延迟使得这种方案在高并发场景下表现出色。
  • 避免超卖:优惠券从Redis中弹出后即减少,确保不会超卖。
  • 简单实现一人一单:通过Redis键值对存储和检查用户抢券信息,轻松实现一人一单的限制。
  • 简化订单生成:在抢券成功后,立即生成订单并将信息存储在Redis中,减少了数据库操作的复杂性。

或者我感觉这个并发场景比较大的情况下,使用RabbitMQ削峰填谷,做限流也可以。

相关推荐
夜泉_ly39 分钟前
MySQL -安装与初识
数据库·mysql
qq_529835352 小时前
对计算机中缓存的理解和使用Redis作为缓存
数据库·redis·缓存
月光水岸New4 小时前
Ubuntu 中建的mysql数据库使用Navicat for MySQL连接不上
数据库·mysql·ubuntu
狄加山6754 小时前
数据库基础1
数据库
我爱松子鱼4 小时前
mysql之规则优化器RBO
数据库·mysql
chengooooooo5 小时前
苍穹外卖day8 地址上传 用户下单 订单支付
java·服务器·数据库
Rverdoser6 小时前
【SQL】多表查询案例
数据库·sql
Galeoto6 小时前
how to export a table in sqlite, and import into another
数据库·sqlite
希忘auto6 小时前
详解Redis在Centos上的安装
redis·centos
人间打气筒(Ada)6 小时前
MySQL主从架构
服务器·数据库·mysql