高频请求对后端接口的冲击,本质上不是带宽问题,而是资源分配问题。攻击者不需要发送海量数据,只需要在短时间内用大量看似正常的请求占满你的数据库连接、线程池或CPU时间片,服务就会从“变慢”迅速恶化到“不可用”。很多团队第一反应是加机器、扩容器,但在攻击流量面前,水平扩展的速度往往跟不上请求堆积的速度。真正有效的第一道防线,是在请求进入业务逻辑之前就完成识别和拦截,也就是接口限流。
接口限流的核心目标不是“拒绝所有高频访问”,而是“保护系统在极端情况下仍能对正常用户提供服务”。这意味着限流策略必须有优先级意识:对明显异常的流量直接丢弃,对边缘流量延迟处理,对核心用户和关键接口保留资源。如果限流本身设计得过于粗暴,比如对整个服务设置一个全局QPS上限,那么攻击者只要打满这个上限,正常用户依然无法访问。所以限流必须分层、分维度、分场景。
先区分限流的几个关键维度最常见的维度是按IP限流。它的优点是实现简单、开销低,在网关层或Nginx层就能做。但IP限流有两个明显缺陷:一是NAT出口下大量正常用户共享同一个公网IP,容易误伤;二是攻击者可以通过代理池或僵尸网络轻松更换IP。因此IP限流适合作为第一层粗筛,阈值可以设置得相对宽松,比如单IP每秒超过200次请求就触发限制,但不要直接封禁,而是返回429状态码并附带Retry-After头,让客户端知道何时可以重试。
更细的维度是按用户ID限流。这需要先完成身份认证,拿到用户标识后再做计数。按用户限流的好处是精准,攻击者即使更换IP,只要登录态不变就无法绕过。但它的代价是每个请求都要查一次计数存储,对Redis或Memcached的压力较大。为了降低开销,可以采用“固定窗口计数”配合“滑动窗口近似”的方式,比如在Redis中用INCR命令对“user:limit:12345:2024-01-15-10:30”这样的键做自增,过期时间设为60秒。这样每个用户每分钟的请求量一目了然,而且不需要存储每次请求的时间戳。
第三个维度是按接口路径限流。不同接口的资源消耗差异巨大,一个查询列表接口可能只读一次缓存,而一个导出报表接口可能触发全表扫描。如果对所有接口使用同一个限流阈值,要么保护不了重接口,要么误伤轻接口。正确做法是对资源消耗高的接口单独设置更低的阈值,并在代码层面做异步化或队列化处理。例如导出接口可以限制为每用户每分钟最多1次,超过后返回“任务已排队”的提示,而不是让请求直接打到数据库。
令牌桶和漏桶的实际选择很多文章会介绍令牌桶和漏桶算法,但很少说清楚在实际工程中该怎么选。漏桶的特点是输出速率恒定,适合平滑流量,但它的缺点是在突发流量下会把请求排队,导致延迟增加。如果你的接口主要面向实时性要求高的场景,比如支付回调或即时通讯,漏桶的排队机制会引入不必要的延迟。令牌桶则允许一定程度的突发,只要桶里有令牌就能立即处理,更适合处理“平时流量平稳、偶尔有瞬时高峰”的业务。
实际落地时,令牌桶并不需要自己从零实现。如果你使用Java技术栈,Guava库提供了RateLimiter类,几行代码就能完成限流。以下是一个简单的示例:
import com.google.common.util.concurrent.RateLimiter;
public class ApiRateLimiter {
private static final RateLimiter limiter = RateLimiter.create(100.0); // 每秒100个令牌
public static boolean tryAcquire() {
return limiter.tryAcquire();
}
}
但Guava的RateLimiter是单机级别的,如果你的服务部署在多台机器上,每台机器都限制100 QPS,那么整体限制就是机器数乘以100。这在分布式环境下往往不够准确。分布式限流需要把计数放到共享存储中,比如Redis。Redis官方推荐的方案是使用Lua脚本保证“检查-计数-扣减”的原子性。下面是一个基于Redis的固定窗口限流脚本:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0
end
return 1
这个脚本的逻辑是:每次请求到达时,对当前窗口的计数器加一,如果是第一次请求则设置过期时间,如果计数超过限制则返回0表示拒绝。它的优点是原子性强,不会出现并发下的计数错误。缺点是固定窗口存在边界问题,比如在窗口切换的瞬间,攻击者可以打满两个窗口的额度。如果你的业务对边界突发敏感,可以改用滑动窗口,用Redis的ZSET记录每个请求的时间戳,每次请求时删除窗口外的记录,再统计剩余数量。滑动窗口更精确,但存储和计算开销也更高。
高频攻击的识别不能只靠计数单纯的限流可以挡住“量”的冲击,但挡不住“质”的伪装。很多高频攻击会模拟正常用户的行为模式,比如请求间隔随机化、携带合法的User-Agent、甚至使用真实的登录态。这时候仅靠QPS阈值就不够了,需要引入行为分析。一个实用的方法是结合“请求特征指纹”和“时间序列分析”。例如,同一个用户ID在5分钟内请求了30个不同的商品详情页,这可能是正常的浏览行为;但如果同一个用户ID在5分钟内请求了同一个商品详情页30次,且每次请求的响应时间都低于50毫秒,这很可能是脚本在抓取数据。
另一个容易被忽视的维度是请求参数的熵值。正常用户的请求参数通常具有多样性和随机性,而攻击脚本往往使用固定模板或小范围变化的参数。你可以对请求参数做哈希,统计一段时间内不同哈希值的数量。如果某个IP或用户ID的请求参数熵值极低,说明请求内容高度重复,即使QPS不高也应该触发风控。这种基于内容特征的识别比单纯计数更难绕过,因为攻击者需要模拟真实的数据分布。
限流后的降级策略比拒绝更重要很多开发者认为限流就是返回429或503,但实际上,直接拒绝请求会带来糟糕的用户体验,尤其是在攻击流量和正常流量混杂的时候。更合理的做法是分级降级:第一级是延迟处理,把请求放入队列,等待一段时间后再处理,如果队列满了再拒绝;第二级是返回缓存数据,如果接口有缓存层,可以在限流触发时直接返回上一次成功的结果,并在响应头中标记“stale”;第三级才是返回错误码。对于核心交易接口,还可以采用“验证码挑战”的方式,当检测到异常高频时,要求客户端完成一次人机验证,通过后再放行。这种方式对正常用户影响较小,对脚本攻击却能有效阻断。
降级策略还需要考虑“熔断”机制。如果限流之后下游服务依然被打垮,说明限流阈值设置得过高,或者攻击流量已经超过了系统的承载极限。这时候应该触发熔断,暂时停止对该接口的调用,快速返回降级响应。熔断的恢复不能太快,否则会陷入“打垮-恢复-再打垮”的循环。一个常见的做法是采用指数退避恢复策略,比如第一次熔断后等待10秒尝试恢复,如果再次失败等待20秒,以此类推。
把限流做成可观测的体系限流规则不是一成不变的,业务增长、接口优化、攻击手段变化都会影响阈值的合理性。因此限流必须配合完善的监控和日志。至少需要记录每个限流维度的触发次数、被拒绝的请求量、限流后的响应时间分布,以及限流规则生效前后的系统负载变化。这些数据可以帮助你判断限流是“保护了系统”还是“误伤了用户”。如果某个接口的限流触发率长期超过5%,说明阈值可能设置得过低,需要调整;如果触发率突然飙升到30%以上,大概率是正在遭受攻击,需要立即介入。
另外,限流日志应该与业务日志分离,避免高频攻击产生的大量日志把日志系统也拖垮。可以采用异步批量写入的方式,或者在限流触发时只记录聚合后的统计信息,而不是记录每一条被拒绝的请求。对于需要事后分析的场景,可以采样记录,比如每100条被拒绝的请求只记录1条完整信息。
最后要强调的是,限流不是安全防护的全部,它解决的是“资源耗尽”类问题。对于SQL注入、XSS、越权访问等攻击,仍然需要WAF、参数校验、权限控制等机制配合。限流应该被定位为“最后一道防线之前的缓冲层”,它的价值在于为其他防护手段争取时间,让系统在攻击初期不至于立即崩溃。把限流做好,高频攻击的威胁就能降低一大半。
