秒杀项目实践(七)——防刷限流

PunkLu 2019年12月22日 84次浏览

防刷限流

使用验证码

使用验证码可以分散秒杀压力。

包装秒杀令牌前置,使用验证码来错峰用户流量。

创建验证码工具类:

package tech.punklu.seckill.util;



import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

import javax.imageio.ImageIO;

/**
 * 验证码工具类
 */
public class CodeUtil {
    private static int width = 90;// 定义图片的width
    private static int height = 20;// 定义图片的height
    private static int codeCount = 4;// 定义图片上显示验证码的个数
    private static int xx = 15;
    private static int fontHeight = 18;
    private static  int codeY = 16;
    private static char[] codeSequence = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
            'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };

    /**
     * 生成一个map集合
     * code为生成的验证码
     * codePic为生成的验证码BufferedImage对象
     * @return
     */
    public static Map<String,Object> generateCodeAndPic() {
        // 定义图像buffer
        BufferedImage buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        // Graphics2D gd = buffImg.createGraphics();
        // Graphics2D gd = (Graphics2D) buffImg.getGraphics();
        Graphics gd = buffImg.getGraphics();
        // 创建一个随机数生成器类
        Random random = new Random();
        // 将图像填充为白色
        gd.setColor(Color.WHITE);
        gd.fillRect(0, 0, width, height);

        // 创建字体,字体的大小应该根据图片的高度来定。
        Font font = new Font("Fixedsys", Font.BOLD, fontHeight);
        // 设置字体。
        gd.setFont(font);

        // 画边框。
        gd.setColor(Color.BLACK);
        gd.drawRect(0, 0, width - 1, height - 1);

        // 随机产生40条干扰线,使图象中的认证码不易被其它程序探测到。
        gd.setColor(Color.BLACK);
        for (int i = 0; i < 30; i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            gd.drawLine(x, y, x + xl, y + yl);
        }

        // randomCode用于保存随机产生的验证码,以便用户登录后进行验证。
        StringBuffer randomCode = new StringBuffer();
        int red = 0, green = 0, blue = 0;

        // 随机产生codeCount数字的验证码。
        for (int i = 0; i < codeCount; i++) {
            // 得到随机产生的验证码数字。
            String code = String.valueOf(codeSequence[random.nextInt(36)]);
            // 产生随机的颜色分量来构造颜色值,这样输出的每位数字的颜色值都将不同。
            red = random.nextInt(255);
            green = random.nextInt(255);
            blue = random.nextInt(255);

            // 用随机产生的颜色将验证码绘制到图像中。
            gd.setColor(new Color(red, green, blue));
            gd.drawString(code, (i + 1) * xx, codeY);

            // 将产生的四个随机数组合在一起。
            randomCode.append(code);
        }
        Map<String,Object> map  =new HashMap<String,Object>();
        //存放验证码
        map.put("code", randomCode);
        //存放生成的验证码BufferedImage对象
        map.put("codePic", buffImg);
        return map;
    }

    public static void main(String[] args) throws Exception {
        //创建文件输出流对象
        OutputStream out = new FileOutputStream("C:\\abc"+System.currentTimeMillis()+".jpg");
        Map<String,Object> map = CodeUtil.generateCodeAndPic();
        ImageIO.write((RenderedImage) map.get("codePic"), "jpeg", out);
        System.out.println("验证码的值为:"+map.get("code"));
    }
}

将OrderController中原有的generatetoken方法从:

@RequestMapping(value = "/generatetoken",method = RequestMethod.POST,consumes = CONTENT_TYPE_FORMED)
    @ResponseBody
    public CommonReturnType generatetoken(@RequestParam(name = "itemId")Integer itemId,
                                        @RequestParam(name = "promoId")Integer promoId) throws BusinessException {
        // 根据token获取用户信息
        String token = httpServletRequest.getParameterMap().get("token")[0];
        if (StringUtils.isEmpty(token)){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
        }
        UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
        if (userModel == null){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
        }
        // 获取秒杀访问令牌
        String promoToken = promoServive.generateSecondKillToken(promoId,itemId,userModel.getId());
        // 返回对应的结果
        if (promoToken == null){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
        }
        return CommonReturnType.create(promoToken);
    }

在OrderController中增加生成验证码的接口:

/**
     * 生成验证码
     * @return
     */
    @RequestMapping(value = "/generateverifycode",method = {RequestMethod.POST,RequestMethod.GET})
    @ResponseBody
    public void generateverifycode(HttpServletResponse response) throws BusinessException, IOException {
        String token = httpServletRequest.getParameterMap().get("token")[0];
        if (StringUtils.isEmpty(token)){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录,不能生成验证码");
        }
        UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
        if (userModel == null){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登录,不能生成验证码");
        }
        //创建文件输出流对象
        Map<String,Object> map = CodeUtil.generateCodeAndPic();
        // 将生成的验证码存储到Redis中
        redisTemplate.opsForValue().set("verify_code_" + userModel.getId(),map.get("code"));
        ImageIO.write((RenderedImage) map.get("codePic"), "jpeg", response.getOutputStream());
    }

启动项目,访问/order/generateverifycode?token=4d2f60ad-07bf-4923-a6b4-b221e2d543f4可以看到后端返回了验证码图片。

将OrderController中的createOrder方法从:

/**
     * 生成秒杀令牌token
     * @param itemId
     * @param promoId
     * @return
     * @throws BusinessException
     */
    @RequestMapping(value = "/generatetoken",method = RequestMethod.POST,consumes = CONTENT_TYPE_FORMED)
    @ResponseBody
    public CommonReturnType generatetoken(@RequestParam(name = "itemId")Integer itemId,
                                        @RequestParam(name = "promoId")Integer promoId) throws BusinessException {
        // 根据token获取用户信息
        String token = httpServletRequest.getParameterMap().get("token")[0];
        if (StringUtils.isEmpty(token)){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
        }
        UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
        if (userModel == null){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
        }
        // 获取秒杀访问令牌
        String promoToken = promoServive.generateSecondKillToken(promoId,itemId,userModel.getId());
        // 返回对应的结果
        if (promoToken == null){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
        }
        return CommonReturnType.create(promoToken);
    }

改成:

@RequestMapping(value = "/generatetoken",method = RequestMethod.POST,consumes = CONTENT_TYPE_FORMED)
    @ResponseBody
    public CommonReturnType generatetoken(@RequestParam(name = "itemId")Integer itemId,
                                        @RequestParam(name = "promoId")Integer promoId,
                                          @RequestParam(name = "verifyCode")String verifyCode) throws BusinessException {
        // 根据token获取用户信息
        String token = httpServletRequest.getParameterMap().get("token")[0];
        if (StringUtils.isEmpty(token)){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
        }
        UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
        if (userModel == null){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
        }

        // 通过verifycode验证验证码的有效性
        // 获取Redis内真实的验证码
        String redisVerifyCode = (String) redisTemplate.opsForValue().get("verify_code_" + userModel.getId());
        if (StringUtils.isEmpty(redisVerifyCode)){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"请求非法");
        }
        if (!redisVerifyCode.equalsIgnoreCase(verifyCode)){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"请求非法,验证码错误");
        }

        // 获取秒杀访问令牌
        String promoToken = promoServive.generateSecondKillToken(promoId,itemId,userModel.getId());
        // 返回对应的结果
        if (promoToken == null){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
        }
        return CommonReturnType.create(promoToken);
    }

接下来修改前端代码,在getitem.html页面中的下单div下面新增:

<div id="verifyDiv" style="display:none;" class="form-actions">
			<img src=""/>
			<input type="text" id="verifyContent" value=""/>
			<button class="btn blue" id="verifyButton" type="submit">
				验证
			</button>	
</div>

再把验证码的相关获取,传向后端的代码改造成如下:

jQuery(document).ready(function(){
		$("#verifyButton").on("click",function(){
			var token = window.localStorage["token"];
			$.ajax({
				type:"POST",
				contentType:"application/x-www-form-urlencoded",
				url:"http://"+g_host+"/order/generatetoken?token="+token,
				data:{
					"itemId":g_itemVO.id,
					"promoId":g_itemVO.promoId,
					"verifyCode":$("#verifyContent").val()
				},
				xhrFields:{withCredentials:true},
				success:function(data){
					if(data.status == "success"){
						var promoToken = data.data;
						$.ajax({
							type:"POST",
							contentType:"application/x-www-form-urlencoded",
							url:"http://"+g_host+"/order/createorder?token="+token,
							data:{
								"itemId":g_itemVO.id,
								"amount":1,
								"promoId":g_itemVO.promoId,
								"promoToken":promoToken
							},
							xhrFields:{withCredentials:true},
							success:function(data){
								if(data.status == "success"){
									alert("下单成功");
									window.location.reload();
								}else{
									alert("下单失败,原因为"+data.data.errMsg);
									if(data.data.errCode == 20003){
										window.location.href="login.html";
									}
								}
							},
							error:function(data){
								alert("下单失败,原因为"+data.responseText);
							}
						});


					}else{
						alert("获取令牌失败,原因为"+data.data.errMsg);
								if(data.data.errCode == 20003){
										window.location.href="login.html";
									}
					}
				},
				error:function(data){
					alert("获取令牌失败,原因为"+data.responseText);
				}
			});

		});
		$("#createorder").on("click",function(){
			var token = window.localStorage["token"];
			if(token == null){
				alert("没有登录,不能下单");
				window.location.href="login.html";
				return false;
			}
			$("#verifyDiv img").attr("src","http://"+g_host+"/order/generateverifycode?token="+token);
			$("#verifyDiv").show();

			


		});

		initView();


	});

启动项目,完成下单,可以看到点击下单以后,下方会弹出来一个输入验证码的模块,输入完验证码后,再点击验证才可下单成功。

限流

限流方案
  1. 限并发

    通过全局的计数器等方式实现。

  2. 令牌桶算法

    应对单一时间内的大流量

  3. 漏桶算法

    让系统对流量的流量的处理分成时间片,平滑地进行处理

限流维度
  1. 单接口的限流
  2. 总接口的限流
限流范围
  1. 集群限流

    依赖redish或其他地中间件技术做统一计数器,往往会产生性能瓶颈

  2. 单机限流

    负载均衡的前提下单机平均限流效果更好

限流实现

使用Guava的RateLimiter实现限流。

在OrderController中声明RateLimiter类变量:

private RateLimiter orderCreateRateLimiter;

在之前创建的init()方法中初始化:

// 初始化orderCreateRateLimiter,限制每秒l流量处理数为300
orderCreateRateLimiter = RateLimiter.create(300);

在OrderController中的创建订单createOrder方法最开始的地方新增以下代码:

// 检查活动限流是否可用
if (orderCreateRateLimiter.acquire() <= 0){
           throw new BusinessException(EmBusinessError.RATELIMIT);
}

这样就完成了限流器的实现。