1 秒杀架构图
2 秒杀场景
- 登陆12306进行火车票抢座
- 1599元购入飞天茅台
- 周董演唱会的门票
- 双十一秒杀活动
3 秒杀的难点
- 瞬时高并发流量的挑战
- 超卖
- 恶意攻击
- 用户体验
- 木桶短板理论,整个系统的瓶颈往往都在 DB
4 如何设计
上图是一个典型的互联网业务,用户完成一个写操作,一般会通过接入层和逻辑层,这里的服务都是无状态,可以通过平行拓展去解决高并发的问题;到了 db 层,必须要落到介质中,可以是磁盘/ssd/内存,如果出现 key 的冲突,会有一些并发控制技术,例如 cas/加锁/串行排队等。
4.1 直筒型
直筒型业务,指的是用户请求 1:1 的洞穿到 db 层,如下图所示。在比较简单的业务中,才会采用这个模型。随着业务规模复杂度上来,一定会有 db 和逻辑层分离、逻辑层和接入层分离。
**
4.2 漏斗型
漏斗型业务,指的是,用户的请求,从客户端到 db 层,层层递减,递减的程度视业务而定。例如当 10w 人去抢 1 个物品时,db 层的请求在个位数量级,这就是比较理想的模型。如下图所示
这个模型,是高并发的基础,翻译一下就是下面这些:
- 及早发现,及早拒绝
- Fast Fail
- 前端保护后端,避免打到数据库
5 具体措施
5.1 前端
资源静态化
前端把能提前放入cdn服务器的东西都放进去,反正把所有能提升效率的步骤都做一下,减少真正秒杀时候服务器的压力。
秒杀链接加盐
实现思路:首先获取秒杀下单url可变参数,根据skuId进行md5加密。校验是否已经到秒杀时间,防止秒杀还没开始就要有人通过脚本刷单。
//用于加密和解密的密钥
private final String cipher="hosjoy-spike-md5-cipher&73@(**$d--=,./;~·2··%##4";
//返回url可变部分
public String getPathVirableMd5(long skuId) {
if(LocalDateTime.now().before(spikeStartTime) ){
throw new ClientException("还没到秒杀时间")
}
String base=skuId+"/"+cipher;
return DigestUtils.md5DigestAsHex(base.getBytes());
}
下单请求
@RequestMapping(value = "/submit/{skuId}/{md5}")
public void submit(@PathVariable("skuId") Long skuId, @PathVariable("md5") String md5){
return spikeService.submitSpikeOrder(skuId, md5);
}
public void submitSpikeOrder(long skuId, String md5){
if(md5==null||!md5.equals(getPathVirableMd5(skuId))){
//md5校验错误
throw new RuntimeException("非法请求");
}
//校验通过,执行下单
this. doSubmitSpikeOrder(skuId);
}
}
限流
前端按钮灰色化:如果参与过秒杀活动会发现,没到秒杀时间时秒杀按钮是灰色状态的,只有时间到了才是可点击状态。并且秒杀开始咯也不是一直可以点的,可能只允许1秒内点10次那种的。
5.2 Nginx
Nginx: 高性能的web服务器,并发也随便顶几万不是梦,但是我们的Tomcat只能顶几百的并发呀,那简单呀负载均衡嘛,一台服务几百,那就多搞点,在秒杀的时候多租点流量机。
恶意请求拦截也需要用到它,一般单个用户请求次数太夸张,不像人为的请求在网关那一层就得拦截掉了,不然请求多了他抢不抢得到是一回事,服务器压力上去了,可能占用网络带宽或者把服务器打崩、缓存击穿等等。
5.3 风控
我可以明确的告诉大家,前面的所有措施还是拦不住很多羊毛党,因为他们是专业的团队,他们可以注册很多账号来薅你的羊毛,而且不用机器请求,就用群控,操作几乎跟真实用户一模一样。
那怎么办,是不是无解了?
这个时候就需要风控同学的介入了,在请求到达后端之前,风控可以根据账号行为分析出这个账号机器人的概率大不大。
5.4 后端
服务单一职责
设计个能抗住高并发的系统,我觉得还是得单一职责。
什么意思呢,我们给秒杀开个服务,我们把秒杀的代码业务逻辑放一起。
单一职责的好处就是就算秒杀没抗住,秒杀库崩了,服务挂了,也不会影响到其他的服务。(高可用)
Redis集群
如果说单机的Redis顶不住,那就做一个redis集群,秒杀本来就是读多写少,那你们是不是瞬间想起来我之前跟你们提到过的,Redis集群,主从同步、读写分离,我们还搞点哨兵,开启持久化直接无敌高可用!
库存预热
秒杀的本质,就是对库存的抢夺,每个秒杀的用户来你都去数据库查询库存校验库存,然后扣减库存,数据库顶不住啊。所以要开始秒杀前你提前把商品的库存加载到Redis中去,让整个流程都在Redis里面去做,然后等秒杀介绍了,再异步的去修改库存就好了。
但是用了Redis就有一个问题了,我们上面说了我们采用主从,就是我们会去读取库存然后再判断然后有库存才去减库存,正常情况没问题,但是高并发的情况问题就很大了。就比如现在库存只剩下1个了,我们高并发嘛,4个服务器一起查询了发现都是还有1个,那大家都觉得是自己抢到了,就都去扣库存,那结果就变成了-3,是的只有一个是真的抢到了,别的都是超卖的。咋办?
超卖问题
redis的 watch + multi
我们可以使用使用redis的 watch + multi 指令,去监听秒杀商品库存,如果库存数发生改变,则后续无法进行修改库存操作。
缺点:
(1)由于watch采用乐观锁机制,没有对其它线程修改操作作限制,因此事务有可能频繁失败;需要用while循环去重复尝试;
(2)增加服务器压力
分布式锁
利用分布式锁,保证同一时刻只有一个线程进行读库存—修改库存操作。
缺点:同一个商品多用户同时下单的时候,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求,并发处理能力较弱。
redis 队列
将库存缓存到redis队列,队列里面放sku_id,例如库存为5个,就放5个id。通过rightPop操作取出商品,预扣减库存,如果pop出来的元素为空,说明售罄 。这里利用了redis单线程操作特性,队列取id即扣减库存,相当于原子操作,高并发场景下不需要开事务,也不用加锁同步,性能、数据一致性均好于以上两种方案。
乐观锁
利用CAS原理,在操作数据库更新库存的时候,更新条件带上之前查询到的库存数量,如果更新结果数为0,说明过程中其它线程修改了库存。
select inventory from sku; update sku set inventory =#{inventory} where id=? and inventory= ?
限流&降级&熔断&隔离
这个为啥要做呢,不怕一万就怕万一,万一你真的顶不住了,限流,顶不住就挡一部分出去但是不能说不行,降级,降级了还是被打挂了,熔断,至少不要影响别的系统,隔离,你本身就独立的,但是你会调用其他的系统嘛,你快不行了你别打挂别的系统那啊。
消息队列(削峰填谷)
你卖的东西少,你直接100个请求改库我觉得没问题,但是万一秒杀一万个,10万个呢?服务器挂了,秒杀就是这种瞬间流量很高,但是平时又没有流量的场景,那消息队列完全契合这样的场景了呀,削峰填谷。
数据库
数据库用MySQL只要连接池设置合理一般问题是不大的,不过最好单独给秒杀建立一个数据库,为秒杀服务,表的设计也是竟可能的简单点。
少卖问题
少卖问题原因分析
(1)redis预扣减库存成功,但是执行真正的下单逻辑失败了,且库存没有回滚;
(2)用户订单提交成功了,但是超时没有支付,且超时后活动已结束或者超时后没有回滚库存;
(3)用户排队成功了,但是排队下单请求消息发送到MQ失败了,或者MQ消息丢了,或者消费者弄丢了数据。
解决方案
(1)异步下单失败后,要即时回滚redis中的sku库存;
(2)缩短支付时间,或者修改秒杀流程:先支付再确认订单;超时未支付后即时回滚redis中的sku库存;
(3)解决MQ消息丢失问题(下文会提到)。