Skip to content

限流 ​

面向需要防止 API 被滥用的策略作者和运维人员。限流规则保存在策略文档中,只在请求被允许之后运行;本页介绍如何编写规则、如何选择计数后端,以及触发限制时客户端看到什么。

策略决定请求是否被允许;限流规则决定被允许的请求能以多高的频率通过。规则是 Permit 的义务:被拒绝或无法决策的请求不消耗任何许可,因此永远不会占用正常调用方的配额。代码中的检查、simulate、explain 和影子求值同样不消耗许可。

规则 ​

每个文档都可以在 rateLimits 中包含规则(字段):

json
{
  "schema": 2,
  "revision": 7,
  "publishedAt": "2026-11-01T08:00:00Z",
  "policies": [],
  "bindings": [],
  "rateLimits": [
    {
      "id": "login-per-ip",
      "action": "http:POST",
      "resource": "http:path:/auth/login",
      "key": "environment.ip",
      "permitsPerSecond": 0.1,
      "windowSeconds": 60,
      "strictFailure": true
    },
    {
      "id": "export-free-tier",
      "action": "http:GET",
      "resource": "http:path:/export/**",
      "condition": { "in": ["free", "scope.roles"] },
      "key": "subject.id",
      "permitsPerSecond": 0.2,
      "windowSeconds": 60
    }
  ]
}

规则按 DENY 语句的方式匹配被允许的请求:某个动作模式、某个资源模式,以及它的条件。一个窗口允许 permitsPerSecond × windowSeconds 个请求,且至少一个。服务路由映射里的路由不是 http 请求,所以写在 http: 和路径上的规则不会限制它:也要为该路由的动作写一条规则(按业务动作授权)。

请求受哪些规则约束:

  • 平台文档的规则,对每个被允许的请求;
  • 应用文档的规则,当请求读取了该文档时:请求有应用,且主体在请求空间中拥有角色,或租户的文档把该应用的某个权限策略绑定给 Anyone 或某个主体(角色权限);
  • 租户文档的规则,对该租户的请求。

它们按这个顺序运行,同一文档内按文档中的顺序。每条匹配的规则消耗一个许可。第一条桶已耗尽的规则返回 429;它之后的规则不消耗,之前的规则保留已消耗的许可。规则是叠加的:租户或应用规则永远不会替换或放宽平台规则,即使 id 相同;只有请求匹配的每条规则都还有许可时,请求才能通过。

键 ​

key 指定用哪个属性的值来选择桶:属性目录中的 String 或 Ip 属性,例如 environment.ip、subject.id、scope.tenant、subject.attributes.plan 或 resource.var.orderId。resource.var.<name> 键必须被规则的每个资源模式捕获。

key桶
未写规则匹配的所有请求共享一个桶。
已写每个值一个桶。该值缺失的请求共享它们自己的一个桶。

值原样就是它的桶 k:<value>,每个限流器都把它写成 UTF-8。带落单 UTF-16 代理项的值(例如令牌属性中的 JSON 转义 "\uD800")没有 UTF-8 编码:它的桶是 e:<value>,其中每个落单的代理项写成 \uXXXX 转义、每个 \ 加倍,所以它从不计入把它换成 ? 的那个值的桶(IDN-10)。你自己的限流器只会拿到格式良好的文本作为桶。

不同文档的规则永远不共享桶:规则按它的文档和 id 计数,即 platform/<id>、app:<appId>/<id> 或 tenant:<tenantId>/<id>(CompiledRateLimit.counterId)。规则 id 不能包含 : 或 /,所以任何规则都无法指向其他文档的计数器:租户无法消耗或重置平台的配额。

读取 subject.id 的键让所有匿名调用方共享一个桶;请用另一条按 environment.ip 计数的规则约束他们。键或条件读取了无法获取的外部属性时,规则没有桶:若写了 onMissing: "nomatch"(仅平台规则)则跳过,否则按限流器失败的方式由 strictFailure 决定。

计数与后端 ​

两种限流器使用同一个滑动窗口计数:估计值为上一个窗口的计数乘以其仍重叠的比例,再加上当前窗口的计数。估计值低于限制时请求被允许;被拒绝的请求不计数。

限流器何时使用范围
ReactiveRedisRateLimiterclasspath 中有 cosec-cocache,且存在 ReactiveStringRedisTemplate bean整个集群,由 Lua 脚本原子地检查并计数,不阻塞调用线程。计数器位于 <cosec.key-prefix>:rate-limiter:{<rule>:<bucket>}:<window>,两个窗口后过期。花括号是 Redis Cluster 的 hash tag:脚本一起读取的一个桶的两个窗口在同一个 slot 上,不同的桶仍分散在整个集群中。
LocalRateLimiter其他情况每个 JVM 独立:有 N 个实例时,实际限制是 N 倍。最多保留 100,000 个桶,空闲一小时的桶会被丢弃;被丢弃的桶从零重新计数。

自定义的 RateLimiter bean 会替换两者(扩展)。每次调用受 cosec.authorization.rate-limit.timeout(默认 500ms,配置)约束;超时视为失败。

响应与响应头 ​

被规则拦下的请求返回 429 Too Many Requests,problem 类型为 too-many-requests(状态码)。至少消耗了一个许可的被允许请求,带有剩余许可最少的那条规则的响应头;429 带有耗尽的那条规则的响应头:

响应头值
RateLimit-Limit该规则每个窗口的许可数,向上取整。
RateLimit-Remaining当前窗口此刻还会放行的请求数,向上取整:不足一个的剩余许可仍能放行一个请求。
RateLimit-Reset距当前窗口结束的秒数,至少为 1。
Retry-After仅在 429 上:与 RateLimit-Reset 相同。

没有匹配任何规则,或匹配的规则全部被跳过的请求,不带这些响应头。

失败行为 ​

限流器失败(Redis 不可达、脚本错误)、超时、未配置,或无法读取规则的桶时,该规则无法计数。由它的 strictFailure 决定:

strictFailure请求
false(默认)跳过该规则,继续执行下一条规则。以 WARN 记录日志。
true以 503 Service Unavailable 拒绝(problem 类型 unavailable,无 Retry-After)。以 ERROR 记录日志。

除外部属性不可用以外,因任何其他原因无法读取桶时,即使没有 strictFailure 也会拒绝请求。失败日志每十秒最多记录一次。跳过宽松规则是失败即拒绝的文档化例外之一;对防范滥用的规则(例如登录尝试)设置 strictFailure,只用于平滑负载的规则则不要设置。

控制 API ​

控制 API 部署在管道之后,因此平台规则像限制其他路径一样限制它;以 scope.tenant 为 key 时,每个租户各有一个桶。覆盖那些会编译、求值或检查的路由:cosec.control.http.base-path 及其 /v1 之下的 simulate、explain、analyze 和 changes:

json
{
  "id": "control-per-tenant",
  "action": "http:POST",
  "resource": [
    "http:path:/cosec/control/v1/simulate",
    "http:path:/cosec/control/v1/explain",
    "http:path:/cosec/control/v1/analyze",
    "http:path:/cosec/control/v1/changes",
    "http:path:/cosec/control/v1/changes/*"
  ],
  "key": "scope.tenant",
  "permitsPerSecond": 0.5,
  "windowSeconds": 60
}
  • 规则写在平台文档里:租户自己的规则在自己的桶里计数,从不放宽它。
  • 桶是调用者的租户,在管道放行请求之后、控制面授权之前计数:管道放进这些路径的该租户的每个主体都会消耗它,不论是不是 管理员;每个 (platform) 主体都消耗平台的桶。在管道中只把控制路径放行给绑定了控制角色的主体,这样其他主体无法挡住一个 租户的管理员。
  • 桶耗尽时,在控制 API 做任何工作之前就答 429。控制 API 只服务这些确切的路径:末尾带 / 或含空段(//)的路径是 404,因此这些模式覆盖它服务的每个请求。
  • 不要把 break-glass 的路由(/break-glass 及其下)放进这条规则:给它们单独一条规则,或者不限,这样耗尽的桶永远不会挡住 紧急撤销。changes/* 仍覆盖 break-glass 的三个步骤:让已审批的 break-glass 授予生效的 :publish、恢复卡住的撤销的 :publish,以及 break-glass 变更的 :abandon。给平台的桶留出余量,或者用 "condition": {"not": {"eq": ["scope.tenant", "(platform)"]}} 把平台主体排除在这条规则之外。
  • documents/{key}:apply 和 :rollback 也会提出变更,可以加入这条规则。

不会替你发布这样的规则,cosec-migrate bootstrap 也不会:限额取决于部署的规模。

发布规则 ​

规则是所属文档的一部分,因此随文档一起检查和编译:无效的 id、同一文档内重复的 id、permitsPerSecond 不大于 0、windowSeconds 小于 1、不是 String 或 Ip 属性的 key,或模式没有捕获的变量,都会让整个文档失败(测试策略)。

像其他条目一样通过控制面修改规则(变更):

  • putRateLimit(rateLimit,可选 ifMatch)添加一条规则,或在原位置替换同 id 的规则;removeRateLimit(id,可选 ifMatch)删除一条规则;
  • POST /documents/{key}:apply 从整份文档提出这些操作:MERGE 写入新增和改动的规则,REPLACE 还会删除文档中没有的规则。顺序是规则的一部分(由第一条耗尽的规则返回),所以位置不对的规则会先删除再重新写入;回滚以同样的方式恢复该修订的规则。

每个操作需要对条目 doc/<document>/rateLimit/<id> 拥有 cosec:write,例如 doc/tenant/acme/rateLimit/export-per-user。租户修改自己的规则,无论收紧还是放宽,都不需要第二个人:它的规则在自己的桶中计数,平台和应用的每条规则仍然作用于它的请求。对平台或应用文档的规则修改(包括删除)与那里的其他变更一样需要审批。

影子窗口比较的是决策,而规则从不改变决策,所以只涉及限流规则的变更在窗口中显示不出任何差异:窗口无法反映配额的情况。

下一步: 在错误进入生产环境之前发现它们,见测试策略。

基于 Apache License 2.0 发布