秒杀

2565 字
13 分钟
秒杀

秒杀背景介绍#

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

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

  • 库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是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结束
  • 取消订单的业务因为之前做过了,在秒杀业务中我们不做,主要是考虑前面抢购下单的部分

秒杀列表页#

image-20230328101327281
image-20230328101327281

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

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

秒杀商品详情页#

image-20230327175517363
image-20230327175517363

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

立即抢购#

会出现下单之前的结算页面

image-20230327180015193
image-20230327180015193

image-20230327180027532
image-20230327180027532

提交订单#

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

功能实现思路#

库存状态标志位#

当秒杀商品售罄时,如果能有一个标志位来标识,我们就无需在访问数据库了,而是直接返回秒杀失败的结果。通过引入库存状态标志位,我们可以在秒杀商品售罄时,迅速返回失败的响应。

@Slf4j
public 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也提供了相应的支持,我们可以非常方便的按时执行任务。

官方参考文档地址

如何实现呢?

  1. 开启定时任务功能 @EnableScheduling
  2. 添加任务 @Scheduled(cron="")
@Component
@EnableScheduling
public 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)
│ │ │ │ │ │
* * * * * *
Terminal window
# 占位符解释
# *: 表示每秒钟都触发
* * * * * ? : 表示每秒触发一次
# ,: 表示在指定的秒触发
15,45 * * * * ? : 表示每分钟的15秒,45秒都触发一次
# -:表示在指定的范围内每秒都触发
20-30 * * * * * 表示在每分钟的20秒-30秒内,每秒触发一次
# /: 表示步进,也就是增量。
15/20 * * * * * : 表示从每分钟的15秒开始,每20秒触发一次,即 15s,35s,55s各触发一次

注意事项:

  1. cron表达式可以是七位,最后一位是年,可以省略
  2. 天和星期会冲突,当表达式其中之一被指定了值以后,为了避免冲突,需要将另一个子表达式的值设为“?”
Terminal window
# 常用的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生成器

https://cron.ciding.cc/

本地库存状态位初始化#

@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. 初始化库存状态位
for(...) {
// eg: 50-1 表示skuId为50的商品还有库存
// eg: 49-0 表示skuId为49的商品没有库存
LocalCacheHelper.put(skuId,"1");
}
}
}

秒杀商品列表#

image-20240503174751864
image-20240503174751864

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

秒杀商品详情#

image-20240503174812802
image-20240503174812802

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

成功获取秒杀下单结算数据后,跳转到秒杀下单结算页面

image-20230329001116671
image-20230329001116671

秒杀下单#

在结算页面中,点击提交订单就开始真正的秒杀下单,对于秒杀服务而言,主要的工作分三步完成:

  • 校验库存标志位,校验是否重复下单
  • 扣减秒杀商品库存
  • 调用订单服务,生成秒杀订单

/**
* 提交秒杀订单
* 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;
}

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

秒杀
https://firefly-mu-weld.vercel.app/posts/microservice-27-seckill/
作者
Daisy
发布于
2026-06-14
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录