限流
接口限流是防止暴力破解、爬虫滥用和 DDoS 攻击的第一道防线。ACE Framework 提供两种开箱即用的限流方式:
@RateLimit宏 — 作用于单个控制器方法,精细化控制rateLimitMiddleware— 全局或路由组级别的统一限流
两者均基于滑动窗口(Sliding Window) 算法,默认使用内存存储,生产环境可替换为 Redis 等分布式存储。
限流原理
@RateLimit 宏
基本用法
cangjie
import ace_framework.*
import ace_security.ratelimit.*
@Controller["/api"]
class UserController {
// 每个 IP 每分钟最多 10 次登录尝试
@Post["/login"]
@RateLimit[maxReq: 10, windowMs: 60000]
func login(@Body body: LoginDto): Response {
// 登录逻辑
}
// 每个 IP 每秒最多 100 次查询
@Get["/search"]
@RateLimit[maxReq: 100, windowMs: 1000]
func search(@Query["q"] keyword: String): Response {
// 搜索逻辑
}
// 不加注解 = 不限流
@Get["/public"]
func publicInfo(): Response {
Response.json({status: "ok"})
}
}@RateLimit 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
maxReq | Int | 是 | 窗口期内最大请求数 |
windowMs | Int64 | 是 | 时间窗口,单位毫秒 |
keyBy | String | 否 | 限流 key 策略,默认 "ip",可选 "user" / "header:X-App-Id" |
message | String | 否 | 超限时的响应消息,默认 "Too Many Requests" |
超限时返回 429 Too Many Requests,响应体:
json
{
"error": "Too Many Requests",
"retryAfter": 42
}同时附加响应 Header:
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1720000042
Retry-After: 42全局中间件
对整个应用或路由组统一限流:
cangjie
import ace_security.ratelimit.*
let app = App()
// 全局:每个 IP 每秒不超过 200 次请求
app.use(rateLimitMiddleware(RateLimitConfig(
maxReq: 200,
windowMs: 1000
)))
// 或者仅对 /api 路由组限流
let apiRouter = Router()
apiRouter.use(rateLimitMiddleware(RateLimitConfig(
maxReq: 50,
windowMs: 60000,
keyBy: "ip"
)))RateLimitConfig 参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
maxReq | Int | — | 窗口期内最大请求数(必填) |
windowMs | Int64 | — | 时间窗口,单位毫秒(必填) |
keyBy | String | "ip" | 计数维度:"ip" / "user" / "header:<名>" |
store | RateLimitStore | 内存 | 自定义存储后端 |
skip | (Context) -> Bool | — | 返回 true 则跳过限流 |
message | String | "Too Many Requests" | 超限响应消息 |
statusCode | Int | 429 | 超限响应状态码 |
滑动窗口算法
内置实现使用固定精度滑动窗口:将窗口切分为若干桶(默认 10 个),每个桶记录该时间片的请求数。当前窗口计数 = 所有未过期桶的计数之和。
时间轴: |---桶0---|---桶1---|---桶2---|---桶3---| windowMs
当前: ↑ now
有效计数: sum(桶1 + 桶2 + 桶3) (桶0 已过期)相比简单计数器,滑动窗口在窗口边界处不会出现"双倍突发"问题。
内存使用
每个唯一 key(IP 或用户 ID)在内存中占用约 200 字节(10 个桶 × Int64)。10 万活跃用户约占 20 MB。内置存储会自动清理过期 key,无需手动维护。
多实例部署:自定义 Redis 存储
单机内存存储在多实例部署时每台机器独立计数,限流效果减弱。生产多实例环境建议接入 Redis:
cangjie
import ace_security.ratelimit.*
// 实现 RateLimitStore 接口
class RedisRateLimitStore <: RateLimitStore {
let redis: RedisClient
init(redis: RedisClient) {
this.redis = redis
}
// 原子递增并返回当前窗口计数
public override func increment(key: String, windowMs: Int64): (Int64, Int64) {
let now = currentTimeMs()
let windowKey = "${key}:${now / windowMs}"
let count = redis.incr(windowKey)
if count == 1 {
// 首次写入,设置过期时间(多一个窗口避免边界问题)
redis.pexpire(windowKey, windowMs * 2)
}
let resetAt = (now / windowMs + 1) * windowMs
return (count, resetAt)
}
public override func reset(key: String): Unit {
redis.del(key)
}
}
// 注册自定义存储
let redisStore = RedisRateLimitStore(redis: RedisClient.connect("localhost:6379"))
app.use(rateLimitMiddleware(RateLimitConfig(
maxReq: 100,
windowMs: 60000,
store: redisStore
)))RateLimitStore 接口
cangjie
interface RateLimitStore {
// 计数并返回 (当前窗口计数, 窗口重置时间戳 ms)
func increment(key: String, windowMs: Int64): (Int64, Int64)
// 重置指定 key 的计数(用于测试或手动解封)
func reset(key: String): Unit
}按用户限流
登录后按用户 ID(而非 IP)限流,防止共享出口 IP 导致误伤:
cangjie
@Get["/export"]
@RateLimit[maxReq: 5, windowMs: 3600000, keyBy: "user"]
func exportData(ctx: Context): Response {
let uid = currentPrincipal(ctx)?.sub ?? return Response.json(401, {})
// 导出逻辑
}keyBy: "user" 时,框架自动从 ctx.state["principal"] 读取 sub 字段作为限流 key。需先挂载 jwtAuth 或其他认证中间件。
限流与认证的顺序
全局 rateLimitMiddleware 应挂载在认证中间件之前,以防止未认证请求绕过限流直接打穿认证层。方法级 @RateLimit 则在路由匹配后执行,顺序固定。
跳过特定请求
使用 skip 回调对内网或白名单 IP 豁免:
cangjie
app.use(rateLimitMiddleware(RateLimitConfig(
maxReq: 100,
windowMs: 60000,
skip: { ctx =>
let ip = ctx.ip()
// 内网 IP 不限流
ip.startsWith("10.") || ip.startsWith("192.168.")
}
)))