接口被疯狂调用怎么办?聊聊接口限流

0 阅读5分钟

前言

线上服务最怕突如其来的流量洪峰:爬虫高频刷接口、前端异常循环请求、恶意调用、活动流量突增…… 大量请求瞬间涌入,数据库、CPU、连接池被打满,正常用户请求跟着超时、报错,严重时直接服务雪崩。

限流,就是保护接口和服务的第一道屏障。它的核心思路很简单:控制单位时间内请求的数量,超出阈值的请求直接拒绝、排队或者降级处理,不让过量请求拖垮整个系统。

限流不是 “一刀切拒绝流量”,而是在服务承载能力范围内,优先保障核心业务可用。

事情起因

我为什么要考虑这个问题?因为我的下载站被人攻击了,凌晨开始一直刷下载接口。

Screenshot 2026-11-03 at 10.02.18.png

早上6点起来,发现出现了异常数据,紧急用AI修复这个问题。花了30分钟出了第一版更新,堵住了攻击,然后就是维持几个小时的优化。其实说起来本身是有限流的,只是没有写封禁的逻辑,要不然肯定不止4000多次下载。

我是怎么限流并封禁IP的?

package com.doragame.aop;

import com.dorachat.sdk.util.IPUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@Aspect
@Component
public class IpRateLimitAspect {

    @Autowired
    private IpRateLimiter ipRateLimiter;

    @Autowired
    private HttpServletRequest request;

    @Around("@annotation(ipRateLimit)")
    public Object around(ProceedingJoinPoint joinPoint, IpRateLimit ipRateLimit) throws Throwable {
        String ip = IPUtil.getClientIP(request);
        if (ip == null) {
            ip = "unknown";
        }
        boolean allowed = ipRateLimiter.execute(
                ip,
                ipRateLimit.time(),
                ipRateLimit.count()
        );
        if (!allowed) {
            log.warn("IP {} 访问 {} 被限流", ip, request.getRequestURI());
            throw new RateLimitException("请求过于频繁,请稍后再试");
        }
        return joinPoint.proceed();
    }
}

AOP切面的代码,用于配置注解来拦截请求。IP为访问用户的IP。

package com.doragame.aop;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IpRateLimit {

    /**
     * 时间窗口(秒)。
     */
    int time() default 10;

    /**
     * 时间窗口内最大请求数。
     */
    int count() default 3;
}

限流注解。

  • time:窗口时长(单位秒)
  • count:窗口内最多允许多少次请求
package com.doragame.aop;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
public class IpRateLimiter {

    private static final Map<String, RateLimitInfo> RATE_LIMIT_CACHE = new ConcurrentHashMap<>();
    private static final Map<String, BanInfo> BAN_CACHE = new ConcurrentHashMap<>();

    private static final int BAN_THRESHOLD = 10;
    private static final long BAN_DURATION_MS = 24 * 60 * 60 * 1000L;

    public boolean execute(String ip, int time, int count) {
        long now = System.currentTimeMillis();
        // ---- 检查封禁 ----
        BanInfo banInfo = BAN_CACHE.get(ip);
        if (banInfo != null && banInfo.banExpireAt > 0) {
            if (now < banInfo.banExpireAt) {
                // 封禁期内
                System.out.println("[BAN] IP " + ip + " 在封禁期内,剩余 "
                        + (banInfo.banExpireAt - now) / 1000 / 60 + " 分钟");
                return false;
            } else {
                // 解封
                System.out.println("[BAN] IP " + ip + " 封禁已过期,解封");
                BAN_CACHE.remove(ip);
                RATE_LIMIT_CACHE.remove(ip);
            }
        }
        // ---- 滑动窗口限流 ----
        long window = time * 1000L;
        RateLimitInfo info = RATE_LIMIT_CACHE.computeIfAbsent(ip, k -> new RateLimitInfo());
        boolean allowed;
        synchronized (info) {
            info.timestamps.add(now);
            clean(info.timestamps, now, window);
            allowed = info.timestamps.size() <= count;
        }
        // ---- 命中限流 ----
        if (!allowed) {
            BanInfo current = BAN_CACHE.computeIfAbsent(ip, k -> new BanInfo());
            synchronized (current) {
                current.hitCount++;
                System.out.println("[RATE_LIMIT] IP " + ip + " 命中限流,hitCount="
                        + current.hitCount + "/" + BAN_THRESHOLD);
                if (current.hitCount >= BAN_THRESHOLD) {
                    current.banExpireAt = now + BAN_DURATION_MS;
                    System.out.println("[BAN] IP " + ip + " 已被封禁24h!hitCount=" + current.hitCount);
                }
            }
            return false;
        }
        return true;
    }

    private static void clean(List<Long> list, long now, long window) {
        list.removeIf(ts -> now - ts > window);
    }

    static class RateLimitInfo {
        List<Long> timestamps = new ArrayList<>();
    }

    static class BanInfo {

        int hitCount = 0;
        long banExpireAt = 0;
    }
}

下面是核心逻辑,敲黑板!定义两个Map,限流缓存和封禁缓存。触发10次违规,则封禁IP访问该接口一天,一天后自动解封。这种就是滑动窗口限流的基本实现,将数据存储到内存中,适用于单机版服务。大致流程如下:

① 先查这个 IP 是不是在封禁期:在封禁里 → 直接拒绝

② 没被封禁:把本次请求时间戳加入列表,删掉窗口以外的旧时间戳

③ 剩下的时间戳数量 ≤ count → 放行;否则触发限流

④ 触发限流后,累加该 IP 的违规次数;累计满 10 次,设置 24 小时封禁

后记

这套方案是我在遭受爬虫攻击后快速落地的应急方案,优点很明显:开发快、开箱即用、不需要引入 Redis 等中间件,配合 AOP 注解,给接口加上限流防护只需要一行注解,非常适合单机小项目、个人站点、轻量服务。

但它有不少局限,也是我后续要优化的方向,这里如实记录下来,避免大家直接照搬踩坑:

第一,它是单机内存限流。所有 IP 的请求记录、封禁信息全部存在 JVM 内存里。如果你的服务部署多实例集群,同一个 IP 轮询打到不同机器,限流和封禁就会失效;服务一旦重启,所有限流统计、封禁名单会直接清空,攻击 IP 又可以重新进来刷接口。

第二,当前封禁的违规次数 hitCount 没有冷却衰减机制。只要 IP 触发过一次限流,这个计数就会一直累加,直到达到封禁阈值或者被封禁过期清除。举个例子:某个用户只是不小心短时间多点了几次下载,很久之后再来访问,之前的违规记录还在,很容易出现误封。理想的做法是给违规次数增加一个衰减窗口,长时间不再触发限流就自动清零违规计数。

第三,性能问题。底层用 ArrayList 存储每一次请求的时间戳,每次请求都要执行removeIf清理过期记录。如果单个 IP 持续高频访问,列表频繁增删,高并发场景下会带来额外 GC 压力;同时加了synchronized锁保证线程安全,同一 IP 的请求会串行竞争锁,流量极高时会有性能损耗。

第四,只适合小规模防御。它只能挡住简单爬虫、恶意刷接口的脚本。如果攻击者大量更换代理 IP、分布式扫接口,依靠单 IP 限流的方式就很难防护,这种场景就需要搭配验证码、UA 校验、行为识别、WAF 等手段一起防御。

站在稳定性角度来说:限流是止损手段,不是根治方案。这次攻击虽然靠限流 + IP 封禁紧急挡住了,保护了下载接口和服务器资源,但限流本身并没有提升接口的承载能力。如果后续业务流量持续上涨,或者想要分布式全局限流,就需要改造方案,换成 Redis+ZSet 实现分布式滑动日志限流,把统计数据放到 Redis 中,做到多实例共享限流和封禁数据。