Skip to content

秒杀 ​

1. 背景介绍 ​

所谓“秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。

秒杀商品通常有三种限制:库存限制、时间限制、购买量限制。

  • 库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是200件,但只拿出50件来参与秒杀。我们可以将其称为“秒杀库存”。商家赔本赚吆喝,图的是人气与流量
  • 时间限制:通常秒杀都是有特定的时间段,只能在设定时间段进行秒杀活动
  • 购买量限制:同一个商品只允许用户最多购买几件。比如某手机限购1件。张某第一次买个1件,那么在该次秒杀活动中就不能再次抢购

2. 数据库设计 ​

sql
CREATE TABLE `seckill_goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `spu_id` bigint(20) DEFAULT NULL COMMENT 'spu_id',
  `sku_id` bigint(20) DEFAULT NULL COMMENT 'sku_id',
  `sku_name` varchar(100) DEFAULT NULL COMMENT '标题',
  `sku_default_img` varchar(150) DEFAULT NULL COMMENT '商品图片',
  `price` decimal(10,2) DEFAULT NULL COMMENT '原价格',
  `cost_price` decimal(10,2) DEFAULT NULL COMMENT '秒杀价格',
  `check_time` datetime DEFAULT NULL COMMENT '审核日期',
  `status` varchar(20) DEFAULT NULL COMMENT '审核状态',
  `start_time` datetime DEFAULT NULL COMMENT '开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
  `num` int(11) DEFAULT NULL COMMENT '已售秒杀商品数',
  `stock_count` int(11) DEFAULT NULL COMMENT '剩余库存数',
  `sku_desc` varchar(2000) DEFAULT NULL COMMENT '描述',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `is_deleted` tinyint(3) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8;

3. 功能分析 ​

当前项目秒杀业务流程:

  • 后台运营人员通过管理系统录入秒杀商品,主要包括商品标题、原价、秒杀价、图片、库存等;提交秒杀活动申请

  • 后台管理人员通过管理系统审核,审核通过表示可以参与秒杀;审核不通过表示不能秒杀

  • 秒杀频道首页列出当天的秒杀商品,用户点击秒杀图片跳转到秒杀商品详情页

  • 商品详细页显示秒杀商品信息,点击立即抢购进入秒杀,抢购成功时预减库存

  • 秒杀成功,进入下单页填写收货地址、电话、收件人等信息,完成下订单,然后跳转到支付页面,支付成功,跳转到成功页,完成秒杀

  • 当用户秒杀下单30分钟内未支付,取消订单,调用微信支付或支付宝的关闭订单接口。

说明:

  • 当前项目中的秒杀活动,我们默认为每天早上8:00开始,晚上8:00结束
  • 取消订单的业务因为之前做过了,在秒杀业务中我们不做,主要是考虑前面抢购下单的部分

3.1 秒杀列表页 ​

image-20230328101327281

业务分析:查询秒杀表中"当前正在秒杀"的商品

sql
# 如何确认当前正在参与秒杀呢?
# 1. 审核通过:status = CHECKED_PASS
# 2. 今天开始:DATE_FORMAT(start_time,'%Y-%m-%d') = new Date()
# 3. 库存大于0:stock_count>0

3.2 秒杀商品详情页 ​

image-20230327175517363

业务分析:根据商品Id查询秒杀商品详情。

3.3 抢购排队页 ​

下面是用户点击《立即抢购》可能会出现的四种提示。

image-20230327174154190

3.4 秒杀商品确认页 ​

抢购成功,准备去下单,这个页面就是商品确认页。

image-20230327180015193

image-20230327180027532

4. 功能实现思路 ​

4.1 秒杀商品列表和详情 ​

如果不考虑缓存,那么每次都需要直接访问数据库。而秒杀列表页和秒杀商品详情页的点击量一般我们认为比较大,那么势必会给数据库带来比较大的压力,所以在此考虑使用缓存预热的方案。

缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据放入缓存

缓存预热方案落地:

  • 什么时候存入Redis?凌晨放入当天所有的秒杀商品数据
  • 如何实现?Spring Scheduling 定时任务

4.2 库存状态位控制访问请求 ​

状态位控制访问请求,何为状态位?就是我们在内存中保存一个状态,当抢购开始时状态为1,可以抢购,当库存为0时,状态位0,不能抢购;状态位的好处,他是在内存中判断,压力很小,可以阻止很多不必要的请求

4.3 秒杀排队实现方式 ​

用户提交秒杀请求,将秒杀商品与用户id关联发送给消息队列,然后返回。通过消息队列排队抢购秒杀资格。秒杀页面通过轮询接口查看是否秒杀成功

我们秒杀只是为了获取一个秒杀资格,获取秒杀资格就可以到下单页下订单,后续业务与正常订单一样

4.4 库存超卖问题 ​

如何防止库存超卖呢?把商品库存存入队列,利用Redis队列的原子性,保证库存不超卖。

image-20230328112312537

5. 接口功能实现 ​

5.1 定时任务-缓存预热 ​

定时任务如何实现 ​

在程序中常常有定时任务的需求,例如每隔一周生成一次报表、每个月月末清空用户积分等等。Spring也提供了相应的支持,我们可以非常方便的按时执行任务。

官方参考文档地址

如何实现呢?

  1. 开启定时任务功能 @EnableScheduling
  2. 添加任务 @Scheduled(cron="")
java
@Component
@EnableScheduling
public class ScheduledTask {

	@Scheduled(cron = "0 0/1 * * * ?")   // 每分钟执行一次
    public void importIntoRedisTask(){
    	
        // TODO something...
    
    }
}

cron表达式 ​

java
 ┌───────────── second (0-59)
 │ ┌───────────── minute (0 - 59)
 │ │ ┌───────────── hour (0 - 23)
 │ │ │ ┌───────────── day of the month (1 - 31)
 │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC)
 │ │ │ │ │ ┌───────────── day of the week (0 - 7)
 │ │ │ │ │ │          (0 or 7 is Sunday, or MON-SUN)
 │ │ │ │ │ │
 * * * * * *
bash
# 占位符解释
# ?因为week和day冲突,其中一个必须是?

# *: 表示每秒钟都触发
* * * * * ? : 表示每秒触发一次

# ,: 表示在指定的秒触发
15,45 * * * * ? : 表示每分钟的15秒,45秒都触发一次


# -:表示在指定的范围内每秒都触发
20-30 * * * * * : 表示在每分钟的20秒-30秒内,每秒触发一次

# /: 表示步进,也就是增量。 
15/20 * * * * * : 表示从每分钟的15秒开始,每20秒触发一次,即 15s,35s,55s各触发一次

注意事项:

  1. cron表达式可以是七位,最后一位是年,可以省略
  2. 天和星期会冲突,当表达式其中之一被指定了值以后,为了避免冲突,需要将另一个子表达式的值设为“?”
bash
# 常用的cron表达式
0/2 * * * * ?   表示每2秒 执行任务

0 0/2 * * * ?    表示每2分钟 执行任务

0 0 2 1 * ?   表示在每月的1日的凌晨2点调整任务

0 0 12 * * ?   每天中午12点触发 

0 0 10,14,16 * * ?   每天上午10点,下午2点,4点 

0 0 12 ? * WED    表示每个星期三中午12点

在线cron生成器

缓存预热业务逻辑 ​

java
@Component
@EnableScheduling
public class ScheduledTask {

	@Scheduled(cron = "0 0 1 * * ?")   // 每天凌晨一点执行
    public void importIntoRedisTask(){
    	
        // 1. 从数据库查询今天的秒杀商品数据
        // where status = CHECKED_PASS AND stock_count > 0 AND DATE_FORMAT(start_time,'%Y-%m-%d') = ?
        
        // 2. 将查询出来的商品列表放入缓存 (hash)
        // key:	PROMO_SECKILL_GOODS	 
        // field: skuId		
        // value: seckillGoods
        
        // 3. 把商品剩余库存放入缓存(list),详情见4.3
        // key: PROMO_SECKILL_GOODS_STOCK_PREFIX +skuId
        // value: skuId
        
        // 4. 初始化状态位 
        // eg: 50:1 表示skuId为50的商品还有库存
        // eg: 49:0 表示skuId为49的商品没有库存
        LocalCacheHelper.put(skuId,"1");
    }
}

5.2 秒杀商品列表 ​

因为有秒杀缓存预热的存在,所以秒杀商品列表直接从Redis中获取。

java
@GetMapping("/seckill")
public Result findAll() {
    
    // 查询Redis中所有的缓存商品
    return Result.ok(promoService.findAll());
}

5.3 秒杀商品详情 ​

java
@GetMapping("seckill/{skuId}")
public Result getSeckillGoods(@PathVariable("skuId") Long skuId) {
    
    // 获取秒杀商品数据详情
    return Result.ok(promoService.getSeckillGoodsDTO(skuId));
    
}

5.4 秒杀下单排队 ​

js
queue () {
      // 选获取秒杀下单码
      const res = await getSeckillSkuIdStr(this.goodsData.skuId)
      console.log(res)
      // 跳转秒杀排队页面
      this.$router.push({
        name: 'queue',
        query: {
          skuId: this.goodsData.skuId,
          skuIdStr: res.data
        }
      })
    }
js
// 在排队页面中
// html加载完成之前,执行
 created () {
      // 发起秒杀下单排队请求
      this.saveOrder()
  }

// html加载完成后执行
mounted() {
    const timer = setInterval(() => {
        // 如果返回结果不是排队的话,就删掉定时任务
        if(this.code != 211) {
            clearInterval(timer);
        }
        // 检查订单状态
        this.checkOrder()
    // 3000的意思是每3秒发送一次 
    }, 3000);
    // 通过$once来监听定时器,在beforeDestroy钩子可以被清除。
    this.$once('hook:beforeDestroy', () => {
        clearInterval(timer);
    })
},

获取下单码 ​

下单码的目的主要是秒杀下单的时候校验用户请求,防止非法请求参与秒杀下单

java
@GetMapping("seckill/auth/getSeckillSkuIdStr/{skuId}")
public Result getSeckillSkuIdStr(@PathVariable("skuId") Long skuId, HttpServletRequest request) {
    
    // 获取用户id
    String userId = AuthContext.getUserId(request);
    
    // 获取秒杀商品信息
    SeckillGoodsDTO seckillGoods = promoService.getSeckillGoodsDTO(skuId);
    if (null != seckillGoods) {
        Date curTime = new Date();
        
        // 判断当前时间是否在秒杀商品开始时间和结束时间之内
        if (DateUtil.dateCompare(seckillGoods.getStartTime(), curTime) && DateUtil.dateCompare(curTime, seckillGoods.getEndTime())) {
            // 生成下单码
            // 可以用其他方式生成
            String skuIdStr = MD5.encrypt(userId);
            return Result.ok(skuIdStr);
        }
    }
    return Result.fail().message("获取下单码失败");
}

秒杀下单排队 ​

发起用户下单请求,进行排队

java
@PostMapping("/seckill/auth/seckillOrder/{skuId}")
public Result seckillOrder(@PathVariable("skuId") Long skuId,String skuIdStr, HttpServletRequest request) throws Exception {
    //1. 校验下单码(下单码生成规则可以自定义)
    

    // 2. 校验状态位


    // 3. 将秒杀用户加入队列
    UserRecorde userRecode = new UserRecorde();
    userRecode.setUserId(userId);
    userRecode.setSkuId(skuId);

    // 发送消息到秒杀队列
    baseProducer.sendMessage(MqTopicConst.SECKILL_GOODS_QUEUE_TOPIC,userRecode);

    return Result.ok();
}

5.5 秒杀下单消费 ​

在5.4中的秒杀下单,只是让用户去通过RocketMQ的消息队列进行排队,排队抢购秒杀资格。

在消息消费者这边,需要真正的进行抢购。

java
// 消费消费者 消费逻辑如下
// 1. 订阅主题 MqTopicConst.SECKILL_GOODS_QUEUE_TOPIC

// 2. 开始消费

![5. 秒杀消息消费逻辑](秒杀-pic/5. 秒杀消息消费逻辑.png)

java
public void seckillOrder(String userId, Long skuId) {

    // 1. 判断状态位  LocalCacheHelper.get(skuId.toString()

    // 2. 判断用户是否已经下过单  RedisConst.PROMO_USER_ORDERED_FLAG + userId
    // 如果用户已经下过单,那么直接返回

    // 3. 获取队列中的商品,如果能够获取,那么有库存,可以下单
    // RedisConst.PROMO_SECKILL_GOODS_STOCK_PREFIX + skuId
    if (deque != null) {
        
        // 减少库存
        if (obj == null) {
                // 如果获取的商品为空,更新本地的库存标志位
                LocalCacheHelper.put(skuId.toString(), "0");
                return;
            }

        // 4. 将订单记录放入Redis缓存,说明用户已经获得下单资格,秒杀成功
        // 生成订单记录
        OrderRecode orderRecode = new OrderRecode();
       
        // 赋值
        
        //生成订单码
        orderRecode.setOrderStr(MD5.encrypt(userId + skuId));

        //订单数据存入Redis  
        // key: RedisConst.PROMO_SECKILL_ORDERS  
        // field:userId  
        // value:orderRecode

        // 5. 秒杀成功就更新库存  注意加锁
    }
}

5.6 检查秒杀下单结果 ​

返回状态码分析 ​

前端代码 排队页面#checkOrder

js
if(response.data.code == 211) {
    this.show = 1
} else {
    //秒杀成功
    if(response.data.code == 215) {
        // 抢购成功,去下单
        this.show = 3
        this.message = response.data.message
    } else {
        // 抢购成功,我的订单
        if(response.data.code == 218) {
            this.show = 4
            this.message = response.data.message
        } else {
            // 抢购失败
            this.show = 2
            this.message = response.data.message
        }
    }
}
html
  <div class="cart py-container" id="item">
    <div class="seckill_dev" v-if="show === 1">
      排队中...
    </div>
    <div class="seckill_dev" v-if="show === 2">
      {{message}}
    </div>
    <div class="seckill_dev" v-if="show === 3">
      抢购成功&nbsp;&nbsp;

      <a  href="javascript:;" @click="jump('seckillTrade')">去下单</a>
    </div>
    <div class="seckill_dev" v-if="show === 4">
      抢购成功&nbsp;&nbsp;
      <a href="javascript:;" @click="jump('myOrder')">我的订单</a>
    </div>

由前端的逻辑可知:

image-20230328225511697

image-20230328225649041

image-20230328225804247

image-20230328225950303

java
public enum ResultCodeEnum implements CodeEnum {

    SECKILL_RUN(211, "正在排队中"),
    SECKILL_FINISH(213, "已售罄"),
    SECKILL_SUCCESS(215, "抢单成功"),
    SECKILL_FAIL(216, "抢单失败"),
    SECKILL_ILLEGAL(217, "请求不合法"),
    SECKILL_ORDER_SUCCESS(218, "下单成功"),
    ;
}

判断逻辑分析 &#8203;

![7. 检查秒杀下单结果逻辑](秒杀-pic/7. 检查秒杀下单结果逻辑.png)

因为目前还没有真正的生成秒杀订单,只是把抢购到的订单记录存在了Redis。在做了5.8接口实现后才会真正在数据库生成秒杀订单,所以在此处暂不考虑状态码=218 秒杀下单成功的情况。

代码实现 &#8203;

java
 @GetMapping("/seckill/auth/checkOrder/{skuId}")
public Result checkOrder(Long skuId, String userId) {

    // 1. 判断用户是否已经下过单
    RBucket<Long> bucket = redissonClient.getBucket(RedisConst.PROMO_USER_ORDERED_FLAG + userId);
    Long skuIdInRedis = bucket.get();
    if (skuIdInRedis != null) {
        //2. 判断Redis中是否已经存在用户的订单
        boolean isHasKey = redissonClient.getMap(RedisConst.PROMO_SECKILL_ORDERS).containsKey(userId);
        if (isHasKey) {
            //抢单成功
            OrderRecode orderRecode = (OrderRecode) redissonClient.getMap(RedisConst.PROMO_SECKILL_ORDERS).get(userId);
            // 秒杀成功!
            return Result.build(orderRecode, ResultCodeEnum.SECKILL_SUCCESS);
        }
    }

    // 3. 判断库存状态位
    String state = (String) LocalCacheHelper.get(skuId.toString());
    if("0".equals(state)) {
        //已售罄 抢单失败
        return Result.build(null, ResultCodeEnum.SECKILL_FAIL);
    }

    //正在排队中
    return Result.build(null, ResultCodeEnum.SECKILL_RUN);
}

5.7 秒杀商品确认页 &#8203;

当抢单成功之后,前面轮训页面显示,抢购成功,去下单。和订单服务的下单商品确认页逻辑类似

image-20230329001136973

点击去下单,进入秒杀商品确认页面。

image-20230329001116671

业务逻辑分析:

获取秒杀确认页面详情。

包括:

  • 收件人信息
  • 商品列表
java
@GetMapping("/seckill/auth/trade")
public Result trade(HttpServletRequest request) {
    // 1. 获取到用户Id

    
    // 2. 从Redis中 获取用户下单记录 OrderRecode,然后获取秒杀商品详情 SeckillGoods
	// RedisConst.PROMO_SECKILL_ORDERS

    // 3. 远程调用用户服务 获取用户收获地址
    

    // 4. 构建OrderTradeDTO  包括:detailArrayList、userAddressList、totalAmount、totalNum

    // 5. 返回
    return Result.ok(orderTradeDTO);
}

5.8 秒杀下单生成订单 &#8203;

step1 &#8203;

java
@PostMapping("/seckill/auth/submitOrder")
public Result submitOrder(@RequestBody OrderInfoParam orderInfo, HttpServletRequest request) {
    // 获取用户Id
    String userId = AuthContext.getUserId(request);
    orderInfo.setUserId(Long.parseLong(userId));
    
    RMap<String, Long> map = redissonClient.getMap(RedisConst.PROMO_SUBMITTING);
    if (map.putIfAbsent(userId, skuId) != null) {
        // 已经提交过订单
        return Result.fail(0L).message("正在提交上一个订单,请稍后再试");
    }

    // 远程调用订单,生成秒杀订单
    Long orderId = orderApiClient.submitOrder(orderInfo);
    if (null == orderId) {
        return Result.fail().message("下单失败,请重新操作");
    }
    
    //删除Redis中 下单信息(OrderRecode)
    redissonClient.getMap(RedisConst.PROMO_SECKILL_ORDERS).remove(userId);
    
    //增加Redis中秒杀提交订单标记,表示用户已经提交了订单
    redissonClient.getMap(RedisConst.PROMO_SUBMIT_ORDER).put(userId,orderId.toString());

    return Result.ok(orderId);
}

step2 &#8203;

提交秒杀订单不需要像普通订单一样,校验很多参数,直接保存订单记录到数据库即可。

java
@PostMapping("/api/order/inner/seckill/submitOrder")
public Long submitOrder(@RequestBody OrderInfoParam orderInfoParam) {

    // 转化OrderInfoParam为 OrderInfo
    OrderInfo orderInfo = orderInfoConverter.convertOrderInfoParam(orderInfoParam);
    
    // 设置订单类型为秒杀订单
    orderInfo.setOrderType(OrderType.PROMO_ORDER.name());

    // 保存订单以及订单详情 到数据库
    Long orderId = orderService.saveOrderInfo(orderInfo);
    return orderId;
}

5.9 检查秒杀下单逻辑完善 &#8203;

在此之前,因为还没有做生成订单业务逻辑,所以无法判断用户是否已经生成了订单。

不过现在可以做了,把之前的业务逻辑完善一下。

image-20230329005214901

java
public Result checkOrder(Long skuId, String userId) {

    // 1. 判断用户是否已经下过单
    if (skuIdInRedis != null) {
        //2. 判断Redis中是否已经存在用户的订单
        if (isHasKey) {
            //抢单成功
            // 返回秒杀成功!
        }
        
        // TODO 
        // 如果用户已经提交了订单,会删除Redis中的OrderRecode,那么在此判断一下是否存在用户提交订单的标记
        // 如果存在,返回抢单成功,状态码218
        boolean isExistOrder = redissonClient.getMap(RedisConst.PROMO_SUBMIT_ORDER).containsKey(userId);
        if(isExistOrder) {
            String orderId = (String) redissonClient.getMap(RedisConst.PROMO_SUBMIT_ORDER).get(userId);
            return Result.build(orderId, ResultCodeEnum.SECKILL_ORDER_SUCCESS);
        }
    }

    // 3. 判断库存状态位
    ...
    // 4. 返回正在排队
}

5.10 定时任务-清除缓存 &#8203;

秒杀过程中我们写入了大量redis缓存,我们可以在秒杀结束或每天固定时间清楚缓存,释放缓存空间

当前项目中的秒杀活动,我们默认为每天早上8:00开始,晚上8:00结束,所以我们可以在每天晚上十点清空Redis缓存

需要清空哪些缓存呢?

  1. 清空商品缓存库存
  2. 清空缓存预热商品列表
  3. 清空用户下单标识
  4. 清空用户下单缓存
  5. 清空用户提交订单缓存
  6. 清空本地秒杀商品库存标志位

最后,还需要修改秒杀表对应的商品状态,表示秒杀活动已经结束

笔记 &#8203;

  • 解决超卖(由多线程访问共享数据库存造成的)
    • 用队列表示商品库存,队列的弹出操作时原子性的
    • 分布式锁
    • 乐观锁(cas设计思想 compare and sign)
  • 定时任务也有框架,一般都涉及事件轮询算法
  • 本地缓存,mybatis的二级缓存就是。一级是sqlsession级别的,二级是namespace级别的
  • 多个网关可以用nginx负载均衡,一个nginx50000并发,顶不住可以用nginx的LVS
  • 为什么用nacos不用euraka,因为nacos是注册中心,也是配置中心,更方便
  • sentinel可以做服务之间的限流。服务之间频繁调用顶不住,可以起多个服务也可以限流
  • 不影响主业务执行的操作可以异步化,比如注册送优惠券。优惠券业务失败也不影响主业务注册
  • Redisson作用1:作为redis的Java客户端操作redis;作用二:基于redis提供很多分布式的对象
  • es和mysql的增量同步与全量同步
  • 布隆过滤器不能删除,有没有好的解决方式?使用竞品,布谷鸟过滤器
  • es为什么快?倒排索引就是分词结果和文档id的映射
  • 登录,基于redis的共享会话实现单点登录
  • 订单超时自动取消,看看用户有没有支付过,先看本地再看支付宝交易记录