前言:

这篇项目笔记中的代码只是对应功能的一部分实现代码,如果有需要源码,可以到我的Gitee上下载:Gitee仓库
原文地址:秒杀项目开发笔记

整体介绍:

乐字节的秒杀项目,作为秋招的第三个项目。秒杀大家都不陌生,双十一、618购物节都会推出各种商品的秒杀活动。而要实现一个让用户满意的秒杀系统也是有一定的难度。

秒杀主要解决两个问题,并发读并发写。并发读的核心优化理念就是尽量减少用户到服务端来读数据,或者让用户读更少的数据;并发写也一样,我们要在数据库层做一个独立的库,来做特殊的处理。我的理解是,不管是并发读也好,并发写也好,都是为了缓解数据库的读写压力,防止大量的请求瞬间到达数据库使数据库响应速度变慢甚至使服务器崩溃。

另外,我们还要设计兜底方案,为最坏的情况做打算。

秒杀整体架构可以概况为“稳、准、快”。

稳:系统要满足高可用,流量在预期范围内必须要稳定,流量超出预期也要撑住,不可以崩溃。保证整个秒杀过程顺利,商品能顺利卖出去。参考铁路12306,每年春运流量高峰期,也能保持系统正常运行不至于崩掉。

准:字面意思,数量精准。秒杀活动上架100台手机,那就只能成交出100台。多一台或者少一台都不可以(同一个用户只能秒杀一台,多一台不行,同时如果秒杀成功,就是一台,少一台也不行)

快:快也好理解,就是性能要足够高,即速度要快。像京东淘宝的购物节,如果不够快,性能不够高,同一时间大量用户涌入平台,系统的响应速度会慢得让人受不了,网页加载慢,图片加载也慢,不免让用户抱怨一句“ ** 卡死了 ”。

“稳、准、快”就对应了我们系统架构的高可用、高一致、高性能

本项目重点在实现后端秒杀功能,前端只写了几个简单的页面

项目技术栈:

  • 前端:Thymeleaf模板、Bootstrap、Jquery
  • 后端:Spring Boot、Mybatis-Plus、Lombok、Redis、RabbitMQ、JMeter压测工具
  • 开发工具:IDEA、Git、Maven、FinalShell、VMware、Redis Desktop Manager

秒杀方案:

分布式会话:

1.用户登录

  • 设计数据库
  • 明文密码进行二次MD5加密
  • 参数校验+全局异常处理

2.共享Session

  • SpringSession
  • Redis

功能开发:

  • 商品列表
  • 商品详情
  • 秒杀
  • 订单详情

系统压测:

  1. JMeter
  2. 自定义变量模拟多用户
  3. JMeter命令行的使用
  4. 正式压测
    • 商品列表
    • 秒杀

页面优化:

  • 页面缓存+URL缓存+对象缓存
  • 页面静态化,前后端分离
  • 静态资源优化
  • CDN优化

接口优化:

  1. Redis预减库存减少数据库的访问
  2. 内存标记减少Redis的访问
  3. RabbitMQ异步下单
    • SpringBoot整合RabbitMQ
    • 交换机

安全优化:

  • 秒杀接口地址隐藏
  • 算术验证码
  • 对接口限流,防刷

项目搭建

项目所有的依赖:

<dependencies>

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

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

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

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


        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>


        <!--commons-codec和commons-lang3   MD5依赖-->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.6</version>
        </dependency>

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

        <!--spring data redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!--commons pool2对象池依赖-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>


        <!--rabbitmq(AMQP)依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!--验证码依赖-->
         <dependency>
                <groupId>com.github.whvcse</groupId>
                <artifactId>easy-captcha</artifactId>
                <version>1.6.2</version>
         </dependency>


<!--        &lt;!&ndash;spring session依赖&ndash;&gt;-->
<!--        <dependency>-->
<!--            <groupId>org.springframework.session</groupId>-->
<!--            <artifactId>spring-session-data-redis</artifactId>-->
<!--        </dependency>-->

    </dependencies>

配置文件 application.yml:

spring:
  #thymeleaf配置
  thymeleaf:
    #关闭缓存
    cache: false
   #数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    hikari:
      #连接池名
      pool-name: DateHikariCP
      #最小空闲连接数
      minimum-idle: 5
      #空闲连接存活最大时间,,30分钟
      idle-timeout: 1800000
      #最大连接数
      maximum-pool-size: 10
      #从连接池返回的连接的自动提交
      auto-commit: true
      # 连接最大存活时间,0表示永久存活
      max-lifetime: 1800000
      #连接超时时间,30秒
      connection-timeout: 30000
      #测试连接是否可用的查询语句
      connection-test-query: SELECT 1


  #redis配置
  redis:
    #服务器地址(Linux虚拟机上)
    host: 192.168.179.130
    port: 6379
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: 10000ms
        max-idle: 200
        min-idle: 5

  #RabbitMQ配置
  rabbitmq:
    host: 192.168.179.130
    username: lyd
    password: lyd
    virtual-host: /lyd
    port: 5672
    listener:
      simple:
        #消费者最小数量
        concurrency: 10
        #消费者最大数量
        max-concurrency: 10
        #限制消费者每次只能处理一条消息,处理完再继续下一条
        prefetch: 1
        #启动时是否默认启动容器
        auto-startup: true
        #消息被拒绝时是否重新进入队列
        default-requeue-rejected: true
    template:
      retry:
        #发布重试,默认false
        enabled: true
        #重试时间,默认1000ms
        initial-interval: 1000
        #重试最大次数,默认3
        max-attempts: 3
        #重试最大间隔时间,默认10000ms
        max-interval: 10000ms
        #下一次重试的间隔乘数
        multiplier: 1





#Mybatis-plus配置
mybatis-plus:
  #??Mapper.xml映射文件
  mapper-locations: classpath*/:mapper/*Mapper.xml
  # 配置MyBatis数据返回类型别名(默认别名是类名)
  type-aliases-package: cpm.lyd.seckill.pojo



## Mybatis SQL 打印(方法接口所在的包,不是Mapper.xml所在的包)
logging:
  level:
    com.lyd.seckill.mapper: debug

公共结果返回对象:
RespBean

package com.lyd.seckill.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 公共返回对象
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {

    private long code;
    private String message;
    private Object obj;

    /**
     * 成功返回结果
     * @return
     */
    public static RespBean success(){
        return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessgae(), null);
    }

    public static RespBean success(Object obj){
        return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBean.success().getMessage(), obj);
    }



    /**
     * 失败返回结果
     * @return
     */
    public static RespBean error(RespBeanEnum respBeanEnum){
        return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessgae(),null);
    }


    public static RespBean error(RespBeanEnum respBeanEnum,Object obj){
        return new RespBean(respBeanEnum.getCode(), respBeanEnum.getMessgae(), obj);
    }
}

公共返回对象枚举
RespBeanEnum

package com.lyd.seckill.vo;


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;

/**
 * 公共返回对象枚举
 */
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {
    //通用
    SUCCESS(200,"SUCCESS"),
    ERROR(500,"服务端异常"),

    //登录模块5002xx
    LOGIN_ERROR(500210,"手机号或密码不正确"),
    MOBILE_ERROR(500211,"手机号格式不正确"),
    BIND_ERROR(500212,"参数校验异常"),
    MOBILE_NOT_EXIST(500213,"手机号码不存在"),
    PASSWORD_UPDATE_FAIL(500214,"更新密码失败"),
    SESSION_ERROR(500215,"用户不存在"),



    //秒杀模块5005xx
    EMPTY_STOCK(500500,"库存不足"),
    REPEATE_ERROR(500501,"该商品每人限购一件"),
    REQUEST_ILLEGAL(500502,"请求非法,请重新尝试"),

    //订单模块5003xx
    ORDER_NOT_EXIST(500300,"订单信息不存在"),

    ;

    private final Integer code;

    private final String message;
}


一、分布式会话

实现用户登录功能

对用户密码进行两次MD5加密

  1. 第一次:客户端提交密码到服务端
  2. 第二次:服务端拿到客户端加密过的密码,再次进行加密后存入数据库。

两次加密,对客户端提交的密码进行加密,是为了防止用户密码在网络中明文传输。服务端再次加密是为了提高密码安全性,防止一次加密遭到逆向破解,双重保险。

导入md5依赖:
<!--commons-codec和commons-lang3   MD5依赖-->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.6</version>
        </dependency>

MD5工具类:
package com.lyd.seckill.utils;

import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.stereotype.Component;

/**
 * MD5工具类
 */
@Component
public class MD5Util {

    //md5加密
    public static String md5(String src){
        return DigestUtils.md5Hex(src);
    }

    private static final String salt="1a2b3c4d";

    //第一次从前端拿到密码,进行加密
    public static String inputPassToFromPass(String inputPass){
        String str ="" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }

    //第二次加密,后端拿到密码,在进入数据库之前再进行加密
    public static String fromPassToDBPass(String fromPass,String salt){
        String str ="" + salt.charAt(0) + salt.charAt(2) + fromPass + salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }


    public static String inputPassToDBPass(String inputPass,String salt){
        String fromPass = inputPassToFromPass(inputPass);
        String dbPass = fromPassToDBPass(fromPass, salt);
        return dbPass;
    }


    public static void main(String[] args) {
        //d3b1294a61a07da9b49b6e22b2cbd7f9
        System.out.println(inputPassToFromPass("123456"));

        //1897a69ef451f0991bb85c6e7c35aa31
        System.out.println(fromPassToDBPass("d3b1294a61a07da9b49b6e22b2cbd7f9","1a2b3c4d"));

        //1897a69ef451f0991bb85c6e7c35aa31
        System.out.println(inputPassToDBPass("123456","1a2b3c4d"));
    }
}

逆向工程:
通过逆向工程生产对应数据库表的类,如pojo、mapper、Service、ServiceImpl、Controller等类。使用的是mybatis-plus官网提供的AutoGenerator代码自动生成器,略过…

校验手机号码:

package com.lyd.seckill.utils;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 手机号码校验
 */

public class ValidatorUtil {

    //手机号模板
    private static final Pattern mobile_pattern =Pattern.compile("[1]([3-9])[0-9]{9}$");

    public static boolean isMobile(String mobile){
        if (StringUtils.isBlank(mobile)){
            return false;
        }
        Matcher matcher = mobile_pattern.matcher(mobile);
        return matcher.matches();
    }
}

手机号码校验规则:

package com.lyd.seckill.vo;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.lyd.seckill.utils.ValidatorUtil;
import com.lyd.seckill.validator.IsMobile;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * 手机号码校验规则
 */

public class IsMobileValidator implements ConstraintValidator<IsMobile,String> {

    private boolean required =false;

    @Override
    public void initialize(IsMobile constraintAnnotation) {
        required = constraintAnnotation.required();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        if (required){
            return ValidatorUtil.isMobile(value);
        }else {
            if (StringUtils.isBlank(value)){
                return true;
            }else {
                return ValidatorUtil.isMobile(value);
            }
        }
    }
}

自定义注解,验证手机号:

/**
 * 验证手机号
 */
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER,TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class})
public @interface IsMobile {

    boolean required() default true;

    String message() default "手机号码格式错误";

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

    Class<? extends Payload>[] payload() default { };
}

全局异常类:

package com.lyd.seckill.exception;

import com.lyd.seckill.vo.RespBeanEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 全局异常
 */

@Data
@NoArgsConstructor
@AllArgsConstructor
public class GlobalException extends RuntimeException{

    private RespBeanEnum respBeanEnum;
}

定义全局异常处理类:

package com.lyd.seckill.exception;

import com.lyd.seckill.vo.RespBean;
import com.lyd.seckill.vo.RespBeanEnum;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;


/**
 * 全局异常处理类
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public RespBean ExceptionHandler(Exception e){
        if (e instanceof GlobalException){
            GlobalException ex=(GlobalException) e;
            return RespBean.error(ex.getRespBeanEnum());
        }else if (e instanceof BindException){
            BindException ex=(BindException) e;
            RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
            respBean.setMessage("参数校验异常:"+ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
            return respBean;
        }
        return RespBean.error(RespBeanEnum.ERROR);
    }
}

编写登录功能:
代码逻辑写在service层

package com.lyd.seckill.controller;

import com.lyd.seckill.service.IUserService;
import com.lyd.seckill.vo.LoginVo;
import com.lyd.seckill.vo.RespBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {

    @Autowired
    private IUserService userService;

    /**
     * 跳转登录页
     * @return
     */
    @RequestMapping("/toLogin")
    public String toLogin(){
        return "login";
    }


    /**
     * 登录功能
     * @param loginVo
     * @return
     */
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response){
        //log.info("{}",loginVo);

        return userService.doLogin(loginVo,request,response);
    }


}

完善登录功能

使用cookie+session记录用户信息

准备Cookie工具类和UUID工具类

package com.lyd.seckill.utils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;

/**
 * Cookie工具类
 *
 *
 */
public final class CookieUtil {

    /**
     * 得到Cookie的值, 不编码
     *
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName) {
        return getCookieValue(request, cookieName, false);
    }

    /**
     * 得到Cookie的值,
     *
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    if (isDecoder) {
                        retValue = URLDecoder.decode(cookieList[i].getValue(), "UTF-8");
                    } else {
                        retValue = cookieList[i].getValue();
                    }
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 得到Cookie的值,
     *
     * @param request
     * @param cookieName
     * @return
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString);
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 设置Cookie的值 不设置生效时间默认浏览器关闭即失效,也不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue) {
        setCookie(request, response, cookieName, cookieValue, -1);
    }

    /**
     * 设置Cookie的值 在指定时间内生效,但不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage) {
        setCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
    }

    /**
     * 设置Cookie的值 不设置生效时间,但编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, boolean isEncode) {
        setCookie(request, response, cookieName, cookieValue, -1, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage, boolean isEncode) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数(指定编码)
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage, String encodeString) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString);
    }

    /**
     * 删除Cookie带cookie域名
     */
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
                                    String cookieName) {
        doSetCookie(request, response, cookieName, "", -1, false);
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     *
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
                                          String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else if (isEncode) {
                cookieValue = URLEncoder.encode(cookieValue, "utf-8");
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request) {// 设置域名的cookie
                String domainName = getDomainName(request);
                if (!"localhost".equals(domainName)) {
                    cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     *
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
                                          String cookieName, String cookieValue, int cookieMaxage, String encodeString) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else {
                cookieValue = URLEncoder.encode(cookieValue, encodeString);
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0) {
               cookie.setMaxAge(cookieMaxage);
            }
            if (null != request) {// 设置域名的cookie
                String domainName = getDomainName(request);
                if (!"localhost".equals(domainName)) {
                    cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 得到cookie的域名
     */
    private static final String getDomainName(HttpServletRequest request) {
        String domainName = null;
        // 通过request对象获取访问的url地址
        String serverName = request.getRequestURL().toString();
        if (serverName == null || serverName.equals("")) {
            domainName = "";
        } else {
            // 将url地下转换为小写
            serverName = serverName.toLowerCase();
            // 如果url地址是以http://开头  将http://截取
            if (serverName.startsWith("http://")) {
                serverName = serverName.substring(7);
            }
            int end = serverName.length();
            // 判断url地址是否包含"/"
            if (serverName.contains("/")) {
                //得到第一个"/"出现的位置
                end = serverName.indexOf("/");
            }

            // 截取
            serverName = serverName.substring(0, end);
            // 根据"."进行分割
            final String[] domains = serverName.split("\\.");
            int len = domains.length;
            if (len > 3 && serverName.charAt(0) == 'w') {
                // www.xxx.com.cn
                domainName = domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
            } else if (len <= 3 && len > 1) {
                // xxx.com or xxx.cn
                domainName = domains[len - 2] + "." + domains[len - 1];
            } else {
                domainName = serverName;
            }
        }

        if (domainName != null && domainName.indexOf(":") > 0) {
            String[] ary = domainName.split("\\:");
            domainName = ary[0];
        }
        return domainName;
    }
}
package com.lyd.seckill.utils;

import java.util.UUID;

/**
 * UUID工具类
 *
 * @author zhoubin
 * @since 1.0.0
 */
public class UUIDUtil {

   public static String uuid() {
      return UUID.randomUUID().toString().replace("-", "");
   }

}

分布式Session:

问题:如果我们的代码,所有操作都是在同一台Tomcat上是没有什么问题。但当我们部署多台系统,再配合Nginx的时候用户登录就会出现问题。

原因:由于Nginx使用默认的负载均衡策略(轮询),请求将会按照时间顺序分发到后端应用上。也就是说,一开始我们在Tomcat1上登录了,这时用户的信息会存放在Tomcat1的Session里。随着时间推移,请求被分发到Tomcat2上,这时的Tomcat2上的Session里没有用户的信息,登录失效,用户就得重新登录。

解决方案:

  1. Session复制
  2. 前端存储
  3. Session粘滞
  4. 后端集中存储

这四种解决方案都各有优缺点,综合下来,我使用的是后端集中存储的方案。存储就需要使用到Redis作为后端缓存,实现分布式Session。

Redis实现分布式Session也有两种方式,一是使用SpringSession实现;二是将用户信息存入Redis。我这里选择第二种。

将用户信息存入Redis

添加依赖:

<!--spring data redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!--commons pool2对象池依赖-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

添加配置application.yml:

  #redis配置
  redis:
    #服务器地址(Linux虚拟机上)
    host: 192.168.179.130
    port: 6379
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        max-active: 8
        max-wait: 10000ms
        max-idle: 200
        min-idle: 5

编写Redis配置类:
自定义RedisTemplate序列化方式

package com.lyd.seckill.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis配置类
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        //key序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //value序列化
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

        //Hash类型 key的序列化
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        //Hash类型 value的序列化
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        //注入连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }



    @Bean
    public DefaultRedisScript<Long> script(){
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        //lock.lua脚本位置和application.yml同级目录
        redisScript.setLocation(new ClassPathResource("stock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }

}

将用户信息存入Redis

//生成cookie
        String ticket = UUIDUtil.uuid();
        //将用户信息存入redis中
        redisTemplate.opsForValue().set("user:"+ticket,user);

根据cookie获取用户

/**
     * 根据cookie获取用户
     * @param userTicket
     * @return
     */
    @Override
    public User getUserByCookie(String userTicket,HttpServletRequest request,HttpServletResponse response) {
        if (StringUtils.isBlank(userTicket)){
            return null;
        }
        User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);

        if (user!=null){
            CookieUtil.setCookie(request,response,"userTicket",userTicket);
        }

        return user;
    }

二、秒杀功能

开发秒杀功能前,还是要先在数据库建表,秒杀商品表,订单表等,然后再次通过逆向工程去生成表对应的一些类。同时再次写几个简单的页面,如秒杀商品列表页,秒杀商品详情页还有秒杀成功的商品支付页和秒杀订单页等。

实现:先从数据库表中通过秒杀商品的id去获取当前秒杀商品的信息,秒杀成功将其库存数量-1,然后写SQL语句去更新秒杀商品的信息,并生成订单。

    /**
     * 秒杀
     * @param user
     * @param goods
     * @return
     */
    @Override
    @Transactional
    public Order seckill(User user, GoodsVo goods) {

        ValueOperations valueOperations = redisTemplate.opsForValue();

        SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
        seckillGoods.setStockCount(seckillGoods.getStockCount()-1);

//        UpdateWrapper<SeckillGoods> seckillGoodsUpdateWrapper = new UpdateWrapper<SeckillGoods>().set("stock_count", seckillGoods.getStockCount())
//                .eq("id", seckillGoods.getId()).gt("stock_count", 0);
//
//        boolean seckillGoodsResult = seckillGoodsService.update(seckillGoodsUpdateWrapper);

        UpdateWrapper<SeckillGoods> seckillGoodsUpdateWrapper = new UpdateWrapper<SeckillGoods>()
                .setSql("stock_count = stock_count-1")
                .eq("goods_id", goods.getId())
                .gt("stock_count",0);

        boolean result = seckillGoodsService.update(seckillGoodsUpdateWrapper);

        if (seckillGoods.getStockCount()<1){
            //判断是否还有库存
            valueOperations.set("isStockEmpty:" + goods.getId(),"0");
            return null;
        }

        //生成订单
        Order order = new Order();
        order.setUserId(user.getId());
        order.setGoodsId(goods.getId());
        order.setDeliveryAddrId(0L);
        order.setGoodsName(goods.getGoodsName());
        order.setGoodsCount(1);
        order.setGoodsPrice(seckillGoods.getSeckillPrice());
        order.setOrderChannel(1);
        order.setStatus(0);
        order.setCreateDate(new Date());

        orderMapper.insert(order);

        //生成秒杀订单
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUserId(user.getId());
        seckillOrder.setOrderId(order.getId());
        seckillOrder.setGoodsId(goods.getId());

        seckillOrderService.save(seckillOrder);
        redisTemplate.opsForValue().set("order:"+user.getId()+":"+goods.getId(),seckillOrder);

        return order;
    }

在进行秒杀抢购时,会先判断当前用户是否登录或者库存是否足够,若已登录,判断该用户是否重复抢购,都判断完成后再进行秒杀抢购。

 /**
     * 秒杀
     * @param model
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping("/doSecKill2")
    public String doSecKill2(Model model, User user,Long goodsId){
        if (user==null){
            return "login";
        }
        model.addAttribute("user",user);
        GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
        //判断库存
        if (goods.getStockCount()<1){
            model.addAttribute("errmsg", RespBeanEnum.EMPTY_STOCK.getMessage());
            return "secKillFail";
        }

        //判断是否重复抢购
        QueryWrapper<SeckillOrder> seckillOrder = new QueryWrapper<>();
        seckillOrder.eq("user_id",user.getId()).eq("goods_id",goodsId);
        seckillOrderService.getOne(seckillOrder);

        if (seckillOrder!=null){
            //秒杀失败
            model.addAttribute("errmsg",RespBeanEnum.REPEATE_ERROR.getMessage());
            return "secKillFail";
        }
        //秒杀成功
        Order order = orderService.seckill(user,goods);
        model.addAttribute("order",order);
        model.addAttribute("goods",goods);
        return "orderDetail";


    }

秒杀初步开发就完成了,接下来就使用JMeter压测工具对秒杀接口进行测试,记录一下QPS,然后再进行优化。压测部分就跳过了,压测结果QPS七百多,接下来就开始优化,优化方式就Redis作为缓存去缓存一些页面,提高响应速度,还有就是页面的静态化处理。

三、优化

缓存优化:避免对数据库频繁的访问,导致性能降低。可以将读取频繁但是变更比较少的数据存入Redis。为什么是变更少的,因为变更多的数据,如果存入Redis,在变更之后如果Redis更新不及时,很容易造成数据不一致的问题。客户端读取的数据不是最新的。

页面优化

缓存
页面缓存

/**
     * 跳转商品列表页面
     * Windows优化前QPS:1225
     *          缓存后QPS:2324
     * @param model
     * @param user
     * @return
     */
    @RequestMapping(value = "/toList",produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toList(Model model,User user,
                         HttpServletRequest request,HttpServletResponse response){
//        if (StringUtils.isBlank(ticket)){
//            return "login";
//        }
//
        User user = (User) session.getAttribute(ticket);
//
//        User user = userService.getUserByCookie(ticket, request, response);
//        if (user==null) {
//            return "login";
//        }
        //Redis中获取页面,如果不为空,直接返回页面
        ValueOperations valueOperations = redisTemplate.opsForValue();
        String html = (String) valueOperations.get("goodsList");
        if (!StringUtils.isBlank(html)){
            return html;
        }

        model.addAttribute("user",user);
        model.addAttribute("goodsList",goodsService.findGoodsVo());
        //return "goodsList";
        //如果为空,手动渲染页面,存入Redis并返回
        WebContext context = new WebContext(request,response,request.getServletContext(),
                request.getLocale(),model.asMap());
        html = thymeleafViewResolver.getTemplateEngine().process("goodsList", context);
        if (!StringUtils.isBlank(html)){
            valueOperations.set("goodsList",html,60, TimeUnit.SECONDS);
        }
        return html;
    }
/**
     * 跳转商品详情页
     * @param model
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping(value = "toDetail2/{goodsId}",produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toDetail2(Model model, User user, @PathVariable Long goodsId,
                           HttpServletRequest request,HttpServletResponse response){

        ValueOperations valueOperations = redisTemplate.opsForValue();
        //Redis中获取页面,如果不为空,直接返回页面
        String html = (String) valueOperations.get("goodsDetail:" + goodsId);
        if (!StringUtils.isBlank(html)){
            return html;
        }

        model.addAttribute("user",user);
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
        Date startDate = goodsVo.getStartDate();
        Date endDate = goodsVo.getEndDate();
        Date nowDate = new Date();
        //秒杀状态
        int seckillStatus = 0;
        //秒杀倒计时
        int remainSeconds = 0;
        //秒杀还未开始
        if (nowDate.before(startDate)){
            //seckillStatus还是0 不处理seckillStatus
            remainSeconds = (int)((startDate.getTime()-nowDate.getTime())/1000);

        }else if (nowDate.after(endDate)){
            //秒杀结束
            seckillStatus = 2;
            remainSeconds  = -1;
        }else {
            //秒杀进行中
            seckillStatus = 1;
            remainSeconds = 0;
        }
        model.addAttribute("remainSeconds",remainSeconds);
        model.addAttribute("seckillStatus",seckillStatus);
        model.addAttribute("goods",goodsVo);

        WebContext context = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap());
        html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", context);
        if (!StringUtils.isBlank(html)){
            valueOperations.set("goodsDetail",html,60,TimeUnit.SECONDS);
        }
        return html;
        //  return "goodsDetail";
    }

再次进行压测,QPS明显提高了很多,页面缓存完,可以再对对象进行缓存。

缓存对象

IUserService.java

 /**
     * 更新密码
     * @param userTicket
     * @param password
     * @param request
     * @param response
     * @return
     */
    RespBean updatePassword(String userTicket,String password,HttpServletRequest request,HttpServletResponse response);

}

UserServiceImpl.java

/**
     * 更新密码
     * @param userTicket
     * @param password
     * @param request
     * @param response
     * @return
     */
    @Override
    public RespBean updatePassword(String userTicket, String password,HttpServletRequest request,HttpServletResponse response) {
        User user = getUserByCookie(userTicket, request, response);
        if (user==null){
            throw new GlobalException(RespBeanEnum.MOBILE_NOT_EXIST);
        }
        user.setPassword(MD5Util.inputPassToDBPass(password,user.getSalt()));
        int result = userMapper.updateById(user);
        if (result==1){
            //删除Redis
            redisTemplate.delete("user:"+userTicket);
            return RespBean.success();
        }
        return RespBean.error(RespBeanEnum.PASSWORD_UPDATE_FAIL);
    }
商品详情静态化
 /**
     * 跳转商品详情页
     * @param model
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping("toDetail/{goodsId}")
    @ResponseBody
    public RespBean toDetail(Model model, User user, @PathVariable Long goodsId){
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
        Date startDate = goodsVo.getStartDate();
        Date endDate = goodsVo.getEndDate();
        Date nowDate = new Date();
        //秒杀状态
        int seckillStatus = 0;
        //秒杀倒计时
        int remainSeconds = 0;
        //秒杀还未开始
        if (nowDate.before(startDate)){
            //seckillStatus还是0 不处理seckillStatus
            remainSeconds = (int)((startDate.getTime()-nowDate.getTime())/1000);

        }else if (nowDate.after(endDate)){
            //秒杀结束
            seckillStatus = 2;
            remainSeconds  = -1;
        }else {
            //秒杀进行中
            seckillStatus = 1;
            remainSeconds = 0;
        }

        DetailVo detailVo = new DetailVo();
        detailVo.setUser(user);
        detailVo.setGoodsVo(goodsVo);
        detailVo.setSecKillStatus(seckillStatus);
        detailVo.setRemainSeconds(remainSeconds);
        return RespBean.success(detailVo);
    }
秒杀静态化
/**
     * 秒杀
     * Windows优化前QPS:752
     *          缓存QPS:1285
     *          优化QPS:2352
     * @param path
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping(value = "/{path}/doSecKill",method = RequestMethod.POST)
    @ResponseBody
    public RespBean doSecKill(@PathVariable String path, User user, Long goodsId){
        if (user==null){
            return RespBean.error(RespBeanEnum.SESSION_ERROR);
        }

        ValueOperations valueOperations = redisTemplate.opsForValue();

        boolean check = orderService.checkPath(user,goodsId,path);
        if (!check){
            return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
        }

        //判断是否重复抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
                .get("order:" + user.getId() + ":" + goodsId);

        if (seckillOrder!=null){
            //秒杀失败
            return RespBean.error(RespBeanEnum.REPEATE_ERROR);
        }

        //内存标记,减少Redis的访问
        if (EmptyStockMap.get(goodsId)){
            return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }
        //获取递减之后的库存(预减库存)
//        Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
        Long stock = ((Long) redisTemplate.execute(script, Collections.singletonList("seckillGoods:" + goodsId)
                , Collections.EMPTY_LIST));

        if (stock<0){
            EmptyStockMap.put(goodsId,true);
            valueOperations.increment("seckillGoods:" + goodsId);
            return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }

        //秒杀成功
        SeckillMessage seckillMessage = new SeckillMessage(user,goodsId);
        mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));

        return RespBean.success(0);
}

然后就是对那几个html页面进行静态化,略过…
再次进行压测,QPS明显提高…

解决库存超卖问题

在进行秒杀减库存时应先判断库存是否足够,而后再进行减库存操作,如果未进行判断,或者张三秒杀时刚刚判断完还剩最后1个库存,另一边李四就秒杀成功,而张三这边的库存未及时更新,再次减库存后就会造成库存超卖问题。所以我们不仅要判断库存是否足够,还要在方法上加入事务,避免两边对库存进行减操作导致的库存不一致。

 /**
     * 秒杀
     * @param user
     * @param goods
     * @return
     */
    @Override
    @Transactional
    public Order seckill(User user, GoodsVo goods) {

        ValueOperations valueOperations = redisTemplate.opsForValue();

        SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
        seckillGoods.setStockCount(seckillGoods.getStockCount()-1);

//        UpdateWrapper<SeckillGoods> seckillGoodsUpdateWrapper = new UpdateWrapper<SeckillGoods>().set("stock_count", seckillGoods.getStockCount())
//                .eq("id", seckillGoods.getId()).gt("stock_count", 0);
//
//        boolean seckillGoodsResult = seckillGoodsService.update(seckillGoodsUpdateWrapper);

        UpdateWrapper<SeckillGoods> seckillGoodsUpdateWrapper = new UpdateWrapper<SeckillGoods>()
                .setSql("stock_count = stock_count-1")
                .eq("goods_id", goods.getId())
                .gt("stock_count",0);

        boolean result = seckillGoodsService.update(seckillGoodsUpdateWrapper);

        if (seckillGoods.getStockCount()<1){
            //判断是否还有库存
            valueOperations.set("isStockEmpty:" + goods.getId(),"0");
            return null;
        }

        //生成订单
        Order order = new Order();
        order.setUserId(user.getId());
        order.setGoodsId(goods.getId());
        order.setDeliveryAddrId(0L);
        order.setGoodsName(goods.getGoodsName());
        order.setGoodsCount(1);
        order.setGoodsPrice(seckillGoods.getSeckillPrice());
        order.setOrderChannel(1);
        order.setStatus(0);
        order.setCreateDate(new Date());

        orderMapper.insert(order);

        //生成秒杀订单
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUserId(user.getId());
        seckillOrder.setOrderId(order.getId());
        seckillOrder.setGoodsId(goods.getId());

        seckillOrderService.save(seckillOrder);
        redisTemplate.opsForValue().set("order:"+user.getId()+":"+goods.getId(),seckillOrder);

        return order;
    }

另外,为了解决同一个用户同时秒杀多件商品的问题,我们可以通过在数据库中建立唯一索引,以此来避免。

为了方便判断是否重复抢购时进行查询,我们可以将用户秒杀成功后生成的订单存入Redis缓存,在短时间内如果已经抢购成功的用户,再次进行抢购时就会根据存在Redis中的订单进行判断。

//判断是否重复抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
                .get("order:" + user.getId() + ":" + goodsId);

        if (seckillOrder!=null){
            //秒杀失败
            return RespBean.error(RespBeanEnum.REPEATE_ERROR);
        }

我们还可以使用内存标记,减少对Redis的访问。

服务优化

我们可以使用RabbitMQ消息中间件来优化

接口优化
  1. 优化思路:减少数据库访问
  2. 系统初始化,把商品库存数量加载到Redis
  3. 收到请求,Redis去预减库存。库存不足,直接返回。否则进入下一步。
  4. 请求入队,立即返回排队中
  5. 请求出队,生成订单,减少库存
  6. 客户端轮询,是否秒杀成功

系统初始化,把商品库存数量加载到Redis

/**
     * 系统初始化,把商品库存数量加载到Redis
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> list = goodsService.findGoodsVo();
        if (CollectionUtils.isEmpty(list)){
            return;
        }
        list.forEach(goodsVo ->{
            redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(),goodsVo.getStockCount());
            EmptyStockMap.put(goodsVo.getId(),false);
                }
        );

    }

Redis预减库存

//获取递减之后的库存(预减库存)
//        Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
        Long stock = ((Long) redisTemplate.execute(script, Collections.singletonList("seckillGoods:" + goodsId)
                , Collections.EMPTY_LIST));

        if (stock<0){
            EmptyStockMap.put(goodsId,true);
            valueOperations.increment("seckillGoods:" + goodsId);
            return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }

        //秒杀成功
        SeckillMessage seckillMessage = new SeckillMessage(user,goodsId);
        mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));

        return RespBean.success(0);

RabbitMQ秒杀

RabbitMQ配置类

/**
 * RabbitMQ配置类-Topic
 */

@Configuration
public class RabbitMQConfigTopic {


    private static final String QUEUE = "seckillQueue";
    private static final String EXCHANGE = "seckillExchange";


    @Bean
    public Queue queue(){
        return new Queue(QUEUE);
    }


    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(EXCHANGE);
    }


    @Bean
    public Binding binding(){
        return BindingBuilder.bind(queue()).to(topicExchange()).with("seckill.#");
    }
}

消息发送者

/**
 * 消息发送者
 */

@Service
@Slf4j
public class MQSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;


    /**
     * 发送秒杀消息
     * @param message
     */
    public void sendSeckillMessage(String message){
        log.info("发送消息: " + message);
        rabbitTemplate.convertAndSend("seckillExchange","seckill.message",message);
    }
 }

消息接收者

/**
 * 消息接受者
 */

@Service
@Slf4j
public class MQReceiver {


    @Autowired
    private IGoodsService goodsService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private IOrderService orderService;


    @RabbitListener(queues = "seckillQueue")
    public void receive(String message){
        log.info("接收的消息:" + message);
        SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class);
        Long goodsId = seckillMessage.getGoodId();
        User user = seckillMessage.getUser();
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
        if (goodsVo.getStockCount()<1){
            return;
        }

        //判断是否重复抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue()
                .get("order:" + user.getId() + ":" + goodsId);

        if (seckillOrder!=null){
            //秒杀失败
            return;
        }
        //下单操作
        orderService.seckill(user,goodsVo);


    }
}

客户端轮询秒杀结果

SecKillController.java

 /**
     * 获取秒杀结果
     * @param user
     * @param goodsId
     * @return orderId:成功,  -1:秒杀失败,  0:排队中
     */
    @RequestMapping(value = "/result",method = RequestMethod.GET)
    @ResponseBody
    public RespBean getResult(User user,Long goodsId){
        if (user==null){
            return RespBean.error(RespBeanEnum.SESSION_ERROR);
        }
        Long orderId = seckillOrderService.getResult(user,goodsId);
        return RespBean.success(orderId);
    }

ISeckillOrderService.java

public interface ISeckillOrderService extends IService<SeckillOrder> {


    /**
     * 获取秒杀结果
     * @param user
     * @param goodsId
     * @return orderId:成功,  -1:秒杀失败,  0:排队中
     */
    Long getResult(User user, Long goodsId);

}

SeckillOrderServiceImpl.java

@Service
public class SeckillOrderServiceImpl extends ServiceImpl<SeckillOrderMapper, SeckillOrder> implements ISeckillOrderService {


    @Autowired
    private SeckillOrderMapper seckillOrderMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 获取秒杀结果
     * @param user
     * @param goodsId
     * @return orderId:成功,  -1:秒杀失败,  0:排队中
     */
    @Override
    public Long getResult(User user, Long goodsId) {
        QueryWrapper<SeckillOrder> orderQueryWrapper = new QueryWrapper<>();
        orderQueryWrapper.eq("user_id",user.getId()).eq("goods_id",goodsId);
        SeckillOrder seckillOrder = seckillOrderMapper.selectOne(orderQueryWrapper);

        if (seckillOrder!=null){
            return seckillOrder.getOrderId();
        }else if (redisTemplate.hasKey("isStockEmpty:" + goodsId)){
            return -1L;

        }else {
            return 0L;
        }

    }
}

优化Redis操作库存

通过测试发现,Redis没有做到原子性,我们可以采用锁去解决

分布式锁:
进来一个线程先占位,当别的线程进来操作时,发现已经有人占位了,就会放弃或者稍后再试。线程操作执行完成之后,需要del去释放位子。

可以使用lua脚本

if redis.call("get",KEYS[1])==ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
if (redis.call("exists",KEYS[1])==1) then
    local stock = tonumber(redis.call("get",KEYS[1]));
    if (stock>0) then
        redis.call("incrby",KEYS[1],-1);
        return stock;
    end;
     return 0;
end;

安全优化

隐藏接口地址
算术验证码
简单接口限流
通用接口限流

隐藏秒杀接口

许多黄牛,会使用脚本去提前获取秒杀接口的地址,然后再秒杀开始时,通过脚本去不断的发送请求,造成服务器压力骤增,同时这样秒杀到的几率就比我们普通用户手动秒杀高,为了避免被黄牛提前知道秒杀接口的地址,我们可以隐藏秒杀接口,等秒杀开始时再去进行正常的秒杀流程。

 /**
     * 获取秒杀地址
     * @param user
     * @param goodsId
     * @return
     */
    String createPath(User user, Long goodsId);


    /**
     * 校验秒杀地址
     * @param user
     * @param goodsId
     * @param path
     * @return
     */
    boolean checkPath(User user, Long goodsId, String path);


/**
     * 获取秒杀地址
     * @param user
     * @param goodsId
     * @return
     */
    @Override
    public String createPath(User user, Long goodsId) {
        String str = MD5Util.md5(UUIDUtil.uuid() + "123456");
        redisTemplate.opsForValue().set("seckillPath:" + user.getId() + ":"
                +goodsId,str,60, TimeUnit.SECONDS);
        return str;
    }


    /**
     * 校验秒杀地址
     * @param user
     * @param goodsId
     * @param path
     * @return
     */
    @Override
    public boolean checkPath(User user, Long goodsId, String path) {
        if (user==null || goodsId<0 || StringUtils.isBlank(path)){
            return false;
        }

        String redisPath= (String)redisTemplate.opsForValue().get("seckillPath:" + user.getId() + ":" + goodsId);
        return path.equals(redisPath);
    }


  
生成验证码

当秒杀倒计时结束,秒杀正式开始时,许多用户点击秒杀按钮,海量的请求瞬间请求到服务端,造成服务端压力增加,为了缓解服务端压力,我们可以在秒杀开始时设置一个验证码,当点击秒杀按钮后,出现验证码并进行验证,验证成功后完成秒杀操作,避免同一时刻大量的秒杀请求同时到达服务端。

我使用的是Gitee上开源的验证码,里面包括算术验证码,图形点击验证码,文字验证码等,我这里就使用算术验证码。

<dependency>
                <groupId>com.github.whvcse</groupId>
                <artifactId>easy-captcha</artifactId>
                <version>1.6.2</version>
         </dependency>
/**
     * 验证码
     * @param user
     * @param goodsId
     * @param response
     */
    @RequestMapping(value = "/captcha",method = RequestMethod.GET)
    public void verifyCode(User user, Long goodsId, HttpServletResponse response){
        if (user==null){
            throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL);
        }

        //设置请求头为输出图片的类型
        response.setContentType("image/jpg");
        response.setHeader("Pargam","No-cache");
        response.setHeader("Cache-Control","no-cache");
        response.setDateHeader("Expires",0);

        //生成验证码,并将结果存入Redis
        ArithmeticCaptcha captcha = new ArithmeticCaptcha(130,32,3);
        redisTemplate.opsForValue().set("captcha:"+user.getId()+":"+goodsId,captcha.text(),300, TimeUnit.SECONDS);

        try {
            captcha.out(response.getOutputStream());
        } catch (IOException e) {
            log.error("验证码生成失败",e.getMessage());
        }

    }

校验验证码

/**
     * 校验验证码
     * @param user
     * @param goodsId
     * @param captcha
     * @return
     */
    boolean checkCaptcha(User user, Long goodsId, String captcha);

 /**
     * 校验验证码
     * @param user
     * @param goodsId
     * @param captcha
     * @return
     */
    @Override
    public boolean checkCaptcha(User user, Long goodsId, String captcha) {
        if (StringUtils.isBlank(captcha) || user==null || goodsId<0){
            return false;
        }
        String redisCaptcha = (String)redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" +goodsId);

        return captcha.equals(redisCaptcha);
    }
接口限流

为什么要进行接口限流,同样还是为了避免黄牛使用脚本对秒杀接口重复请求刷取的情况,起到一个防刷、限流的作用。
接口限流的原理也很简单,比如在一个时间刻度上,在0~1分钟,设置一分钟内的请求次数为100次,并使用计数器来记录请求次数,当请求一次计数器记录一次,当一分钟内请求已经达到一百次,则抛出异常信息表示请求频繁,稍后再试。如果一分钟时间到了,在下一分钟开始时,计数器的记录清零,并重新开始计数,达到限流的目的。

接口限流有简单接口限流和通用接口限流,两种限流方式我都实现了,我这里就写一下通用接口限流的方式。

通用接口限流

UserContext.java

package com.lyd.seckill.config;


import com.lyd.seckill.pojo.User;

public class UserContext {


    private static ThreadLocal<User> userHolder = new ThreadLocal<>();

    public static void setUser(User user){
        userHolder.set(user);
    }

    public static User getUser(){
        return userHolder.get();
    }
}

UserArgumentResolver.java

@Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        return UserContext.getUser();
    }

自定义接口限流注解

/**
 * 通用接口限流(自定义注解)
 */

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

    int second();
    int maxCount();
    boolean needLogin() default true;
}

public class AccessLimitInterceptor implements HandlerInterceptor {

    @Autowired
    private IUserService userService;

    @Autowired
    private RedisTemplate redisTemplate;


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod){
            User user = getUser(request,response);
            UserContext.setUser(user);
            HandlerMethod hm = (HandlerMethod) handler;
            AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
            if (accessLimit==null){
                return true;
            }
            int second = accessLimit.second();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();

            String key = request.getRequestURI();
            if (needLogin){
                if (user==null){
                    render(response, RespBeanEnum.SESSION_ERROR);
                    return false;
                }
                key+=":"+user.getId();
            }
            ValueOperations valueOperations = redisTemplate.opsForValue();
            Integer count = (Integer) valueOperations.get(key);
            if (count==null){
                valueOperations.set(key,1,second, TimeUnit.SECONDS);
            }else if (count<maxCount){
                valueOperations.increment(key);
            }else {
                render(response,RespBeanEnum.ACCESS_LIMIT_REAHCED);
            }
        }
        return true;
    }


    /**
     * 构建返回对象
     * @param response
     * @param respBeanEnum
     */
    private void render(HttpServletResponse response, RespBeanEnum respBeanEnum) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        RespBean respBean = RespBean.error(respBeanEnum);
        out.write(new ObjectMapper().writeValueAsString(respBean));
        out.flush();
        out.close();

    }


    /**
     * 获取当前登录用户
     * @param request
     * @param response
     * @return
     */
    private User getUser(HttpServletRequest request, HttpServletResponse response) {
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        if (StringUtils.isBlank(ticket)){
            return null;
        }
        return userService.getUserByCookie(ticket,request,response);
    }
}

WebConfig.java

@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accessLimitInterceptor);
    }
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐