秒杀web项目

一、秒杀项目技术

1)数据库设计

Mysql:考虑将秒杀的商品单独建表,因为秒杀活动可能很多次,多次操作主表将使主表越来越难以维护。
redis:考虑将其前缀设计为相应的类名。注意里面存的是String类型的,如果有些不方便转成String的可以利用阿里的FastJson工具进行转换。

举例:
BeanToString()这个方法,就是来转化,先获取传入数据的Class类型,根据类型判断,int,long,String 类型,通过API转换直接转换成String即可,或是其他的自定义对象,则利用fastjson库将我们项目中定义的JavaBean 对象,转化为json字符串。

2)明文密码两次md5

客户在网址页面输入密码之后马上就会做一次md5,传递给LoginVo并参数检验。为了安全,在md5过程中加salt

避免密码明文的传输被截获,也避免了数据库丢失被md5反查。
数据库中的存放的是两次MD5以后的密码,所以黑客黑掉了数
据库也不行,因为是两次MD5,如果没有第二次就可能根据数据库的密码和网址中的js文件。

3)JSR303参数检验

参数检验必须引入:

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

在这里插入图片描述
并自行编写了@isMobile注解进行手机号码检验。

@javax.validation.Constraint(validatedBy = {IsMobileValidator.class}) /*传入 校验器*/
public @interface IsMobile{
    /*默认必须 有值*/
    boolean required() default true;
    /*如果校验不通过 输出 信息*/
    java.lang.String message() default "手机号码格式不对啊!";

    java.lang.Class<?>[] groups() default {};

    java.lang.Class<? extends javax.validation.Payload>[] payload() default {};
}
/** 工具类: 1.验证手机号是否正确
 *
 */
public class VaildataUtil {

    //Pattern 类用于创建一个匹配模式 联合Matcher 类使用 达到字符串匹配的效果
    private static final Pattern PATTERN = Pattern.compile("1\\d{10}");
    /**验证手机号是否正确
     * @param input
     * @return
     */
    public static boolean isMobile(String input) {
        if (input.isEmpty()) {
            return false;
        }
        Matcher match = PATTERN.matcher(input);//Pattern 返回一个 matcher 对象
        return match.matches();//matches 方法 返回值boolean 全字符匹配
    }
}

4)全局异常处理器

因为报出的异常很多,想要进行全局统一配置。

/**该注解会 适用所有的@RequestMapper() 结合@ExceptionHander 实现全局异常处理
 *  */
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
    @ExceptionHandler(value = Exception.class) /*定义拦截 异常的范围 此时 是拦截所有异常*/
    public Result<String> exceptionHandler(HttpServletRequest request,Exception e){
        e.printStackTrace();
        if (e instanceof GlobalException){
            GlobalException globalException = (GlobalException)e;
            return Result.error(globalException.getCodeMsg());
        }else if (e instanceof BindException){
            /*注意:此处的BindException 是 Spring 框架抛出的Validation异常*/
            BindException ex = (BindException)e;
            List<ObjectError> errors = ex.getAllErrors();/*抛出异常可能不止一个 输出为一个List集合*/
            ObjectError error = errors.get(0);/*取第一个异常*/
            String errorMsg = error.getDefaultMessage(); /*获取异常信息*/
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(errorMsg));
        }else {
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }
}

@ControllerAdvice(等同于@Compont配合后面的使用,适用于所有@RequestMapping) + @ExceptionHandler:对所有@RequestMapping方法进行检查,拦截。并进行异常处理。

5)分布式session

背景:对于秒杀服务,实际的应用可能部署在不止一个服务器上,而是分布式的多台服务器,这时候要是用户登录是在第一个服务器,但是操作的时候第二个请求在第二个服务器,就会丢失用户的Session信息,最少你的重新登录。

加入用户登录的请求在第一台服务器上运行,下订单在第二台服务器,这样第二台就不知道这个用户了,session没有了,目前也有对于session的同步措施,即几台服务器对于用户的session进行同步,但是存在性能问题。

session并没有存在服务器中,而是存在缓存中,用一个redis在存储session,这就是分布式session。
redis缓存的方法,另外使用一个redis服务器专门用于存放用户的session信息。这样就不会出现用户session丢失的情况。

6)缓存优化

在这里插入图片描述

解决办法

页面缓存+URL缓存:
这种缓存技术一般用于不会经常变动信息,并且访问次数较多的页面,这样就不用每次都动态加载。
原来的页面是将model传参数,通过springboot自动渲染。
利用redis的快速,页面手动渲染为html,并设置有效期60s,原来的自动渲染与现在的手动渲染。

 @Autowired
    MiaoshaUserService miaoshaUserService;
    @Autowired
    RedisService redisService;
    @Autowired
    GoodsService goodsService;
    @Autowired
    ThymeleafViewResolver thymeleafViewResolver;
    @Autowired
    ApplicationContext applicationContext;

    @RequestMapping("/to_list")
    public String list(Model model,MiaoshaUser user) {
        model.addAttribute("user", user);
        //查询商品列表
        List<GoodsVo> goodsList = goodsService.listGoodsVo();
        model.addAttribute("goodsList", goodsList);
        return "goods_list";
    }
     /**
     * QPS:1267 load:15 mysql
     * 5000 * 10
     * QPS:2884, load:5
     * */
    @RequestMapping(value="/to_list", produces="text/html")
    @ResponseBody
    public String list(HttpServletRequest request, HttpServletResponse response, Model model, MiaoshaUser user) {
        model.addAttribute("user", user);
        //取缓存
        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";
        SpringWebContext ctx = new SpringWebContext(request,response,
                request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
        //手动渲染
        html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
        if(!StringUtils.isEmpty(html)) {
            redisService.set(GoodsKey.getGoodsList, "", html);
        }
        return html;
    }

在这里插入图片描述
URL缓存和页面缓存类似。

第一次访问:
在这里插入图片描述
第二次:
在这里插入图片描述

对象缓存:
相比页面缓存是更细粒度缓存 + 缓存 更新。在实际项目中, 不会大规模使用页面缓存,因为涉及到分页,一般只缓存前面1-2页。

public MiaoshaUser getById(long id) {
		//取缓存
		MiaoshaUser user = redisService.get(MiaoshaUserKey.getById, ""+id, MiaoshaUser.class);
		if(user != null) {
			return user;
		}
		//取数据库
		user = miaoshaUserDao.getById(id);
		if(user != null) {
			redisService.set(MiaoshaUserKey.getById, ""+id, user);
		}
		return user;
	}
	// http://blog.csdn.net/tTU1EvLDeLFq5btqiK/article/details/78693323
	public boolean updatePassword(String token, long id, String formPass) {
		//取user
		MiaoshaUser user = getById(id);
		if(user == null) {
			throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
		}
		//更新数据库
		MiaoshaUser toBeUpdate = new MiaoshaUser();
		toBeUpdate.setId(id);
		toBeUpdate.setPassword(MD5Util.fromPassToDBPass(formPass, user.getSalt()));
		miaoshaUserDao.update(toBeUpdate);

		//处理缓存
		redisService.delete(MiaoshaUserKey.getById, ""+id);
		user.setPassword(toBeUpdate.getPassword());
		redisService.set(MiaoshaUserKey.token, token, user);
		return true;
	}

在这里插入图片描述

GET与POST什么区别?
答:GET是幂等的,即重复执行几次都没变化,但是POST会对服务端的数据产生变化。

页面静态化+前后端分离:

将页面缓存到客户的浏览器上,当用户访问页面的时候,直接不与服务器有交互,直接从本地缓存中拿取页面,节省网络流量。

之前的逻辑:点击链接,访问后端controller 访问业务层,成功获取数据,将数据渲染到html页面再将整个html页面返回给客户显示

现在:点击链接,除了第一次访问。访问直接访问用户本地的缓存的html页面 (浏览器会缓存下来静态static下文件),静态资源,然后通过前端ajAx来访问后端,获取页面需要显示的数据返回即可。

高并发的场景下,用户会不断刷新网页带来带宽的浪费和对服务器的访问压力,于是将页面缓存在本地,当再次访问这个URL的时候,如果没有更新就不会下载网页,而是直接使用本地的缓存。

7)秒杀接口QPS优化(预减redis库存、异步下单、HashMap标志位)

在这里插入图片描述

在这里插入图片描述

异步下单:rebbitmq

  1. 当秒杀开始时,将请求入队,同时返回前端code0(显示排队中)。
  2. 后端的监听这个交换机,进行redis库存预判、判断是否重复秒杀、执行秒杀
  3. 如果成功就会生成订单。

预减库存:
通过实现InitializingBean接口重写将现在商品ID与库存存放在redis中,只要有人秒杀就先预先减库存,这个-1的库存不是负数就执行rabbitmq入队,出队时创建订单。——减少Mysql访问

不需要访问数据库再去减库存了,如果库存值正确,进行下一步

HashMap标志位:
再思考,其实当超过库存后的所有商品都无法秒杀了,就没必要去访问redis数据库了,虽然redis数据库很快,当并发很高的时候,也会给Redis服务器带来很大的负担,故,在本地维护一个HashMap(商品ID,是否能秒杀),第一次将库存减至0以下就将其set到HashMap中true,每次秒杀之前先判断这个标志位——减少redis访问

在这里插入图片描述

8)安全优化(秒杀地址隐藏、数学验证码、接口限流防刷)

在这里插入图片描述
在这里插入图片描述
秒杀地址隐藏:

每次点击秒杀按钮,才会生成秒杀地址,之前是不知道秒杀地址的。不是写死的,是从服务端获取,动态拼接而成的地址。
进行秒杀之前,在服务端生成随机数存在redis中(设置过期时间),然后将这个数传给前端,拼接成URL访问。

秒杀接口地址的隐藏可以防止恶意用户通过频繁调用接口来请求的操作,但是无法防止机器人,刷票软件恶意频繁点击按钮来刷请求秒杀地址接口的操作。

创建验证码:

高并发下场景,在刚刚开始秒杀的那一瞬间,迎来的并发量是最大的,减少同一时间点的并发量,将并发量分流也是一种减少数据库以及系统压力的措施(使得1s中来10万次请求过渡为10s中来10万次请求)

接口限流防刷:

放到redis中,设置有效期,比如1分钟有效,用户做一次操作就+1,超过数值就返回错误信息。

为了广义上的通用性,我们想讲这种限流操作写成注解来实现,那么注解的编写必须写他的拦截器类AccessInterceptor,即继承HandlerInterceptorAdapter 拦截器,再将其注册到WebConfig(继承WebMvcConfigurerAdapter ,Spring框架的配置类)中 @AccessLimit(seconds = 5,maxCount = 5,needLogin = true)

/**定义一个注解 : 用于 限流作用(在固定时间内限制访问次数)
 * 降低代码复杂度和冗余度 提高复用性
 * */
@Retention(RetentionPolicy.RUNTIME)//运行期间有效
@Target(ElementType.METHOD)//注解类型为方法注解
public @interface AccessLimit {
    int seconds(); //固定时间
    int maxCount();//最大访问次数
    boolean needLogin() default true;// 用户是否需要登录
}
 
/**用于实现 注解的 拦截器 需要实现HandlerInteceptorAdapter
 * */
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {
 
    @Autowired
    MiaoshaUserService miaoshaUserService;
 
    @Autowired
    RedisService redisService;
    /*改写这个方法,表示在方法执行之前拦截*/
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {//如果是HandlerMethod 类,强转,拿到注解
            /*拿到用户*/
            MiaoshaUser user = getUser(request,response);
            /*为了方便实现user拦截器,存入当前user对象,这里直接就可以直接结合 登陆功能 做了*/
            UserContext.setUser(user);
            HandlerMethod hm = (HandlerMethod)handler;
            AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
            if (accessLimit == null){
                return true;//没有注解 就放行表示执行完成
            }
            int maxCount = accessLimit.maxCount();//获取方法上注解的参数
            int seconds = accessLimit.seconds();
            boolean needLogin = accessLimit.needLogin();//判断登录 这里需要拿到用户User
            /**由于之前只是通过拦截器 获取方法上的User变量,这里做一个拦截器来 判断用户是否登录
             * 用到之前 UserArguementResolver中获得用户的代码
             * */
            String urlKey = request.getRequestURI();
            /*第一部: 登陆验证*/
            if (needLogin){//如果注解中 表示需要登录
                if (user==null){//但是查不到用户
                    render(response,CodeMsg.SERVER_ERROR);//将错误码写入输出流输出出去
                    return false;//拦截 该方法,拦截器中只能 返回 true or false
                }
                //需要登录的拼上 用户id 来区别
                urlKey+="_"+user.getId();
            }else {
                //do nothing! //不登录的就不拼
            }
            //第三部:访问时限设计,即定义缓存的生效时间 传入一个时间,获得一个有时间限制的前缀对象
            MiaoshaKey ky = MiaoshaKey.withExpire(seconds);
            //第二步:计数 限流逻辑
            Integer count = redisService.get(ky,urlKey,Integer.class);
            if (count == null){
                redisService.set(ky,urlKey,1);//如果没有,说明没访问过,置1
            }else if (count <maxCount){//设置 如果小于我们 的防刷次数
                redisService.incr(ky,urlKey);//小于5 就+1
            }else {//说明大于最大次数
                render(response,CodeMsg.REQUEST_OVER_LIMIT);
                return false;
            }
            return true;
        }
 
        return super.preHandle(request, response, handler);
    }
 
    /**render 方法为了 拦截的时候 输出到 浏览器,获得 response
     * */
    private void render(HttpServletResponse response, CodeMsg serverError) throws IOException {
/*注意 这里 输出的是 json 数据,所以 务必要定义 contentType 以及编码*/
        response.setContentType("application/json;charset=utf-8");
        OutputStream out = response.getOutputStream();
        String str = JSON.toJSONString(Result.error(serverError));//转化为Json传输出
        out.write(str.getBytes("UTF-8"));
        out.flush();
        out.close();
    }
 
 
    /**借用 获得用户的代码
     * */
    private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response){
        String paramToken = request.getParameter(MiaoshaUserService.COOKIE_TOKEN_NAME);
        String cookieToken = getCookieValue(request,MiaoshaUserService.COOKIE_TOKEN_NAME);
 
        if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken))
        {/*如果 cookie 中都没有值 返回 null 此时返回的 值 是给 MiaoshaUser 对象的 就是解析的参数值*/
            return null;
        }
        /*有限从paramToken 中取出 cookie值 若没有从 cookieToken 中取*/
        String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
        return miaoshaUserService.getByToken(response,token);/*拿到 user 对象*/
 
    }
    private String getCookieValue(HttpServletRequest request, String cookieTokenName) {
        /*在 请求中 遍历所有的cookie 从中取到 我们需要的那一个cookie 就可以的*/
        Cookie[] cookies =  request.getCookies();
        /*请求中没有cookies 的时候返回null ?? 没有cookie ? 没有登录吗?*/
        if (cookies == null || cookies.length ==0)
        {
            return null;
        }
        for (Cookie cookie: cookies) {
            if (cookie.getName().equals(cookieTokenName))
                return cookie.getValue();
        }
        return null;
    }
}

9)Nginx设置反向代理隐藏IP地址和端口号

在这里插入图片描述
并不需要在hosts中设置,直接在nginx中设置就能隐藏IP地址和端口号
原来:

现在:
在这里插入图片描述

二、出现的问题

1)在引入redis的过程中循环引用问题

  1. 代码
@Service
public class RedisService {

    @Autowired  //byType
    JedisPool jedisPool;
    @Autowired
    RedisConfig redisConfig;
	//问题所在
    @Bean
    public JedisPool JedisFactory(){

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());
        jedisPoolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait()*1000);

        JedisPool jp = new JedisPool(jedisPoolConfig
                ,redisConfig.getHost()
                ,redisConfig.getPort()
                ,redisConfig.getTimeout()*1000
                ,redisConfig.getPassword(),0);
        return jp;
    }
    
    public <T> T get(String key, Class<T> clazz){
        Jedis jedis = null;
        try{
            jedis = jedisPool.getResource();
            String str = jedis.get(key);
            T t = stringToBean(str,clazz);
            return t;
        }finally {
            returnToPool(jedis);
        }
    }

    private <T> T stringToBean(String str, Class<T> aClass) {
        if(str==null||str.length()<=0||aClass==null){
            return null;
        }
        if(aClass==int.class||aClass==Integer.class){
            return (T) Integer.valueOf(str);
        }else if(aClass==String.class){
            return (T) str;
        }else if(aClass==long.class||aClass==Long.class){
            return (T) Long.valueOf(str);
        }else{
            //其他类型我们认为其是一个Bean
            return JSON.toJavaObject(JSON.parseObject(str),aClass);
        }

    }

    public <T> boolean set(String key,T value){
        Jedis jedis = null;
        try{
            jedis = jedisPool.getResource();
            String str = beanToString(value);
            if(str==null||str.length()<=0){
                return false;
            }
            jedis.set(key,str);
            return true;
        }finally {
            returnToPool(jedis);
        }
    }

    private <T> String beanToString(T value) {
        if(value==null){
            return null;
        }
        Class<?> aClass = value.getClass();
        if(aClass==int.class||aClass==Integer.class){
            return ""+value;
        }else if(aClass==String.class){
            return (String)value;
        }else if(aClass==long.class||aClass==Long.class){
            return ""+value;
        }else{
            //其他类型我们认为其是一个Bean
            return JSON.toJSONString(value);
        }
    }
    private void returnToPool(Jedis jedis) {
        if(jedis!=null){
            jedis.close();
        }
    }
	
}
  1. 问题简述:
    在这里插入图片描述 当我们引入redis时,采用的也是JedisPool并设定了JedisPool的一些参数,我们必须将其在properties中的参数通过new JedisPoolCofig()方法设定完毕再通过new JedisPool()生成,故我们将其写成了一个Bean注册到Spring容器中,但是我将其写在了RedisService.java中,但是其前面@autowired引入了JedisPool用于创建RedisService的bean,这就出现了循环引用,即JedisPool依赖RedisService,RedisService也依赖了JedisPool。

spring是允许循环依赖 (前提是单例的情况下的,非构造方法注入的情况下)。
属性注入:不能写有参构造器,必须写set方法
构造方法注入:必须写有参构造,不需set方法
简约版本@AutoWired自动装配:用于放置于构造器(构造方法注入),字段(属性注入)之上。注意:这种注册进容器的是外面那个class类,比如上面的RedisService已经注册进了容器中。
在这里插入图片描述
@Bean即表示声明该方法返回的实例是受 Spring 管理的 Bean

如何解决:打破循环
将其中创建JedisPool的bean方法提出到新的java文件。

2)redis连接超时或拒绝连接

原因:我是linux启动的redis,但是用windows的代码,故需要连接linux虚拟机的ip地址才行。
虚拟机中的ens33的ip地址。
在这里插入图片描述
如果第一次使用时没有显示ip地址,移步:解决ens33没有ip地址

在这里插入图片描述

结束。

启动redis:
在这里插入图片描述
在这里插入图片描述

4)压测JMeter

在这里插入图片描述
在这里插入图片描述
redis-benchmark总结

压测步骤

由于本项目加入了session,就必须提前将session存入redis才能压测。

  1. 数据库中提前插入5000个用户名及固定密码,salt值固定
  2. 让着5000个用户进行登录操作,并将其的sessionid存入缓存,并提取,将其的ID与缓存中存的sessionID放在同一个txt文件中,
  3. JMeter创建5000线程,每个线程提前携带用户的sessionID登录进去

本项目的压测后的性能瓶颈在mysql的访问。

  1. 数据库的服务加上网络IO速度很慢,应减少对于Mysql的访问(利用Redis预减库存操作)
  2. 利用消息中间件rabbitMQ进行流量削峰和异步访问(因为业务逻辑处理比较慢,就将其放在receiver处理,sender不会阻塞,receiver监听这个交换机)
  3. 利用HashMap的标志位处理,减少对redis的访问。

mysql与redis压测结果

在这里插入图片描述
redis缓存的效率非常高。远高于mysql。

在Linux下进行压测to_list页面,5000并发执行10次,实现的QPS:1267.

压测秒杀接口,注意先生成用户及其token,在jmeter中添加配置文件token,压测QPS:1306

超卖问题——压测把库存变成了负数

@RequestMapping("/do_miaosha")
    public String list(Model model, MiaoshaUser user,
                       @RequestParam("goodsId")long goodsId) {

    	model.addAttribute("user", user);
    	//没登录就回去登录
    	if(user == null) {
    		return "login";
    	}
    	//判断库存
    	GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    	int stock = goods.getStockCount();

    	if(stock <= 0) {
    		model.addAttribute("errmsg", CodeMsg.MIAO_SHA_OVER.getMsg());
    		return "miaosha_fail";
    	}

    	//判断是否已经秒杀到了
    	MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    	//不能重复秒杀
    	if(order != null) {
    		model.addAttribute("errmsg", CodeMsg.REPEATE_MIAOSHA.getMsg());
    		return "miaosha_fail";
    	}
    	//减库存 下订单 写入秒杀订单,注意此操作必须是原子操作,故将其写到一个service中
    	OrderInfo orderInfo = miaoshaService.miaosha(user, goods);

    	model.addAttribute("orderInfo", orderInfo);
    	model.addAttribute("goods", goods);
        return "order_detail";
    }

假设两个线程同时判断库存,这时都成功了,再进行减库存就会出现多减了库存甚至负数的情况!并发量大就暴露了这个问题。

解决办法:
在这里插入图片描述

  1. 通过联合唯一索引,来保证仅能有某用户取到某订单,不能重复订单。
  2. SQL加上对库存数量的判断,防止库存变成负数。
@Update("update miaosha_goods set stock_count=stock_count-1 where goods_id=#{goodsId} and stock_count>0")
	public void reduceStock(MiaoshaGoods goods);  

  1. 我们知道mysql写操作默认加了锁(同一数据不能同时进行写操作),但读操作没有锁。可以在库存较低时给逐步读操作加上悲观锁(select …for update)
  2. 当然也可以mysql乐观锁,在表上加上一个version字段,读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。如果两个用户都使得version成为3,那么一定有一个被回滚。

5)用户登录的流程

  1. Controller通过map映射返回html页到达登录页面
  2. 整个页面是个表单,注意在前端将用户名和md5(密码+salt)返回给后端。(防止明文密码在http传输过程中被泄露)
  3. 后端接收到首先进行JSR303参数校验(比如@NotNull,自己编写@isMoblie是否是手机号:实现ConstraintValidator)
  4. 验证成功后,登录,通过用户名取用户对象信息(先从缓存中取,取不到再从数据库中取,取到了存在缓存中,方便下次读取)返回一个user对象,判断user是否为空,若是空就抛出异常,不空就对比密码。
  5. 登录成功以后,生成随机的uuid作为session,存在cookie中发回客户端同时redis中存一份缓存,至此登录完成。

6)应对高并发的措施

在这里插入图片描述

7)缓存击穿以及缓存雪崩等问题?

问题问题详述解决办法
缓存雪崩当某一个时刻出现大规模的缓存失效的情况,那么就会导致大量的请求直接打在数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机不同缓存设置不同的过期时间,并且对于登录session来说可以登录一次刷新缓存时间。②在原有的失效时间上加上一个随机值,比如1-5分钟随机。这样就避免了因为采用相同的过期时间导致的缓存雪崩。③如果真的发生了缓存雪崩怎么办?熔断机制,流量达到一定值返回“系统拥挤”,异步消息队列。④redis集群:通过hash值计算到底存放在哪台redis服务器上,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用。⑤缓存预热:根据热点数据进行提前缓存。
缓存击穿这个key在redis不存在,在mysql存在的,缓存击穿是一个热点的Key,有大并发集中对其进行访问,突然间这个Key失效了,导致大并发全部打在数据库上,导致数据库压力剧增。这种现象就叫做缓存击穿。1. 缓存击穿代表的是热点key,对于热点的key可以设置永不过期的key。2.加锁,拿到锁的线程才能去访问mysql
缓存穿透这个key在redis和mysql都是不存在的,请求传进来的key是不存在Redis中的,那么就查不到缓存,查不到缓存就会去数据库查询。假如有大量这样的请求,这些请求像“穿透”了缓存一样直接打在数据库上1. 布隆过滤器(map),类似一个判断器(有一定的误判概率),布隆过滤器说不行就直接返回,行再去redis查。

8)大量的使用缓存,对于缓存服务器,也有很大的压力,有时候Redis 压力比mysql还要大很多,思考如何减少Redis的访问?

内存标记:
Redis的预减库存,在内部维护一个Hashmap(商品ID,是否能够秒杀),初始都设为true,随着第一次的redis库存减到负数,即将其设为false,说明没有库存了,后续的所有将不会访问redis,减少对redis的访问。

9)高并发情况下,业务来不及同步,数组库堆积了大量insert和update的操作?

rebbitMQ异步消息队列,sender不会等待receiver的回复,而是直接返回排队中,每次请求过来,先不去处理请求,而是放入消息队列,然后在后台布置一个监听器,分别监听不同业务的消息队列,有消息来的时候,在进行秒杀抢票操作。这样防止多个请求同时操作的时候,数据库连接过多的异常。

**采取rebbitmq的好处是:**能够进行流量削峰、应用解耦(比如订单系统突然出现问题了,rebbitMQ会暂存这段时间的订单,不会在客户端直接报错,保证了CAP中的A高可用)、异步处理等。

10)前端降低高并发

比如设置验证码输入才能进行下一步。

11)redis缓存一致性问题——redis缓存更新策略

  1. 只要出现了数据更新,就先更新数据库,成功后再淘汰掉这个缓存,这样能避免多线程下更新缓存和更新的数据库不一致存在脏数据。
  2. 问为什么是删除掉缓存而不是更新缓存
    :我们的终极目标是使得redis与mysql数据一致,如果是更新缓存时,假设两个线程进来,A先更新数据库,B再更新数据库,此时数据库中是B的信息,更新redis假设是B先更新了,A在更新,此时redis中存在的是A的信息。这样就不一致了。
    如果采取删除掉缓存,就是更新了数据库后,redis根本查不到这个数据了,一定从mysql取,这样保证了一致性。
模式具体内容
Cache Aside Pattern失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。命中:应用程序从cache中取数据,取到后返回。更新:先把数据存到数据库中,成功后,再让缓存失效。
Read/write through这个模式就是将更新数据库的操作有缓存来做,应用认为后端就是一个单一的储存。

12)RabbitMQ作用?

  1. 流量削峰,缓解短时间的高流量压垮应用。
  2. 高级消息队列,实现消费者和生产者之间的解耦。
  3. 可以配置“持久化”“发布确认”等高可靠特性。
  4. 吞吐量不高
  5. 异步下单

13)Redis作用?

Mysql属于磁盘IO,性能低——需要进行内存换入和换出。

  1. 优势:一款基于内存的key-value数据库,整个数据库都在内存中运行操作,每秒可以支持10W左右的读写操作,而且支持数据持久化,还可以支持多种数据结构。

  2. 缺点:容量受到物理内存的限制。不能做海量数据的高性能读写。适合较少数据的高性能读写。

在这里插入图片描述

  1. redis底层

http://www.cnblogs.com/jaycekon/p/6227442.html
https://www.cnblogs.com/jaycekon/p/6277653.html

  1. 持久化方式:

    1. RDB:默认redis默认将数据快照写入磁盘dumps.rdb。原理:redis启动一个子线程,子线程写入rdb文件,用此文件覆盖老文件。
    2. AOF:记录写指令的日志,保存在磁盘上。默认关闭的。 只许追加文件但不可以改写文件,redis启动之初会读取该文件重新构建数据,换言之,redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
  2. 分布式:
    Master会将数据同步到slave,而slave不会将数据同步到master。Slave启动时会连接master来同步数据。
    读写分离模型:
    我们可以利用master来插入数据,slave提供检索服务。这样可以有效减少单个机器的并发访问数量。并且可以多个slave,整个集群的读和写的可用性都很高。
    缺点:每个节点都必须保存完整的数据,如果数据量很大的情况下,能力受限于单节点。
    数据分片模型:
    每个节点都是独立的master,通过不同业务实现数据分片。

14)如何防止同一用户多次下单?

订单表中建立一个唯一索引(所引是用户Id与商品goodsId),使得第一个记录可以插入,第二个则出错,然后通过事务回滚,防止一个用户同时发出多个请求的处理,秒杀到多个商品。

15)假如减了库存用户没有支付,库存怎么还原继续参加抢购?

设定一个最长付款时间,比如30分钟,后台有个定时任务(使用定时器Timer),轮训超过30分钟的待付款订单(数据库里面判定订单状态),然后关闭订单,恢复库存。

16)美团1面问题:redis和mysql这样做的缓存一致以后,那对于秒杀场景不就是请求全打到mysql吗?

确实是这样的,但是作为秒杀系统来说,只要是购买都一定会去数据库进行修改的,这也是我们的最终目的。

考虑到这样的问题存在,我们可以采取消息队列(回复一个排队中),或者前端的验证码限制,包括一个用户只能购买商品一个。

17)缓存和数据库的一致性问题

无论
是“先写数据库再删除缓存”还是“先删除缓存再写库”都有可能出现数据不一致的现象。
比如:
1.
在这里插入图片描述

  1. 如果先写了库后删除缓存,在删除缓存之前,另一个线程来读取,也会出现数据不一致的情况。

综上,读和写是并发的,导致了不一致问题。


解决方案:

  1. 方案一:延时双删策略+设置缓存过期时间
    1)写请求步骤:先删除缓存,再写数据库,休眠500ms,再次删除缓存。
    在这里插入图片描述

T1等待时间 > T2读取数据并写入redis的时间
 保证了,在T1操作的时间内,不会产生脏数据留存在redis上。如果还需要mysql的主从分离架构,就在等待时间上再加上一段主从同步的时间。

2)将缓存设置过期时间
防止脏数据的第二手方法。

  1. 方案二:异步更新缓存(基于订阅binlog的同步机制)
    1. 技术整体思路:Mysql的binlog增量订阅消费+消息队列+增量数据更新到redis
    2. 增量:mysql的updata、insert、delete
    3. 订阅:利用阿里的canal框架,对binlog进行订阅。
    4. 消息队列:kafka、rabbitMQ

项目新增知识

1)抽象类

抽象类中含有无具体实现的方法,所以不能用抽象类创建对象
在这里插入图片描述

2)spring项目的jar包执行方式

有关spring的源码:
进去会先找到jar包中的“META-INF文件夹”里面的“MANIFEST.MF”文件,里面有:
在这里插入图片描述
也就是会先启动main-class在里面回调start-class方法。

spring-boot-classes里面是我们写的源代码
spring-boot-lib是添加的依赖

项目总结!!

项目亮点:
1.使用分布式Seesion,可以实现让多台服务器同时可以响应。
2.使用redis做缓存提高访问速度和并发量,减少数据库压力,利用内存标记减少redis的访问。
3.使用页面静态化,加快用户访问速度,提高QPS,缓存页面至浏览器,前后端分离降低服务器压力。
4.使用消息队列完成异步下单,提升用户体验,削峰和降流。
5.安全性优化:双重md5密码校验,秒杀接口地址的隐藏,接口限流防刷,数学公式验证码。

主要内容

  • 分布式Seesion
    我们的秒杀服务,实际的应用可能不止部署在一个服务器上,而是分布式的多台服务器,这时候假如用户登录是在第一个服务器,第一个请求到了第一台服务器,但是第二个请求到了第二个服务器,那么用户的session信息就丢失了。
    解决:session同步,无论访问那一台服务器,session都可以取得到,利用redis缓存的方法,另外使用一个redis服务器专门用于存放用户的session信息。这样就不会出现用户session丢失的情况。(每次需要session,从缓存中取即可)
  • redis缓解数据库压力
    本项目大量的利用了缓存技术,包括用户信息缓存(分布式session),商品信息的缓存,商品库存缓存,订单的缓存,页面缓存,对象缓存减少了对数据库服务器的访问
  • 通用缓存key封装
    大量的缓存引用也出现了一个问题,如何识别不同模块中的缓存(key值重复,如何辨别是不同模块的key)
    解决:利用一个抽象类,定义BaseKey(前缀),在里面定义缓存key的前缀以及缓存的过期时间从而实现将缓存的key进行封装。让不同模块继承它,这样每次存入一个模块的缓存的时候,加上这个缓存特定的前缀,以及可以统一制定不同的过期时间。
  • 页面静态化(前后端分离)
    页面静态化的主要目的是为了加快页面的加载速度,将商品的详情和订单详情页面做成静态HTML(纯的HTML),数据的加载只需要通过ajax来请求服务器,并且做了静态化HTML页面可以缓存在客户端的浏览器。
  • 消息队列完成异步下单
    使用消息队列完成异步下单,提升用户体验,削峰和降流

1.系统初始化,把商品库存数量stock加载到Redis上面来。
2.后端收到秒杀请求,Redis预减库存,如果库存已经到达临界值的时候,就不需要继续请求下去,直接返回失败,即后面的大量请求无需给系统带来压力。
3.判断这个秒杀订单形成没有,判断是否已经秒杀到了,避免一个账户秒杀多个商品,判断是否重复秒杀。
4.库存充足,且无重复秒杀,将秒杀请求封装后消息入队,同时给前端返回一个code (0),即代表返回排队中。(返回的并不是失败或者成功,此时还不能判断)
5.前端接收到数据后,显示排队中,并根据商品id轮询请求服务器(考虑200ms轮询一次)。
6.后端RabbitMQ监听秒杀MIAOSHA_QUEUE的这名字的通道,如果有消息过来,获取到传入的信息,执行真正的秒杀之前,要判断数据库的库存,判断是否重复秒杀,然后执行秒杀事务(秒杀事务是一个原子操作:库存减1,下订单,写入秒杀订单)。
7.此时,前端根据商品id轮询请求接口MiaoshaResult,查看是否生成了商品订单,如果请求返回-1代表秒杀失败,返回0代表排队中,返回>0代表商品id说明秒杀成功。

  • 安全性优化
    双重md5密码校验,秒杀接口地址的隐藏,接口限流防刷,数学公式验证码。

秒杀架构设计

在这里插入图片描述

  • 将请求拦截在系统上游,降低下游压力:秒杀系统特点是并发量极大,但实际秒杀成功的请求数量却很少,所以如果不在前端拦截很可能造成数据库读写锁冲突,最终请求超时。
  • 利用缓存:利用缓存可极大提高系统读写速度。
  • 消息队列:消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。

后续开发

1.Java web开发账号单一登录的功能,防止同一账号重复登录,后面登录的踢掉前面登录的

https://blog.csdn.net/hk9024/article/details/51836824

https://blog.csdn.net/qq_34826261/article/details/102667780

https://blog.csdn.net/weixin_42551369/article/details/100043662

2.分库分表

分表

分表背景: 当一张表的数据量很大时,就会查询一次所花的时间会变多
分表目的:减小数据库的负担,缩短查询时间

垂直分表:按列拆
某个表中的字段比较多,可以新建立一张“扩展表”,将不经常使用或者长度较大的字段拆分出去放到“扩展表”中。

水平分表:按行拆
即按照某种规则分成几个表,在同一库中。

一般是通过主键等字段进行hash和取模后拆分。

分库

分库背景:数据库的连接资源比较宝贵且单机处理能力也有限
分库目的:适应高并发

垂直分库
就是按照业务模块来划分出不同的数据库

水平分库
与水平分表类似,将一个大表存在不同的数据库中

分库带来的问题:
跨库join查询:一般允许跨表join,但是不允许跨库join
解决办法:
①将两个服务的数据实时同步到一个只读库,然后在只读库查询;
②mysql开启FEDERATED引擎,在数据库中建立远程表,也可以join查询

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
慕课网Java高并发秒杀(课程) 很好的spring,springMVC,mybatis,bootstrap,jQuery,mysql,Restful学习案例 SQL脚本 CREATE DATABASE seckill; USE seckill; -- todo:mysql Ver 5.7.12for Linux(x86_64)中一个表只能有一个TIMESTAMP CREATE TABLE seckill( `seckill_id` BIGINT NOT NUll AUTO_INCREMENT COMMENT '商品库存ID', `name` VARCHAR(120) NOT NULL COMMENT '商品名称', `number` int NOT NULL COMMENT '库存数量', `start_time` TIMESTAMP NOT NULL COMMENT '秒杀开始时间', `end_time` DATETIME NOT NULL COMMENT '秒杀结束时间', `create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (seckill_id), key idx_start_time(start_time), key idx_end_time(end_time), key idx_create_time(create_time) )ENGINE=INNODB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8 COMMENT='秒杀库存表'; -- 初始化数据 INSERT into seckill(name,number,start_time,end_time) VALUES ('3000元秒杀iphone6',100,'2016-01-01 00:00:00','2016-12-31 00:00:00'), ('2000元秒杀ipad',100,'2016-01-01 00:00:00','2016-05-01 00:00:00'), ('6000元秒杀mac book pro',100,'2016-07-01 00:00:00','2016-12-31 00:00:00'), ('7000元秒杀iMac',100,'2016-05-01 00:00:00','2016-12-31 00:00:00') -- 秒杀成功明细表 -- 用户登录认证相关信息(简化为手机号) CREATE TABLE success_killed( `seckill_id` BIGINT NOT NULL COMMENT '秒杀商品ID', `user_phone` BIGINT NOT NULL COMMENT '用户手机号', `state` TINYINT NOT NULL DEFAULT -1 COMMENT '状态标识:-1:无效 0:成功 1:已付款 2:已发货', `create_time` TIMESTAMP NOT NULL COMMENT '创建时间', PRIMARY KEY(seckill_id,user_phone),/*联合主键*/ KEY idx_create_time(create_time) )ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='秒杀成功明细表' SHOW CREATE TABLE seckill\G;#显示表的创建信息 Mybatis两个问题?①sql写在哪里?②怎么实现DAO接口?第一个问题:注解或者XML选择XML.第二个问题:Mapper自动实现DAO接口或者API编程方式实现DAO接口.选择Mapper.

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值