商品秒杀系统技术总结

这篇总结基于M课网的秒杀API的课程基础上来写的

这个课程我个人觉得有很多值得学习的地方,包括接口实现的思路,编码规范等等。

登录模块

​ 在登录这块,注册实现比较简单;主要是对password经过两层的盐值加密,前端加密然后后端也对其进行一次加密后再存放到数据库中。

登录验证

​ 这里主要对登录的验证沿用了拦截器来实现,主要的实现流程如下:

  1. 用户登录验证通过后(用户名和密码验证通过),生成随机码,对随机码进行加密操作

  2. 将加密后的随机码作为key,value存放user的json串存放到redis中(当然,可以不必要存放所有的字段,存放一些后续操作所需要的字段即可,这边我直接将user存放到redis中),并对其设置过期时间

  3. 放入redis后将生成的加密随机码放入cookie中,下次用户请求过来会带上cookie过来进行身份验证

  4. 我这边身份验证放到的拦截器中,就是简单的根据cookie的value来获取redis中的缓存,能够获取到user的即为验证通过

    这里有个小注意:

    Spring Boot 2.0后用配置类继承WebMvcConfigurerAdapter时,会提示这个类已经过时了

    此时可以通过实现WebMvcConfigurer接口解决该问题


JSR-303数据效验

(详细介绍:https://www.ibm.com/developerworks/cn/java/j-lo-jsr303/)

自定义参数解析器

获取User对象可以实现了自定义参数解析器来为方法上的User参数进行解析,从cookie中获取到随机码,然后从redis中获取到User对象装填到方法的参数上。

  • Springmvc的自定义参数解析器是当接口参数中有某个类的时候触发,此时可以从该参数解析器中返回我们所需要的内容

  • 这里以User参数为例,自定义参数解析器实现HandlerMethodArgumentResolver接口,该接口下有两个方法

    • supportsParameter:当进入方法的参数解析时会调用这个方法,当这个方法返回true 的时候会执行resolveArgument方法对参数进行解析
  • 注意,要记得将其加入到List

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    @Autowired
    UserArgumentResolver userArgumentResolver;
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
    argumentResolvers.add(userArgumentResolver);
    }
    }

商品和订单模块

​ 这里主要提及一下页面缓存、url缓存和对象缓存,如字面意思。来看下面的这段代码(注意阅读注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* thymeleaf的页面渲染
* 注意点:因为在thymeleaf.spring5的API中把大部分的功能移到了IWebContext下面,用来区分边界。
* 剔除了ApplicationContext 过多的依赖,现在thymeleaf渲染不再过多依赖spring容器。
*
* 在spring4中的使用:
* SpringWebContext ctx = new SpringWebContext(request,response,
* request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
* String html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
*
* RequestMapping中produces属性设置返回数据的类型以及编码;必须与@ResponseBody注解使用
*/
@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);
// model 就是将参数存入 ,其中的所有参数 都是为了将页面渲染出来 放入其中,在返回一个静态的html源码
IWebContext webContext = new WebContext(request,response,
request.getServletContext(),request.getLocale(), model.asMap());
//手动渲染
String html = thymeleafViewResolver.getTemplateEngine().process("goods_list", webContext);
if(!StringUtils.isEmpty(html)) {
//页面缓存
redisService.set(GoodsKey.getGoodsList, "", html);
}
return html;
}

@RequestMapping(value="/to_detail/{goodsId}",produces="text/html")
@ResponseBody
public String detail(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);

//取缓存
String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
//手动渲染
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);

long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();

int miaoshaStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
miaoshaStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
miaoshaStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
miaoshaStatus = 1;
remainSeconds = 0;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
model.addAttribute("remainSeconds", remainSeconds);

IWebContext webContext = new WebContext(request,response,
request.getServletContext(),request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", webContext);
if(!StringUtils.isEmpty(html)) {
//url缓存
redisService.set(GoodsKey.getGoodsDetail, ""+goodsId, html);
}
return html;
}
//对象缓存即:将对象化成json存放到reids中;在更新对象缓存的时候要注意先更新db数据库的再更新缓存中的数据来保持一致性

​ 这里对于页面缓存的实现,可以使用thymeleaf来实现;也可以使用对象缓存,然后用ajax获取数据并且利用vue或者angular来渲染页面,就不用将整个html页面存放到redis中去了。


秒杀模块

优化商品的秒杀API的主要内容:

(秒杀订单中商品ID和用户的ID做UNIQUE 约束)

  1. 隐藏秒杀地址

    • 当你可以对商品进行秒杀的时候,先去根据用户获取秒杀的url(如秒杀url:/{path}/do_seckill),然后将这个path根据用户放入到redis中
    • 获取到路径返回后再去请求刚才获取到的url进行商品的秒杀,此时会对商品的url上的path进行验证,是相同的path才能进行秒杀操作
    • 这里需要对获取path的接口进行防刷(限流)操作
  2. 接口防刷

    • 使用自定义注解拦截器的方式实现接口的防刷

    • 自定义注解:

      1
      2
      3
      4
      5
      6
      @Retention(RUNTIME)
      @Target(METHOD)
      public @interface AccessLimit {
      int seconds();//n秒内可以请求maxCount次这个接口
      int maxCount();//最大次数
      }
    • 使用拦截器拦截秒杀API的请求(利用HandlerMethod hm.getMethodAnnotation(AccessLimit.class) 来获取自定义的AccessLimit注解)

    • 当满足在n秒内请求数量在maxCount以内的即可放行当前的请求

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
      throws Exception {
      if(handler instanceof HandlerMethod) {
      HandlerMethod hm = (HandlerMethod)handler;
      //方法上无该注解则直接放行
      AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
      if(accessLimit == null) {
      return true;
      }
      int seconds = accessLimit.seconds();
      int maxCount = accessLimit.maxCount();
      String key = request.getRequestURI();
      //获取现在的次数
      String count = jedisUtils.get(key, RedisDBEnum.ACCESS_BD.getDb());
      if(count == null) {
      jedisUtils.setex("access" + key, String.valueOf(1), seconds, RedisDBEnum.ACCESS_BD.getDb());
      }else if(Integer.valueOf(count) < maxCount) {
      jedisUtils.incr("access" + key, RedisDBEnum.ACCESS_BD.getDb());
      }else {
      render(response, CodeMsg.ACCESS_LIMIT_REACHED);
      return false;
      }
      }
      return true;
      }
  3. 图形验证码方式防刷

    • 在用户进入秒杀商品页面(跳转该页面时,服务端生成与用户对应的验证码,并且将该验证码与用户对应的方式放入到redis中存放,设置过期时间)的时候增加验证码输入框

    • 注意需要为验证码增加一个刷新验证码的接口,用户刷新验证码的时候生成一个新的验证码并且删除旧的验证码,再将新的验证码放入到redis中同时设置过期时间

      • 记得一定要对刷新验证码的接口进行防刷的操作(因为刷新验证码接口需要对redis进行数据操作,对redis 操作是有网络开销的,不做防刷操作的话,被别人恶意请求的话是会对服务器产生负担的)
    • 验证码的类型:可以生成算术题目的验证码,也可以是简单的字母+数字组合的验证码;可自行选择,只有验证码验证通过后再进行商品的秒杀

    • 下面引用一个别人写好的例子(使用swing生成一个算术类型的验证码):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      public Results<String> getSeckillVerifyCod(HttpServletResponse response,User user,@RequestParam("goodsId")long goodsId) {
      try {
      BufferedImage image = orderService.createVerifyCode(user, goodsId);
      OutputStream out = response.getOutputStream();
      ImageIO.write(image, "JPEG", out);
      out.flush();
      out.close();
      return null;
      }catch(Exception e) {
      e.printStackTrace();
      return Results.faild(CodeMsg.SECKILL_FAIL);
      }
      }

      public BufferedImage createVerifyCode(User user, long goodsId) {
      if(user == null || goodsId <=0) {
      return null;
      }
      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("seckill_vc_" + user.getId()+"_"+goodsId, rnd,60 ,RedisDBEnum.ACCESS_BD.getDb());
      //输出图片
      return image;
      }

      private static int calc(String exp) {
      try {
      ScriptEngineManager manager = new ScriptEngineManager();
      ScriptEngine engine = manager.getEngineByName("JavaScript");
      return (Integer)engine.eval(exp);
      }catch(Exception e) {
      e.printStackTrace();
      return 0;
      }
      }

      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;
      }
  4. MQ实现异步商品秒杀

    为什么要异步下单请往下看。。

    • 首先需要将秒杀商品的sku放入到redis中
    • 当到了秒杀的时间段开始秒杀的时候
    • 先判断redis中的sku是否还有(大于0),没有则直接返回秒杀失败;还有sku的时候,对redis中sku 的值进行减一操作
    • 此时需要查看是否该用户已经秒杀过该商品了(视业务而定,在这里一个用户只能秒杀一次)
      • 如果已经秒杀过该商品的用户,直接返回(可以对sku进行恢复)
    • 如果不是秒杀过该商品的用户,那么将用户和商品信息放入到消息队列中(可以使用Direct Exchange模式)
    • 加入消息队列后即可返回给用户(排队或其他消息),然后轮询订单的接口查看订单是否已经准备好
      • 下单的系统监听该消息队列,获取到消息时先判断用户是否已经秒杀过。如果没有则进行减库存、下订单的操作

为什么要异步下单?

(个人理解,大神们有更好的理解可以发表一下,非常感谢!)

  • 在电商平台中,需要考虑到技术方面的各个环节,在这个项目中实现的商品秒杀并不是基于服务化的环境去搭建的系统,在这里只是给出了一些实现的思路而已。

  • 下单操作就需要业务,网络和并发量等方面的问题了,一般服务分得细的话,订单的操作应该交由特定的服务去处理,这里就可以交由消息队列去实现异步的下单操作。

  • 秒杀的API可以先返回排队或者请稍等的文案,然后又前台去轮询订单是否已经生成。这样做还能降低系统的复杂度,并且如果是同步的操作那么在很短时间处理很大量的并发请求的话难度是很高的,异步方式去处理的话能够保障(注意:不是保证!)系统的可用性了。