秒杀
秒杀背景介绍
所谓“秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
秒杀商品通常有三种限制:库存限制、时间限制、购买量限制。
- 库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是200件,但只拿出50件来参与秒杀。我们可以将其称为“秒杀库存”。商家赔本赚吆喝,图的是人气与流量
- 时间限制:通常秒杀都是有特定的时间段,只能在设定时间段进行秒杀活动
- 购买量限制:同一个商品只允许用户最多购买几件。比如某手机限购1件。张某第一次买个1件,那么在该次秒杀活动中就不能再次抢购
数据库设计
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;功能分析
当前项目秒杀业务流程:
-
后台运营人员通过管理系统录入秒杀商品,主要包括商品标题、原价、秒杀价、图片、库存等;提交秒杀活动申请
-
后台管理人员通过管理系统审核,审核通过表示可以参与秒杀;审核不通过表示不能秒杀
-
秒杀频道首页列出当天的秒杀商品,用户点击秒杀图片跳转到秒杀商品详情页
-
商品详细页显示秒杀商品信息,点击立即抢购进入秒杀订单确认页面
-
点击立即下单,参与抢购,抢购成功,用户生产订单,扣减秒杀库存;抢购失败,扣减秒杀库存失败,用户生成订单失败
-
当用户秒杀下单30分钟内未支付,取消订单,调用微信支付或支付宝的关闭订单接口。
说明:
- 当前项目中的秒杀活动,我们默认为每天早上8:00开始,晚上8:00结束
- 取消订单的业务因为之前做过了,在秒杀业务中我们不做,主要是考虑前面抢购下单的部分
秒杀列表页

业务分析:查询秒杀表中”当前正在秒杀”的商品
# 如何确认当前正在参与秒杀呢?# 1. 审核通过:status = CHECKED_PASS# 2. 今天开始:DATE_FORMAT(start_time,'%Y-%m-%d') = new Date()# 3. 库存大于0:stock_count>0秒杀商品详情页

业务分析:根据商品Id查询秒杀商品详情。
立即抢购
会出现下单之前的结算页面


提交订单
提交订单之后,可能会有如下结果

功能实现思路
库存状态标志位
当秒杀商品售罄时,如果能有一个标志位来标识,我们就无需在访问数据库了,而是直接返回秒杀失败的结果。通过引入库存状态标志位,我们可以在秒杀商品售罄时,迅速返回失败的响应。
@Slf4jpublic class LocalCacheHelper {
/** * 缓存,key为skuId,value为库存状态0表示售罄,1表示未售罄 */ private final static Map<String, Object> cacheMap = new ConcurrentHashMap<String, Object>();
/** * 加入缓存 * * @param key * @param cacheObject */ public static void put(String key, Object cacheObject) { cacheMap.put(key, cacheObject); log.info("当前本地缓存 cacheMap:{}", JSON.toJSONString(cacheMap)); }
/** * 获取缓存 * @param key * @return */ public static Object get(String key) { return cacheMap.get(key); }
/** * 清除缓存 * * @param key * @return */ public static void remove(String key) { cacheMap.remove(key); }
public static void removeAll() { cacheMap.clear(); }}库存状态的初始化
由于我们每一天都可能有秒杀活动,所以库存状态的初始化,可以选择在每天的凌晨这种访问量不高的时段内,通过定时任务,查询出当天要参与秒杀活动的商品,将其库存状态初始化为1,即未售罄的状态。
// 从数据库中获取List<SeckillGoods> seckillGoodsList = ...
// 遍历并初始化库存标志位for(SeckillGoods seckillGoods : seckillGoodsList) { // 1表示有库存状态 LocalCacheHelper.put(seckillGoods.getSkuId().toString(), "1");}如何实现定时任务呢?通过Spring Scheduling实现
接口功能实现
定时任务
定时任务如何实现
在程序中常常有定时任务的需求,例如每隔一周生成一次报表、每个月月末清空用户积分等等。Spring也提供了相应的支持,我们可以非常方便的按时执行任务。
如何实现呢?
- 开启定时任务功能 @EnableScheduling
- 添加任务 @Scheduled(cron="")
@Component@EnableSchedulingpublic class ScheduledTask {
@Scheduled(cron = "0 0/1 * * * ?") // 每分钟执行一次 public void importIntoRedisTask(){
// TODO something...
}}cron表达式
┌───────────── 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) │ │ │ │ │ │ * * * * * *# 占位符解释
# *: 表示每秒钟都触发* * * * * ? : 表示每秒触发一次
# ,: 表示在指定的秒触发15,45 * * * * ? : 表示每分钟的15秒,45秒都触发一次
# -:表示在指定的范围内每秒都触发20-30 * * * * * : 表示在每分钟的20秒-30秒内,每秒触发一次
# /: 表示步进,也就是增量。15/20 * * * * * : 表示从每分钟的15秒开始,每20秒触发一次,即 15s,35s,55s各触发一次注意事项:
- cron表达式可以是七位,最后一位是年,可以省略
- 天和星期会冲突,当表达式其中之一被指定了值以后,为了避免冲突,需要将另一个子表达式的值设为“?”
# 常用的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点本地库存状态位初始化
@Component@EnableSchedulingpublic 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. 初始化库存状态位 for(...) {
// eg: 50-1 表示skuId为50的商品还有库存 // eg: 49-0 表示skuId为49的商品没有库存 LocalCacheHelper.put(skuId,"1");
} }}秒杀商品列表

@GetMapping("/seckill")public Result findAll() {
// 调用promoService的方法,根据如下条件查询 // 1. 审核通过:status = CHECKED_PASS // 2. 今天开始:DATE_FORMAT(start_time,'%Y-%m-%d') = new Date() // 3. 库存大于0:stock_count>0
return Result.ok(promoService.findAll());}秒杀商品详情

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

当点击立即抢购之后,我们就会跳转到结算页面,先来看一看前端代码
async trade () { // 获取下单码 const res = await getSeckillSkuIdStr(this.goodsData.skuId)
// 跳转结算页 this.$router.push({ name: 'seckillTrade', query: { skuId: this.goodsData.skuId, skuIdStr: res.data } }) }获取下单码
下单码的目的主要是秒杀下单的时候校验用户请求,防止非法请求参与秒杀下单

@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("获取下单码失败");}获取秒杀结算数据

@GetMapping("/seckill/auth/trade/{skuId}")public Result<OrderTradeDTO> trade(@PathVariable("skuId") Long skuId,String skuIdStr, HttpServletRequest request) {
//1. 校验下单码(抢购码规则可以自定义) String userId = AuthContext.getUserId(request); if (!skuIdStr.equals(MD5.encrypt(userId + skuId))) return Result.build(null, SeckillCodeEnum.SECKILL_ILLEGAL);
// 2. 校验状态位 String skuStatus = (String) LocalCacheHelper.get(skuId.toString()); if (skuStatus == null) return Result.build(null, SeckillCodeEnum.SECKILL_ILLEGAL); // 请求非法 if (skuStatus.equals("0")) return Result.build(null, SeckillCodeEnum.SECKILL_FINISH); // 库存售罄
// 3. 获取结算信息 OrderTradeDTO tradeData = ... return Result.ok(tradeData);
}成功获取秒杀下单结算数据后,跳转到秒杀下单结算页面

秒杀下单
在结算页面中,点击提交订单就开始真正的秒杀下单,对于秒杀服务而言,主要的工作分三步完成:
- 校验库存标志位,校验是否重复下单
- 扣减秒杀商品库存
- 调用订单服务,生成秒杀订单

/** * 提交秒杀订单 * POST http://localhost/seckill/auth/submitOrder*/@PostMapping("/seckill/auth/submitOrder")public Result submitOrder(@RequestBody OrderInfoParam orderInfo, HttpServletRequest request) {
Long skuId = orderInfo.getOrderDetailList().get(0).getSkuId(); // 1. 获取用户id String userId = AuthContext.getUserId(request);
// 2. 校验本地库存状态位 Object stockFlag = LocalCacheHelper.get(skuId.toString()); if (!"1".equals(stockFlag)) { return Result.build(null, SeckillCodeEnum.SECKILL_FINISH); }
// 3. 校验是否重复下单 RSet<Long> set = redissonClient.getSet(RedisConst.PROMO_USER_ORDERED_FLAG + userId); boolean ret = set.tryAdd(skuId); if (!ret) { return Result.build(null, SeckillCodeEnum.SECKILL_DUPLICATE_TRADE); }
orderInfo.setUserId(Long.valueOf(userId));
// 4. 提交秒杀订单 boolean submitOrderRet = promoService.submitOrder(orderInfo); if (!submitOrderRet) { return Result.build(null, SeckillCodeEnum.SECKILL_FINISH); }
// 返回下单成功 return Result.build(null, SeckillCodeEnum.SECKILL_ORDER_SUCCESS);
} /* 提交秒杀订单 */ boolean submitOrder(OrderInfoParam orderInfo);public boolean submitOrder(OrderInfoParam orderInfo) {
Long skuId = orderInfo.getOrderDetailList().get(0).getSkuId();
// 1. 扣减库存 int affectedRows = seckillGoodsMapper.decreaseStock(skuId, 1); if (affectedRows < 1) { // 没有库存了 // 更新本地库存状态位 LocalCacheHelper.put(skuId.toString(), "0"); return false; }
// 2. 调用订单接口,生成订单 Long orderId = orderApiClient.submitOrder(orderInfo); if (orderId != null) { return true; }
return false;}文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!