秒杀系列(一)防止超卖

秒杀系统

秒杀系统相信网上已经介绍了很多了,我也不想粘贴很多定义过来了。

废话少说,秒杀系统主要应用在商品抢购的场景,比如:

  • 电商抢购限量商品
  • 卖周董演唱会的门票
  • 火车票抢座
  • ……

秒杀系统抽象来说就是以下几个步骤:

  • 用户选定商品下单
  • 校验库存
  • 扣库存
  • 创建用户订单
  • 用户支付等后续步骤

听起来就是个用户买商品的流程而已嘛!确实,所以我们为啥要说它是个专门的系统呢?

为什么要做所谓的“系统”

如果你的项目流量非常小,完全不用担心有并发的购买请求,那么做这样一个系统意义不大。

但如果你的系统要像 12306 那样,接受高并发访问和下单的考验,那么你就需要一套完整的流程保护措施,来保证你系统在用户流量高峰期不会被搞挂了。(就像 12306 刚开始网络售票那几年一样)

这些措施有什么呢:

  • 严格防止超卖:库存 100 件你卖了 120 件,等着辞职吧。

  • 防止黑产:防止不怀好意的人群通过各种技术手段把你本该下发给群众的利益全收入了囊中。

  • 保证用户体验:高并发下,别网页打不开了,支付不成功了,购物车进不去了,地址改不了了。

  • ……

这些问题非常之大,涉及到各种技术,也不是一下子就能讲完的,甚至根本就没法讲完。

之前列举的措施中,如果防止黑产和网页卡顿等现象出现,其实用户也能一定程度上理解,毕竟秒杀都很可能会卡顿和抢不到,最多大家没参与到活动,口吐芬芳一波。

但是,如果要是超卖了,本该拿到商品的用户可就不乐意了,轻则亏本发货,重则起诉赔偿,哪一样都吃不了兜着走。

以下开始从零开始搭建秒杀系统 demo。

数据库建表(简版)

库存表 stock

1
2
3
4
5
6
7
8
9
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
`count` int(11) NOT NULL COMMENT '库存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '乐观锁,版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

订单表 stock_order

1
2
3
4
5
6
7
8
9
10
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order`
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT '库存ID',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

防止超卖

通过 HTTP 接口发起购买请求

只是个 demo,所以采用 Spring MVC + MyBatis 结构。

先用常规方案做一个购买请求接口

Controller 层 V0

提供一个接口,入参为商品 ID

1
2
3
4
5
6
7
8
9
10
11
12
13
@RequestMapping("/createWrongOrder/{sid}")
@ResponseBody
public String createWrongOrder(@PathVariable int sid) {
log.info("购买物品编号sid =【{}】",sid);
int id=0;
try {
id = orderService.createWrongOrder(sid);
log.info("创建订单id:【{}】",id);
} catch(Exception e) {
log.error("Exception",e);
}
return String.valueOf(id);
}

Service 层 V0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public int createWrongOrder(int sid)throws Exception {
// 校验库存
Stock stock = checkStock(sid);
// 扣库存
saleStock(stock);
// 创建订单
int id = createWrongOrder(stock);
return id;
}

private Stock checkStock(int sid) {
Stock stock = stockService.getStockById(sid);
if(stock.getSale().equals(stock.getCount())) {
throw new RuntimeException("库存不足");
}
return stock;
}

private int saleStock(Stock stock) {
stock.setSale(stock.getSale() + 1);
return stockService.updateStockById(stock);
}

private int createWrongOrder(Stock stock) {
StockOrder order = new StockOrder();
order.setSid(stock.getId());
order.setName(stock.getName());
int id = orderMapper.insertSelective(order);
return id;
}

测试:发起并发购买请求,复现超卖问题

推荐使用【 JMeter 】来模拟大量用户同时请求购买接口的场景。

为啥不使用 Postman?因为暂时不支持并发请求,只能顺序请求。

如何通过 JMeter 进行压测,可参考这篇 【 JMeter 压测教程 】。

同时开启 1000 线程,抢数据库插入的 100 台 iPhone,结果卖了 16 台,但是创建了 1000 个订单。

哭唧唧……是该表扬 Spring 强大的并发处理能力,还是该口吐芬芳 MySQL 这么成熟的数据库却不会给自己锁库存……

避免超卖问题:更新商品库存的版本号

为解决上述超卖问题,有几种方案:

  • 悲观锁:在 Service 层给表更新添加事务,这样每个线程更新请求的时候先锁表的这一行,更新完库存之后释放锁。

缺点:性能问题,1000 个线程存在阻塞。需要乐观锁。

  • 乐观锁:一般有两种方案,CAS 和 version。最简单的办法就是,给每个商品库存一个版本号 version 字段。

修改之前的代码,createWrongOrder 修改为新的悲观锁/乐观锁方案 ~

悲观锁和乐观锁两种方案的分析与比较

  • 悲观锁(Pessimistic Lock),顾名思义就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 block 直到它拿到锁。传统的关系型数据库里就用到了很多这种锁机制,比如行锁、表锁等,读锁、写锁等,都是在操作之前先上锁。

  • 乐观锁(Optimistic Lock),顾名思义就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于 write_condition 机制的其实都是提供的乐观锁。

两种锁各有优缺点,不能单纯定义哪个好于哪个,需要结合实际业务场景做选择。

  • 乐观锁比较适合数据修改比较少,读取比较频繁的场景,即使出现了少量的冲突,也省去了大量锁开销,能提高系统吞吐量。
  • 但是如果经常发生冲突(写数据比较多的情况下),上层应用不断 retry,这样反而降低了性能,对于这种情况,悲观锁可能更合适。

悲观锁方案

Controller 层 V1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 事务for update更新库存
* @param sid
* @return
*/
@RequestMapping("/createPessimisticOrder/{sid}")
@ResponseBody
public String createPessimisticOrder(@PathVariable int sid) {
int id;
try {
id = orderService.createPessimisticOrder(sid);
log.info("购买成功,剩余库存为:【{}】",id);
} catch(Exception e) {
log.error("购买失败:【{}】",e.getMessage());
return"购买失败,库存不足";
}
return String.format("购买成功,剩余库存为:%d",id);
}
Service 层 V1

Service 中,给卖商品流程加上事务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Override
public int createPessimisticOrder(int sid) {
// 校验库存(悲观锁for update)
Stock stock = checkStockForUpdate(sid);
// 更新库存
saleStock(stock);
// 创建订单
int id = createOrder(stock);
return stock.getCount() - (stock.getSale());
}

/**
* 检查库存 ForUpdate
* @param sid
* @return
*/
private Stock checkStockForUpdate(int sid) {
Stock stock = stockService.getStockByIdForUpdate(sid);
if(stock.getSale().equals(stock.getCount())) {
throw new RuntimeException("库存不足");
}
return stock;
}

/**
* 更新库存
* @param stock
*/
private void saleStock(Stock stock) {
stock.setSale(stock.getSale() + 1);
stockService.updateStockById(stock);
}

/**
* 创建订单
* @param stock
* @return
*/
private int createOrder(Stock stock) {
StockOrder order = new StockOrder();
order.setSid(stock.getId());
order.setName(stock.getName());
int id = orderMapper.insertSelective(order);
return id;
}

这里使用 Spring 的事务,@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
,如果遇到回滚,则返回Exception,并且事务传播使用 PROPAGATION_REQUIRED —— 支持当前事务,如果当前没有事务,就新建一个事务,关于 Spring 事务传播机制可以自行查阅资料。

悲观锁方案测试

设置 100 个商品,清空订单表,使用 JMeter 更改请求接口为悲观锁接口,发起 200 个请求。

结果:200 个请求,100 个返回抢购成功,100 个返回抢购失败,并且商品卖给了前 100 个进来的请求,十分有序。

所以,悲观锁在大量请求的请求下,有着更好的卖出成功率。

但是需要注意的是,如果请求量巨大,悲观锁会导致后面的请求进行了长时间的阻塞等待,用户就必须在页面等待,很像是”假死”,可以通过配合令牌桶限流,或者是给用户显著的等待提示来优化。

乐观锁版本号方案

Controller 层 V2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 乐观锁更新库存
* @param sid
* @return
*/
@RequestMapping("/createOptimisticOrder/{sid}")
@ResponseBody
public String createOptimisticOrder(@PathVariable int sid) {
int id;
try {
id = orderService.createOptimisticOrder(sid);
log.info("购买成功,剩余库存为:【{}】", id);
} catch (Exception e) {
log.error("购买失败:【{}】", e.getMessage());
return "购买失败,库存不足";
}
return String.format("购买成功,剩余库存为:%d", id);
}
Service 层 V2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public int createOptimisticOrder(int sid)throws Exception {
// 校验库存
Stock stock = checkStock(sid);
// 乐观锁更新库存
saleStockOptimistic(stock);
// 创建订单
int id = createOrder(stock);
return stock.getCount() - (stock.getSale() + 1);
}

private void saleStockOptimistic(Stock stock) {
log.info("查询数据库,尝试更新库存");
int count = stockService.updateStockByOptimistic(stock);
if(count == 0) {
throw new RuntimeException("并发更新库存失败,version不匹配");
}
}
Mapper
1
2
3
4
5
6
7
8
9
<update id="updateByOptimistic" parameterType="org.shadowalker.seckilldao.dao.Stock">
UPDATE stock
<set>
sale = sale + 1,
version = version + 1,
</set>
WHERE id = #{id, jdbcType=INTEGER}
AND version = #{version, jdbcType=INTEGER}
</update>

在实际减库存的 SQL 操作中,首先判断 version 是否是我们查询库存时候的 version,如果是,扣减库存,秒杀成功;如果 version 变了,则不更新数据库,秒杀失败。

乐观锁不需要版本号字段方案 Mapper
1
2
3
4
5
6
7
8
<update id="updateByOptimistic" parameterType="org.shadowalker.seckilldao.dao.Stock">
UPDATE stock
<set>
sale = sale + 1,
</set>
WHERE id = #{id,jdbcType=INTEGER}
AND sale = #{sale,jdbcType=INTEGER}
</update>
乐观锁版本号方案测试:重新发起并发购买请求,验证正确秒杀

库存恢复 100 台,清空订单表,通过 JMeter 重新发起 1000次并发请求。

结果:卖出去 39 台,库存 version 更新为 39,创建了 39 个订单。没有超卖。

说明:由于并发访问的原因,很多线程更新库存失败了,所以在这种设计下,1000 人同时发起购买,只有 39 个人能买到。

其实这完全 OK,一方面用户其实无感知,另一方面还减少了秒杀造成的低收益甚至是亏本(本身秒杀就是为了提升人气和引流的),最关键的是,防止了超卖。

当然,如果用户更多的话,最终大概率是可以全部卖完的。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2017-2021 Shadowalker
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信