Skip to content

限流

接口限流是防止暴力破解、爬虫滥用和 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 参数

参数类型必填说明
maxReqInt窗口期内最大请求数
windowMsInt64时间窗口,单位毫秒
keyByString限流 key 策略,默认 "ip",可选 "user" / "header:X-App-Id"
messageString超限时的响应消息,默认 "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 参数

参数类型默认值说明
maxReqInt窗口期内最大请求数(必填)
windowMsInt64时间窗口,单位毫秒(必填)
keyByString"ip"计数维度:"ip" / "user" / "header:<名>"
storeRateLimitStore内存自定义存储后端
skip(Context) -> Bool返回 true 则跳过限流
messageString"Too Many Requests"超限响应消息
statusCodeInt429超限响应状态码

滑动窗口算法

内置实现使用固定精度滑动窗口:将窗口切分为若干桶(默认 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.")
    }
)))

基于 Apache-2.0 许可证发布