秒杀专题-系统的设计(一)
观察从客户端请求访问到服务器,整个过程经历了 从服务器网关->代码(Service层)->数据库
根据木桶理论,整个访问的速度取决于系统中响应速度最慢的地方。而访问数据库是内存对磁盘进行IO,是系统中效率速度最低的地方,同时数据库所支持的QPS也是最小的,所有系统在大并发时数据库是最容易崩溃的。
因此所有对并发秒杀的优化核心在于如何减少全部请求直接对数据库的访问,全部思路和技术也是围绕此展开
简单优化思路如下:
将数据放到Redis中,也就是放到内存中,提高查询的效率,而不去直接访问DB,对于已经秒杀完的产品可以直接全部返回。绝大部分秒杀失败的请求都被Redis挡住,快速做了处理。
使用MQ,将Redis放进处理程序的请求进行异步处理,直接对用户进行返回而不等待同步处理完成。提高了对用户的响应速度,但并没有减少整体的处理时间,因为到实际处理的代码还是同步在操作数据库(创建订单,减库存)。
前端的缓存,页面缓存,减少对刷新页面服务器的请求
总结上面的思路就得到了如下处理方式:
把所有可以缓存的东西缓存起来
用户登录:用户第一次登录是携带账号,密码进行登录的,必须要查询一次数据库。第一次之后就用
token
存到用户cookie
中进行登录,但是这个token
如果写到DB中那么之后的登录即使是检查token
登陆也要访问DB,这就是需要避免的。所以可以将用户对象存储起来,(使用JSON提供的功能,对象可以被序列化也可以通过字节码反序列化变成对象),那么之后用户再登录直接通过token
就能在redis
中找到用户对象进行使用秒杀商品的库存信息:显然每次秒杀后库存是减少了的,但不应该立即就去
MySQL
中进行修改,那样又会直接访问DB,这些信息同样也是缓存在redis
中。秒杀商品的订单信息:用户秒杀之后生成了对应的订单用来组织用户再次秒杀,那么显然成功秒杀的订单应该存在于
redis
中。秒杀是否已经结束:正常来说用户每次访问都会先去
redis
中查询库存尝试减库存,但是考虑到redis
也是通过网络提供服务,所以对于秒杀是否还在进行这种信息(不需要入库的信息)可以直接放在本地内存中(使用内存标记)其实就是使用一个数据结构存储特定商品的秒杀状态。
单机优化思路
增加redis缓存,在Redis中减库存。所有请求都会过redis,只有成功减库存的才会进行MySQL。减少了 除秒杀成功之外的请求,增加了全部请求对Redis的访问。
使用内存标记,在库存已经减完的情况下不再去访问Redis,请求redis也算网络开销了,内存中就是JVM可以直接访问到,速度最快。只有在内存标记被置为售完之前的请求,(瞬间并发冲进来的那一部分请求)会访问redis,修改完内存标记后,剩下的请求不会访问redis了。
使用MQ提升用户体验。首先MQ将创建订单和MySQL中真实减库存的操作去异步处理,但是这一步是没有提升效率的,因为原本即使并发去执行操作MySQL,也是线程安全的(而且因为Redis保证了进来的线程均是秒杀成功的线程)而且是串行执行的,放到MQ中仍然是串行执行(执行的线程数也一样)。但是区别在于整个串行执行过程中,所有秒杀到商品的线程是在阻塞等待去操作MySQL(操作同一行的会阻塞,也就是减库存),客户端的请求也就阻塞了,而异步可以马上给用户一个反馈,并让客户端再进行定时来请求结果(结果是存在Redis中的)。那么这样,原本阻塞到减库存和创建订单全部成功的长请求,被分割成了两段,第一次请求可以快速响应,第二段是连续多段的缓存访问,阻塞DB->快速响应,查询结果(redis),(DB服务被MQ去执行了).
我们可以得到目前的系统链路图大致如下:
可以看到,目前为止整个系统对于DB的冲击已经十分小了。单机的QPS就差不多这样了。但是这个系统仍然还有其他非常多值得讨论的细节。
但是后端还有一些需要进行处理的问题,比如超卖&重复秒杀
超卖&重复秒杀
这个算是最好解决的问题了。超卖问题出现在程序直接去检查MySQL
中的库存来作为库存是否充足的判断标准,但实际上一个服务线程的运行流程是:检查库存
->减少库存
。这中间至少包含了两步,一定不是原子性的操作,而导致了多个服务线程可以减少同一份库存。
只需要直接操作Redis
中的缓存就可以了,无论多少个线程都是被Redis
单线程执行的,每个线程的操作结果一定正确。而之后只需要判断操作结果是否大于等于0即可,线程就安全了,代码如下:
@RequestMapping("/seckill/{id}")
public Result SecKill(@PathVariable("id") String secId, HttpServletRequest request){
//获取登录用户
User user = secKillService.getLoginUser(request);
if(user==null){
return ResultUtil.error(CodeMsgUtil.USER_NOT_LOGIN);
}
//加内存标记
if(proMap.containsKey(secId)&&proMap.get(secId)){
return ResultUtil.error(CodeMsgUtil.SEC_SOLD_OUT);
}
//预减库存
Long remain = redisService.decr(secId); //redis是安全的
if(remain < 0){
proMap.put(secId, true);
//不能秒杀的归还库存
redisService.incr(secId);
return ResultUtil.error(CodeMsgUtil.SEC_SOLD_OUT);
}
//到这里多少并发的线程都是线程安全的了
如果一定要检查MySQL
去看库存数量,那么在sql语句
中加上对库存数量≥0的限制就可以了,这样会有很多的线程尝试去减库存,但只有等于秒杀商品数量的线程可以成功减库存。因为MySQL
中对同一行数据的操作(同一件商品的库存信息)是加了行锁的,所以在这里也变成了线程安全的操作。
<update id="SecKillGoods">
update seckill_goods_list
set stock_count = stock_count - 1
where good_id = #{secId} and stock_count > 0
</update>
而对于重复秒杀而已,订单在MQ
中处理完成之后会写入到Redis
中,用于之后用户再次秒杀时阻止。但MQ
处理订单,到写入Redis
中间有相当长的一段时间,可能此时用户已经再次进来秒杀。所以这里存在的情况是:任务刚进MQ
队列,还没有写MySQL
也没有写Redis
,所以此时去Redis
和MySQL
中检查是都无法得到订单信息的。
这里考虑一种在订单表中通过用户id和商品id建立唯一索引(用户和秒杀的商品联系起来)的方法,MySQL
自身的特性会阻止第一个订单之后的订单写入,那么这样MQ
最终在创建重复订单时就会失败,重复秒杀就不可能了。
当然还可以利用Redis
在缓存中多记录一些信息来实现,充分利用Redis
的单线程特性。