针对秒杀项目做的一些优化
Posted 赵jc
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了针对秒杀项目做的一些优化相关的知识,希望对你有一定的参考价值。
秒杀
做了一个秒杀项目,并对其做了一定的优化!
业务逻辑
数据库的设计
- 为什么将秒杀的商品单独建一张表(秒杀表)而不再商品表添加一个字段来判断呢,因为今天可能是秒杀,明天可能是9.9包邮,这样的话导致商品表越来越难以维护
一些全局配置
- 全局异常处理
- 错误信息描述
JSR303参数验证(手机号)
使用JSR对参数进行验证(这里以手机号为例)
登录
-
用ThreadLocal存储用户的信息,多线程情况下保证用户的安全(收到请求,并且到响应完成都是一个请求(一个线程)所以使用ThreadLocal)
-
商品页、商品详情页 需要判断session状态。我们实现了一个HandlerMethodArgumentResolver这个接口,并重写了supportsParameter和resolveArgument两个方法,这两个方法可以获取到control注解类所带的参数,判断是否有SeckkillUser这个参数(是否有秒杀用户),有的话就是登录了可以进行接下来获取用户的操作的操作,没有的话就返回。
-
进行登录验证,有时候手机端token并不是放在cookie中传递,而直接放在参数中传递(兼容性)
-
秒杀场景肯定不可能一个服务器:第一个用户session放在第一个服务器,用户第二次登录session放在了第二台服务器,此时导致session失效,所以需要用一个redis单独管理我们的session(分布式session)
- 但容器当中的session过期规则是最后一次的访问时间加上过期时间,所以我们需要重新设置cookie
分布式Session问题?
互联网公司为了可以支撑更大的流量,后端往往需要多态服务器共同来支撑前端用户请求,那如果用户A服务器登录了,第二次请求的时候跑到服务B就会出现登录失效的问题
分布式Session一般会有以下几种解决方案:
- 客户端存储:直接将信息存储在cookie中,cookie是存储在客户端上的一小段数据,客户端通过http协议进行cookie交互,通常用来存储一些不敏感的信息
- Session复制:任何一个服务器上的Session发生改变(增删改)该节点会将这个Session的所有内容序列化,然后广播给其他所有节点
- 共享session:将用户的Session等信息使用缓存中间件(例如Redis)来统一管理,保障分发到每一个服务器的相应结果都一致
建议采用共享Session的方案
秒杀
秒杀主要分为以下几步
第一步:判断商品库存是否充足
第二步:判断是否已经秒杀到了
以下三步属于原子操作,需要放在一个事务中进行
第三步:减库存
第四步:下订单
第五步:写入秒杀订单
关于超卖问题的解决
- 数据库加唯一索引:防止用户重复购买
- SQL加库存数量判断:防止库存变为负数
整体流程
登录
- 1.用户先进行登录,但此时会被accessIntercptor拦截器拦截到(用户通过token去获取user也没有获取到为null,并将这个null存到ThreadLocal里面去),但因为并没有添加注解,所以并不做拦截,直接返回true,
- 2.之后通过UserArgumentResolver去判断登录状态,因为ThreadLocal存储的是空对象,所以此时是未登录状态
- 3.接下来才是真正的登录方法,获取到前端传递来的信息进行判断,先根据userId在Redis中进行查找,如果缓存中有用户信息,则直接返回用户信息,如果不存在则在数据库中查找,如果数据库中存在的话,将用户的信息存放到redis缓存中去,并返回user信息,如果不存在user,则直接返回用户不存在,不在进行接下来的流程
- 4.如果用户存在,在将输入的密码进行再次加密后与数据库中的密码进行对比,一致的话才进行接下来的操作,不一致的话直接返回密码不一致
- 5密码一致的话,生成token,并将token和对应的user信息存储到redis中去,并将token放到cookie中去返回给前端
- 到这里的话,才算一次真正的登录完成
秒杀前
- 前端不断获取后端秒杀商品的开始时间和结束时间,然后推算出秒杀的状态和距离秒杀的开始时间,之后前端根据秒杀的状态和剩余时间,显示秒杀按钮的转态,和还剩多少时间秒杀开始
秒杀时
- 1.系统初始化的时候先获取到秒杀商品的列表,之后遍历秒杀商品的列表,将秒杀商品对应的库存放到redis中去,在维护一个内存标记的map,也一起和库存数量对应起来,默认为false(减少对redis的访问)
- 2.先去预减库存,在判断是否重复秒杀
- 3.之后再放入消息队列中,返回排队中
- 4.消息队列receive的时候进行二次判断,判断库存是否充足,是否重复秒杀,如果都满足的话才进入真正的秒杀方法
- 5.真正开始秒杀,去数据库中减库存,如果减库存成功了,才进行创建订单的操作(
生成订单和秒杀订单也是一个原子性操作,需要用一个事务来绑定
,创建订单成功后,在redis中将该userID和goodsId及对应的订单信息存到redis当中去,方便判断重复秒杀的时候使用),如果减库存失败,直接返回商品已被秒杀完,并在redis做一个标记(方便轮询的时候使用) - 6.前端不断的去轮询判断状态,如果秒杀成功,则返回秒杀的商品id,如果存在商品已经秒杀完的标记,则直接返回秒杀失败,否则继续轮询等待
优化
优化前QPS:500x10 1206
优化后QPS:500x10 1560
优化的核心思路便是:减少对数据的访问
这里没有设计数据库的分库分表。
UserArgumentResolver 和AccessInterceptor
接口的限流使用的是注解加拦截器的方式实现的
- AccessInterceptor继承HandlerIntercepetorAdapter,并重写了prehandler()这个方法,方法会先通过token去查找用户,之后将用户的信息存放在一个ThreadLocal里面,方便后续判断登录状态时使用(因为一个请求到响应完成都是在一个线程里面的),之后再通过hannlermethod的getAnnotation这个方法获取到注解,如果注解为空则不做任何处理,如果注解不为空,则进行一系列的判断
判断用户的登录状态是用UserArgumentResolver实现的
- UserArgumentResolver实现了HandlerMethodArgument这个接口,并重写了supportsParmeter()和resvolerArgumen()这两个方法,supportsParmeter这个方法是判断control中的参数是否含有seckkill这个参数,如果有的话才允许接下来resvolerArgument()的判断,如果没有的话直接返回即可,之后再resvolerArgument()中可以进行判断(从ThreadLocal里面去取值)
最后不要忘记将AccessInteceptor和UserArgumentResvoler注册到webmvcconfigureAdapter适配器当中去
缓存优化
Redis缓存中缓存了哪些内容
- 1.用户userId对应的秒杀用户
- 2.token对应的秒杀用户
- 3.如果商品秒杀成功,并创建了订单,则将userId和goodsId对应的订单信息存放到redis缓存中去,方便判断是否重复秒杀的时候用到
- 4.系统初始化的时候,从数据库中的到所有秒杀商品的列表,遍历列表,将goodsId对应的库存数量放到redis当中去,并在内存使用一个map去标记,默认为false,之后的的请求会先判断这个标记对应的商品库存是否还有剩余,如果没有的话直接返回,如果有的话,才会进行接下来的操作(redis预减库存,判断是否重复秒杀)
- 5.如果在真正秒杀时,去数据库减库存失败,则会将该秒杀商品在redis做一个标记,方便轮询的时候查看,如果有这个标记,则直接返回
- 6.接口限流时,将一些accesskey存放到redis中去自增操作,并进行判断
Redis的封装
- 使用JedisPool池化技术方便多次使用,并对Redis进行封装,方便使用
- redis一个key获取一个value,只用string来当key的话不利于维护,所以引入了对应的前缀keyPrefix,每个模块对应自己的keyPrefix方便管理,通过自己模块的变量不同来进行区分
页面缓存
将整个页面在Redis中缓存,减少对数据库的查询和页面的渲染次数
-
取缓存
-
手动渲染模板(使用ThymeleafViewResolver来帮助我们手动渲染页面)
- 结果输出
对象缓存
将秒杀用户的对象缓存起来,方便使用
客户端的缓存(页面静态化+前后端分离)
不再使用thymeleaf,而使用AJAX和jQuery(高级的有vue.js)
接口优化
用rabbitmq将同步下单改为异步下单
- 系统初始化,把商品库存数量加载到Redis中(实现InitializingBean接口,重写afterPropertiesSet将商品的库存加载到Redis缓存中)
- 收到请求,Redis预减库存,库存不足,直接返回,否则进入队列(但如果一次性来很多请求,假如库存有10个,但一次性来了100个请求,11以后的请求都回去预减库存,但此时库存已经为0了,所以加了一个localGoodsOverMap来做内存标记减少对redis的访问)
-
请求入队,立即返回排队中,此时并不是秒杀成功(就像12306抢票,正在抢票中,请等待,并不表示一定会成功,也有可能失败)
-
请求出队,生成订单,减少库存(核心:异步下单)在receive当中真正去判断库存、创建订单
- 客户端轮询,是否秒杀成功(与上一步是并行操作)
安全方面
明文密码两次MD5处理
因为使用的是http协议,所以在网络传输时有一定的安全问题,所以使用了MD5加密的方式,但现在有很多MD5反推到工具,所以使用了两次MD5+salt(密匙)的方式来进行加密。
- 第一步:客户端存储一个固定的salt,用户输入密码后,用salt和MD5加密后的密码用form表单的方式传送给后端
- 第二步:后端拿到客户端传来的密码后,在使用用户自己填的salt和MD5进行二次加密
- 将两次加密后的密码存储到数据库中
秒杀接口地址隐藏
思路:秒杀开始前,先去请求接口获取秒杀地址
- 前端先通过后端获取一个任意的字符串来当做path(createSeckkillPath),之后的秒杀请求都需要带上这个path参数
- 改造秒杀接口(带上pathvariable参数),后端检查前端的path和之前返回给前端的path是否相同,如果相同才执行以下的逻辑
接口限流
使用了注解加拦截器的方式来限制访问次数
一些常见的问题
- 1.商品表和秒杀商品表是否有一定主外键的关系?
商品表和秒杀商品表不存在主外键的关系,是存在一些冗余(但正因为存在一定的冗余,所有导致后期便于维护)
- 2.全局异常处理的参数检验异常是什么?
jsr303的参数检验异常 - 3.jsr303参数检验的概念
JSR 303是Java为bean数据合法性校验提供的标准框架。
对登录信息中的手机号和密码进行检验
用正则表达式对手机号进行验证
手机号校验中pattern对象中的complie方法用于编译一个正则表达式,并返回一个编译好的pattern对象; pattern中的macher方法用于封装一个要操作的字符串,并返回macher对象,用于操作字符串;macher类中的maches方法用于判断字符串和正则表达式是否匹配 - 4.threadlocal用于存储用户的基本信息,线程的本地变量,再多线程情况下保证线程的安全(服务端接收到请求,再到响应请求完成都是一个线程),所以可以使用threadlocal 好处:保证多线程情况下的安全,进行线程级别的参数传递坏处:不可继承,配合线程池使用时如果没有remove的话会造成脏读和内存泄漏(因为threadlocal底层是一个map结构,key存储的就是当前thredlocal的class,value存储的是具体的值,是一个强引用,在gc的时候不会被回收,所以导致内存泄漏,而内存泄漏如果不及时处理的话,就会导致内存溢出,比较麻烦)
新跳转一个页面的时候进行拦截,先将user放到ThreadLocal里,之后查看是否有注解,如果有进行判断,如果没有则什么都不做
- 5.生成token,先将token和其对应的用户放在redis中,并将token放在cookie中(名称和有效期),返回给客户端,但当用户再次通过token获取到用户时,需要延长redis缓存中token的有效期(需要重新进行设置,reis缓存中和客户端都需要重新设置)
- 6.页面缓存,将整个页面放在redis中缓存,减少对数据库的查询和页面的渲染次数(一般是客户端浏览器自动帮我们渲染的,这里我们手动渲染后,在设置到缓存里,之后再次请求的时候就会直接从redis中去取)
- 7.对象级别的缓存,将秒杀用户的对象缓存起来(在哪里用到了,为什么方便)第一次登录的时候将用户存储到缓存中去,之后再次登录的话就可以直接从缓存中去取,或者之后拦截器进行拦截的时候,根据token获取用户的时候可以直接从缓存中获取用户信息
- 8.页面静态化(客户端的缓存)这里使用的是比较简单的ajax和jquery,高级的有vue.js
- 9.接口优化,使用rabbitmq将同步下单改为异步下单(系统初始化的时候,将商品的数量加载到redis中,并使用内存标记默认为false,当redis库存减少为0时改为true,之后的请求直接返回,以此来减少对redis的访问)
- 10.异步下单(收到请求后,先封装一个seckillmessage信息,然后将这个信息发送到消息队列中去(seckkillqueue),然后立即返回排队中,但此时并不代表秒杀成功,也有可能会秒杀失败,就像12306抢票一样,之后才从队列中取消息,在对取到的消息进行一系列的操作(真正的去判断库存,判断是否已经秒杀到了,开始真正的秒杀),此时在客户端会并行的一直去轮询进行判断是否已经秒杀到了,如果有则跳转到秒杀订单,如果没有则根据返回的状态吗判断是继续轮询还是商品已经被秒杀完了
- 11.安全方面,明文密码两次md5进行处理(input输入的第一次md5加密,将加密后的字符串在进行第二次md5加密之后存到数据库中
- 12.秒杀接口地址隐藏(秒杀开始前,前端先请求后端返回一个path字符串,之后的秒杀请求都会带上这个字符串,后端会检查前端的path和之前返回的path是否相同,如果相同才进行接下来的逻辑,如果不同直接返回请求非法)
- 13.接口限流(使用了注解加拦截器的方式来限制访问的次数,如果没有添加注解,注解为空则没有任何限制,如果有注解则需要进行一系列的判断)
拓展
秒杀常见的一些问题和解决办法
- 高并发:秒杀的特点就是这样时间极短、 瞬间用户量大。高并发是我们不得不面对的一个问题。
- 超卖:但凡是个秒杀,都怕超卖(本文采用了为订单表的相关字段设置唯一索引和减少库存时的sql语句中进行库存判断的方法去防止超卖)
- 恶意请求:黄牛可能用机器去跟我抢,而我们肯定抢不过机器啦!
- 链接暴露:内部开发人员知道秒杀的链接,提前去请求秒杀地址。
- 数据库:每秒上万甚至十几万的QPS(每秒请求数)直接打到数据库,基本上都要把库打挂掉,而且你服务不单单是做秒杀的还涉及其他的业务,你没做降级、限流、熔断啥的,别的一起都会挂掉。
前端解决
- 资源静态化:秒杀一般都是特定的商品还有页面模板,现在一般都是前后端分离的,页面一般都是不会经过后端的,但是前端也要有自己的服务器啊,那么就把能提前放入到cdn服务器的东西都放进去,减少真正秒杀的时候后端服务器的压力(本项目使用的是简单的Ajax和jquery的方式实现前后端分离,也有更高级的vue和cdn服务器)
- 秒杀地址隐藏:我们上面说了链接要是提前暴露出去可能有人直接访问url就提前秒杀了,所以我们需要改造成动态的url,真正秒杀时先去后端请求一个随机加密的字符串当做path,之后的请求都需要携带这个path才行
- 秒杀按钮置灰:没到秒杀时间,一般秒杀按钮都是置灰的,只有到秒杀时间了,秒杀按钮才能点击,这是因为怕大家在快到时间的最后几秒疯狂点击,导致大量的请求去请求服务器,导致服务器挂掉,这个时候就需要前端的配合,定时的去后端服务器,获取最新的使劲,到指定的时间点时才给按钮设置为可用状态。并且按钮点击之后也可以置灰几秒,不然也会一直点击导致重复请求
- 前端限流:这个很简单,一般秒杀不会让你一直点的,一般都是点击一下或者两下然后几秒之后才可以继续点击,这也是保护服务器的一种手段。,或一定时间内允许点击几次(我们使用注解加拦截器的方式来限制前端请求这个接口的次数)
- 后端限流:我们使用的是内存标记法(系统初始化之前会先将每件商品的库存加载到redis中去,并用一个map去对应每件商品的库存,默认为false,之后的请求都会去redis中预减库存,如果对应商品对应的库存为false,则进行接下来的秒杀,当库存为0时,改变对应商品库存为true,之后请求判断map中为true时,直接返回无库存)
- Nginx(高性能web服务器):大家想必都不陌生了吧,这玩意是高性能的web服务器,并发也随便顶几万不是梦,但是我们的Tomcat只能顶几百的并发呀,那简单呀负载均衡嘛,一台服务几百,那就多搞点,在秒杀的时候多租点流量机。
后端
- 服务单一职责:秒杀模块和订单模块、用户登录模块分开,单独为秒杀建立一个服务。如果秒杀服务真的挂掉的话也不影响其他的功能模块
- Redis集群:之前不是说单机的Redis顶不住嘛,那简单多找几个兄弟啊,秒杀本来就是读多写少,那你们是不是瞬间想起来我之前跟你们提到过的,Redis集群,主从同步、读写分离,我们还搞点哨兵,开启持久化直接无敌高可用!
- 库存预热:秒杀的本质,就是对库存的争抢,每个秒杀的用户来请求后都去数据库中查询检验,然后预减库存,下订单啥的十分麻烦,所以我们可以在真正秒杀开始前,先将库存放到redis当中,秒杀开始后直接去redis中去判断是否还有库存和预减库存,如果还有库存的话在真正的去数据库当中修改库存
但是用了Redis就有一个问题了,我们上面说了我们采用主从,就是我们会去读取库存然后再判断然后有库存才去减库存,正常情况没问题,但是高并发的情况问题就很大了。
**多品几遍!!!**就比如现在库存只剩下1个了,我们高并发嘛,4个服务器一起查询了发现都是还有1个,那大家都觉得是自己抢到了,就都去扣库存,那结果就变成了-3,是的只有一个是真的抢到了,别的都是超卖的。咋办?那就是使用事务啦
- 事务:我们将真正的秒杀三部曲(减库存、下订单、创建秒杀订单)放在一个事务中,保证这三步要么全部执行,要么全部不执行
- 限流&降级&熔断&隔离: 这个为啥要做呢,不怕一万就怕万一,万一你真的顶不住了,限流,顶不住就挡一部分出去但是不能说不行,降级,降级了还是被打挂了,熔断,至少不要影响别的系统,隔离,你本身就独立的,但是你会调用其他的系统嘛,你快不行了你别拖累兄弟们啊
- 消息队列(削峰填谷):一说到这个名词,很多小伙伴就知道了,秒杀就是这种瞬间流量很高,但是平时又没有流量的场景,那消息队列完全契合这样的场景了呀,削峰填谷。
- 数据库:单独给秒杀建立一个数据库,为秒杀服务,表的设计也是尽可能的简单点,现在的互联网架构部署都是分库的。至于表就看大家怎么设计了,该设置索引的地方还是要设置索引的,建完后记得用explain看看SQL的执行计划。(不了解的小伙伴也没事,mysql章节去康康)
以上是关于针对秒杀项目做的一些优化的主要内容,如果未能解决你的问题,请参考以下文章