什么是秒杀?
“秒杀是在 瞬间击杀 的意思。 也是网上竞拍的一种新方式;所谓“秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
秒杀的流程
这里通过两张图简单介绍下秒杀的流程,真正的系统比这复杂很多
秒杀系统痛点有哪些?
1.高并发: 时间极短、 瞬间用户量大,而且用户会在开始前不断刷新页面,还会积累一大堆重复请求的问题,请求过多把数据库打宕机了,系统响应失败,导致与这个系统耦合的系统也GG,一挂挂一片
2.链接暴露: 有人知道了你秒杀的url,然后在秒杀开始前通过这个url传要传递的参数来进行购买操作
3.超卖: 你只有一百件商品,由于是高并发的问题,一起拿到了最后一件商品的信息,都认为还有,全卖出去了,最终卖了一百十件,仓库里根本没这么多货
4.恶意请求: 因为秒杀的价格比较低,有人会用脚本来秒杀,全给一个人买走了,他再转卖,或者同时以脚本操作多个账号一起秒杀。(就是我们常见的黄牛党)
5.数据库: 一瞬间高QPS把数据库打宕机了,谁都抢不到,活动失败GG,这可能与高并发有重叠的点,不过着眼于数据库的具体方面
解决方案
1.高并发的解决方案
既然是超高并发,就想着降低和分散流量,需要考虑一些加进来的系统会不会被打挂
1.nginx做负载均衡(一个tomcat可能只能抗住几百的的并发,nginx还可以对一些恶意ip做限制)
2.资源静态化,把前端的模板页面放到CDN服务器中(放到别的服务器中,减轻自己服务器的压力)
3.页面的按钮,按一次后置灰X秒(防止一直用户一直点,同一个用户重复点击,虽然不会再卖给他,但是请求还是会到后端给系统压力,需要在前端按钮上做限制,比如点一下限制五秒内不能点击。)
4.同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面(用来防止其他程序员跳过按钮,直接用for循环不断发起http请求,具体的话可以请求每次进来都去redis查有没有对应的id的key值,没有就在redis中设置X秒过期的key)
5.对于写请求,用消息队列(比如商品有一万件,就放一万个请求进来,当然要做好每秒几个的限制,不能一秒内全放进来,都成功了就继续放下一批,没成功就剩下的请求全部返回失败),
6.读请求用redis集群顶住(一个redis也只能顶住几万的并发,叫上兄弟)
7.记住一定一定要先把数据库里的东西提前加载到redis来,别等用户查了再加
2.链接暴露的解决方案:
1.有人会说,在上面已经做好了请求X秒一条的限制了嘛,为什么还要防止链接暴露?
其实在秒杀没开始前,这个秒杀的接口也是存在的,如果这个接口的url被人知道了,他直接可以在秒杀开始前就通过请求传输必要的参数来进行秒杀。我们要做的就是在秒杀开始前,谁都不知道秒杀(也就是付款、减少库存的接口)这个接口的url是什么。
2.如何防止秒杀的url暴露?
我们要做的是,在秒杀时间到的时候,才能获得url。而且秒杀场景中,肯定会有一个倒计时的模块,来告诉你还有几秒开始秒杀。我们逻辑如下:
①页面中有一个计时模块,是访问秒杀页面的时候去从服务器里拿的,计时结束,显示秒杀的按钮。 问题:(为什么不直接取用户的时间?)
②点击秒杀按钮后,再次请求服务器时间,与秒杀的时间对比,如果秒杀进行中,返回一个由加密过的秒杀url 问题:(为什么还要再次请求服务器时间?怎么加密url?)
③通过这个加密过的url来进行支付、减库存操作。
解决问题:
问题1:为什么不直接取用户的时间?
用户的本机时间是可以修改的。
问题2:为什么还要再次请求服务器时间?
虽然第一次请求到了服务器时间,但是时间的倒计时操作是页面来完成的,比如在几天前就打开这个秒杀页面等待倒计时,等秒杀开始时可能与服务器时间存在几秒的误差。
问题3:怎么加密url?
通过一个无序的String字符串,也就是我们常说的盐值字符串,进行MD5加密得到哈希值,在步骤②中点击秒杀按钮返回url的时候,返回这个MD5值进行url的拼接。
真正秒杀接口的mapping格式为
因为这个md5是等秒杀开始才得到的,使得秒杀的链接得以保护。
3.超卖问题的解决方案:
先设想这么一个场景:
我们假设现在商品只剩下一件了,此时数据库中 num = 1;
但有100个线程同时读取到了这个 num = 1,所以100个线程都开始减库存了。
这里有三个解决思路,分别是悲观锁、乐观锁和redis
悲观锁:
将减少库存的操作加入到事务中,(注意此时数据库的引擎需要是Innodb的只有innodb才支持事务和行级锁,而事务和行级锁这两个概念,一般也是同时出现),此时一个线程开始修改这一行的时候,会先获得排他锁,获得锁后开始修改,没有获得锁的线程会阻塞。
有关行级锁和行级锁引发的问题请移步我的另一篇文章
思考:
在我实现的秒杀系统中用的正是事务来实现的,将订单信息的插入(insert)和减少库存(update)放到一个事务中。可以思考下先做insert还是update?
答案应该是先insert而后update,因为insert的锁的行不会有人竞争,而update的排它锁的行会出现大量竞争,而事务锁释放的时机是整个事务完成,而不是这个方法执行完毕的时候,所以需要后update,尽量减少库存行锁的获取时间,来提高并发效率。
乐观锁:
需要在数据库表中加入版本号字段,sql语句如下,先查询出版本号,在下次更新的时候通过判断版本号是否更改和库存是否不为0来决定是否update
1 select version from goods WHERE id= 1001
2 update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);
这种方式采用了版本号的方式,其实也就是CAS的原理。
假设此时version = 100, num = 1; 100个线程进入到了这里,同时他们select出来版本号都是version = 100。
然后直接update的时候,只有其中一个先update了,同时更新了版本号。
那么其他99个在更新的时候,会发觉version并不等于上次select的version,就说明version被其他线程修改过了。那么我就放弃这次update
redis:
首先要了解,redis是单线程串行执行的
利用redis的单线程预减库存。比如商品有100件。那么我在redis存储一个k,v。例如 <gs1001, 100>
每一个用户线程进来,key值就减1,等减到0的时候,全部拒绝剩下的请求。
那么也就是只有100个线程会进入到后续操作。所以一定不会出现超卖的现象
4.恶意请求的解决方案:
恶意请求我们分析下来有两个问题,
1.怎么限制让一个人只能秒杀一件商品?
这个其实比较容易想到,数据库肯定有一张订单表(包含用户id字段、商品id字段),只要每次去查一下用户的id和这件商品的id有没有在这张表里就行了,存在说明该用户买过这个商品了,就不给买了。
问题在于,是不是每次都需要去查询然后插入呢?有没有更快的方法?
一般支付成功扣款和把订单信息加入到订单表中这两个操作设为一个事务。
把用户id和商品id作为联合主键索引存储到数据库中,下次如果再想插入一样用户id和商品id的行,是会报错的。(主键索引的唯一性),事务中出现错误,支付操作自然也失败了。
2.如果一个人用脚本掌握了多个账号去执行秒杀,怎么办?
可以让用户付款的时候回答问题,防止脚本的操作。比如12306买火车票的时候,是不是会有按顺序点击图中相同的文字?这就是为了防止脚本。
这边插入一条与恶意请求无关的需求,比如饿了么,他创建订单的时间不是在支付成功的时候,而是你选择完菜品点击支付的时候,如果你返回,有十五分钟的时间去支付。没支付前应该是放在redis中的,设置一个key的过期时间,如果支付成功了,就落地到mysql并在redis中删除这个数据。(当然还有别的实现方式,这里只是我想到的一种)
5.数据库层面的解决方案:
1.用消息队列来削峰
比如一秒钟进来1W个写请求,我们数据库只能顶住一秒5000个,那我们就每秒放出来4000个。
消息队列的好处和带来的问题请移步我另一篇文章
为什么要用消息队列?会带来什么问题?
2.用缓存来顶住大量的查询请求
引入redis,如果redis中有要查询的数据,就直接返回,如果没有,从数据库查询的时候,把查询的结果放到redis中,以后查询都会落到redis层。
在这里又会出现引用redis常见的问题问题①:一开始没有这个key,需要第一次查询才会到redis,此时秒杀开始,又是一堆并发进来把数据库打挂了,活动失败,GG。
解决方案:在活动没开始前,就把可能会访问到的数据都加载到redis中,虽然解决方法很简单,但是一开始没想到这个请况,会是很严重的问题。
问题②:如果这个数据特别热,突然这个key过期了,然后一堆并发请求过来查询这个数据,还是把数据库打挂了。
解决方案: 这也是我们常说的缓存击穿的问题,先看下这个问题的解决方案
1.设置这个key不过期(比如淘宝首页,只需要在有修改的时候去更新这个key就行)
2.数据库的查询时使用互斥锁,这个时候只有一个线程来查询,不会有一瞬间大量的查询sql,查询之后也放在了redis中,后面的查询就不会打到sql。(查询的互斥锁会把查询的结果集加入互斥锁,其他的不受影响,也就是说大大降低了相同查询的并发量)
引入redis中还会出现其他的问题,比如缓存雪崩、缓存穿透,想更深入了解redis的机制请点击我的Redis文集:Redis知识点
3.数据库层面读写分离
设置主从数据库,主数据库负责写,从数据库负责读,可以极大程度的缓解X锁和S锁争用。
关于数据库分库分表、读写分离的更详细的介绍请点击
数据库的分库分表、读写分离和主从复制