秒杀Seckill项目总结

秒杀项目总结

概述

利用了两周时间完成了一个秒杀项目,这是第一次正式做项目,收获很多,下面记录一下自己的收获

1.关于Service层调用dao层

  • Service内只调自己的mapper,其他的Service 而不调其他的mapper

Service中要更新缓存,如果你调用了其他的mapper更改了,那么缓存可能不更改,缓存不一致

2.关于Dto、Vo和实体的使用

  • Dto:数据传输对象,用于和前端交互数据的对象,可以简单理解为需要通过controller层传递给前端数据(可能是实体、vo的封装,集合或者只包括它们其中一些成员变量)
  • Vo:包装类对象,用于对实体类和一些分离参数的封装,使得操作只需要操作该包装对象
  • entity:实体对象,直接作用于dao层的对象,是数据库字段的封装

例如:

DTO:我需要传递给前端我在Service或者controller层操作的一些数据(非实体内部成员),那么我们需要把它封装为Dto对象

VO:像登录参数的封装就属于Vo对象,还有就是实体之间构成的一些参数封装成的对象(多个表中某些字段构成的)

3.关于枚举类定义以及结果封装

我们通常需要一个枚举类定义程序的状态,它包含两个可以获取到的元素(状态码和状态信息),为了可以获取到这两个信息,我们会声明一个状态码的接口,声明两个get方法,再让枚举类实现该接口来获取状态信息

public interface IError {

    int getCode();

    String getMessage();

}
@Getter
@AllArgsConstructor
public enum ErrorCodeEnum implements IError {
    /**
     * 100X  用户相关错误
     * //
     */
    PASSWORD_EMPTY(1001, "密码能不为空"),
    MOBILE_PATTERN_WRONG(1002, "手机号格式错误"),
    MOBILE_NOT_EXIST(1003, "手机号不存在"),
    LOGIN_FAIL(1004, "登陆失败,密码错误"),
    USER_NOT_EXSIT(1005, "用户不存在"),
    NOT_LOGIN(1006,"尚未登录"),
    /**
     * 000X 公共错误
     */
    SUCCESS(2000, "OK"),
    NET_ERROR(2001, "网络错误"),
    PARAM_ERROR(2002, "参数错误"),
    /**
     * 400X 业务错误
     */
    SECKILL_FAIL(4001, "秒杀失败,库存不足");

    private int code;

    private String message;
}

结果封装

我们把数据和状态传递给前端的时候,需要把状态信息和状态码传递过去,我们前端才可以根据状态码判断需要执行的操作

该结果类需要在网路之间传输,需要进行序列化操作,最简单的序列化操作就是实现Serializable接口,定义一个全局的版本号。在该类中,包括状态码、状态信息msg、数据对象T和是否成功(成功或者错误信息)的标志

@Data
public class Result<T> implements Serializable {
    private static final long serialVersionUID = 3956528717501837568L;
    private boolean success;
    private int code;
    private String msg;
    private T data;
    public Result() {
    }
    public Result(boolean success, int code, String msg, T data) {
        this.success = success;
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
    public static <T> Result<T> error(IError error) {
        return error(error.getCode(), error.getMessage());
    }
    public static <T> Result<T> success(T data) {
        return new Result<T>(true, ErrorCodeEnum.SUCCESS.getCode(), ErrorCodeEnum.SUCCESS.getMessage(), data);
    }
    public static Result<String> ok() {
        return success("ok");
    }
    public static <T> Result<T> fillArgs(IError iError, Object... args) {
        return error(iError.getCode(), String.format(iError.getMessage(), args));
    }
}

我们可以注意到最后一个fillArgs方法是支持枚举类错误信息中带%s的,可以使用String.format替换,这就是支持带参数传递的错误信息

4.关于注解的使用

注解效验器使用

我在另外一篇博文中说到了参数效验器的使用,也说到自定义效验器,就是基于注解的形式,用于判断前端传来的数据在服务端再进行一次效验,例如:传来的手机号是否符号格式要求

  • 声明一个注解@IsMobile
  • 编写验证器代码(具体任何效验),实现ConstraintValidator接口,重写isValid方法
  • 注解标注在具体成员变量上,再在封装类上标注@Valid注解

注解实现拦截器

  • 声明一个注解:拦截器拦截后你想实现的功能和具体的参数
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
    int seconds();//时间范围
    int maxCount();//最大尝试次数
    boolean needLogin() default true;//声明是否需要登录
}
  • 编写拦截器,实现HandlerInterceptor接口,实现具体的拦截方法,重写preHandler方法
  • 在配置类中注入拦截器然后再重写方法把拦截器注册到拦截器工厂,并声明要拦截的URL
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private LimitAccessCountInterceptor limitAccessCountInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(limitAccessCountInterceptor).addPathPatterns("/**").excludePathPatterns("/login/**");
    }
}

注解mapper

public interface UserMapper {
    @Select("select * from user where id = #{id};")
    public User getById(int id);

    @Insert("insert into user (name) values(#{name});")
    public Boolean insert(User user);
}

5.异常的声明以及全局异常处理器

我们一般需要为我们的应用程序创建自己的异常,再抛出异常时打印到控制台和日志,我们可以声明一个全局异常类,并通过自定义异常处理器去操作异常

  • 我们通过传入状态来创建一个异常,该异常属于运行时异常,需要继承RuntimeException
public class GlobalException extends RuntimeException {
    private IError i;

    public GlobalException(IError i) {
        this.i = i;
    }

    public IError getI() {
        return i;
    }
}
  • 编写全局异常处理器—用于Service层抛出异常

在方法上标注上@ExceptionHandler注解来拦截指定类型异常,这里我们拦截所有的异常

  • 如果是绑定异常则获取异常信息并通过参数传入结果返回
  • 如果是我们自定义的全局异常则作为错误信息返回
  • 如果是其他异常则返回网络错误的状态
@ControllerAdvice
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler(value = Exception.class)//拦截所有异常
    public Result<String> exceptionHandler(HttpServletRequest request, Exception e) {
        e.printStackTrace();
        if (e instanceof BindException) {
            BindException ex = (BindException) e;
            List<ObjectError> errors = ex.getAllErrors();
            String error = errors.get(0).toString();
            String reg = "[^\u4e00-\u9fa5]";//使用正则提取错误中的中文信息
            String str = error.replaceAll(reg, "");
            return Result.fillArgs(ErrorCodeEnum.BIND_ERROR, str);//带参数异常
        } else if (e instanceof GlobalException) {
            GlobalException ex = (GlobalException) e;
            return Result.error(ex.getI());
        } else {
            log.error("其他异常");
            return Result.error(ErrorCodeEnum.NET_ERROR);
        }
    }
}

6.ThreadLocal存储user

在页面中很多地方都需要用于处于登录状态才能获取并操作该页面,我们无法为每一个controller中每个方法都执行获取用户的操作,我们可以在第一次登录时把用户保存到Cookie中或者session中

但是当后端在很多方法中都需要用户的时候我们没办法每次都去获取,我们可以在第一次获取到时把它保存到ThreadLocal中(一个用户操作对应一个线程,所以我们可以放心保存),需要的时候直接拿出来使用

public class UserContext {
    private static ThreadLocal<User> userContext = new ThreadLocal<>();

    public static void setUserContext(User userContext) {
        UserContext.userContext.set(userContext);
    }

    public static User getUserContext() {
        return userContext.get();
    }
}

ThreadLocal当然可以保存很多东西,比如交易Id、票据等,当这些东西保存到数据库的话会浪费很多资源,我们可以直接放到ThreadLocal中

7.基于RabbitMQ实现商品秒杀扣减库存

  • 初始化将库存加载到Redis
  • 收到请求,预减库存,库存不足返回(利用Redis单线程),已经有订单返回
  • 成功请求入队
  • 请求出队,生成订单;客户端轮询秒杀是否成功

这样不仅把秒杀和扣减库存解耦,还让生成订单成为了异步操作,我们只需要提交秒杀请求,而不需要等到获取到结果再返回。

8.Redis的深度使用

Redis根据前缀存储

我们使用redis存储时,通常不会考虑到不同用户不同操作不同请求的存储方式,通过这个项目我学会了使用前缀的方式存储数据到Redis中,不同用户根据id存储,不同操作根据方式存储,不同请求根据url存储,这样就可以区分每个put的key是来自哪个操作哪个请求和哪个用户,进行后台管理时也会很方便

最重要的一点是,我们可以通过前缀设置该key的过期时间

  • 创建一个前缀接口
public interface KeyPrefix {
    public int expireSeconds();

    public String getPrefix();

}
  • 创建一个抽象类实现该接口(提供一个实现模板)
@AllArgsConstructor
@RequiredArgsConstructor
public abstract class BasePrefix implements KeyPrefix {
    @NonNull
    private String prefix;//前缀
    private int expireSeconds;//过期时间,默认永久
    @Override
    public int expireSeconds() {
        return expireSeconds;
    }
    @Override
    public String getPrefix() {
        String className = getClass().getSimpleName();
        return className + ":" + prefix;
    }
}
  • 就可以创建不同的前缀了,例如:根据用户获取方式不同的创建不同的前缀,不设置过期时间
public class UserKey extends BasePrefix {

    public UserKey(String prefix, int expireSeconds) {
        super(prefix, expireSeconds);
    }

    public UserKey(String prefix) {
        super(prefix);
    }

    public static UserKey getById = new UserKey("id");
    public static UserKey getByName = new UserKey("name");
}
  • 要保存到Redis需要为RedisService实现自定义的set、get方法
 public <T> boolean set(KeyPrefix prefix, String key, T value) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String str = beanToString(value);
            if (str == null || str.length() <= 0) {
                return false;
            }
            //生成真正的key
            String realKey = prefix.getPrefix() + key;
            int seconds = prefix.expireSeconds();
            if (seconds <= 0) {
                jedis.set(realKey, str);
            } else {
                jedis.setex(realKey, seconds, str);
            }
            return true;
        } finally {
            returnToPool(jedis);
        }
    }
public <T> T get(KeyPrefix prefix, String key, Class<T> clazz) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            //生成真正的key
            String realKey = prefix.getPrefix() + key;
            String str = jedis.get(realKey);
            T t = stringToBean(str, clazz);
            return t;
        } finally {
            returnToPool(jedis);
        }
    }

Redis在分布式session的使用

在使用分布式Session的时候,我们需要把生成的token值作为key保存到redis中,依赖获取用户user对象

private String addOrUpdateCookie(HttpServletResponse response, String token, SeckillUser user) {
        if (StringUtils.isEmpty(token)) {
            token = UUIDUtil.uuid();
        }
        redisService.set(UserKey.token, token, user);//用户前缀中的token前缀,存储时将加上“token”
        Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);
        int ex = SecKillUserKey.token.expireSeconds();
        cookie.setMaxAge(ex);//过期时间子在前缀中获取
        cookie.setPath("/");
        response.addCookie(cookie);
        return token;
    }

Redis缓存验证码结果

我们为秒杀操作添加验证码的时候,我们需要有一个地方保存验证码的计算结果,我们选择Redis保存

Redis缓存页面

页面每次渲染需要很长时间,我们可以把html缓存到redis中,过期时间短一些,防止页面不刷新

Redis缓存库存数量

我们可以在加载商品信息页面的时候把商品的库存缓存到redis中,避免高并发情况下每次都去访问数据库,redis中预减库存为0后,其他线程都将直接返回秒杀失败

Redis缓存加密后的秒杀接口需要的参数

为了防止秒杀接口被刷,我们要携带参数请求并判断参数的正确性,该参数加密后存储到redis中,需要判断的时候拿出来与前端传来的进行判断

9.加密

用户密码加密

只在前端进行加密是不行的,我们后端拿到表单密码(第一次加密后的密码)还需要在进行一次加密,在与数据库中的值进行比对,防止一次加密,前端直接获取到最终数据库中的密码

秒杀请求接口加密

防止秒杀请求接口被获取到,直接访问接口秒杀商品,我们就需要对秒杀请求传入一个必须的参数,在后端进行加密后保存到redis并返回给前端,前端拿到数据后提交给真正的秒杀请求,后端判断拿到的数据和redis存储的是否对应,不对应则请求失败

1.前端携带id请求秒杀接口,实际上是获取秒杀路径的接口
2.后端获取id加密保存到redis,并返回加密数据给前端
3.前端成功获取到数据后跳转请求到真正的秒杀接口(携带后端传过来的加密数据)
4.后端秒杀接口判断该参数和redis中的数据是否相等,不相等说明参数过期或者参数是错误的(伪造的)

10.layer web组件使用

弹窗显示后端传来的结果中的msg信息

11.JMeter压测工具使用

用来压测秒杀可以承受的并发量

下载地址:https://jmeter.apache.org/download_jmeter.cgi

bin/JMeter就是可运行程序,Java GUI写的

  • 配置环境变量
vim /etc/profile

在最后加入:

export JMETER=/home/jmeter/apache-jmeter-2.13

export CLASSPATH=${JMETER}/lib/ext/ApacheJMeter_core.jar:${JMETER}/lib/jorphan.jar:$JMETER/lib/logkit-2.0.jar:${CLASSPATH}

export PATH=${JMETER}/bin/:${PATH}

保存后,source /etc/profile 使环境变量生效。

jmeter -v 确认是否配置成功。
  • 在电脑中创建好测试文件.jmx文件,上传到服务器

通过执行jmeter -n -t**/路径/*.jmx -l *.jtl进行压测,得到.jtl结果文件,下载到电脑就可以在JMeter中打开查看了

12.页面优化

页面缓存、URL缓存、对象缓存

使用Springboot提供的Thymeleaf模板引擎获取页面html缓存到Redis中

首先我们要把前端需要的数据放到model对象中

  • 为Controller注入Thymeleaf解析器
@Autowired
    private ThymeleafViewResolver thymeleafViewResolver;
  • 在请求方法中获取WebContext
WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
  • 通过解析器和context参数获取html
html = thymeleafViewResolver.getTemplateEngine().process("goods_list", context);
代码
  1. 加载页面时先查缓存,缓存有就直接获取
  2. 没有的话执行完获取操作添加到model中后
  3. 用Thymeleaf解析器解析页面并写入缓存
@RequestMapping(value = "/to_list", produces = "text/html")
    @ResponseBody
    public String toList(HttpServletResponse response, HttpServletRequest request,
                         Model model, SeckillUser user) throws IOException {
        WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
        if (user == null) {
            response.sendRedirect("/login/to_login");
            return "not login";
        } else {
            //查缓存
            String html = redisService.get(GoodsKey.goodsList, "", String.class);
            if (!StringUtils.isEmpty(html)) {
                return html;
            }
            model.addAttribute("user", user);
            List<GoodsVo> goodsVoList = goodsService.getGoodsVoList();
            log.info("goods list:{}", goodsVoList);
            model.addAttribute("goodsList", goodsVoList);
            /**
             * 使用页面缓存
             * 1.取缓存
             * 2.为空则渲染写入缓存
             */
            html = thymeleafViewResolver.getTemplateEngine().process("goods_list", context);
            redisService.set(GoodsKey.goodsList, "", html);
            return html;
        }
    }

页面静态化

在前后端分离后,静态资源不需要从服务器端加载渲染,浏览器会帮我们缓存到客户端,当然我们也可以使用CDN等加速(有限从最近节点获取)保存静态资源

  • JS/CSS压缩
  • CDN加速
  • 合并CSS/JS文件,减少连接数

13.唯一索引防止超卖

除了在sql中加上库存判断外,我们为了保证商品不超卖还需要添加一个唯一索引

唯一索引,即是唯一的意思,在数据库表结构中对字段(一个或者多个字段)添加唯一索引后进行数据库进行存储操作时数据库会判断库中是否已经存在此数据,不存在此数据时才能进行插入操作。

我们建立两个字段的唯一索引:商品id和用户id

alter table table_name add unique(column1,column2)
  • 创建订单时,如果存在商品id和用户id都在存在(与待创建的一样)的订单,则不能插入

14.秒杀限流

  • 使用验证码减少同一时间内的请求数量

使用Graphics画出表达式并通过BufferedImage生成验证码通过ImageIO通过write方法写到response,并使用JDK6提供的脚本支持ScriptEngine来调用js.eval()方法计算验证码不等式的值,保存到redis中,根据用户输入的去redis中比对。

  1. 生成表达式
private static char[] ops = new char[] {'+', '-', '*'};
    private String generateVerifyCode(Random rdm) {
        int num1 = rdm.nextInt(10);
        int num2 = rdm.nextInt(10);
        int num3 = rdm.nextInt(10);
        char op1 = ops[rdm.nextInt(3)];
        char op2 = ops[rdm.nextInt(3)];
        String exp = ""+ num1 + op1 + num2 + op2 + num3;
        return exp;
    }
  1. 生成验证码
int width = 80;
        int height = 32;
        //create the image
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics g = image.getGraphics();
        // set the background color
        g.setColor(new Color(0xDCDCDC));
        g.fillRect(0, 0, width, height);
        // draw the border
        g.setColor(Color.black);
        g.drawRect(0, 0, width - 1, height - 1);
        // create a random instance to generate the codes
        Random rdm = new Random();
        // make some confusion
        for (int i = 0; i < 50; i++) {
            int x = rdm.nextInt(width);
            int y = rdm.nextInt(height);
            g.drawOval(x, y, 0, 0);
        }
        // generate a random code
        String verifyCode = generateVerifyCode(rdm);
        g.setColor(new Color(0, 100, 0));
        g.setFont(new Font("Candara", Font.BOLD, 24));
        g.drawString(verifyCode, 8, 24);
        g.dispose();
        //把验证码存到redis中
        int rnd = calc(verifyCode);
        redisService.set(SecKillUserKey.getVerifyCode, user.getId()+","+goodsId, rnd);
        //输出图片
        return image;
  1. ScriptEngine调用js的eval方法
ScriptEngineManager manager = new ScriptEngineManager();
            ScriptEngine engine = manager.getEngineByName("JavaScript");
            return (Integer)engine.eval(exp);//计算表达式的值并返回结果
  • 使用拦截器防止用户在几秒内多次请求(防刷)

把最大限制次数在第一次请求时放到redis中,之后每请求一次redis对key执行decr方法,<0的话则返回错误信息,限制访问。

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            //获取用户
            SeckillUser user = getUser(request, response);
            //放到UserContext的ThreadLocal存储
            UserContext.setUserContext(user);
            log.info("把用户{}存储到ThreadLocal中",user.getId());
            HandlerMethod method = (HandlerMethod) handler;
            AccessLimit limit = method.getMethodAnnotation(AccessLimit.class);
            if (limit == null) {
                return true;
            }
            int seconds = limit.seconds();
            int maxCount = limit.maxCount();
            boolean requiredLogin = limit.needLogin();

            String key = request.getRequestURI();//用于redis查找防刷商品
            if (requiredLogin) {
                if (user == null) {
                    log.error("用户登录信息获取错误");
                    sendError(ErrorCodeEnum.NOT_LOGIN, response);
                    return false;
                } else {
                    key += "_" + user.getId();
                }
            }
            //限流防刷访问
            SecKillUserKey getAccessCount = SecKillUserKey.getAccessCount(seconds);
            Integer count = redisService.get(getAccessCount, key, Integer.class);
            if (count == null) {
                redisService.set(getAccessCount, key, maxCount);
            } else if (count > 0) {
                redisService.decr(getAccessCount, key);
            } else {
                log.error("操作达到上限,稍等再试");
                sendError(ErrorCodeEnum.REQUEST_TOO_MUCH, response);
                return false;
            }
            return true;
        }
        return true;
    }
  • 本文作者: dzou | 微信:17856530567
  • 本文链接: http://www.dzou.top/post/seckill-sum.html
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
  • 并保留本声明和上方二维码。感谢您的阅读和支持!