API 是现代分布式系统的神经网络,但也是暴露给外界最直接的攻击平面。任何一个缺乏资源保护的 API,都可能在恶意攻击或程序 Bug 面前瞬间瘫痪,引发雪崩效应,甚至造成核心业务损失。本文将从风控系统的视角,系统性地剖析 API 访问频率限制的设计与实现,不止于介绍经典的计数算法,更会深入探讨分布式环境下的原子性、多级惩罚与封禁策略的工程实践,以及如何构建一个从简单到复杂的、具备弹性与可演进性的风控防御层。
现象与问题背景
在一个复杂的线上系统中,API 的调用者身份多样,意图也千差万别。我们在一线工程中面临的典型频率滥用场景包括但不限于:
- 身份认证接口的暴力破解: 攻击者通过自动化脚本,以极高频率尝试“用户名+密码”组合,进行撞库或凭证填充攻击(Credential Stuffing)。
- 电商平台的库存和价格嗅探: 竞争对手或“羊毛党”通过爬虫高频次地访问商品详情页 API,获取实时的价格与库存信息,给系统带来巨大压力的同时,也可能窃取商业敏感数据。
- 营销活动的规则套利: 在优惠券发放、秒杀等活动中,利用脚本以远超人类极限的速度请求接口,抢占稀缺资源,破坏活动公平性。
- 客户端 Bug 引发的资源风暴: 某个版本的客户端 App 存在 Bug,在特定条件下进入无限循环,疯狂请求某个 API,导致服务器资源(CPU、内存、数据库连接)被迅速耗尽。
这些问题的共性在于,它们都是在应用层(L7)发起的合法或看似合法的请求,传统的网络层防火墙(L3/L4)无法有效识别和拦截。其最终目的不仅仅是消耗带宽,更是为了耗尽更高价值的业务处理资源。因此,我们需要在业务网关或服务本身构建一道精细化的流量“阀门”,这便是 API 访问频率限制的核心诉求:在保护系统可用性的前提下,最大限度地保障合法用户的正常访问。
关键原理拆解
从计算机科学的角度看,API 访问频率限制的核心问题,是在一个给定的时间窗口内,对某一维度的请求进行计数。这个看似简单的问题,其背后却蕴含着对数据结构与算法的深刻理解,以及在分布式环境下的种种挑战。
(大学教授视角)
我们首先要精确定义“频率”。“每分钟不超过100次”是一个模糊的表述。假设当前时间是 10:01:30,这个“一分钟”是指 10:01:00 到 10:02:00,还是 10:00:30 到 10:01:30?不同的定义对应不同的算法实现,也带来了精确性和资源消耗的权衡。
-
固定窗口计数器(Fixed Window Counter): 这是最简单直观的算法。我们将时间划分为固定大小的窗口,例如,每分钟一个窗口(10:00-10:01, 10:01-10:02)。在每个窗口内,我们维护一个计数器。当请求到来时,若在当前窗口内,计数器加一。若计数器超过阈值,则拒绝请求。
- 优点: 实现简单,内存占用极低(每个监控对象只需一个计数器和一个时间戳)。时间复杂度为 O(1)。
- 缺点: 存在临界问题。假设限制是每分钟60次。攻击者可以在 10:00:59 集中发送60次请求,然后在 10:01:01 再次集中发送60次请求。在系统看来,两个窗口都没有超限,但实际上在2秒内发生了120次请求,其瞬时速率是限额的60倍。这在很多场景下是不可接受的。
-
滑动窗口日志(Sliding Window Log): 为了解决固定窗口的临界问题,该算法记录下每个请求发生的时间戳。当新请求到来时,系统会检查过去一个时间窗口内(例如,从`now() – 60s` 到 `now()`)的日志数量。如果数量超过阈值,则拒绝。
- 优点: 计数非常精确,完美解决了临界问题。
- 缺点: 资源消耗巨大。需要存储每一个请求的时间戳,如果一个用户每分钟允许1000次请求,那么就需要为一个用户存储1000个时间戳。这在海量用户和高并发场景下,内存开销是无法承受的。其时间复杂度为 O(N),N为窗口内的请求数。
-
滑动窗口计数器(Sliding Window Counter): 这是对前两种算法的优化与折衷。它将一个大的时间窗口(如1分钟)划分为多个更小的窗口(如6个10秒的窗口)。系统会记录每个小窗口内的请求数。当请求到来时,系统会计算当前时间点往前推一个大窗口所覆盖的所有小窗口(可能包含一个不完整的小窗口)的计数值之和。
- 优点: 很好地平衡了精度和资源消耗。它不需要记录每个请求的时间戳,只需为每个小窗口维护一个计数器。内存占用远小于滑动窗口日志,而平滑度远高于固定窗口。
- 缺点: 实现复杂度相对较高,仍然存在一定程度的精度损失(取决于小窗口的粒度),但在工程上完全可以接受。
在分布式系统中,状态的存储和原子性操作是另一个核心挑战。当多个应用实例共同为一个用户或IP提供服务时,计数器必须是全局共享的。这通常依赖于一个外部的、高性能的、支持原子操作的存储系统,例如 Redis。对共享计数器的“读取-修改-写回”操作必须是原子的,否则在高并发下会出现竞态条件(Race Condition),导致计数不准,限流策略被绕过。
系统架构总览
一个成熟的风控限流系统通常不会嵌入在单一业务服务中,而是作为一个独立的基础设施存在。其典型的部署位置是在 API 网关层,或者作为一个被网关调用的独立微服务。这样可以实现逻辑复用,并保护后端所有业务服务。
我们可以设想这样一幅架构图:
- 客户端请求首先到达负载均衡器(如 Nginx、F5)。
- 负载均衡器将请求转发至 API 网关集群(如 Kong、Envoy 或自研网关)。
- 在网关的请求处理流水线(Pipeline)中,有一个专门的“频率限制与惩罚”过滤器(Filter)。
- 该过滤器会解析请求,提取关键维度信息,如用户ID、设备指纹、来源IP、请求路径等,构成一个唯一的请求标识 Key。
- 过滤器携带这个 Key,同步调用“风控决策服务(Risk Control Service)”。
- 风控决策服务内部:
- 首先查询一个高性能的“封禁名单(Blocklist)”,通常存储在 Redis Set 或 Bloom Filter 中。如果命中,直接拒绝请求,快速失败。
- 若未被封禁,则调用“频率计数模块”。该模块与一个集中的“状态存储(State Store)”(通常是 Redis Cluster)交互,利用其原子操作(如 Lua 脚本)来更新和获取指定 Key 在特定时间窗口内的计数值。
- 根据计数值和预设的规则阈值,决策服务返回一个结果:ALLOW(允许)、DENY(拒绝)或 CHALLENGE(挑战,如要求输入验证码)。
- 如果决策是 DENY,并且该 Key 触发了预设的惩罚升级策略,决策服务会异步地将该 Key 加入封禁名单,并设置一个自动解封的过期时间(TTL)。
- 网关过滤器根据决策服务的返回结果,执行相应操作:要么将请求放行至后端业务服务,要么直接返回一个 HTTP 429 (Too Many Requests) 错误。
这个架构将风控逻辑与业务逻辑完全解耦,使得风控策略的升级和维护不影响业务服务的稳定性。同时,将决策核心下沉到独立服务,也便于未来扩展更复杂的风控模型,如集成机器学习模型进行异常行为检测。
核心模块设计与实现
(极客工程师视角)
理论说完了,我们来点硬核的。怎么用代码把这套体系搭起来?关键在于两点:一个高效的分布式计数器,和一个灵活的惩罚机制。
模块一:基于 Redis + Lua 的滑动窗口计数器
为什么是 Redis + Lua?因为 Redis 的 Sorted Set (ZSET) 数据结构简直是为滑动窗口量身定做的,而 Lua 脚本能保证多个 Redis 命令的原子性执行,避免了客户端与 Redis 服务器之间多次网络往返带来的延迟和竞态条件。
我们的策略是:用 ZSET 来存储每个请求的时间戳(作为 score 和 member)。ZSET 会自动按 score (时间戳) 排序,这使得移除过期时间戳的操作非常高效。
-- rate_limiter.lua
-- KEYS[1]: a unique key for the resource, e.g., "ratelimit:user_id:12345:api:/v1/order"
-- ARGV[1]: current timestamp (in microseconds)
-- ARGV[2]: window size (in microseconds)
-- ARGV[3]: limit
-- ARGV[4]: optional, a new TTL for the key
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local new_ttl = ARGV[4]
-- 1. 移除窗口之外的旧请求记录
-- ZREMRANGEBYSCORE is O(log(N) + M) where N is the number of elements and M is the number of elements being removed.
-- Because we clean up on every request, M is small. This is very efficient.
local oldest = now - window
redis.call("ZREMRANGEBYSCORE", key, "-inf", oldest)
-- 2. 获取窗口内的当前请求数
-- ZCARD is O(1).
local count = redis.call("ZCARD", key)
if count < limit then
-- 3. 未达到阈值,添加当前请求记录
-- ZADD is O(log(N)) for each element added. We add one.
redis.call("ZADD", key, now, now)
-- 4. 刷新 key 的过期时间,避免冷数据永久占用内存
if new_ttl then
redis.call("EXPIRE", key, new_ttl)
else
-- 默认给一个窗口两倍的过期时间
redis.call("EXPIRE", key, window / 1000000 * 2)
end
return 1 -- 1 for ALLOW
else
return 0 -- 0 for DENY
end
这段 Lua 脚本做了四件事,而且是原子性的:
1. 用 `ZREMRANGEBYSCORE` 命令,原子地移除所有早于 `(当前时间 - 窗口大小)` 的记录。这是实现“滑动”的关键,且效率极高。
2. 用 `ZCARD` 命令,获取当前窗口内有效的请求数量,这是一个 O(1) 操作。
3. 如果数量小于阈值,就用 `ZADD` 将当前请求的时间戳(作为 score 和 member)加入 ZSET。
4. 返回决策结果,并顺手用 `EXPIRE` 刷新一下 key 的过期时间,防止不活跃用户的 key 永久占用内存。
在 Go 语言的服务中,我们可以这样调用它:
package ratelimiter
import (
"context"
"time"
"github.com/go-redis/redis/v8"
)
var allowScript = redis.NewScript(`
-- [Lua script from above]
...
`)
type RateLimiter struct {
client *redis.Client
}
func (r *RateLimiter) Allow(ctx context.Context, key string, limit int64, window time.Duration) (bool, error) {
now := time.Now().UnixNano() / 1000 // microseconds
windowMicro := window.Microseconds()
// 原子地执行 Lua 脚本
res, err := allowScript.Run(ctx, r.client, []string{key}, now, windowMicro, limit).Result()
if err != nil {
// 如果 Redis 挂了,是 fail-open 还是 fail-close?
// 这里我们选择 fail-open,并记录错误,防止 Redis 故障导致整个业务不可用。
// 在高安全要求的场景(如支付),可能会选择 fail-close。
return true, err
}
return res.(int64) == 1, nil
}
模块二:多级惩罚与封禁名单
仅仅返回 HTTP 429 是不够的,对于持续攻击者,我们需要“拉黑”机制。这个机制不能是简单的“一次超限,永久封禁”,否则容易被竞争对手恶意利用(刷你的接口导致你的真实用户被封)。一个好的惩罚机制是渐进式的。
我们可以为每个用户/IP 维护一个“惩罚等级”状态。
- **等级 0 (Normal):** 正常状态。
- **等级 1 (Warning):** 第一次触发限流,不封禁,但可能触发内部告警。
- **等级 2 (Block_5m):** 在警告状态下短时间再次触发,封禁5分钟。
- **等级 3 (Block_1h):** 5分钟解封后,短时间再次触发,封禁1小时。
- **等级 4 (Block_Permanent):** 多次触发后,永久封禁,需要人工介入解封。
这个状态机可以用简单的 Redis Key 来实现。当一个请求被限流模块判定为 DENY 时,我们异步地执行以下逻辑:
// 这部分逻辑应该异步执行,不要阻塞正常的请求路径
func (p *PenaltyService) EscalatePenalty(ctx context.Context, key string) {
penaltyLevelKey := "penalty_level:" + key
blocklistKey := "blocklist:" + key
// 使用 INCR 来原子地提升惩罚等级
level, err := p.redisClient.Incr(ctx, penaltyLevelKey).Result()
if err != nil {
// handle error
return
}
var blockDuration time.Duration
switch level {
case 1:
// 第一次触发,仅设置一个短时间的 "观察期",比如 10 分钟后等级自动清零
p.redisClient.Expire(ctx, penaltyLevelKey, 10*time.Minute)
return // 不封禁
case 2:
blockDuration = 5 * time.Minute
case 3:
blockDuration = 1 * time.Hour
default:
// level >= 4
blockDuration = -1 // -1 表示永久封禁
}
// 将用户加入封禁名单,并设置 TTL
if blockDuration > 0 {
p.redisClient.Set(ctx, blocklistKey, "blocked", blockDuration)
} else if blockDuration == -1 {
p.redisClient.Set(ctx, blocklistKey, "blocked", 0) // 0 TTL means persist
}
// 封禁后,将惩罚等级计数器清零,等待下一次循环
p.redisClient.Del(ctx, penaltyLevelKey)
// 同时可以发送日志或告警到监控系统
log.Printf("Key %s has been blocked for %v, penalty level was %d", key, blockDuration, level)
}
这里的关键点是,封禁检查必须在频率检查之前。在 API 网关的过滤器中,第一步就是查 `blocklist:{key}` 是否存在,如果存在,直接拒绝。这是一种高效的剪枝,避免了对已被封禁的用户执行昂贵的频率计算。
性能优化与高可用设计
在高流量场景下,每一毫秒的延迟和每一次的资源消耗都至关重要。
- 本地缓存(Local Cache): 对于那些频繁通过检查的“好”用户,我们可以在网关实例的内存中进行一级缓存(例如,使用一个带过期时间的 LRU Cache)。比如,缓存一个“白名单”,有效期1秒。在这1秒内,该用户的所有请求直接放行,无需访问 Redis。这会极大地降低对 Redis 的压力。当然,这引入了1秒的不一致性,即用户可能在这1秒内超发请求,这是一种性能与严格性之间的权衡。
- 采样与异步化: 并不是所有 API 都需要100%精确的限流。对于一些非核心功能,可以进行采样限流。例如,只对10%的请求执行完整的 Redis 检查逻辑。此外,如前述代码所示,惩罚升级的逻辑务必异步化,可以通过消息队列(如 Kafka)或一个独立的 goroutine 池来完成,确保不拖慢主请求路径。
- Redis 高可用: 状态存储 Redis 绝不能是单点。必须部署为哨兵模式(Sentinel)或集群模式(Cluster)。需要仔细考虑 Redis 主备切换时的行为。在切换的瞬间,可能会有少量数据(几百毫秒)未同步,导致计数丢失。对于绝大多数限流场景,这种微小的不一致是完全可以接受的。
- 降级预案(Fail-over Strategy): 当 Redis 集群完全不可用时怎么办?这是一个必须回答的问题。
- Fail-Open (失败放行): 允许所有流量通过。这是保业务可用性的选择,但代价是系统可能会被流量冲垮。这通常是默认选项,同时配合后端服务的熔断降级机制。
- Fail-Close (失败关闭): 拒绝所有需要检查的流量。这是保系统稳定性的选择,但代价是造成业务大面积中断。适用于支付、交易等对一致性和安全性要求极高的场景。
- 本地限流降级: 一种折衷方案。当检测到 Redis 故障时,网关实例切换到一个非常保守的本地内存限流器(例如,每个 IP 每秒只能请求1次)。这既防止了系统被彻底打垮,也为部分用户保留了可用性。
架构演进与落地路径
一个健壮的风控系统不是一蹴而就的,它应该随着业务的发展和威胁的变化而演进。
第一阶段:单体应用内的嵌入式限流。
在项目初期,业务量不大,可以直接在业务代码中引入一个限流库(如 Go 的 `golang.org/x/time/rate`),使用 Redis 作为后端。这种方式实现快,成本低,但与业务逻辑强耦合,不利于策略的统一管理。
第二阶段:抽离为中心化的风控微服务。
随着业务线增多,将限流和风控逻辑抽离成一个独立的微服务。所有业务服务通过 RPC 调用该服务进行决策。这样做的好处是统一了风控策略,降低了维护成本,并且可以独立扩缩容。此时,风控服务的性能和可用性变得至关重要。
第三阶段:能力下沉至 API 网关。
将通用的、高性能的限流能力直接在 API 网关层实现。这是最理想的架构,因为它在流量的入口处就完成了拦截,避免了无效请求对后端微服务链路的任何消耗。可以使用 Nginx+Lua,或者在 Envoy 中通过 External Authorization Filter 实现与风控服务的联动。
第四阶段:向自适应和多维度风控平台演进。
当简单的频率计数已无法应对复杂的攻击模式时,系统需要演进。
- 多维度关联分析: 不再是单一的 `IP` 或 `UserID`,而是组合维度,如“同一IP在1小时内登录了超过5个不同账号”、“同一设备ID在10分钟内下单但未使用同一收货地址”。
- 正向行为激励: 对于长期表现良好、信用分高的用户,可以动态地给予他们更高的 API 调用频率额度。
- 动态风险评分: 引入规则引擎甚至机器学习模型,根据用户的一系列行为(登录、浏览、加购、下单)进行实时风险评分。限流不再是一个固定的阈值,而是根据风险分数动态调整的策略。例如,高风险用户可能每次操作都需要图片验证码,而低风险用户则完全无感。
至此,系统已经从一个被动的“限流器”,演变成一个主动的、智能的“实时风险决策大脑”,这才是风控系统建设的最终目标。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。