前言
限流是秒杀业务最常用的手段。限流是从用户访问压力的角度来考虑如何应对系统故障。这里我是用限制访问接口次数(Redis+拦截器+自定义注解)和验证码的方式实现简单限流。
一、接口限流
- 接口限流是为了对服务端的接口接收请求的频率进行限制,防止服务挂掉。
- 栗子:假设我们的秒杀接口一秒只能处理12w个请求,结果秒杀活动刚开始就一下来了20w个请求。这肯定是不行的,我们可以通过接口限流将这8w个请求给拦截住,不然系统直接就整挂掉。
- 实现方案:
- Sentiel等开源流量控制组件(Sentiel主要以流量为切入点,提供流量控制、熔断降级、系统自适应保护等功能的稳定性和可用性)
- 秒杀请求之前进行验证码输入或答题等
- 限制同一用户、ip单位时间内请求次数
- 提前预约
- 等等
这里我使用的是Redis+Lua脚本+拦截器实现同一用户单位时间内请求次数限制。
自定义注解
含义:限制xx秒内最多请求xx次
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @Version: 1.0.0 * @Author: Dragon_王 * @ClassName: AccessLimit * @Description: 通用接口限流,限制xx秒内最多请求次数 * @Date: 2024/3/3 17:09 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface AccessLimit { //时间,单位秒 int second(); //限制最大请求次数 int maxCount(); //是否需要登录 boolean needLogin() default true; }
Redis+Lua脚本+拦截器
主要关心业务逻辑:
@Component public class AccessLimitInterceptor implements HandlerInterceptor{ @Autowired private IUserService userService; @Autowired private RedisTemplate redisTemplate; //加载lua脚本 private static final DefaultRedisScript<Boolean> SCRIPT; static { SCRIPT = new DefaultRedisScript<>(); SCRIPT.setLocation(new ClassPathResource("script.lua")); SCRIPT.setResultType(Boolean.class); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { //获取登录用户 User user = getUser(request, response); 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(); //获取当前请求地址作为key String key = request.getRequestURI(); //如果needLogin=true,是必须登录,进行用户状态验证 if (needLogin) { if (user == null) { render(response, RespBeanEnum.SESSION_ERROR); return false; } key += ":" + user.getId(); } //使用lua脚本 Object result = redisTemplate.execute(SCRIPT, Collections.singletonList(key),new String[]{String.valueOf(maxCount), String.valueOf(second)}); if (result.equals(false)){ //render函数就是一个让我返回报错的函数,这里的RespBeanEnum是我封装好的报错的枚举类型,无需关注,render函数你也无需管,只要关心return false拦截 render(response,RespBeanEnum.ACCESS_LIMIT_REACHED); //拦截 return false; } } return true; } private void render(HttpServletResponse response, RespBeanEnum respBeanEnum) throws IOException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); PrintWriter printWriter = response.getWriter(); RespBean bean = RespBean.error(respBeanEnum); printWriter.write(new ObjectMapper().writeValueAsString(bean)); printWriter.flush(); printWriter.close(); } /** * @Description: 获取当前登录用户 * @param request * @param response * @methodName: getUser * @return: com.example.seckill.pojo.User * @Author: dragon_王 * @Date: 2024-03-03 17:20:51 */ private User getUser(HttpServletRequest request, HttpServletResponse response) { String userTicket = CookieUtil.getCookieValue(request, "userTicker"); if (StringUtils.isEmpty(userTicket)) { return null; } return userService.getUserByCookie(userTicket, request, response); } }
lua脚本,如果第一次访问就存入计数器,每次访问+1,如果计数器大于5返回false
local key = KEYS[1] local maxCount = tonumber(ARGV[1]) local second = tonumber(ARGV[2]) local count = redis.call('GET', key) if count then count = tonumber(count) if count < maxCount then count = count + 1 redis.call('SET', key, count) redis.call('EXPIRE', key, second) else return false end else redis.call('SET', key, 1) redis.call('EXPIRE', key, second) end return true
二、验证码
引入验证码依赖(这是个开源的图形验证码,直接拿过来用):
<!--验证码依赖--> <dependency> <groupId>com.github.whvcse</groupId> <artifactId>easy-captcha</artifactId> <version>1.6.2</version> </dependency> <dependency> <groupId>org.openjdk.nashorn</groupId> <artifactId>nashorn-core</artifactId> <version>15.3</version> </dependency>
/** * @Description: 获取验证码 * @param user * @param goodsId * @param response * @methodName: verifyCode * @return: void * @Author: dragon_王 * @Date: 2024-03-03 12:38:14 */ @ApiOperation("获取验证码") @GetMapping(value = "/captcha") public void verifyCode(User user, Long goodsId, HttpServletResponse response) { if (user == null || goodsId < 0) { throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL); } //设置请求头为输出图片的类型 response.setContentType("image/jpg"); response.setHeader("Pargam", "No-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expires", 0); //生成验证码 ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3); //奖验证码结果存入redis redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId, captcha.text(), 300, TimeUnit.SECONDS); try { captcha.out(response.getOutputStream()); } catch (IOException e) { log.error("验证码生成失败", e.getMessage()); } }
这里用的是bootstrap写的简单前端:
<div class="row"> <div class="form-inline"> <img id="captchaImg" width="130" height="32" style="display: none" onclick="refreshCaptcha()"/> <input id="captcha" class="form-control" style="display: none"/> <button class="btn btn-primary" type="button" id="buyButton" onclick="getSeckillPath()">立即秒杀 </button> </div> </div> <script>
校验验证码逻辑也很简单 (从redis中取出存入的图形结果和输入框中比对):
/** * @Description: 校验验证码 * @param user * @param goodsId * @param captcha * @methodName: checkCaptcha * @return: boolean * @Author: dragon_王 * @Date: 2024-03-03 15:48:13 */ public boolean checkCaptcha(User user, Long goodsId, String captcha) { if (user == null || goodsId < 0 || StringUtils.isEmpty(captcha)) { return false; } String redisCaptcha = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId); return captcha.equals(redisCaptcha); }
总结
以上就是用redis+自定义注解+Lua脚本+拦截器限制访问接口次数和验证码的方式实现简单限流。