优惠券秒杀

HeJin大约 10 分钟项目实战Redis项目实战

01.全局唯一ID

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

  • 唯一性。
  • 高可用。
  • 高性能。
  • 递增性。
  • 安全性。

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息。

image-20230206132835502
image-20230206132835502

ID的组成部分:

  • 符号位:1bit,永远为0。
  • 时间戳:31bit,以秒为单位,可以使用69年。
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID。

02.Redis实现全局唯一ID

RedisIdWorkeropen in new window

@Component
public class RedisIdWorker {

    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;

    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private final 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"));
        long count = stringRedisTemplate.opsForValue().increment(
                RedisConstants.ID_WORKER + keyPrefix + ":" + date);
        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }

}

测试:这里起了300个线程,每个线程生成100个ID,总共生成30000个ID。

@Resource
private RedisIdWorker redisIdWorker;

private ExecutorService es = Executors.newFixedThreadPool(500);

@Test
public void testIdWorker() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300);
    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            long id = redisIdWorker.nextId("order");
            log.debug("id: {}", id);
        }
        latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        es.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    log.info("耗时: {}", end - begin);
}

结果:

com.hmdp.HmDianPingApplicationTests      : id: 140429698114024747
com.hmdp.HmDianPingApplicationTests      : id: 140429698114024749
com.hmdp.HmDianPingApplicationTests      : id: 140429698114024748
com.hmdp.HmDianPingApplicationTests      : id: 140429698114024750
com.hmdp.HmDianPingApplicationTests      : id: 140429698114024752
com.hmdp.HmDianPingApplicationTests      : id: 140429698114024751
com.hmdp.HmDianPingApplicationTests      : 耗时: 2951
2023-01-14 10:18:57.477  INFO 2108 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'

Process finished with exit code 0

全局唯一ID生成策略:

  • UUID。
  • Redis自增。
  • snowflake算法。
  • 据库自增。

Redis自增ID策略:

  • 每天一个key,方便统计订单量。
  • ID构造是 时间戳 + 计数器。

03.添加优惠券

接口:http://localhost:8081/voucher/seckill

请求方式:POST

请求数据:

{
    "shopId": 1,
    "title": "100元代金券",
    "subTitle": "周一至周五均可使用",
    "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
    "payValue": 8000,
    "actualValue": 10000,
    "type": 1,
    "stock": 100,
    "beginTime": "2023-01-14T10:00:00",
    "endTime": "2023-01-14T23:00:00"
}

04.实现秒杀下单

需求

用户可以在店铺页面中抢购这些优惠券:

image-20230206133242904
image-20230206133242904

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单。
  • 库存是否充足,不足则无法下单。

实现

VoucherOrderController

/**
 * 秒杀下单
 * @param voucherId 优惠券ID
 * @return R
 */
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
    return voucherOrderService.seckillVoucher(voucherId);
}

VoucherOrderServiceImplopen in new window

@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
private RedisIdWorker redisIdWorker;

@Override
@Transactional(rollbackFor = Exception.class)
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");
    log.info("订单id: {}", orderId);
    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);
}

05.库存超卖问题

使用jmeter并发测试

image-20230201150649492
image-20230201150649492
image-20230201150625811
image-20230201150625811

查看聚合报告:

image-20230201150740045
image-20230201150740045

发现异常率不足50%,这显然不正确。因为我们库存设置的是100,模拟200个请求去抢优惠券,只会有100个能抢到。正常情况下,异常率为50%。

此时再查看数据库:

image-20230201151031527
image-20230201151031527

再查看订单表:

image-20230201151133471
image-20230201151133471

这里就出现了库存超卖的问题。本来只有100个优惠券,现在出现了109条订单,出现了数据安全问题,必须解决

超卖问题分析

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

悲观锁

认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。

乐观锁

认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法。
  • CAS法。

超卖这样的线程安全问题,解决方案有哪些?

  • 悲观锁:添加同步锁,让线程串行执行。

    • 优点:简单粗暴。
    • 缺点:性能一般。
  • 乐观锁:不加锁,在更新时判断是否有其它线程在修改。

    • 优点:性能好
    • 缺点:存在成功率低的问题。

06.乐观锁解决超卖

@Override
@Transactional(rollbackFor = Exception.class)
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)
            // 乐观锁解决库存超卖
            .eq("stock", voucher.getStock())
            .update();
    if (!success){
        return Result.fail("库存不足!");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1 订单id
    long orderId = redisIdWorker.nextId("order");
    log.info("订单id: {}", orderId);
    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);
}

jmeter进行测试:

image-20230201152634360
image-20230201152634360
image-20230201152657903
image-20230201152657903

查看测试结果:

image-20230201153000249
image-20230201153000249
image-20230201152833544
image-20230201152833544

正常失败率应该是50%。

查看数据库:

image-20230201153034979
image-20230201153034979

卖出了41件,查看订单表:

image-20230201153136447
image-20230201153136447

也有41个订单,说明没有超卖。但是库存没有卖完,这也必须要解决。

需要对乐观锁的方案进行改进。只要判断当前线程减库存的时候,库存大于0就可以了。

@Override
@Transactional(rollbackFor = Exception.class)
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", voucher.getVoucherId())
            // 乐观锁解决库存超卖: 当前库存 > 0
            .gt("stock", 0)
            .update();
    if (!success){
        return Result.fail("库存不足!");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1 订单id
    long orderId = redisIdWorker.nextId("order");
    log.info("订单id: {}", orderId);
    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);
}

再次使用jmeter进行测试:

image-20230201153859156
image-20230201153859156

发现异常率等于50%,正常。

查看库存表:

image-20230201153945856
image-20230201153945856

查看订单表:

image-20230201154023340
image-20230201154023340

也是100个订单。这样就完美解决了库存超卖问题。

07.实现一人一单功能

基本实现

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。

@Override
@Transactional(rollbackFor = Exception.class)
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.一人一单
    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", voucher.getVoucherId())
            .gt("stock", 0)
            .update();
    if (!success){
        return Result.fail("库存不足!");
    }
    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1 订单id
    long orderId = redisIdWorker.nextId("order");
    log.info("订单id: {}", orderId);
    voucherOrder.setId(orderId);
    // 7.2 用户id
    voucherOrder.setUserId(userId);
    // 7.3 代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

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

使用jmeter进行测试:

image-20230201155629372
image-20230201155629372

查看库存表:

image-20230201155705059
image-20230201155705059

查看订单表:

image-20230201155734086
image-20230201155734086

发现同一个用户下了10单。不符合需求。

加锁完善

VoucherOrderServiceImplopen in new window

@Override
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("库存不足!");
    }

    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        // 获取代理对象(事务)
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }
}

@Override
@Transactional(rollbackFor = Exception.class)
public Result createVoucherOrder(Long voucherId) {
    // 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();
    if (!success){
        return Result.fail("库存不足!");
    }
    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1 订单id
    long orderId = redisIdWorker.nextId("order");
    log.info("订单id: {}", orderId);
    voucherOrder.setId(orderId);
    // 7.2 用户id
    voucherOrder.setUserId(userId);
    // 7.3 代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

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

因为这里需要获取代理对象(事务生效),所以需要引入aspectj依赖:

<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);
    }

}

jmeter测试结果:

image-20230201161905050
image-20230201161905050

查看库存表:

image-20230201161938023
image-20230201161938023

查看订单表:

image-20230201162007984
image-20230201162007984

到这里就实现了单机环境下一人一单的功能。

08.一人一单的并发安全问题

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

  • 加锁的时候,JVM有一个锁监视器来监视锁的获取。在单机环境下,是可以解决并发问题的。
  • 集群模式下,不同的机器有不同的JVM锁监视器,常规的加锁手段就没有用了。考虑分布式锁。

09.分布式锁

什么是分布式锁

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

分布式锁的实现

分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQLRedisZookeeper
互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

基于Redis的分布式锁

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁。

    • 非阻塞:尝试一次,成功返回true,失败返回false。

      # 添加锁,NX是互斥、EX是设置超时时间
      SET lock thread1 NX EX 10
      
  • 释放锁:

    • 手动释放。

      # 释放锁,删除即可
      DEL key
      
    • 超时释放:获取锁时添加一个超时时间。