day05-优惠券秒杀01

科技资讯 投稿 7600 0 评论

day05-优惠券秒杀01

功能03-优惠券秒杀01

4.功能03-优惠券秒杀

4.1全局唯一ID

4.1.1全局ID生成器

当用户抢购时,就会生成订单,并保存到tb_voucher_order这张表中。订单表如果使用数据库的自增id就存在一些问题:

    id的规律性太明显:用户可以根据id猜测一些信息,从而非法得到数据
  1. 受单表数据量的限制:由于单张表的数据限制,需要进行分表,而如果每张表都采取自增长,容易出现id重复,会影响订单之后的业务,比如说售后服务(因为售后服务一般是根据订单id来进行的)

解决方案:使用全局ID生成器。

分布式系统下用来生成全局唯一ID的工具(也称为分布式唯一ID),一般要满足下列特性:

  • 高可用

  • 递增性

(2)全局唯一ID生成策略:

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

    redis是独立于数据库之外的,它只有一个,当所有人都来访问redis时,它的自增一定是唯一的(唯一性)

  1. redis具有高性能(高性能)

  2. Redis Incr 命令将 key 中储存的数字值增一

  3. 为了增加id的安全性,我们不会直接使用自增redis自增的id,而是拼接一些其他信息:(安全性)

      符号位:1bit,永远为0

  4. 序列号:32bit,秒内的计数器,这样可以支持每秒产生2^32个不同的ID

4.2Redis实现全局唯一ID

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * @author 李
 * @version 1.0
 */
@Component
public class RedisIdWorker {
    //开始时间戳(1970-01-01T00:00:00到2022-01-01T00:00:00的秒数
    private static final long BEGIN_TIMESTAMP = 1640995200L;

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

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    //public static void main(String[] args {
    //    //开始时间
    //    LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0;
    //    //得到1970-01-01T00:00:00Z.到指定时间为止的具体秒数
    //    long second = time.toEpochSecond(ZoneOffset.UTC;
    //    System.out.println(second;//1640995200L
    //}

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

        //2.生成序列号(keyPrefix代表业务前缀
        /*
         * Redis的 Incr命令将 key 中储存的数字值增1,如果key不存在,那么key的值会先被初始化为0,然后再执行INCR操作。
         * 根据这个特性,我们每一天拼接不同的日期,当做key。也就是说同一天下单采用相同的key,不同天下单采用不同的key
         * 这种方法不仅可以防止订单号使用完(redis的的自增最多可以有2^64位,我们采取其中32位作计数器),
         * 还可以根据不同的日期,统计该天的订单数量
         */
        //2.1获取当前的日期(精确到天)
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd";
        //2.2做自增长
        Long count = stringRedisTemplate.opsForValue(.increment("icr:" + keyPrefix + ":" + date;

        //3.拼接并返回
        //将时间戳左移32位,空出来的右边32位使用count填充,共64位
        return timeStamp << COUNT_BITS | count;
    }
}

(2)测试类(部分代码)

@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500;

@Test
public void testIdWorker( throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300;
    //线程,生成100个id
    Runnable task = ( -> {
        for (int i = 0; i < 100; i++ {
            long id = redisIdWorker.nextId("order";
            System.out.println("id" + id;
        }
        latch.countDown(;
    };
    long start = System.currentTimeMillis(;
    //共执行300次任务
    for (int i = 0; i < 300; i++ {
        es.submit(task;
    }
    //让所有线程执行完才计时
    latch.await(;
    long end = System.currentTimeMillis(;
    System.out.println("共用时=" + (end - start;
}

关于countdownlatch

await 方法是阻塞方法,使用await可以让main线程阻塞,当CountDownLatch 内部维护的变量变为0时,就不再阻塞,直接放行。那么什么时候CountDownLatch 维护的变量变为0 呢?我们只需要调用一次countDown,内部变量就减少1。

测试结果:

4.2.1总结

全局唯一ID生成策略:

    UUID
  • Redis自增
  • snowflake算法
  • 数据库自增(使用一张表来单独记录id)
    每天一个key,方便统计订单量
  • ID结构:时间戳+计数器

4.2实现优惠券秒杀下单

4.2.1需求分析&业务流程

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

    tb_voucher:(优惠券表)优惠券的基本信息、优惠金额、使用规则等(包括平价券和秒杀券)

要求在店铺详情中实现下单购买秒杀券:

    秒杀是否开始或者结束,如果尚未开始或者已经结束则无法下单
  1. 秒杀券的库存是否充足,不足则无法下单

优惠券订单表结构:

业务流程分析:

4.2.2代码实现

package com.hmdp.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 优惠券订单实体
 * 
 * @author 李
 * @version 1.0
 */
@Data
@EqualsAndHashCode(callSuper = false
@Accessors(chain = true
@TableName("tb_voucher_order"
public class VoucherOrder implements Serializable {
    private static final long serialVersionUID = 1L;
    //主键
    @TableId(value = "id", type = IdType.INPUT
    private Long id;
    //下单的用户id
    private Long userId;
    //购买的代金券id
    private Long voucherId;
    //支付方式 1:余额支付;2:支付宝;3:微信
    private Integer payType;
    //订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款
    private Integer status;
    //下单时间
    private LocalDateTime createTime;
    //支付时间
    private LocalDateTime payTime;
    //核销时间
    private LocalDateTime useTime;
    //退款时间
    private LocalDateTime refundTime;
    //更新时间
    private LocalDateTime updateTime;
}

(2)mapper接口

package com.hmdp.mapper;

import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 *  Mapper 接口
 *
 * @author 李
 * @version 1.0
 */
public interface VoucherOrderMapper extends BaseMapper<VoucherOrder> {

}

(3)IVoucherOrderService 服务类

package com.hmdp.service;

import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 *  服务类
 *
 * @author 李
 * @version 1.0
 */
public interface IVoucherOrderService extends IService<VoucherOrder> {

    Result seckillVoucher(Long voucherId;
}

(4)VoucherOrderServiceImpl 服务实现类

package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
 * 服务实现类
 *
 * @author 李
 * @version 1.0
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId {
        //根据id查询优惠券信息
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId;
        if (voucher == null {
            return Result.fail("该优惠券不存在,请刷新!";
        }
        //判断秒杀券是否在有效时间内
        //若不在有效期,则返回异常结果
        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("秒杀券库存不足!";
        }
        //库存充足,则扣减库存(操作秒杀券表)
        boolean success = seckillVoucherService.update(.setSql("stock = stock -1".eq("voucher_id", voucherId.update(;
        if (!success {//操作失败
            return Result.fail("秒杀券库存不足!";
        }
        //扣减库存成功,则创建订单,返回订单id
        VoucherOrder voucherOrder = new VoucherOrder(;
        //设置订单id
        long orderId = redisIdWorker.nextId("order";
        voucherOrder.setId(orderId;
        //设置用户id
        Long userId = UserHolder.getUser(.getId(;
        voucherOrder.setUserId(userId;
        //设置代金券id
        voucherOrder.setVoucherId(voucherId;

        //将订单写入数据库(操作优惠券订单表)
        this.save(voucherOrder;

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

(5)控制器 VoucherOrderController

package com.hmdp.controller;


import com.hmdp.dto.Result;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.service.IVoucherService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * 秒杀券前端控制器
 *
 * @author 李
 * @version 1.0
 */
@RestController
@RequestMapping("/voucher-order"
public class VoucherOrderController {
    @Resource
    private IVoucherOrderService voucherOrderService;

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

(6)测试,在前端页面点击购买,显示抢购成功,订单号如下:

对应的秒杀券的库存减一:

4.3超卖问题

4.3.1问题分析

并发的问题:当有多个用户同时对一个秒杀券进行抢购,并发会让系统出现超卖问题:即卖出的秒杀券数量>实际的秒杀券库存

运行上述设置,测试结果如下:

  1. 订单表中,对应的数量为104单,但是对应的秒杀券的库存最多只有100张。也就是说:出现了超卖问题

出现超卖问题的原因:

4.3.2解决方案

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

常见的方式有两种:

表中设置一个版本号字段,线程在修改表之前,先查询一次版本号。对数据库表操作时,再查询一次版本号,如果值和之前的一致,说明此时表的数据在两次查询之间没有被修改过,我们就可以进行业务操作,并设置新的版本号。

update语句会对当前修改的行进行锁定操作(数据库有行级锁,不用担心一行记录被同时修改)。

因此,进行表修改时,由于数据库行锁,其他线程会等待数据修改后再更新库存

sql执行是交给数据库的,如果开启了事务的话,就是两个事务的并发问题,此时将会启动两阶段封锁协议,保证事务并发安全

这里为了简化,使用库存代替版本号,原理和方案1是一致的:线程在修改表之前,先查询一次库存的值。对数据库表操作时,再查询一次库存值,如果值和之前的一致,说明此时表的数据在两次查询之间没有被修改过,我们就可以进行业务操作。

CAS 有三个操作数:内存值 V、预期值 A、要修改的值 B。CAS 最核心的思路就是,仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B。

为了简便,这里使用方案2,但实际的业务还是建议使用版本法来避免其他问题。

4.3.3代码实现

(2)测试:

还原tb_seckill_voucher表的测试数据:

测试结果:

原因分析:这是因为,当有一个线程去修改数据时,其他很多的线程也来同时请求,它们都根据第一次查询的stock值去判断,发现stock值变化了,因此当第一个线程修改数据后,都没有去对数据进行操作),导致发生了库存充足,仍然抢不到券的情况(抢券失败率偏高)。

分析:线程A获取stock值,通过业务判断,然后去对库存值进行update操作;因为update语句会对当前修改的行进行锁定操作,因此,进行表修改时,由于数据库行锁,其他线程会等待数据修改后再更新库存。当等待后获取锁,将where stock > 0作为update条件,这时,只要stock不小于0就仍可以售券。

再次对其测试:可以看到200个线程并发,100张秒杀券全部售完。并且没有出现超卖现象,同时解决了库存充足却抢不到券的问题。

4.3.4总结

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

    悲观锁:添加同步锁,让线程串行执行
    优点:简答粗暴
  • 缺点:性能一般
  • 乐观锁:不加锁,在更新时判断是否有其他线程在修改
      优点:性能好
  • 缺点:成功率低
  • 4.4一人一单

    4.5分布式锁

    4.6Redis优化秒杀

    4.7Redis消息队列实现异步秒杀

    编程笔记 » day05-优惠券秒杀01

    赞同 (35) or 分享 (0)
    游客 发表我的评论   换个身份
    取消评论

    表情
    (0)个小伙伴在吐槽