秒杀系统优化方案吐血整理
前一段时间好好研究了秒杀的问题,我把里面的问题好好总结了,可以说是比较全面的了,真的是吐血整理了。
由于我先是在word中整理的,格式都整理得比较好,放到博客上格式挺难调,暂时按word的格式来吧,有时间了在好好排版下。
主要需要解决的问题有两个:
- 高并发对数据库产生的压力
- 竞争状态下如何解决库存的正确减少(超卖问题)
优化的思路:
1) 尽量将请求拦截在系统上游
2)读多写少经量多使用缓存
3) redis缓存 +RabbitMQ+ mysql 批量入库
1. 初始秒杀设计
1.1 业务分析
秒杀系统业务流程如下:
由图可以发现,整个系统其实是针对库存做的系统。用户成功秒杀商品,对于我们系统的操作就是:1.减库存。2.记录用户的购买明细。下面看看我们用户对库存的业务分析:
记录用户的秒杀成功信息,我们需要记录:1.谁购买成功了。2.购买成功的时间/有效期。这些数据组成了用户的秒杀成功信息,也就是用户的购买行为。
为什么我们的系统需要事务?
1.若是用户成功秒杀商品我们记录了其购买明细却没有减库存。导致商品的超卖。
2.减了库存却没有记录用户的购买明细。导致商品的少卖。对于上述两个故障,若是没有事务的支持,损失最大的无疑是我们的用户和商家。在MySQL中,它内置的事务机制,可以准确的帮我们完成减库存和记录用户购买明细的过程。
1.2 难点分析
当用户A秒杀id为10的商品时,此时MySQL需要进行的操作是:
1.开启事务。2.更新商品的库存信息。3.添加用户的购买明细,包括用户秒杀的商品id以及唯一标识用户身份的信息如电话号码等。4.提交事务。
若此时有另一个用户B也在秒杀这件id为10的商品,他就需要等待,等待到用户A成功秒杀到这件商品,然后MySQL成功的提交了事务他才能拿到这个id为10的商品的锁从而进行秒杀,而同一时间是不可能只有用户B在等待,肯定是有很多很多的用户都在等待竞争行级锁。秒杀的难点就在这里,如何高效的处理这些竞争?如何高效的完成事务?
1.3 功能实现
我们只是实现秒杀的一些功能:1.秒杀接口的暴露。2.执行秒杀的操作。3.相关查询,比如说列表查询,详情页查询。我们实现这三个功能即可。
1.4 数据库设计
Seckill秒杀表单
Success_seckill购买明细表
在购买明细表中seckill_id和user_phone是联合主键,当重复秒杀的时候,加入ignore防止报错,只是会返回0,表示重复秒杀。
INSERT ignore INTO success_killed(seckill_id,user_phone,state)
VALUES (#{seckillId},#{userPhone},0)
在购买明细表中seckill_id和user_phone是联合主键,当重复秒杀的时候,加入ignore防止报错,只是会返回0,表示重复秒杀。
INSERT ignore INTO success_killed(seckill_id,user_phone,state)
VALUES (#{seckillId},#{userPhone},0)
1.5 DAO层设计
秒杀表的DAO:减库存(id,nowtime)、由id查询商品、由偏移量查询商品
购买明细表的DAO:插入购买明细、根据商品id查询明细SucceesKill对象(携带Seckill对象)—mybatis的复合查询
减库存和增加明细的sql
<update id="reduceNumber"> UPDATE seckill SET number = number-1 WHERE seckill_id=#{seckillId} AND start_time <![CDATA[ <= ]]> #{killTime} AND end_time >= #{killTime} AND number > 0; </update> <insert id="insertSuccessKilled"> <!--当出现主键冲突时(即重复秒杀时),会报错;不想让程序报错,加入ignore--> INSERT ignore INTO success_killed(seckill_id,user_phone,state) VALUES (#{seckillId},#{userPhone},0) </insert>
1.6 Service层设计
暴露秒杀地址(接口)DTO
public class Exposer { //是否开启秒杀 private boolean exposed; //加密措施 private String md5; private long seckillId; //系统当前时间(毫秒) private long now; //秒杀的开启时间 private long start; //秒杀的结束时间 private long end;}
封装执行秒杀后的结果:是否秒杀成功
public class SeckillExecution { private long seckillId; //秒杀执行结果的状态 private int state; //状态的明文标识 private String stateInfo; //当秒杀成功时,需要传递秒杀成功的对象回去 private SuccessKilled successKilled;}
秒杀过程
接口暴露:
public Exposer exportSeckillUrl(long seckillId) { //缓存优化 Seckill seckill = getById(seckillId); //若是秒杀未开启 Date startTime = seckill.getStartTime(); Date endTime = seckill.getEndTime(); //系统当前时间 Date nowTime = new Date(); if (startTime.getTime() > nowTime.getTime() || endTime.getTime() < nowTime.getTime()) { return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime()); } //秒杀开启,返回秒杀商品的id、用给接口加密的md5 String md5 = getMD5(seckillId); return new Exposer(true, md5, seckillId); }
如果当前时间还没有到秒杀时间或者已经超过秒杀时间,秒杀处于关闭状态,那么返回秒杀的开始时间和结束时间;如果当前时间处在秒杀时间内,返回暴露地址(秒杀商品的id、用给接口加密的md5)
为什么要进行MD5加密?
我们用MD5加密的方式对秒杀地址(seckill_id)进行加密,暴露给前端用户。当用户执行秒杀的时候传递seckill_id和MD5,程序拿着seckill_id根据设置的盐值计算MD5,如果与传递的md5不一致,则表示地址被篡改了。
为什么要进行秒杀接口暴露的控制或者说进行秒杀接口的隐藏?
现实中有的用户回通过浏览器插件提前知道秒杀接口,填入参数和地址来实现自动秒杀,这对于其他用户来说是不公平的,我们也不希望看到这种情况。所以我们可以控制让用户在没有到秒杀时间的时候不能获取到秒杀地址,只返回秒杀的开始时间。当到秒杀时间的时候才
返回秒杀地址即seckill_id以及根据seckill_id和salt加密的MD5,前端再次拿着seckill_id和MD5才能执行秒杀。假如用户在秒杀开始前猜测到秒杀地址seckill_id去请求秒杀,也是不会成功的,因为它拿不到需要验证的MD5。这里的MD5相当于是用户进行秒杀的凭证。
执行秒杀:
//秒杀是否成功,成功: 增加明细,减库存;失败:抛出异常,事务回滚 @Transactional public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException { if (md5 == null || !md5.equals(getMD5(seckillId))) { //秒杀数据被重写了 throw new SeckillException("seckill data rewrite"); } //执行秒杀逻辑:增加购买明细+减库存 Date nowTime = new Date(); try { //先增加明细,然后再执行减库存的操作 int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone); //看是否该明细被重复插入,即用户是否重复秒杀 if (insertCount <= 0) { throw new RepeatKillException("seckill repeated"); } else { //减库存,热点商品竞争 int updateCount = seckillDao.reduceNumber(seckillId, nowTime); if (updateCount <= 0) { //没有更新库存记录,说明秒杀结束或者是已经卖完 rollback throw new SeckillCloseException("seckill is closed"); } else { //秒杀成功,得到成功插入的明细记录,并返回成功秒杀的信息 commit SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone); return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled); } } } catch (SeckillCloseException e1) { throw e1; } catch (RepeatKillException e2) { throw e2; } catch (Exception e) { logger.error(e.getMessage(), e); //所以编译期异常转化为运行期异常 throw new SeckillException("seckill inner error :" + e.getMessage()); } }
首先检查用户是否已经登录,查看cookie中是否有phone的信息,如果没有,返回没有注册的错误信息。
接着执行秒杀,首先验证md5,看地址是否被篡改。先增加明细(为什么要先增加明细见后面优化的过程),看是否该明细被重复插入,即用户是否重复秒杀,如果是,抛异常。然后减库存,因为sql在减库存的时候判断了当前时间和秒杀时间是否对应,如果数据库update返回0没有更新库存记录,说明秒杀结束;或者是库存已经没有主动抛出错误rollback。(前面在获取秒杀地址的时候已经挡住了秒杀关闭的请求(没到时间或者时间已过),然后从获取到秒杀地址到执行秒杀还可能会在这段时间秒杀结束)
最后秒杀成功,得到购买明细信息,接着commit。
注意事务在这里的处理:
1.7 Web层设计
交互流程
2. 初始优化设计
红色部分代表可能高并发的点,绿色表示没有影响
2.1 详情页缓存
通过CDN缓存静态资源,来抗峰值。
动静态数据分离
详情页静态资源是部署在CDN节点中,也就是说访问静态资源或者详情页是不用访问我们的系统的。
限流小技巧:用户提交之后按钮置灰,禁止重复提交
为什么要单独ajax请求获取服务器的时间?
为了保持时间一致,因为详情页放在CDN上和系统存放的位置是分离的。
2.2 秒杀接口地址缓存
无法使用CDN是因为,CDN适合的请求的资源是不易变化的。
秒杀接口是变化的,可以使用redis服务端缓存可以用集群抗住非常大的并发。1秒钟可以承受10万qps。多个Redis组成集群,可以到100w个qps
一致性:当秒杀的对象改变的时候修改我们的数据库同时修改缓存。
原本查询秒杀商品时是通过主键直接去数据库查询的,选择将数据缓存在Redis,在查询秒杀商品时先去Redis缓存中查询,以此降低数据库的压力。如果在缓存中查询不到数据再去数据库中查询,再将查询到的数据放入Redis缓存中,这样下次就可以直接去缓存中直接查询到。
这里有一个继续优化的点:在redis中存放对象是将对象序列化成byte字节。
通过Jedis储存对象的方式有大概三种
- 本项目采用的方式:将对象序列化成byte字节,最终存byte字节;
- 对象转hashmap,也就是你想表达的hash的形式,最终存map;
- 对象转json,最终存json,其实也就是字符串
其实如果你是平常的项目,并发不高,三个选择都可以,这种情况下以hash的形式更加灵活,可以对象的单个属性,但是问题来了,在秒杀的场景下,三者的效率差别很大。
10w数据 |
时间 |
内存占用 |
存json |
10s |
14M |
存byte |
6s |
6M |
存jsonMap |
10s |
20M |
存byteMap |
4s |
4M |
取json |
7s |
|
取byte |
4s |
|
取jsonmap |
7s |
|
取bytemap |
4s |
bytemap最快啊,为啥不用啊,因为项目用了超级高性能的自定义序列化工具protostuff。
2.3 秒杀操作优化
Mysql真的低效吗?
在mysql端一条update压力测试约4wQPS,即使是现在最好的秒杀产品应该也达不到这个数字。
然而实际上远没有这么高的QPS,那么时间消耗在哪呢?
串行化操作,大量的堵塞
2.3.1 瓶颈分析
客户端执行update,当我们的sql通过网络发送到mysql的时候,这本身就有网络延迟在里面,并且还有GC的时间,GC又分为新生代GC和老年代GC,新生代会暂停所有的事务代码,也就是我们的java代码,一般在几十毫秒
也即是说如果由java客户端去控制这些事务的话,update减库存,网络延迟,update数据操作结果返回,然后执行GC;然后执行insert,发生网络延迟,等待insert执行结果返回,也可能出现GC,最后commit或者rollback。当这些执行完了之后,第二个等待行锁的线程才有可能拿到这个数据行的锁,再去执行update减库存。
不是我们的mysql慢,也不是java慢,可能存在我们的java客户端执行这些sql,然后等待这些sql的结果,再去做判断再去执行这些sql,这一长串的事务在java客户端执行,但是java客户端和数据库之间会有网络延迟,或者是GC这些时间也要加载事务的执行周期里面,而同一行的事务是串行化的。
那么我们的QPS分析就是所有的sql执行时间+网络延迟时间+可能的GC,这就是当前执行一行数据的时间。
优化的方向
2.3.2 简单优化
将原本先update(减库存)再进行insert(插入购买明细)的步骤改成:先insert再update。
为什么要先insert后update?
首先是在更新操作的时候给行加锁,插入并不会加锁,如果更新操作在前,那么就需要执行完更新和插入以后事务提交或回滚才释放锁。而如果插入在前,更新在后,那么只有在更新时才会加行锁,之后在更新完以后事务提交或回滚释放锁。
在这里,插入是可以并行的,而更新由于会加行级锁是串行的。
也就是说是更新在前加锁和释放锁之间两次的网络延迟和GC,如果插入在前则加锁和释放锁之间只有一次的网络延迟和GC,也就是减少的持有锁的时间。
这里先insert并不是忽略了库存不足的情况,而是因为insert和update是在同一个事务里,光是insert并不一定会提交,只有在update成功才会提交,所以并不会造成过量插入秒杀成功记录。
2.3.3 深度优化
客户端逻辑事务SQL在MYSQL端执行,完全屏蔽网络延迟和GC,MYSQL只需告诉最终结果。
1. 阿里巴巴做了一个mysql源码层的修改方案,当执行完update之后,它会自动做回滚,回滚的条件影响的记录数是1,就会commit;如果是0就会rollback,不由java客户端来控制commit或者rollback,不给java客户端和mysql之间通信的网络延迟,本质上减低了网络延迟或者GC的干扰,但是这个成本高,要修改mysql源码,只有大公司能做。
2.我们可以将执行秒杀操作时的insert和update放到MySQL服务端的存储过程里,而Java客户端直接调用这个存储过程,这样就可以避免网络延迟和可能发生的GC影响。另外,由于我们使用了存储过程,也就使用不到Spring的事务管理了,因为在存储过程里我们会直接启用一个事务。
2.3.4 优化总结
3. 深入优化设计
3.1 初始方案问题分析
在前面针对数据库的优化中,由于数据库行级锁存在竞争造成大量的串行阻塞,我们使用了存储过程(或者触发器)等技术绑定操作,整个事务在MySQL端完成,把整个热点执行放在一个过程当中一次性完成,可以屏蔽掉网络延迟时间,减少行级锁持有时间,提高事务并发访问速度。
可是问题时并发的流量实际上都是直接穿透让MYSQL自己去抗,比如说库存是否卖完以及用户是否重复秒杀都完全是靠查询数据库去判断,造成数据库不必要的负担非常大,然而这些都可以放在缓存做一个标记在服务层进行拦截,对于中小规模的并发还可以,但是真正的超高并发,显然这个还不完善。
3.2 优化的方向和思路
方向:将请求尽量拦截在系统上游
传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小【一趟火车其实只有2000张票,200w个人来买,基本没有人能买成功,请求有效率为0】
思路:限流和削峰
限流:屏蔽掉无用的流量,允许少部分流量流向后端。
削峰:瞬时大流量峰值容易压垮系统,解决这个问题是重中之重。常用的消峰方法有异步处理、缓存和消息中间件等技术。
异步处理:秒杀系统是一个高并发系统,采用异步处理模式可以极大地提高系统并发量,其实异步处理就是削峰的一种实现方式。
缓存:秒杀系统本身是一个典型的读多写少的应用场景【一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%】,非常适合使用缓存。
消息队列:消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。
3.3 前端优化
3.3.1 静态资源缓存
1. 页面静态化
对商品详情和订单详情进行页面静态化处理,页面是存在html,动态数据是通过接口从服务端获取,实现前后端分离,静态页面无需连接数据库打开速度较动态页面会有明显提高。
2.页面缓存
通过CDN缓存静态资源,来抗峰值。不使用CDN的话也可以通过在手动渲染得到的html页面缓存到redis。
3.3.2 限流手段
1. 使用数学公式验证码
描述:点击秒杀前,先让用户输入数学公式验证码,验证正确才能进行秒杀。
好处:
1)防止恶意的机器人和爬虫
2)分散用户的请求
实现:
1)前端通过把商品id作为参数调用服务端创建验证码接口
2)服务端根据前端传过来的商品id和用户id生成验证码,并将商品id+用户id作为key,生成的验证码作为value存入redis,同时将生成的验证码输入图片写入imageIO让前端展示。
3)将用户输入的验证码与根据商品id+用户id从redis查询到的验证码对比,相同就返回验证成功,进入秒杀;不同或从redis查询的验证码为空都返回验证失败,刷新验证码重试
2. 禁止重复提交
用户提交之后按钮置灰,禁止重复提交
3.4 中间代理层
可利用负载均衡(例如反响代理Nginx等)使用多个服务器并发处理请求,减小服务器压力。
3.5 后端优化
3.5.1 控制层(网关层)
限制同一UserID访问频率:尽量拦截浏览器请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。
1. 利用缓存
设置缓存有效时间,在缓存中计数,如果在缓存的有效时间内请求的次数超了的话,就返回请求访问太频繁。
2. 利用RateLimiter
RateLimiter是guava提供的基于令牌桶算法的限流实现类,通过调整生成token的速率来限制用户频繁访问秒杀页面,从而达到防止超大流量冲垮系统。(令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
3.5.2 服务层
当用户量非常大的时候,拦截流量后的请求访问量还是非常大,此时仍需进一步优化。
1. 业务分离:将秒杀业务系统和其他业务分离,单独放在高配服务器上,可以集中资源对访问请求抗压。——应用的拆分
2. 采用消息队列缓存请求:将大流量请求写到消息队列缓存,利用服务器根据自己的处理能力主动到消息缓存队列中抓取任务处理请求,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。
3. 利用缓存应对读请求:对于读多写少业务,大部分请求是查询请求,所以可以读写分离,利用缓存分担数据库压力。
4. 利用缓存应对写请求:缓存也是可以应对写请求的,可把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
可以将缓存和消息中间件 组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。
方案:本地标记 + redis预处理 + RabbitMQ异步下单 + 客户端轮询
描述:通过三级缓冲保护,1、本地标记 2、redis预处理 3、RabbitMQ异步下单,最后才会访问数据库,这样做是为了最大力度减少对数据库的访问。
实现:
- 在秒杀阶段使用本地标记对用户秒杀过的商品做标记,若被标记过直接返回重复秒杀,未被标记才查询redis,通过本地标记来减少对redis的访问
- 抢购开始前,将商品和库存数据同步到redis中,所有的抢购操作都在redis中进行处理,通过Redis预减少库存减少数据库访问
- 为了保护系统不受高流量的冲击而导致系统崩溃的问题,使用RabbitMQ用异步队列处理下单,实际做了一层缓冲保护,做了一个窗口模型,窗口模型会实时的刷新用户秒杀的状态。
- client端用js轮询一个接口,用来获取处理状态
3.5.3 数据库层
数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧。但依然可以进行如下方向的优化:
对于秒杀系统,直接访问数据库的话,存在一个【事务竞争优化】问题,可使用存储过程(或者触发器)等技术绑定操作,整个事务在MySQL端完成,把整个热点执行放在一个过程当中一次性完成,可以屏蔽掉网络延迟时间,减少行级锁持有时间,提高事务并发访问速度。
3.7 优化秒杀流程
- 秒杀活动开始之前有个活动倒计时,时间到了则会放开秒杀的权限,并生成一个验证码展示在前面页面,并把验证结果存在redis中,这里利用redis有过期时间的特性,也给验证码的缓存加了个过期时间。这里的redis缓存用的是redis的string类型。
- 在秒杀之前先要填一个验证码verifyCode,点击秒杀按钮时,先发送ajax请求到后台获取真实的秒杀地址path,这里秒杀地址是隐藏的,目的是防止有人恶意刷秒杀接口。所谓隐藏地址,其实是在请求地址中加一段随机字符串,这段字符串是变化的,因此秒杀请求地址是动态的;
- 先说下如何获取真实的秒杀地址,后台先访问redis,验证一下这个验证码有没有过期以及这个verifyCode是不是正确,验证码验证通过后,先删除这个验证码缓存,然后生成真实地址;
- 真实地址随机字符串由uuid以及md5加密生成,并且保存在redis中,并且设置了有效期;
- 从浏览器端向秒杀地址发起请求,带上path参数去后台调用真正的秒杀接口,下面是秒杀接口的逻辑;
- 访问redis,验证path有没有过期,以及是不是正确。这里验证path以及上面的校验验证码,都是用userId对应生成的一个key值去取redis中的数据;
- path验证通过后,先访问内存标识,看秒杀的这个商品有没有卖完,减少对redis的不必要访问。每一种参与秒杀活动的商品都在内存里用HashMap设置了一个标识,标识某个商品id商品是否卖完了。这里的是否卖完的内存标识设置以及每种参与秒杀商品的库存存入redis是在系统启动时做的;
- 如果内存标识中这个商品没有卖完,则要看这个用户在这次活动中是否重复秒杀,因为我们的秒杀规则是一个用户id对于某个商品id的商品只能秒杀一件。如何判断该用户有没有秒杀过这件商品呢,秒杀记录也保存在redis缓存中;
- 如果判断秒杀过则返回提示,如果没有秒杀过,继续;
- 上面说过系统加载时redis中保存了各商品对应的库存,这里用到redis的原子操作的方法decr,将对应商品的库存减1,此时数据库时的库存还没有减,因此是预减库存;
- desc方法返回该商品此时的库存,如果小于0,说明商品已经卖完了,此次秒杀无效,并且设置该商品的内存标识为true,表示已卖完;
- 正确地预减库存后,然后就要真正操作数据库了,数据库一般是性能瓶颈,比较耗时,因此决定用异步方式处理。对于每一条秒杀请求存入消息队列RabbitMQ中,消息体中要包含哪个用户秒杀哪个商品的信息,这里是封装了一个消息体类,这样一个秒杀请求就进入了消息队列,一个秒杀请求还没有完成,真正的秒杀请求的完成得要持久化到数据库,生成订单,减了数据库的库存才能算数,这时在客户端显示的一般是排队中,比如以前在抢购小米手机时,我就看到这样的展示,过一会再刷新页面就显示没抢到;
- 消息队列处理秒杀请求。先从消息体中解析出用户id和商品id,查数据库看这个商品是否卖完了,查数据库看该用户对于这个商品是否有过秒杀记录;数据库减库存,数据库生成订单,这两项持久化地写数据库操作放在同一个事务中,要么都执行成功,要么都失败。并把秒杀记录对象,包括秒杀单号、订单号、用户id、商品id,存入redis中。如果数据库减库存失败,表明商品卖完了,则要在redis中设置该商品已卖完的标识。消息队列处理秒杀请求。先从消息体中解析出用户id和商品id,查数据库看这个商品是否卖完了,查数据库看该用户对于这个商品是否有过秒杀记录;
- 数据库减库存,数据库生成订单,这两项持久化地写数据库操作放在同一个事务中,要么都执行成功,要么都失败。并把秒杀记录对象,包括秒杀单号、订单号、用户id、商品id,存入redis中。如果数据库减库存失败,表明商品卖完了,则要在redis中设置该商品已卖完的标识。
- ajax发起秒杀请求,秒杀请求的处理逻辑最后也只是把这条请求放入消息队列,并不能返回是否秒杀成功的结果。因此,当秒杀请求正确响应后,即请求放入消息队列后,需要另外一个请求去轮询秒杀结果,秒杀成功的标志是生成秒杀订单,并把秒杀订单对象放入redis中。所以轮询秒杀结果,只用去轮询redis中是否有对应于该用户的该商品的秒杀订单对象,如果有,则表明秒杀成功,并在前台给出提示。
上面的秒杀流程对应的流程图如下:
步骤1到12,主体是redis预减库存,生成消息队列:
步骤13到14是处理消息队列:
步骤15,是客户端请求秒杀结果:
4. 问题解析
1. 如何解决库存的超卖问题?
卖超原因:
(1)一个用户同时发出了多个请求,如果库存足够,没加限制,用户就可以下多个订单。(2)减库存的sql上没有加库存数量的判断,并发的时候也会导致把库存减成负数。
解决办法:
(1):在后端的秒杀表中,对user_id和goods_id加唯一索引,确保一个用户对一个商品绝对不会生成两个订单。
(2):我们的减库存的sql上应该加上库存数量的判断
数据库自身是有行级锁的,每次减库存的时候判断count>0,它实际上是串行的执行update的,因此绝对不会卖超!。
UPDATE seckill
SET number = number-1
WHERE seckill_id=#{seckillId}
AND start_time <#{killTime}
AND end_time >= #{killTime}
AND number > 0;
2. 如何解决少卖问题—Redis预减成功而DB扣库存失败?
前面的方案中会出现一个少卖的问题。Redis在预减库存的时候,在初始化的时候就放置库存的大小,redis的原子减操作保证了多少库存就会减多少,也就会在消息队列中放多少。
现在考虑两种情况:
1)数据库那边出现非库存原因比如网络等造成减库存失败,而这时redis已经减了。
2)万一一个用户发出多个请求,而且这些请求恰巧比别的请求更早到达服务器,如果库存足够,redis就会减多次,redis提前进入卖空状态,并拒绝。不过这两种情况出现的概率都是非常低的。
两种情况都会出现少卖的问题,实际上也是缓存和数据库出现不一致的问题!
但是我们不是非得解决不一致的问题,本身使用缓存就难以保证强一致性:
在redis中设置库存比真实库存多一些就行。
3. 秒杀过程中怎么保证redis缓存和数据库的一致性?
在其他一般读大于写的场景,一般处理的原则是:缓存只做失效,不做更新。
采用Cache-Aside pattern:
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
更新:先把数据存到数据库中,成功后,再让缓存失效。
4. Redis中的库存如何与DB中的库存保持一致?
Redis中的数量不是库存,它的作用仅仅时候只是为了阻挡多余的请求透传到db,起到一个保护DB的作用。因为秒杀商品的数量是有限的,比如只有10个,让1万个请求去访问DB是没有意义的,因为最多只有10个请求会下单成功,剩余的9990个请求都是无效的,是可以不用去访问db而直接失败的。
因此,这是一个伪问题,我们是不需要保持一致的。
5. 为什么要隐藏秒杀接口?
html是可以被右键->查看源代码,如果秒杀地址写死在源文件中,是很容易就被恶意用户拿到的,就可以被机器人利用来刷接口,这对于其他用户来说是不公平的,我们也不希望看到这种情况。所以我们可以控制让用户在没有到秒杀时间的时候不能获取到秒杀地址,只返回秒杀的开始时间。
当到秒杀时间的时候才返回秒杀地址即seckill_id以及根据seckill_id和salt加密的MD5,前端再次拿着seckill_id和MD5才能执行秒杀。假如用户在秒杀开始前猜测到秒杀地址seckill_id去请求秒杀,也是不会成功的,因为它拿不到需要验证的MD5。这里的MD5相当于是用户进行秒杀的凭证。
6. 一个秒杀系统,500用户同时登陆访问服务器A,服务器B如何快速利用登录名(假设是电话号码或者邮箱)做其他查询?