MeteorCat / 接口限流

Created Sun, 15 Dec 2024 22:25:04 +0800 Modified Wed, 29 Oct 2025 23:24:53 +0800
989 Words

接口限流

如果从事过接口开发的时候发现过接口流量异常, 可能有针对接口进行 刷流量 的情况, 所以就需要对接口进行请求限流防止服务崩溃.

如果直接没有对接口进行限流任由网络刷接口会导致数据库|NoSQL服务直接崩溃, 比如数据库 oom kill

这里需要区分开发过程两种网络服务:

  • 脚本语言: PHP等
  • 编译语言: Java等

对于脚本类型的语言开发接口, 脚本服务并不是常驻内存服务就需要 Redis 做内存访问锁, 如下代码处理:

$redis = new Redis();

// 假设获取到客户端IP
$ip_address = "192.168.1.111";

// 直接判断 redis 之中Key是否存在
// 存在就是目前还在超时或者超时时间偏差值
$key = "timeout_{$ip_address}";
if($redis->exists($key)){
    // 这里代表存在该Key需要判断是否超过指定时间
    $value = $redis->get($key);
    
    // 计算Redis请求时间和当前时间的误差值
    $now = time();
    if(is_numeric($value) &&  (($now - $value) > 1) ){
        exit("timeout"); // 防止请求过快直接拦截
    }
}


// 写入接口请求的时间戳
$now = time(),
$redis->set($key,$now); // 设置该请求时间
$redis->expire($key,1);// 设置1s之后超时销毁

上面是个简单的接口限流方式, 实际上还需要根据请求的路径 PATH_INFO 做限流让各自接口做不同限流方式.

脚本语言的因为没办法常驻内存所以需要依赖外部 Redis 这种外部内存数据库做标识, 而 Java 这种业务简单的情况可以依靠自身进程来做访问限制处理.

但是大型业务进程会采用分布式部署在不同主机, 所以单独服务进程内存限流并不是合理选择, 所以也需要依赖外部Redis这种共享访问状态.

这里采用简单进程做限流处理:

package com.meteorcat.news.now.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 拦截器配置
 */
@Configuration
public class RestWebMvcConfigurer implements WebMvcConfigurer {

    final RestInterceptor restInterceptor;

    public RestWebMvcConfigurer(RestInterceptor restInterceptor) {
        this.restInterceptor = restInterceptor;
    }


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(restInterceptor)
                .addPathPatterns("/**");
    }
}

之后就是具体拦截业务:

package com.meteorcat.news.now.config;


import com.meteorcat.news.now.services.MemInterceptorService;
import com.meteorcat.news.now.utils.AccessLimitException;
import com.meteorcat.news.now.utils.IPUtility;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 访问拦截器
 * 用于访问限流等处理
 */
@Log4j2
@Component
public class RestInterceptor implements HandlerInterceptor {

    /**
     * 外部配置的超时请求秒
     */
    @Value("${rest.interceptor.access.timeout:3}")
    private Integer accessTimeout;


    /**
     * 内存记录
     */
    final MemInterceptorService memInterceptorService;

    public RestInterceptor(MemInterceptorService memInterceptorService) {
        this.memInterceptorService = memInterceptorService;
    }


    /**
     * 访问拦截
     *
     * @param request  请求
     * @param response 响应
     * @param handler  句柄
     * @return boolean
     * @throws Exception 异常
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String ipAddress = IPUtility.GetIpAddress(request);
        long timestamp = System.currentTimeMillis();
        log.debug("ACCESS: {} - {} - {}", ipAddress, timestamp, accessTimeout);

        // 判断获取误差, 不允许重复请求
        if (memInterceptorService.expire(ipAddress, timestamp, accessTimeout)) {
            throw new AccessLimitException();
        }

        // 写入请求记录
        memInterceptorService.set(ipAddress, timestamp);
        return true;
    }
}

这里的 AccessLimitException 是自定义的异常, 用于给 RestControllerAdvice 配置拦截异常返回:

/**
 * 拦截服务器异常响应
 */
@RestControllerAdvice
public class RestControllerAdviceConfig {
    /**
     * 接口限流
     */
    @ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
    @ExceptionHandler(value = AccessLimitException.class)
    public Object accessLimit(AccessLimitException ignore) {
        return Response.send(HttpStatus.NOT_ACCEPTABLE, "ACCESS LIMIT", null);
    }
}

最后这里就是简单配置持久化常驻内存的进程记录标识:

package com.meteorcat.news.now.services;

import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * 常驻内存的限流判断
 */
@Service
public class MemInterceptorService {

    final Map<String, Long> access = new HashMap<>();


    /**
     * 判断是否请求限制
     *
     * @return true(需要限流) | false(直接放行)
     */
    public boolean expire(String ipAddress, long timestamp, int offset) {
        Long pre = access.get(ipAddress);
        if (Objects.isNull(pre)) {
            return false;
        }
        if ((timestamp - pre) < (offset * 1000L)) {
            return true;
        }
        access.remove(ipAddress);
        return false;
    }

    /**
     * 写入请求限制器
     */
    public void set(String ipAddress, long timestamp) {
        access.put(ipAddress, timestamp);
    }

}