wasm~tinygo写一个按着路由进行限流的插件

Route Limit 插件

插件功能

基于URL路径的限流插件,可以针对不同的URL路径配置独立的限流规则,每个URL的限流策略相互隔离。

核心特性

  • 支持按URL路径配置独立的限流规则
  • 每个URL可以设置不同的统计周期和请求数限制
  • 支持正则表达式匹配URL路径
  • 依赖 Redis 实现分布式限流

实现逻辑

1. 请求处理流程

请求到达 → 检查域名白名单 → 匹配URL规则 → Redis限流判断 → 放行/拒绝

2. 核心组件

配置解析 (parseConfig)

  • 解析域名白名单 hosts
  • 解析限流规则列表 rules,每个规则包含:
    • path: URL路径(支持正则)
    • unitSecond: 统计周期(秒),默认30秒
    • limit: 周期内最大请求数,默认100次
  • 初始化 Redis 客户端连接

域名过滤 (SkipHost)

  • 检查请求的域名是否在白名单中
  • 如果未配置 hosts,则不限制域名
  • 不在白名单中的域名直接放行

规则匹配 (MatchRule)

  • 遍历配置的限流规则
  • 使用路径过滤器(支持正则)匹配当前请求路径
  • 返回匹配的规则用于限流

3. 限流算法 - 滑动窗口

采用 Redis Sorted Set + Lua 脚本 实现滑动窗口限流:

-- Lua 脚本执行原子操作
1. ZREMRANGEBYSCORE: 清理过期数据(窗口外的记录)
2. ZCOUNT: 统计当前窗口内的请求数
3. 判断是否超过限制
4. ZADD: 添加新请求记录(score=时间戳,member=唯一ID)
5. EXPIRE: 设置key过期时间(防止永久存在)

Redis Key 格式

{keyPrefix}:{path}

示例:route-limit:/user/list

4. 限流响应

超过限制时返回:

HTTP 429 Too Many Requests
{
  "code": 429,
  "message": "Too Many Requests",
  "path": "/user/list",
  "limit": "50/30s"
}

配置参数

参数 类型 必填 默认值 说明
serviceName string - Redis服务名称
servicePort int - Redis端口
domain string - Redis域名
username string - Redis用户名
password string - Redis密码
timeout int - 连接超时时间(ms)
keyPrefix string route-limit Redis key前缀
hosts []string - 域名白名单
rules []object - 限流规则列表

Rules 配置

参数 类型 必填 默认值 说明
path string - URL路径(支持正则)
unitSecond int 30 统计周期(秒)
limit int 100 周期内最大请求数

配置示例

serviceName: "test-redis-service"
servicePort: 6379
domain: "xxx.redis.rds.aliyuncs.com"
username: "user"
password: "password"
timeout: 1000
keyPrefix: "route-limit"
hosts:
  - "api.example.com"
rules:
  - path: "/auth/token"
    unitSecond: 60
    limit: 10
  - path: "/user/list"
    unitSecond: 30
    limit: 50
  - path: "/product/.*"
    unitSecond: 30
    limit: 100

使用场景

  1. 接口级限流:对特定API接口设置精细的限流策略
  2. 敏感接口保护:对登录、注册等敏感接口进行严格限流
  3. 资源隔离:不同接口之间的限流互不影响
  4. 灵活配置:支持正则匹配,方便批量配置同类接口

核心代码

now := time.Now()
	nowTimestamp := now.Unix() // 秒数
	intervalTime := int64(rule.UnitSecond)

	// 使用 Lua 脚本实现:清理过期数据 + 计数 + 添加新记录 + 设置过期时间
	// 返回值:0 表示未超限,1 表示已超限
	luaScript := `
		local key = KEYS[1]
		local now = tonumber(ARGV[1])
		local window = tonumber(ARGV[2])
		local limit = tonumber(ARGV[3])
		local member = ARGV[4]
		local expire_time = tonumber(ARGV[5])
		
		-- 删除过期数据(分数小于 now - window 的成员)
		redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
		
		-- 获取当前窗口内的请求数
		local count = redis.call('ZCOUNT', key, now - window, now)
		
		if count >= limit then
			return 1  -- 超过限制
		end
		
		-- 添加新请求
		redis.call('ZADD', key, now, member)
		
		-- 设置key的过期时间,防止key永久存在
		redis.call('EXPIRE', key, expire_time)
		
		return 0  -- 未超过限制
	`

	// 生成redis key,格式: {keyPrefix}:{path}
	// 将path中的/替换为:,避免redis key中出现特殊字符
	redisKey := fmt.Sprintf("%s:%s", config.KeyPrefix, rule.Path)

	// 准备参数
	var keyArr []interface{}
	keyArr = append(keyArr, redisKey)

	var valueArr []interface{}
	requestId := uuid.New()
	expireTime := rule.UnitSecond * 2 // 过期时间设为统计周期的2倍
	valueArr = append(valueArr, nowTimestamp, intervalTime, rule.Limit, requestId.String(), expireTime)

	// 记录匹配信息用于调试
	log.Debugf("Route limit matched: path=%s, rule=%s, limit=%d/%ds", ctx.Path(), rule.Path, rule.Limit, rule.UnitSecond)

	// 执行 Lua 脚本
	err := config.Client.Eval(luaScript, 1, keyArr, valueArr, func(response resp.Value) {
		if response.Integer() == 1 {
			// 超过限制
			fmt.Println("TOO_MANY_REQUESTS 429, path:", ctx.Path(), ", rule:", rule.Path, ", limit:", rule.Limit, "/", rule.UnitSecond, "s, ipAddress:", util.GetClientIP())
			headers := [][2]string{{"Content-Type", "application/json"}}
			msg := fmt.Sprintf(`{"code":429,"message":"Too Many Requests","path":"%s","limit":"%d/%ds"}`, ctx.Path(), rule.Limit, rule.UnitSecond)
			proxywasm.SendHttpResponse(429, headers, []byte(msg), -1)

		} else {
			// 未超过限制,继续请求
			proxywasm.ResumeHttpRequest()
		}
	})

	if err != nil {
		log.Errorf("rate limit error while calling redis: %v, path: %s", err, ctx.Path())
		proxywasm.ResumeHttpRequest()
	}

	return types.ActionPause
posted @ 2025-12-24 14:01  张占岭  阅读(4)  评论(0)    收藏  举报