秒杀 ​
1. 背景介绍 ​
所谓“秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
秒杀商品通常有三种限制:库存限制、时间限制、购买量限制。
- 库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是200件,但只拿出50件来参与秒杀。我们可以将其称为“秒杀库存”。商家赔本赚吆喝,图的是人气与流量
- 时间限制:通常秒杀都是有特定的时间段,只能在设定时间段进行秒杀活动
- 购买量限制:同一个商品只允许用户最多购买几件。比如某手机限购1件。张某第一次买个1件,那么在该次秒杀活动中就不能再次抢购
2. 数据库设计 ​
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 秒杀列表页 ​

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

业务分析:根据商品Id查询秒杀商品详情。
3.3 抢购排队页 ​
下面是用户点击《立即抢购》可能会出现的四种提示。

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


4. 功能实现思路 ​
4.1 秒杀商品列表和详情 ​
如果不考虑缓存,那么每次都需要直接访问数据库。而秒杀列表页和秒杀商品详情页的点击量一般我们认为比较大,那么势必会给数据库带来比较大的压力,所以在此考虑使用缓存预热的方案。
缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据放入缓存
缓存预热方案落地:
- 什么时候存入Redis?凌晨放入当天所有的秒杀商品数据
- 如何实现?Spring Scheduling 定时任务
4.2 库存状态位控制访问请求 ​
状态位控制访问请求,何为状态位?就是我们在内存中保存一个状态,当抢购开始时状态为1,可以抢购,当库存为0时,状态位0,不能抢购;状态位的好处,他是在内存中判断,压力很小,可以阻止很多不必要的请求
4.3 秒杀排队实现方式 ​
用户提交秒杀请求,将秒杀商品与用户id关联发送给消息队列,然后返回。通过消息队列排队抢购秒杀资格。秒杀页面通过轮询接口查看是否秒杀成功
我们秒杀只是为了获取一个秒杀资格,获取秒杀资格就可以到下单页下订单,后续业务与正常订单一样
4.4 库存超卖问题 ​
如何防止库存超卖呢?把商品库存存入队列,利用Redis队列的原子性,保证库存不超卖。

5. 接口功能实现 ​
5.1 定时任务-缓存预热 ​
定时任务如何实现 ​
在程序中常常有定时任务的需求,例如每隔一周生成一次报表、每个月月末清空用户积分等等。Spring也提供了相应的支持,我们可以非常方便的按时执行任务。
如何实现呢?
- 开启定时任务功能 @EnableScheduling
- 添加任务 @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)
│ │ │ │ │ │
* * * * * *# 占位符解释
# ?因为week和day冲突,其中一个必须是?
# *: 表示每秒钟都触发
* * * * * ? : 表示每秒触发一次
# ,: 表示在指定的秒触发
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
@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中获取。

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

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

queue () {
// 选获取秒杀下单码
const res = await getSeckillSkuIdStr(this.goodsData.skuId)
console.log(res)
// 跳转秒杀排队页面
this.$router.push({
name: 'queue',
query: {
skuId: this.goodsData.skuId,
skuIdStr: res.data
}
})
}// 在排队页面中
// 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);
})
},获取下单码 ​
下单码的目的主要是秒杀下单的时候校验用户请求,防止非法请求参与秒杀下单

@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("获取下单码失败");
}秒杀下单排队 ​
发起用户下单请求,进行排队

@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的消息队列进行排队,排队抢购秒杀资格。
在消息消费者这边,需要真正的进行抢购。
// 消费消费者 消费逻辑如下
// 1. 订阅主题 MqTopicConst.SECKILL_GOODS_QUEUE_TOPIC
// 2. 开始消费
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
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
}
}
} <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">
抢购成功
<a href="javascript:;" @click="jump('seckillTrade')">去下单</a>
</div>
<div class="seckill_dev" v-if="show === 4">
抢购成功
<a href="javascript:;" @click="jump('myOrder')">我的订单</a>
</div>由前端的逻辑可知:




public enum ResultCodeEnum implements CodeEnum {
SECKILL_RUN(211, "正在排队中"),
SECKILL_FINISH(213, "已售罄"),
SECKILL_SUCCESS(215, "抢单成功"),
SECKILL_FAIL(216, "抢单失败"),
SECKILL_ILLEGAL(217, "请求不合法"),
SECKILL_ORDER_SUCCESS(218, "下单成功"),
;
}判断逻辑分析 ​

因为目前还没有真正的生成秒杀订单,只是把抢购到的订单记录存在了Redis。在做了5.8接口实现后才会真正在数据库生成秒杀订单,所以在此处暂不考虑状态码=218 秒杀下单成功的情况。
代码实现 ​
@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 秒杀商品确认页 ​
当抢单成功之后,前面轮训页面显示,抢购成功,去下单。和订单服务的下单商品确认页逻辑类似

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

业务逻辑分析:

获取秒杀确认页面详情。
包括:
- 收件人信息
- 商品列表
@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 秒杀下单生成订单 ​

step1 ​
@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 ​
提交秒杀订单不需要像普通订单一样,校验很多参数,直接保存订单记录到数据库即可。
@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 检查秒杀下单逻辑完善 ​
在此之前,因为还没有做生成订单业务逻辑,所以无法判断用户是否已经生成了订单。
不过现在可以做了,把之前的业务逻辑完善一下。

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 定时任务-清除缓存 ​
秒杀过程中我们写入了大量redis缓存,我们可以在秒杀结束或每天固定时间清楚缓存,释放缓存空间
当前项目中的秒杀活动,我们默认为每天早上8:00开始,晚上8:00结束,所以我们可以在每天晚上十点清空Redis缓存
需要清空哪些缓存呢?
- 清空商品缓存库存
- 清空缓存预热商品列表
- 清空用户下单标识
- 清空用户下单缓存
- 清空用户提交订单缓存
- 清空本地秒杀商品库存标志位
最后,还需要修改秒杀表对应的商品状态,表示秒杀活动已经结束
笔记 ​
- 解决超卖(由多线程访问共享数据库存造成的)
- 用队列表示商品库存,队列的弹出操作时原子性的
- 分布式锁
- 乐观锁(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的共享会话实现单点登录
- 订单超时自动取消,看看用户有没有支付过,先看本地再看支付宝交易记录