[TOC]
本项目主要是模拟应对大并发场景下,如何完成商品的秒杀,以及针对秒杀场景下为应对大并发所做的优化。
项目的技术结构如下图所示:
秒杀场景下主要解决的问题:
- 分布式会话
- 用户登录、商品列表、商品详情、订单详情模块
- 缓存优化
- 系统压测,测试系统的QPS
- 信息队列
- 接口安全
本项目的分布式改造版本:https://github.com/Grootzz/dis-seckill
-
第一步;克隆仓库到本地
git clone https://github.com/Grootzz/seckill
-
第二步;构建工程
cd seckill mvn clean package
-
第三步;启动
java -jar /target seckill-1.0-SNAPSHOT.jar
访问入口:http://localhost:8080/login/to_login
初始账号/密码:18342390420/000000
。
这里的数据库设计只是为了模拟秒杀场景,实际的数据库会复杂很多。需要注意的是,因为参与秒杀的只有部分商品,所以单独建立一个miaosha_goods
存储于秒杀商品相关的字段。
- 秒杀用户表:
miaosha_user
- 商品表:
goods
- 参与秒杀的商品表:
miaosha_goods
- 秒杀订单表:
miaosha_order
- 订单表:
order_info
登录部分主要有以下几个部分:
-
明文密码两次MD5处理
-
JSR303参数检验和全局异常处理器
-
分布式Session
- 客户端:C_PASS=MD5(明文+固定salt)
- 服务端:S_PASS=MD5(C_PASS+随机salt)
加密:出于安全考虑
第一次 (在前端加密,客户端):密码加密是(明文密码+固定盐值)生成md5用于传输,目的,由于http是明文传输,当输入密码若直接发送服务端验证,此时被截取将直接获取到明文密码,获取用户信息。
加盐值是为了混淆密码,原则就是明文密码不能在网络上传输。
第二次:在服务端再次加密,当获取到前端发送来的密码后。通过MD5(密码+随机盐值)再次生成密码后存入数据库。
防止数据库被盗的情况下,通过md5反查,查获用户密码。方法是盐值会在用户登陆的时候随机生成,并存在数据库中,这个时候就会获取到。
第二次的目的: 黑客若是同时黑掉数据库,通过解析前端js文件,知道如果md5加密的过程,就知道此时用户的密码。
但是此时我们要是在后端加入随机盐值和传输密码的md5组合,黑客是无法知道通过后端密码加密过程的,从而无法知道密码。
JSR303 是一套 JavaBean 参数校验的标准,它定义了很多常用的校验注解。如@NotNull
、@Email
、@Max
等。
在这个系统中,我们自定义了一个注解@IsMobile
完成手机号码的参数检验,@IsMobile
的校验处理器为IsMobileValidator
。
定义一个全局异常GlobalException
和全局异常处理器GlobalExceptionHandler
,可以完成系统异常的捕获和异常的统一处理。
在用户登录成功之后,将用户信息存储在redis中,然后生成一个token返回给客户端,这个token为存储在redis中的用户信息的key,这样,当客户端第二次访问服务端时会携带token,首先到redis中获取查询该token对应的用户使用是否存在,这样也就不用每次到数据库中去查询是不是该用户了,从而减轻数据库的访问压力。
-
数据库设计
-
商品列表页
-
商品详情页
-
订单详情页
- 页面级缓存+URL缓存+对象缓存
- 页面静态化,前后端分离
- 静态资源优化
- CDN优化
所谓页面缓存,指的是对于服务端的请求,不直接从系统中获取页面资源,而是先从缓存中获取页面资源,如果缓存中不存在页面资源,则系统将渲染页面并存储页面到缓存中,然后将页面返回。
来看商品列表页的请求过程;请求到服务端,服务端查询数据库中的商品列表信息然后存储在Model
对象中,Thymeleaf
页面获取在Model
对象中的商品列表信息然后动态渲染,再返回给客户端。如果每次请求都做这样的工作,势必会对服务器和系统造成一定的压力(系统的压力主要来源于每次Thymeleaf
页面获取在Model
对象的信息都要渲染一次),所以可以做一个页面级的缓存,减轻数据库和系统的压力。
在本项目中,我们对商品列表页做一个缓存,因为商品列表页的数据相对表话不是太频繁,所以将其缓存在redis
中,这样不用每次都查询数据库中的商品信息,然后再使用Thymeleaf
渲染返回,而是直接从redis
中返回。另外,由于商品列表页请求返回的是html
,所以这里使用ThymeleafViewResolver
手动渲染页面,这样就可以将页面直接通过系统返回给客户端。(详细过程在edu.uestc.controller.GoodsListController#toList
中)。
而所谓URL缓存,实际上和页面缓存是一样的,在本项目中,我们对商品详情页做了缓存,商品详情页的请求需要goodsId
,也就是说,对每一个goodsId
都做了一个缓存,其他的和商品列表页的缓存思路是一致的,只不过商品取详情页是需要动态的根据goodsId
来取。
通过上面的缓存差异可知,URL缓存和页面缓存的不同之处在于,URL缓存需要根据URL中的参数动态地取缓存,而页面缓存则不需要。
一般来讲,URL缓存和页面缓存的缓存时间都比较短。在本项目中,我们设置商品详情页和商品列表页的缓存时间为60s。
对象缓存是一种更细粒度的缓存,顾名思义就是对对象就行缓存,在本项目中,我们将edu.uestc.service.MiaoshaUserService#getMiaoshaUserById
获取的对象进行了缓存,另外,edu.uestc.service.MiaoshaUserService#getMisaoshaUserByToken
获取的对象也做了一个缓存。edu.uestc.service.MiaoshaUserService#updatePassword
方法同样做了对象级的缓存,但是值得注意的是,这个方法里对缓存中的数据进行了更改,因此,需要将将更改的对象先从缓存中取出,然后删除缓存中对应的数据,然后在将新的数据更新到数据库中,再将数据缓存到redis
中。对于缓存的更新,可以参考缓存更新方法。
为什么要先删除缓存在写入缓存呢?因为如果不删除,以前的请求仍然可以访问通过原来的token
访问到以前的数据(这里的token
可以查看edu.uestc.service.MiaoshaUserService#updatePassword
的逻辑得知),除了造成数据的不一致还会有安全问题,所以需要删除以前的缓存在写入新的缓存。
页面静态化指的是将页面直接缓存到客户端。常用的技术有Angular.js
,Vue.js
。
其实现方式就是通过ajax
异步请求服务器获取动态数据,对于非动态数据部分缓存在客户端,客户端通过获取服务端返回的json
数据解析完成相应的逻辑。
在本项目中,我们对商品详情页和订单详情页做了一个静态化处理。
对于商品详情页,异步地从服务端获取商品详情信息,然后客户端完成页面渲染工作。除此之外,对于秒杀信息的获取也是通过异步获取完成的。例如,当秒杀开始时,用户执行秒杀动作,客户端就会轮询服务器获取秒杀结果。而不需要服务器直接返回页面。
而对于订单详情页,实际上也是同样的思路。
- JS/CSS压缩,减少流量。客户端完成解压工作。
- 多个JS/CSS组合,减少连接数。一次TCP连接完成多个HTTP交互。
- CDN就近访问。
CDN的全称是Content Delivery Network,即内容分发网络。CDN是构建在网络之上的内容分发网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。
-
Redis预减库存减少数据库的访问
-
内存标记减少redis访问
-
请求先入队缓冲,异步下单,增强用户体验
-
RabbitMQ安装与Spring Boot集成
-
Nginx水平扩展
-
压测
核心思想:减少对数据库的访问。
在做秒杀时,需要先查询数据库中的商品库存,确保逻辑正确,在本项目中,我们将库存信息信息存储在redis中,从而可以减少对数据库的访问。
秒杀接口优化思路:减少数据库的访问
- 系统初始化时,将商品库存信息加载到redis中;
- 服务端收到请求后,redis预减库存,如果库存不足,则直接进入下一步;
- 服务端将请求入队,立即返回向客户端返回排队中的信息,提高用户体验;
- 服务端请求出队,生成秒杀订单,减少库存;
- 客户端轮询是否秒杀成功。
实际上,访问redis也是有网络开销的,所以,在本项目中使用内存标记减少对redis的访问。
具体实现为:
- 在
edu.uestc.controller.MiaoshaController
定义了一个HashMap<Long, Boolean>
,在某一商品已经秒杀结束时,在HashMap
中对该商品的库存进行标记,如果库存为0,则将该商品标记为已经秒杀结束。然后在从redis中读取库存信息前做一次判断,如果该商品已经秒杀结束,则不用在访问redis中的库存信息。
服务器收到秒杀请求后,不是直接访问数据库,而是将请求放置在队列(RabbitMQ)中,这样可以提高用户体验。然后秒杀请求出队,服务器生成秒杀订单,减少缓存中的库存,这就是异步下单的过程。
具体的实现过程为:
- 如果秒杀商品库存尚有,则生成一条秒杀消息发送到消息队列中(信息中含有用户信息与商品id);
- 消息的消费者收到秒杀消息后,从数据库中读取用户是否已经完成秒杀,如果没有,则减库存,下订单,写入订单信息到数据库中。
- 待完成
- 待完成
超卖问题实际上是两个问题:
- 商品的库存减为负数,也就出现了超卖问题,这是不合理的;
- 同一个用户秒杀到了两个一样的商品,这种情形也是超卖,应当避免。
来看看两个问题出现的情形:
对于第一个问题,我们知道,秒杀需要执行两个关键的操作,第一个是从数据库减库存,第二个是生成订到插入到数据库,这两个操作共同构成了秒杀操作,因此,秒杀操作是一个事务。
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
// 1. 减库存
boolean success = goodsService.reduceStock(goods);
if (!success) {
setGoodsOver(goods.getId());
return null;
}
// 2. 生成订单;向order_info表和maiosha_order表中写入订单信息
return orderService.createOrder(user, goods);
}
如果商品是由10个,而达到goodsService.reduceStock()
的请求有100个,当它们同时执行减库存操作时,会导致库存变为-90,这就引发了超卖问题。如何解决呢?
来看看第减库存操作的数据库的Mapper
,
@Update("UPDATE miaosha_goods SET stock_count = stock_count-1 WHERE goods_id=#{goodsId}")
int reduceStack(MiaoshaGoods miaoshaGoods);
因为每次UPDATE
对于数据库来说都是原子的,如果每次减库存操作之前先判断库存是否大于零,则可以利用数据库层面的原子性来保证库存不会为负数,这也就解决了超卖的问题。
@Update("UPDATE miaosha_goods SET stock_count = stock_count-1 WHERE goods_id=#{goodsId} AND stock_count > 0")
int reduceStack(MiaoshaGoods miaoshaGoods);
AND stock_count > 0
即为数据库层面解决超卖的保证。
对于第二个问题,考虑一种情形,如果一个未秒杀成功的用户同时对一个商品发出两次秒杀请求,对于两次秒杀请求,服务器层面会判断用户的两次秒杀请求为合法请求,然后完成从数据库减库存和将订单插入到数据库的操作,显然,这是不合理的。因为一个用户只能秒杀一个商品,如果执行成功,则订单表中会出现两条条商品id和用户id相同的记录,一个商品的库存被同一个用户减了两次(也可能是多次),这就引发了超卖问题。
因此,为了解决这个问题,我们要充分利用事务的特性。从数据库减库存和将订单记录插入到数据库构成了事务,如果一个操作未执行成功,则事务会回滚。如果我们对miaosha_order
中的user_id
和goods_id
字段创建一个联合唯一索引,则在插入两条user_id
和goods_id
相同的记录时,将会操作失败,从而事务回滚,秒杀不成功,这就解决了同一个用户发起对一个商品同时发起多次请求引发的超卖问题。
至此,通过上述的分析,超卖问题就可以得到解决了。总结起来如下。
思路总结:
-
SQL加库存数量的判断:防止库存变为负数;
-
数据库加唯一索引:防止用户重复购买。
安全优化的手段
- 隐藏接口地址
- 数学公式验证码
- 接口限流防刷
为什么要做秒杀接口地址的隐藏?
首先看客户端秒杀执行的逻辑:
<button type="button" id="buyButton" onclick="doMiaosha()">立即秒杀</button>
function doMiaosha() {
$.ajax({
url: "/miaosha/do_miaosha_static",
type: "POST",
data: {
goodsId: $("#goodsId").val(),
},
success: function (data) {
if (data.code == 0) {
getMiaoshaResult($("#goodsId").val());
} else {
layer.msg(data.msg);
}
},
error: function () {
layer.msg("客户端请求有误");
}
});
}
用户点击秒杀按钮后,会向服务端请求秒杀商品的秒杀信息,客户端的POST请求是以明文的方式发送给服务器的,如果使用一种工具将POST请求体中的数据和请求的URL组合起来,构成一个完整的POST请求,然后不停地向服务器请求资源,则会给服务器带来很大的压力,同时,这样一种作弊的方式带来的用户体验也是极差的,这样一种设计缺陷会被别有用心的人用于不正当交易,因此,需要一种方式克服这种缺陷,这就引出了秒杀接口的隐藏。
怎么做秒杀接口地址的隐藏?
在秒杀开始之前,秒杀接口地址不要写到客户端,而是在秒杀开始之后,将秒杀地址动态地在客户端和服务器间进行交互完成拼接。这样一来,秒杀开始之前,秒杀地址对客户端不可见。
实现思路:
- 秒杀开始之前,先去请求接口获取秒杀地址;
- 接口改造,带上
@pathVariable
参数; - 添加生成地址的接口;
- 秒杀收到请求,先验证
@pathVariable
参数。
用户在提交获取秒杀地址的请求之前,需要将goodsId和verifyCode一同提交到服务端,服务器通过@RequestParam
参数获取goodsId和verifyCode,然后检验验证码是否正确,如果正确,则返回秒杀地址给客户端,客户端得到秒杀地址后,拼接秒杀地址然后异步地向这个地址发出请求获取秒杀结果,这样就完成了秒杀接口地址的隐藏。
需要注意的是,这里需要将goodsId和verifyCode一同提交到服务端做校验,如果只提交goodsId,那么客户端仍然可以使用明文的方式获取随机生成的接口秒杀地址,但是,引入了verifyCode后,客户端需要将验证码也一起发送到服务端做验证,验证成功才返回随机生成的秒杀地址,不成功则返回非法请求,通过这样一种双重验证的方式,就可以方式用户使用不合理的手段参与秒杀,引入验证码有效地防止了这一点,因为验证码的输入需要用户真正参与进来。
这种方式主要是防止客户端通过明文地址+goodsId将秒杀请求不停地发送到服务端,同时,也有效的防止机器人等手段参与秒杀。
验证码的作用:
- 防止利用机器人等手段防止非目标用户参与秒杀;
- 减少单位时间内的请求数量。对于一个秒杀商品,在开始秒杀后肯定会有许多用户参与秒杀,那么在开始秒杀的时候,用户请求数量是巨大,从而对服务器产生较大的压力,而通过验证码的方式就可以有效地将集中式的请求分散,从而达到削减请求峰值的目的。
实现思路:
在服务端计算出验证码的表达式的值,存储在服务端,客户端输入验证码的表达式值,传入服务端进行验证。
- 点击秒杀之前,向让用户输入验证码,分散用户的请求;
- 添加生成验证码的接口;
- 在获取秒杀路径的时候,验证验证码;
- ScriptEngine的使用(用于计算验证码上的表达式)。
当秒杀未开始时,商品详情页异步地向服务端发出获取商品详细信息的请求,同时,获取验证码。服务端收到获取验证码的请求后,生成验证码返回给客户端,同时,将验证码的结果存储再redis中,以便客户端发起秒杀请求时做验证码的校验。
有了对用户的访问进行一定的限制,就可以减轻服务器压力。例如通过访问次数的限制就是一种限流防刷的手段。即限制用户下一定的时间间隔内对接口的访问次数。
实现思路:对接口限流
一般来讲如果使用计时器来做这个功能,实现起来比较复杂。在这里,我们可以充分利用redis中的key-value过期机制来完成。
在redis中存储一个用于记录访问次数的变量,在过期时间内被继续访问,则次数变量加1,如果在过期时间内访问次数超出限制,则返回“频繁提交提示用户”。过期时间到了之后,将该变量删除。
因为可能需要对很对接口对限流防刷操作,如果对每一个接口都实现一遍限流防刷,则会导致代码过度冗余,因此,可以定义一个方法拦截器@AccessInterceptor
拦截用户对接口的请求,统一对拦截限流逻辑处理,这样可以有效地减少代码的冗余。针对需要拦截请求的接口,添加注解@AccessLimit
即可。