秒杀就是同一个时刻有大量的请求争抢购买同一个商品,并且完成交易的过程
也就是大量的并发读和并发写
先制作一个增删改查的秒杀系统,但是想让这个系统支持高并发访问就没那么容易了,
如何让这个秒杀系统面对百万级的请求流量不出故障,如何保证高并发情况下的数据一致性问题
rabbitmq主要是用来做一些异步,解耦模块,也可以用来流量削峰
课程介绍:
先是搭建项目
然后是分布式共享session
然后是开发秒杀功能(简单的增删改查),进行压力测试(可能会出现商品超卖,并发扛不住的问题):这里主要是四个功能,商品列表,商品详情,秒杀,订单详情
最后进行优化(主要是三方面的优化,页面优化,服务优化,接口安全上的优化)
页面优化:主要是缓存(页面缓存,url缓存,对象缓存),还有静态化分离(页面静态化,前后端分离),还有静态资源的优化
服务优化:RabbitMQ消息队列,分布式锁
安全优化:隐藏秒杀的地址(前期这个地址不会出来,只有到点了,才会出现秒杀的地址,让用户进行秒杀),验证码(区分是真实的用户还是脚本),接口限流(对于频繁访问的ip做一些控制)
总共五张表,用户表,商品表,秒杀商品表,订单表,秒杀订单表
由于商品可能秒杀和不秒杀价格可能同时存在,所以特地建一张秒杀商品表,区分于商品表
1.数据库
用户表t_user
(1)id 用户id 也就是手机号码
(2)nickname 昵称
(3)password MD5(MD5(password+salt)+salt) 经过两次md5加密
(4)salt
(5)head 头像
(6)register_date 注册时间
(7)last_login_date 最后一次登陆时间
(8)login_count 登录次数
商品表t_goods
(1)id 商品id
(2) goods_name
(3)goods_title
(4)goods_img
(5)goods_detail
(6)goods_price
(7)goods_stock 商品库存 ,-1表示数量没有限制
订单表t_order
(1)id 商品id
(2) user_id 用户id,谁创建的这个订单
(3)goods_id 商品id
(4)delivery_addr_id 收货地址id
(5)goods_name 商品名称
(6)goods_count 商品数量,买几个
(7)goods_price 商品单价
(8)order_channel 下单渠道,1表示pc端,2表示安卓,3表示ios
(9)status 订单状态:0表示新建未支付,1表示已支付,2表示已发货,3表示已收货,4表示已退款,5表示已完成
(10)create_date 订单创建时间
(11)pay_date 支付时间
t_seckill_goods 秒杀商品表
(1)id 秒杀商品id
(2)goods_id 商品id
(3)seckill_price 秒杀价格
(4)stock_count 库存数量
(5)start_date 秒杀开始时间
(6)end_date 秒杀结束时间
t_seckill_order 秒杀订单表
(1)id 秒杀订单id
(2)user_id 用户id
(3)order_id 订单id
(4)goods_id 商品id
秒杀商品表 有人说这个表是多余的,直接在商品表中增加一个字段,这个字段为0表示普通商品,这个字段为1,表示秒杀商品,但是这样有一些问题,商品的秒杀时和平常的价格是不一样的
1. 登录功能
登陆成功就会跳转到商品列表页面list.html,点击商品,进入详情页detail.html
而且登陆成功需要生成一个登录凭证ticket,将登录凭证保存到cookie里面
//生成一个随机字符串作为用户的ticket
String ticket=UUIDUtil.uuid();
//user是用户实体类对象,将ticket字符串:user对象添加到session里面
request.getSession().setAttribute(ticket,user);
//再将session添加到cookie里面,第三个参数是这个cokkie字段的名字
CookieUtil.setCookie(request,response,"userTicket",ticket);
登录后我们查看cookie:
2.分布式session
如果我们将登录代码部署在多台服务器上面,
ngnix采用的默认的负载均衡策略是轮询,请求会按照时间顺序逐一分发到后端服务器上去
也就是说:一开始我们可能是在tomcat1上面去登录,这样用户信息就存在tomcat1的session里面,接下来请求可能就被分发到tomcat2上面,此时tomcat2的session里面没有用户信息,于是又要重新登录,这就是分布式session问题
有以下几个解决问题的方法:
(1)Session复制
就是将其中一台服务器的session复制给其他服务器
这样做的优点在于不需要修改代码,只需修改tomcat的配置即可,非常简单
缺点是session的同步传输会占用内网的带宽,多台tomcat同步的话性能会指数级下降,每一台服务器都保存相同的session特别占用内存,造成了内存浪费(冗余)
(2)前端存储
这样的优点是不占用服务器端内存
但是缺点是存在安全风险,cookie很容易被拦截,而且大小也受制于cookie的大小
(3)Session粘滞
当用户发出第一个request后,负载均衡器动态的把该用户分配到某个节点,并记录该节点的路由,以后该用户的所有request都绑定到这个路由,该用户只会与该server发生交互
缺点是:如果某台服务器挂掉了,Session就会丢失
(4)后端集中存储(也叫使用redis实现分布式session)
就是将这个session存储在redis里,请求来了之后,不管哪个服务器分配来处理这个请求,去redis里面查出这个session
我们采取的是后端集中存储的方法,将session存储到redis里面,不管哪个服务器要用户信息,直接去redis中获取
使用redis实现分布式session,有两种实现方法
方法一:使用spring session来实现
引入redis和spring session的依赖
在application.yml配置文件中进行配置
redis:
host:192.168.10.100 //redis服务器的ip地址
port:6379
database:0 //默认操作几号数据库
......
方式二:
@Autowired
private RedisTemplate redisTemplate;
//生成一个随机字符串作为用户的ticket
String ticket=UUIDUtil.uuid();
//存入redis,user表示用户实体类对象
redisTemplate.opsForValue.set("user"+ticket,user);
//将ticket放入到cookie里面
CookieUtil.setCookie(request,response,"userTicket",ticket);
User user=redisTemplate.opsForValue().get("user:"+userTicket);
if(user==null)
{
跳转到登录页面
}
else
{
跳转到商品列表页面
}
注意:这里虽然使用了redis,但是这里我们用的redis只是装一个根节点,哨兵,主从复制,读写分离,集群都没有考虑
redis的高级数据结构位图bitmaps,HyperLogLog,GEO没有涉及
磁盘持久化方案也没有涉及
3.拦截器判断用户有没有登录
之前每次都要从session或者redis里面根据ticket字符串来获取user对象,然后去数据库中查询这个用户是否存在,
具体参考这篇博客: 拦截器Interceptor_Pr Young的博客-CSDN博客
4.秒杀功能
用户登录成功后,跳转到商品列表页,商品列表页有一个详情按钮,点击详情跳转到商品详情页,在这个页面可以进行秒杀(在秒杀还没开始的时候,秒杀按钮是灰色的,不能按的,倒计时结束,这个秒杀按钮才可以按),秒杀成功后就会进入订单页
这个功能里需要创建四张数据表 商品表,秒杀商品表,订单表,秒杀订单表
还需要开发 商品列表页,商品详情页,订单页三个页面
商品列表页:
商品详情页
怎么实现倒计时功能呢?
设置一个秒杀开始时间t1和秒杀结束时间t2
当前的时间在t1之前就,秒杀还未开始
当前的时间在t1之后,在t2之前,就开始秒杀
当前时间在t2之后,就结束秒杀
点击秒杀按钮的时候就会调用秒杀方法,先查此时数据库中该商品是否有库存(去秒杀商品表中查找),并且判断当前用户是否已经秒杀过(去秒杀订单表中查找,如果表中已经存在这个用户id表示这个用户已经秒杀过了,不能再秒杀了)
5.使用JMeter进行压测
当100个人同时去点击这个立即秒杀
压力测试:测的是并发,
QPS:Queries Per Second,每秒的查询率,是一台查询服务器每秒能够响应的查询次数(每秒执行查询sql的次数)
TPS:Transactions Per Second,意思是每秒事务数,从客户机发送请求开始计时,到服务机收到请求然后发送给客户机处理结果,客户机收到处理结果停止计时
JMeter:选择某一个页面进行压力测试
1000个线程 访问这个页面,吞吐量为262
测试商品列表接口和秒杀接口(商品列表接口只需要读取数据,而秒杀接口需要更新数据)
商品列表页windows优化前qps:1332,Linux优化前qps 207
秒杀接口:5000个用户同时秒杀id=1的商品
window优化前qps:785 linux优化前qps:
qps小还不是问题,问题在于发现库存变为负数(也就是超卖了)
而且还有个问题就是:秒杀接口没有隐藏起来(虽然按钮是灰色的,但是你知道秒杀路径的话依然可以直接访问这个路径)
6.第一个优化 使用缓存
(1)页面缓存:使用的是thymleaf模板,需要从服务器端查询数据,然后将数据全部放到浏览器做展示示,可以将这些数据放到redis里面
缓存商品列表页
//redis中获取页面,如果可以获取到页面,直接返回页面
String html=redisTemplate.opsForValue().get("goodsList");
if(StringUtil.isEmpty(html)==false)
{
return html;
}
else//页面为空就要将其存入缓存
{
先去获取到html,然后存到redis里面
redisTemplate.opsForValue().set("goodsList",html);
return html;
}
(2)url缓存:把商品详情页也缓存起来,还要传入一个商品ID
String html=redisTemplate.opsForValue().get("goodsList"+goodsId);
尽管做了缓存,还是需要从redis中发送整个页面给前端,于是后面还会进行前后端分离
(3)对象缓存:
吞吐量变成了2394(之前是1332)
7.第二个优化:“页面静态化:即使使用页面缓存,还是需要将一整个thymleaf引擎发送给前端,前后端分离,前端就是html,里面的一些动态数据才需要从后端发给前端
静态化商品详情页面
以上两个优化都是页面优化,接下来是接口优化
8.接口优化
即使加了缓存之后,有些接口也要访问数据库,比如去数据库中获取某件商品的库存,然后扣减库存,这部分就要通过redis来扣减库存
即使你用了redis来扣减库存不需要去数据库中读取数据了,但是我们仍然需要频繁的和redis进行交互,redis放在一个单独的服务器上,所以我们还是需要频繁的和redis服务器进行交互,可以通过内存标记来减少对redis服务器的访问
下单操作也要进行优化,下单的时候如果直接去找数据库,数据库仍然是扛不住这么大量的并发,可以用队列,先让请求进入到队列里面进行缓冲,通过队列进行异步下单增强用户体验
总结:(1)通过redis预减库存,减少对数据库的访问
(2)内存标记,减少对redis的访问
(3) 请求进入队列缓存,异步下单
redis预减库存,如果库存不足,直接返回库存不足,这样已经可以大幅提高性能
如果库存是足的,就将这个请求封装成一个对象,发送给rabbitmq,这样前期大量的请求过来依然可以快速处理掉,后面消息队列再慢慢处理(起到一个流量削峰的作用),此时显示排队中,
也就是将redis中每个秒杀商品:这个秒杀商品的库存存入到redis里面
使用topic模式
@Configuration
public class RabbitMQTopicConfig
{
@Bean
public Queue queue()
{
return new Queue("seckillQueue");//队列名称就叫queue,消息持久化
}
//定义交换机
pubcli TopicExchange topicExchange()
{
return new TopicExchange("topicExchange");
}
@Bean //绑定队列到交换机,需要带上路由键route key
public Binding binding()
{
return BindingBuilder.bind(seckillQueue()).to(topicExchange()).with("*.queue.#");
}
}
接收方:
@RabbitListen(queues="seckillQueue")
//接收到消息开始下单
public void receive(String message)
{
//将字符串转为对象
SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class);
//获取要秒杀的商品的id以及用户对象
Long goodsId = seckillMessage.getGoodsId();
User user = seckillMessage.getTUser();
//根据商品id获取商品对象
GoodsVo goodsVo = itGoodsServicel.findGoodsVobyGoodsId(goodsId);
if (goodsVo.getStockCount() < 1)
{
return;
}
//判断是否重复抢购,查询redis中是否已经有该用户id:该秒杀商品id这一行数据
TSeckillOrder tSeckillOrder = (TSeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (tSeckillOrder != null)
{
return;
}
//正式下单
itOrderService.secKill(user, goodsVo);
}
//将请求封装成一个对象
SeckillMessage seckillMessag = new SeckillMessage(user, goodsId);
//发送这个对象到消息队列中
mqSender.send(seckillMessag);
9.最后是接口安全性保障
也就是黄牛把脚本准备好,快速抢秒杀商品,导致很多正真的用户抢不到秒杀商品
隐藏接口地址
用验证码隔离脚本和延长请求时间长度
接口限:漏桶算法(也可以采用令牌桶算法)