前言
线上服务最怕突如其来的流量洪峰:爬虫高频刷接口、前端异常循环请求、恶意调用、活动流量突增…… 大量请求瞬间涌入,数据库、CPU、连接池被打满,正常用户请求跟着超时、报错,严重时直接服务雪崩。
限流,就是保护接口和服务的第一道屏障。它的核心思路很简单:控制单位时间内请求的数量,超出阈值的请求直接拒绝、排队或者降级处理,不让过量请求拖垮整个系统。
限流不是 “一刀切拒绝流量”,而是在服务承载能力范围内,优先保障核心业务可用。
事情起因
我为什么要考虑这个问题?因为我的下载站被人攻击了,凌晨开始一直刷下载接口。
早上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 中,做到多实例共享限流和封禁数据。