本项目专攻秒杀模块,共分为七个章节
1.Spring Boot环境搭建
2.集成Thymeleaf , Result结果封装
3.集成Mybatis+ Druid
4.集成Jedis+ Redis安装+通用缓存Key封装
1.数据库设计
2.明文密码两次MD5处理
两次加密:
1、当你输入提交到表单使用md5对输入的密码加密
2、当你将表单中的密码插入到数据库时,再对表单的密码加密
为什么两次md5?
客户端:我们使用密码+固定Salt来形成最终密码
服务端:将用户输入
3 JSR303参数检验+全局异常处理器
为什么要做JSR303参数检验?
前端的校验只是有效性的校验(手机号输错,密码错误),服务端的校验是防止恶意的用户。
JSR303检验账号是否符合规范标准,@IsMobile自己写的注解
/*
获取表单提交的数据
* 看表单中参数是否能正确传递
* */
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min = 32)
private String password;
。。。
}
@Valid注解写在输入参数前面,输入参数对应的类,里面的各项成员变量上面还能加注解约束
@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(HttpServletResponse response,
@Valid LoginVo loginVo){
log.info(loginVo.toString());
//登录
String token=miaoshaUserService.login(response,loginVo);
return Result.success(token);
}
当参数校验返回false即校验失败时,那么就会出现一个BindException异常,为了显示友好就写一个全局异常处理器去拦截这个异常。当然其他的异常也能够被拦截。
怎么实现友好显示的?
当用户登录时,如果后台登录方法查不到用户或者密码不匹配那么就会抛一个全局异常,抛出的这个异常会被我们定义的全局异常处理器拦截,拦截到之后会return一个错误信息,前台ajax就会回调显示这个错误信息,用户能更友好的看到错误信息。
4.分布式Session
背景:分布式集群,多台服务器。客户端第一次请求落在第一台服务器上,第二次请求落在第二台服务器上。那么第二次Session就会丢失。
解决方案: 1.容器原生的Session同步,就是将一台计算机上的Session同步到其他计算机上,这样性能开销大。
2.分布式Session,实际情况中用的比较多。Session并没有存到容器中来而是存到了缓存中,这就是分布式Session。
分布式Session具体实现: 用户登录成功,会生成一个token。token用于生成键,用户信息作为值,将这对键值对存到redis中,然后实例一个Cookie(“token”,token),将这个Cookie写进去写到response中,那么下次这个用户再次发请求就会带着这个Cookie。配置参数解析器就能根据Cookie携带的值到redis中查到用户信息,然后注入到方法的请求参数中。
public String login(HttpServletResponse response, LoginVo loginVo) {
。。。。
//生成cookie
String token= UUIDUtil.uuid();
//cookie写到response,session写到redis
addCookie(response,token,user);
return token;
}
private void addCookie(HttpServletResponse response, String token, MiaoshaUser user) {
redisService.set(MiaoshaUserKey.token,token,user);
Cookie cookie=new Cookie(COOKI_NAME_TOKEN,token);
cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
cookie.setPath("/");
response.addCookie(cookie);
}
1.数据库设计
数据库并没有遵循三范式,有冗余,但是冗余是必须的。
2.商品列表页
3.商品详情页
4.订单详情页
秒杀功能:
三步:
判断库存、判断是否已经秒杀到了、减库存下订单(事务)。
卖超:
(1)减库存SQL,加上库存是否小于零的条件。
(2)订单表结构增加唯一索引(用户id和秒杀商品id),防止一个用户下多次单。
(3)减库存这个操作的返回值为1的时候才继续后面的下订单,否则会出现生成的订单数量远远多于卖出商品的数量。
另外还实现了倒计时功能,判断当前是否可以秒杀(就是比较时间的大小):
@RequestMapping("/to_detail/{goodsId}")
public String detail(Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
miaoshaStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
miaoshaStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
miaoshaStatus = 1;
remainSeconds = 0;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
model.addAttribute("remainSeconds", remainSeconds);
return "goods_detail";
}
function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
}
}
1, JMeter入门
2,自定义变量模拟多用户
生成500个用户的token和密码保存到一个文件当中,压测时加载文件模拟多用户
大并发的瓶颈就是数据库。应对并发最有效的就是缓存
1.页面缓存+ URL缓存+对象缓存
页面缓存适用场景:适合于变化不大的场景,比如商品列表。实际项目中商品列表可能会分页,不可能每页都缓存,只是缓存前两页。
页面缓存:第一次请求过来就将渲染好的页面存到redis中,下次请求就直接从redis中取页面。
页面缓存并不是将所有页面都缓存,而是将变化不大的,页面缓存和URL缓存都设置过期时间(60s),而对象缓存根据token获取用户,且对象缓存永久有效,
@RequestMapping(value = "/to_list",produces = "text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response,
Model model, MiaoshaUser user) {
model.addAttribute("user", user);
//1、先取缓存
String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
if(!StringUtils.isEmpty(html)){
//缓存不为空
return html;
}
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList",goodsList);
//return "goods_list";
//缓存为空
IWebContext ctx=new WebContext(request,response,request.getServletContext(),
request.getLocale(),model.asMap());
//手动渲染
html=thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
if(!StringUtils.isEmpty(html)){
redisService.set(GoodsKey.getGoodsList,"",html); //存入缓存
}
return html;
}
对象缓存:实现分布式Session就是,将用户对象缓存到redis中。
2.页面静态化,前后端分离
页面静态化:就是浏览器将HTML页面存在客户端,通过ajax获取数据拿到客户端在渲染页面。(这样就不用下载页面了,只需要下载动态数据就好了。
//商品列表页跳转到商品详情页面,goods_detail.htm放到静态文件夹里面
<td><a th:href="'/goods_detail.htm?goodsId='+${goods.id}">详情</a></td>
//静态页面goods_detail.htm,里面的js
$(function(){
//countDown();
getDetail();
});
function getDetail(){
//这个方法获取请求传过来的参数
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:"/goods/detail/"+goodsId,
type:"GET",
success:function(data){
if(data.code == 0){
//渲染页面的方法
render(data.data);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
3.静态资源优化
注意:js文件在浏览器本地会有缓存,如果改动了js文件,下次请求加载的还是本地缓存的js文件,导致前端代码跑不通。解决方法引入js文件的链接后面加一个版本参数。代码跑不通就debug,查看数据流是不是对的,这样能尽快锁定哪里出了问题。
总目标:减少数据库的访问量。
如何对他做优化?
减少对数据库的访问, redis和mq
把订单同步下单改为异步
好处:库存不足后,后面的请求对数据库基本没有压力
异步下单,既不是返回成功,也不是返回失败,而是返回排队中
1 Redis预减库存减少数据库访问
容器初始化的时候将秒杀商品的库存和内存标记加载到Redis中,前面来的请求将redis缓存的库存减完后,后面的请求过来直接返回秒杀结束。
2.内存标记减少Redis访问
比如说前面10个请求已经将redis中缓存的库存减到0了,那么后面的请求会继续将redis中的库存减为负数,显然后面的请求将redis中的库存减成负数是多余的,而且还增加了redis的访问量。那么这里就做一个内存标记,缓存中库存大于零的时候内存标记为false,当缓存中的库存减为0时内存标记就为true。当为false时请求能往下走,反之直接返回秒杀结束。
3. RabbitMQ队列缓冲,异步下单,增强用户体验
服务端异步的请求出队,将订单写到缓存,用户去查找,看成功还是失败
创建秒杀信息类
MiaoshaMessage(用户信息和秒杀商品id)
将信息类发送出去
Direct交换机,将信息类对象转为字符串,进队
接收者:将string还原为对象
从信息类里面拿用户信息和商品id后
入队成功的时候去轮询
怎么做轮询?判断一个用户有没有秒杀到商品
获取秒杀结果,调用方法(如果秒杀订单不为空,成功,等于空,两种情况,失败和排队中,无法辨别,这是根据标记来判断是不是因为库存不足导致的失败)
生成库存不足标记的方法,往redis里面设置一个值,新建miaoshakey,永久生效
如果redis里面存在这个key,就说明卖完了
4. RabbitMQ安装与Spring Boot集成
package com.imooc.miaosha.controller;
import com.imooc.miaosha.access.AccessLimit;
import com.imooc.miaosha.domain.MiaoshaMessage;
import com.imooc.miaosha.rabbitmq.MQSender;
import com.imooc.miaosha.redis.*;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import com.imooc.miaosha.domain.MiaoshaOrder;
import com.imooc.miaosha.domain.MiaoshaUser;
import com.imooc.miaosha.domain.OrderInfo;
import com.imooc.miaosha.result.CodeMsg;
import com.imooc.miaosha.result.Result;
import com.imooc.miaosha.service.GoodsService;
import com.imooc.miaosha.service.MiaoshaService;
import com.imooc.miaosha.service.MiaoshaUserService;
import com.imooc.miaosha.service.OrderService;
import com.imooc.miaosha.vo.GoodsVo;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
@Controller
@RequestMapping("/miaosha")
public class MiaoshaController implements InitializingBean {
@Autowired
MiaoshaUserService userService;
@Autowired
RedisService redisService;
@Autowired
GoodsService goodsService;
@Autowired
OrderService orderService;
@Autowired
MiaoshaService miaoshaService;
@Autowired
MQSender sender;
private HashMap<Long,Boolean> localOverMap=new HashMap<Long,Boolean>();
//系统初始化时,将库存加载进缓存,并将秒杀商品的状态标记为false
@Override
public void afterPropertiesSet() throws Exception {
List<GoodsVo> goodsList = goodsService.listGoodsVo();
//判断一下商品列表是否为空
if(goodsList==null){
return;
}
for (GoodsVo goods : goodsList) {
Integer stockCount = goods.getStockCount();
redisService.set(GoodsKey.getMiaoshaGoodsStock,""+goods.getId(),stockCount);
localOverMap.put(goods.getId(),false);
}
}
/**
* QPS:
* 1000 * 10
* */
@RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(Model model, MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@PathVariable("path")String path) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//校验秒杀path
boolean check=miaoshaService.checkPath(user,goodsId,path);
if(!check){
return Result.error(CodeMsg.REQUEST_ERROR);
}
//先判断一下该秒杀商品的状态(内存标记,减少redis访问)
Boolean b = localOverMap.get(goodsId);
if(b){ //说明缓存中的商品已经减为0
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//收到请求,减少缓存中的库存
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
if(stock<0){
localOverMap.put(goodsId,true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//入队
MiaoshaMessage miaoshaMessage=new MiaoshaMessage();
miaoshaMessage.setUser(user);
miaoshaMessage.setGoodsId(goodsId);
sender.sendMiaoshaMessage(miaoshaMessage);
return Result.success(0);//0代表排队中
/*//判断库存
GoodsVo goods = goodsService.getGoodsVoById(goodsId);//10个商品,req1 req2
int stock = goods.getStockCount();
if(stock <= 0) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//减库存 下订单 写入秒杀订单
OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
return Result.success(orderInfo);*/
}
/*
* 返回orderId :成功
* -1:秒杀失败
* 0:排队中
* */
@RequestMapping(value="/result", method=RequestMethod.GET)
@ResponseBody
public Result<Long> miaoshaResult(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if (user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//获取秒杀结果
long result=miaoshaService.getMiaoshaResult(user.getId(), goodsId);
return Result.success(result);
}
}
}
缺陷:库存在缓存中的key是永不过期的,当你该库存的时候,需要将缓存中的key先删除
/**
* orderId:成功
* -1:秒杀失败
* 0: 排队中
* */
public long getMiaoshaResult(Long userId, long goodsId) {
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
if(order==null){
//如果订单为空,有两种状态,排队和库存不足导致的失败,根据标记状态来判断
boolean isOver = getGoodsOver(goodsId);
if(isOver){
return -1;
}else{
return 0;
}
}else {
return order.getOrderId();
}
}
在做减库存操作时,如果减库存失败,在缓存中添加一个key。因此在去查询秒杀结果时,如果订单为空(有两种状态,排队和库存不足导致的失败)再根据标记状态(缓存中有没有对应的key)来判断是那种个情况,如果有说明是库存不足,如果没有说明是正在排队中。
5.访问Nginx水平扩展
系统的负载均衡nginx,如果前面没有加缓存,单群加服务器没有作用,全都落在db上,db并发是有限的,再加服务器也是没用的,我们基于带有有良好的扩展性。
防止恶意用户刷我们的接口,秒杀开始之前不知道访问那个地址,比较安全
验证码作用: 1、防止机器人或工具刷
2、没有验证码,大家只是点击鼠标请求集中,数据库压力大 (有的话消耗时间,将瞬间的并发量分散到10s开)
接口限流防刷: 系统本身容量有限,防止用户恶意刷接口,在某个时间端内限制用户访问的次数。
1.秒杀接口地址隐藏
思路:秒杀开始之前,先去请求接口获取秒杀地址
1.接口改造,带上PathVariable参数
2.添加生成地址的接口
3.秒杀收到请求,先验证PathVariable
//获取动态秒杀路径
function getMiaoshaPath(){
var goodsId = $("#goodsId").val();
g_showLoading();
$.ajax({
url:"/miaosha/path",
type:"GET",
data:{
goodsId:goodsId,
verifyCode:$("#verifyCode").val()
},
success:function(data){
if(data.code == 0){
var path = data.data;
doMiaosha(path);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
@AccessLimit(seconds = 10,maxCount = 5,needLogin = true)
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getmiaoshaPath(HttpServletRequest request,MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@RequestParam(value = "verifyCode",defaultValue = "0")int verifyCode) {
if (user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//判断验证码
boolean check=miaoshaService.checkverifyCode(user,goodsId,verifyCode);
if(!check){
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
//生成秒杀path
String path= miaoshaService.setmiaoshaPath(user,goodsId);
return Result.success(path);
}
前端拿到path后在调用秒杀接口
秒杀要接受path,校验
怎么验证? get缓存redis里面的key和传过来的path是否相等
public String setmiaoshaPath(MiaoshaUser user, long goodsId) {
if(user==null|| goodsId<=0){
return null;
}
String path= MD5Util.md5(UUIDUtil.uuid()+"123456");
//将秒杀path存到redis,有效期60s
redisService.set(MiaoshaKey.getMiaoshaPath,""+user.getId()+"_"+goodsId,path);
return path;
}
public boolean checkPath(MiaoshaUser user, long goodsId,String path) {
if(path==null || user==null){
return false;
}
//从redis里面根据key取path
String str = redisService.get(MiaoshaKey.getMiaoshaPath, "" + user.getId() + "_" + goodsId, String.class);
return path.equals(str);
}
2.数学公式验证码
public boolean checkverifyCode(MiaoshaUser user, long goodsId, int verifyCode) {
if(user == null || goodsId <=0) {
return false;
}
//从缓存中取验证码和输入的比较
Integer OldCode = redisService.get(MiaoshaKey.getMiaoshaVerifyCode, user.getId() + "," + goodsId, Integer.class);
if(OldCode==null || OldCode-verifyCode!=0){
return false;
}
//验证之后,将缓存中的验证码删除
redisService.delete(MiaoshaKey.getMiaoshaVerifyCode,user.getId() + "," + goodsId);
return true;
}
3.接口防刷
需求:设置10秒钟内,最多请求5次,超过这个次数就算为非法请求,提示访问太频繁。
设计:使用拦截器,将这个功能与业务代码分离,能让其他方法形成复用。
获取注解上的时间,设置为缓存key的过期时间。去缓存中获取已访问次数,如果缓存为空的话,说明第一次访问,设置缓存并将次数设为1。之后在不超过最大访问次数的基础上,每次访问缓存中的数加1.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod){
//先获取用户
MiaoshaUser user=getUser(request,response);
//将获取的用户存起来,方便后面的调用传递
UserContext.setUser(user);
HandlerMethod hm=(HandlerMethod)handler;
//获取方法上的注解
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if (accessLimit==null){
return true; //没有注解
}
//有注解获取注解的参数
int seconds=accessLimit.seconds();
int maxCount=accessLimit.maxCount();
boolean needLogin=accessLimit.needLogin();
//获取key
String key=request.getRequestURI();
//如果需要登陆
if(needLogin){
if(user==null){
//提示错误信息
render(response,CodeMsg.SESSION_ERROR);
return false;
}
//key需要加上用户id
key+="_"+user.getId();
}else {
//如果不需要登陆什么都不做
}
//查询访问次数
AccessKey ak= AccessKey.withExpire(seconds);
Integer count = redisService.get(ak, key, Integer.class);
if(count==null){
//说明是第一次访问
redisService.set(ak,key,1);
}else if(count<maxCount){
redisService.incr(ak,key);
}else {
//大于次数
render(response,CodeMsg.ACCESS_ERROR);
return false;
}
}
return true;
}
自定义的注解
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
int seconds();
int maxCount();
boolean needLogin() default true;
}