引言
当我们秒杀开始时,不会直接调秒杀接口,而是获取真正秒杀接口的地址,根据每个用户秒杀的不同商品是不一样的。这样可以避免有些人提前通过脚本准备好固定地址进行秒杀。这种方式的缺点是有可能能提前获取到秒杀接口地址,这种时候可以再进行一次验证码的防护。如果没有验证码的话,一秒内可能有很多请求,加上验证码可以延迟请求的时间,服务器承受的压力就没有那么大。为了减少并发量,还可以进行一次接口的限流。
一、秒杀接口地址隐藏
针对不同用户秒杀不同商品,设计秒杀接口地址不同。
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)
被拦截后进入拦截器判断是否频繁登录