后端接口的限流不是简单地在网关加一个计数器,它本质上是资源分配策略与攻击面收敛的组合工程。高频攻击之所以有效,是因为绝大多数业务接口在设计时默认“调用方是可信的”,而攻击者恰恰利用这种信任,用极低的成本耗尽你的数据库连接、CPU时间片或带宽。限流要解决的核心问题不是“挡住多少请求”,而是“让系统在过载时依然能对合法用户提供可预期的降级服务”。

很多人把限流理解为“超过阈值就拒绝”,这种认知会直接导致两个严重后果:一是阈值设置过高,限流形同虚设;二是阈值设置过低,正常用户在高并发场景下被误伤。真正有效的限流体系必须同时回答三个问题:以什么维度计数、在什么时间窗口内计数、超限后如何处理。这三个问题没有标准答案,取决于你的接口特性和业务容忍度。

先确定限流维度,再谈算法

限流维度决定了你的防御边界在哪里。最常见的维度是IP地址,但它也是最容易被绕过的。攻击者可以通过代理池、僵尸网络或云函数动态更换来源IP,单纯的IP限流在对抗专业攻击时几乎无效。更可靠的维度组合包括:用户ID、API Key、设备指纹、会话令牌,以及它们的组合。例如,一个登录接口应该同时限制“单IP的尝试次数”和“单账号的尝试次数”,前者防止分布式爆破,后者防止撞库。

对于未认证的公开接口,IP仍然是主要维度,但你需要配合其他信号来提升准确性。比如结合User-Agent的合法性、请求头完整性、TLS指纹等特征,对可疑来源执行更严格的阈值。对于已认证接口,优先使用用户ID或租户ID作为主维度,IP作为辅助维度,这样即使攻击者更换IP,也无法绕过针对账号的速率限制。

还有一个容易被忽视的维度是“接口+参数”的组合。例如一个搜索接口,攻击者可能通过构造极其复杂的查询表达式来放大后端计算开销。此时仅限制请求次数是不够的,还需要对参数复杂度进行评分,将高成本请求纳入独立的限流桶。

固定窗口、滑动窗口与令牌桶的适用场景

固定窗口算法实现最简单,但存在临界突变问题。假设限制为每分钟100次,攻击者可以在第59秒和第61秒之间集中发送200次请求,因为两个窗口各自独立计数。这种突发足以对数据库造成瞬时压力。如果你的接口对瞬时并发敏感,固定窗口不适合作为唯一防线。

滑动窗口通过将时间细分为多个小窗口并加权计算,能有效平滑临界问题。例如将1分钟划分为6个10秒的子窗口,当前窗口的计数等于各子窗口按时间衰减后的加权和。实现复杂度略高,但能更准确地反映“近期”的真实负载。对于大多数中等规模业务,滑动窗口是性价比最高的选择。

令牌桶和漏桶算法更适合需要允许一定突发流量的场景。令牌桶以恒定速率生成令牌,请求需要消耗令牌才能通过,桶满则丢弃多余令牌。它允许短时间内消耗积攒的令牌来应对正常业务高峰,同时限制长期平均速率。漏桶则强制请求以固定速率流出,平滑突发但可能引入排队延迟。对于支付回调、消息推送等需要削峰填谷的接口,令牌桶更灵活。

实际生产环境中,建议采用分层限流:在网关层用简单的固定窗口或令牌桶做粗粒度防护,在应用层用滑动窗口或基于Redis的分布式限流做细粒度控制。网关层负责拦截明显的洪水攻击,应用层负责保护具体业务逻辑。

Redis分布式限流的正确实现方式

单机限流在分布式部署下会失效,因为每个实例各自计数,攻击者只需将请求分散到不同节点即可绕过。分布式限流需要集中式计数器,Redis是最常用的选择。但直接使用INCR加EXPIRE的方式存在原子性问题:如果INCR成功后进程崩溃,EXPIRE未执行,计数器将永久存在,导致后续请求全部被拒绝。

正确的做法是使用Lua脚本保证原子性,或者使用Redis的SET命令配合NX和PX参数。下面是一个基于Lua的滑动窗口限流实现,适合需要精确控制时间窗口的场景:

-- KEYS[1]: 限流键名
-- ARGV[1]: 窗口大小(秒)
-- ARGV[2]: 最大请求数
-- 返回: 1表示允许,0表示拒绝
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(redis.call('TIME')[1])
local expire_at = now + window

-- 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 获取当前窗口内请求数
local current = redis.call('ZCARD', key)

if current < limit then
    redis.call('ZADD', key, now, now .. '-' .. math.random())
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end

这段代码使用有序集合存储每次请求的时间戳,每次请求前清理窗口外的记录,然后判断当前窗口内的请求数是否超限。使用时间戳作为score,随机字符串作为member避免重复。EXPIRE命令确保键在窗口结束后自动清理,防止内存泄漏。

对于更高并发的场景,可以考虑使用Redis Cluster或专门的限流中间件。但要注意,Redis本身也可能成为攻击目标。如果攻击者能直接访问Redis,限流体系就失去了意义。因此Redis必须部署在内网,并启用密码认证和访问控制。

超限后的处理策略比限流本身更重要

当请求被限流拒绝时,返回什么状态码和响应体,直接影响用户体验和攻击者的后续行为。最常见的做法是返回HTTP 429 Too Many Requests,并在响应头中包含Retry-After字段,告知客户端何时可以重试。但很多实现忽略了Retry-After的准确性,如果返回的等待时间过长,正常用户可能放弃操作;过短则攻击者会立即重试。

更精细的策略是分级处理:对于轻微超限的请求,可以返回429并设置较短的Retry-After;对于明显恶意的请求,直接断开连接或加入黑名单。判断恶意程度的依据包括:超限比例、请求频率的加速度、是否携带明显伪造的请求头等。

还有一种容易被忽视的情况是“限流后的雪崩”。当大量请求被拒绝后,客户端可能自动重试,导致请求量进一步放大。如果你的客户端是自己控制的,应该在客户端实现退避算法,如指数退避加随机抖动。对于不受控的第三方客户端,需要在服务端设置绝对上限,超过该上限的请求直接丢弃而不返回任何响应,避免消耗带宽。

结合业务特征的动态限流

静态阈值最大的问题是无法适应业务变化。一个接口在凌晨三点的正常流量可能只有白天的十分之一,如果按照白天峰值设置阈值,夜间攻击者可以轻松发起低频但持续的攻击而不触发限流。动态限流根据历史流量自动调整阈值,例如使用滑动窗口内的平均请求数乘以一个弹性系数作为当前阈值。

实现动态限流需要维护每个接口的流量基线。可以定期采样过去一段时间的请求量,计算均值和标准差,然后设置阈值为均值加上三倍标准差。当实际流量超过这个动态阈值时触发限流。这种方法能自动适应业务增长和周期性波动,但需要防止攻击者通过“温水煮青蛙”的方式逐渐抬高基线。因此需要设置一个绝对上限,无论基线如何变化,都不能超过该上限。

另一个值得关注的维度是接口的成本权重。不同接口消耗的资源差异巨大,一个简单的查询接口可能只需要几毫秒,而一个报表生成接口可能需要数秒。如果对所有接口使用相同的限流阈值,攻击者会集中攻击高成本接口。应该为每个接口分配成本权重,限流时按权重累计消耗,而不是简单计数请求次数。

多层防线与监控告警

限流不应该只在一个层面实现。有效的防护体系至少包含四层:网络层的防火墙和DDoS防护、网关层的全局限流、应用层的业务限流、数据库层的连接数限制。每一层解决不同的问题,网络层过滤明显的流量型攻击,网关层控制总体请求速率,应用层保护具体业务逻辑,数据库层防止连接池被耗尽。

监控是限流体系的眼睛。你需要实时监控每个接口的请求量、拒绝量、平均响应时间和错误率。当拒绝率突然升高时,可能是攻击正在发生,也可能是业务活动导致正常流量激增。区分这两种情况需要结合业务日历和用户行为分析。告警阈值应该设置在拒绝率超过一定比例且持续一段时间后触发,避免因瞬时波动产生大量误报。

日志记录同样关键。每次限流拒绝都应该记录请求的来源、时间、目标接口和拒绝原因。这些日志不仅用于事后分析,也是改进限流策略的数据基础。通过分析被拒绝请求的特征,可以发现攻击模式并提前调整防御策略。

限流不是一次性配置就完事的工作,它需要根据业务变化和攻击趋势持续迭代。定期回顾限流日志,分析误杀率和漏过率,调整阈值和维度组合。一个经过良好调优的限流体系,应该让合法用户在正常业务高峰下几乎感知不到限制的存在,同时让攻击者在发起有效攻击前就消耗掉大量资源。