【学习笔记】seckill-秒杀项目--(10)安全优化

引言

当我们秒杀开始时,不会直接调秒杀接口,而是获取真正秒杀接口的地址,根据每个用户秒杀的不同商品是不一样的。这样可以避免有些人提前通过脚本准备好固定地址进行秒杀。这种方式的缺点是有可能能提前获取到秒杀接口地址,这种时候可以再进行一次验证码的防护。如果没有验证码的话,一秒内可能有很多请求,加上验证码可以延迟请求的时间,服务器承受的压力就没有那么大。为了减少并发量,还可以进行一次接口的限流。

一、秒杀接口地址隐藏

针对不同用户秒杀不同商品,设计秒杀接口地址不同。

1.1 控制层修改

/**
 * 秒杀
 * @author 47roro
 * @date 2022/4/16
 * @param path
 * @param user
 * @param goodsId
 * @return java.lang.String
 **/
@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);
    }
    //判断是否重复抢购(mybatis plus)
    SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
    if(seckillOrder != null){
        return RespBean.error(RespBeanEnum.REPEAT_ERROR);
    }
    //内存标记减少redis访问
    if(EmptyStockMap.get(goodsId)){
        return RespBean.error(RespBeanEnum.EMPT_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.EMPT_STOCK);
    }

    SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
    mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
    return RespBean.success(0);
}
/**
 * 获取秒杀地址
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @return com.example.seckill.vo.RespBean
 **/
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public RespBean getPath(User user, Long goodsId) {
    if (user == null) {
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    String str = orderService.createPath(user, goodsId);
    return RespBean.success(str);
}

1.2 订单服务接口修改

/**
 * 获取秒杀地址
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @return java.lang.String
 **/
String createPath(User user, Long goodsId);

/**
 * 校验秒杀地址
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @param path
 * @return java.lang.Boolean
 **/
Boolean checkPath(User user, Long goodsId, String path);

1.3 订单服务修改

/**
 * 获取秒杀地址
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @return java.lang.String
 **/
@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;
}

/**
 * 校验秒杀地址
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @param path
 * @return java.lang.Boolean
 **/
@Override
public Boolean checkPath(User user, Long goodsId, String path) {
    if (user==null|| !StringUtils.hasLength(path)){
        return false;
    }
    String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:" +
            user.getId() + ":" + goodsId);
    return path.equals(redisPath);
}

1.4 前端页面修改

 function getSeckillPath(){
     var goodsId = $("#goodsId").val();
     g_showLoading();
     $.ajax({
         url: "/seckill/path",
         type: "GET",
         data: {
             goodsId: goodsId,
         },
         success: function (data) {
             if (data.code == 200) {
                 var path = data.obj;
                 doSeckill(path);
             } else {
                 layer.msg(data.message);
             }
         },
         error: function () {
             layer.msg("客户端请求错误");
         }
     })
 }

1.5 结果测试

获取到唯一path,与redis中存储的一致。
在这里插入图片描述

1.6 小结

这种方式还存在一种缺点,就是有些人可以通过获取到一次地址后,能立马获取拼接规则,如果知道了拼接规则的话,可以快速发起大量请求。这种时候可以通过加上验证码进行限制。脚本不会进行验证码的校验。能够隔离掉一部分的脚本请求。

二、 生成图形验证码

验证码作用:

  • 防止一部分脚本;
  • 拉长短时间并发的时间长度。

最好避免简单验证码。可以用数学公式,图形翻转等。验证码可以使用开源的项目。
点击秒杀开始前,先输入验证码,分散用户请求。

2.1 前端页面修改

<div class="row">
    <div class="form-inline">
        <img id="captchaImg" width="130" height="32" onclick="refreshCaptcha()"
             style="display: none">
        <input id="captcha" class="form-control" style="display: none">
        <button class="btn btn-primary" type="button" id="buyButton"
                onclick="getSeckillPath()">立即秒杀
            <input type="hidden" name="goodsId" id="goodsId">
        </button>
    </div>
</div>
function refreshCaptcha(){
    $("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val() + "&time=" + new Date());

}
function countDown() {
	var remainSeconds = $("#remainSeconds").val();
	var timeout;
	//秒杀还未开始
	if (remainSeconds > 0) {
	    $("#buyButton").attr("disabled", true);
	    $("#seckillTip").html("秒杀倒计时:" + remainSeconds + "秒");
	    timeout = setTimeout(function () {
	        $("#countDown").text(remainSeconds - 1);
	        $("#remainSeconds").val(remainSeconds - 1);
	        countDown();
	    }, 1000);
	    // 秒杀进行中
	} else if (remainSeconds == 0) {
	    $("#buyButton").attr("disabled", false);
	    if (timeout) {
	        clearTimeout(timeout);
	    }
	    $("#seckillTip").html("秒杀进行中");
	    $("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val() + "&time=" + new Date());
	    $("#captchaImg").show();
	    $("#captcha").show();
	} else {
	    $("#buyButton").attr("disabled", true);
	    $("#seckillTip").html("秒杀已经结束");
	    $("#captchaImg").hide();
	    $("#captcha").hide();
	}
}

2.2 控制层修改

/**
 * 生成验证码
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @param response
 **/
@RequestMapping(value = "/captcha", method = RequestMethod.GET)
public void verifyCode(User user, Long goodsId, HttpServletResponse response) {
    if (null==user||goodsId<0){
        throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL);
    }
    // 设置请求头为输出图片类型
    response.setContentType("image/jpg");
    response.setHeader("Pragma", "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());
    }
}

2.3 测试结果

在这里插入图片描述

三、校验验证码

3.1 前端修改

添加验证码的传输
在这里插入图片描述

3.2 控制层修改

进行验证码校验

/**
 * 获取秒杀地址
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @return com.example.seckill.vo.RespBean
 **/
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public RespBean getPath(User user, Long goodsId, String captcha) {
    if (user == null) {
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    boolean check = orderService.checkCaptcha(user, goodsId, captcha);
    if(!check){
        return RespBean.error(RespBeanEnum.ERROR_CAPTCHA);
    }
    String str = orderService.createPath(user, goodsId);
    return RespBean.success(str);
}

3.3 接口及实现类修改

实现类:

/**
 * 校验验证码
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @param captcha
 * @return boolean
 **/
@Override
public boolean checkCaptcha(User user, Long goodsId, String captcha) {
    if(!StringUtils.hasLength(captcha) || user == null || goodsId < 0){
        return false;
    }
    String redisCaptcha = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId);
    return captcha.equals(redisCaptcha);
}

接口:

/**
 * 验证码校验
 * @author 47roro
 * @date 2022/5/13
 * @param user
 * @param goodsId
 * @param captcha
 * @return boolean
 **/
boolean checkCaptcha(User user, Long goodsId, String captcha);

3.4 结果测试

输入错误答案:
在这里插入图片描述
输入正确答案:
在这里插入图片描述

四、接口限流

通过限流可以控制系统的QPS,减小服务器的压力。

通用接口限流

4.1 用户环境类

将用户保存在ThreadLocal中,

/**
 * @author 47roro
 * @create 2022/5/13
 * @description:
 */
public class UserContext {

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

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

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

4.2 用户解析修改

从threadlocal中获取用户

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

4.3 配置登录拦截器

/**
 * @author 47roro
 * @create 2022/5/13
 * @description: 注解拦截器
 */
@Component
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_REACHED);
				return false;
			}
		}
		return true;
	}


	/**
	 * 构建返回对象
	 * @author 47roro
	 * @date 2022/5/13
	 * @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();
	}

	/**
	 * 获取当前登录用户
	 * @author 47roro
	 * @date 2022/5/13
	 * @param request
	 * @param response
	 * @return com.example.seckill.pojo.User
	 **/
	private User getUser(HttpServletRequest request, HttpServletResponse response) {
		String cookie = CookieUtil.getCookieValue(request, "userCookie");
		if (!StringUtils.hasLength(cookie)) {
			return null;
		}
		return userService.getUserByCookie(cookie, request, response);
	}
}

自定义注解:

/**
 * @author 47roro
 * @create 2022/5/13
 * @description: 访问限制注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
    int second();
    int maxCount();
    boolean needLogin() default true;
}

4.4 MVC配置修改

将登录拦截器添加进MVC配置

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

4.5 秒杀控制器注解

在秒杀控制器上添加登录拦截注解
@AccessLimit(second = 5, maxCount = 5, needLogin = true)
被拦截后进入拦截器判断是否频繁登录

4.6 结果测试

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
spring-boot-seckill分布式秒杀系统是一个用SpringBoot开发的从0到1构建的分布式秒杀系统,项目案例基本成型,逐步完善中。 开发环境: JDK1.8、Maven、Mysql、IntelliJ IDEA、SpringBoot1.5.10、zookeeper3.4.6、kafka_2.11、redis-2.8.4、curator-2.10.0 启动说明: 1、启动前 请配置application.properties中相关redis、zk以及kafka相关地址,建议在Linux下安装使用。 2、数据库脚本位于 src/main/resource/sql 下面,启动前请自行导入。 3、配置完成,运行Application中的main方法,访问 http://localhost:8080/seckill/swagger-ui.html 进行API测试。 4、秒杀商品页:http://localhost:8080/seckill/index.shtml ,部分功能待完成。 5、本测试案例单纯为了学习,某些案例并不适用于生产环境,大家根据所需自行调整。 秒杀架构: 架构层级 1、一般商家在做活动的时候,经常会遇到各种不怀好意的DDOS攻击(利用无辜的吃瓜群众夺取资源),导致真正的我们无法获得服务!所以说高防IP还是很有必要的。 2、搞活动就意味着人多,接入SLB,对多台云服务器进行流量分发,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。 3、基于SLB价格以及灵活性考虑后面我们接入Nginx做限流分发,来保障后端服务的正常运行。 4、后端秒杀业务逻辑,基于Redis 或者 Zookeeper 分布式锁,Kafka 或者 Redis 做消息队列,DRDS数据库中间件实现数据的读写分离。 优化思路 1、分流、分流、分流,重要的事情说三遍,再牛逼的机器也抵挡不住高级别的并发。 2、限流、限流、限流,毕竟秒杀商品有限,防刷的前提下没有绝对的公平,根据每个服务的负载能力,设定流量极限。 3、缓存、缓存、缓存、尽量不要让大量请求穿透到DB层,活动开始前商品信息可以推送至分布式缓存。 4、异步、异步、异步,分析并识别出可以异步处理的逻辑,比如日志,缩短系统响应时间。 5、主备、主备、主备,如果有条件做好主备容灾方案也是非常有必要的(参考某年锤子的活动被攻击)。 6、最后,为了支撑更高的并发,追求更好的性能,可以对服务器的部署模型进行优化,部分请求走正常的秒杀流程,部分请求直接返回秒杀失败,缺点是开发部署时需要维护两套逻辑。 分层优化 1、前端优化:活动开始前生成静态商品页面推送缓存和CDN,静态文件(JS/CSS)请求推送至文件服务器和CDN。 2、网络优化:如果是全国用户,最好是BGP多线机房,减少网络延迟。 3、应用服务优化:Nginx最佳配置、Tomcat连接池优化、数据库配置优化、数据库连接池优化

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值