限流
面向需要防止 API 被滥用的策略作者和运维人员。限流规则保存在策略文档中,只在请求被允许之后运行;本页介绍如何编写规则、如何选择计数后端,以及触发限制时客户端看到什么。
策略决定请求是否被允许;限流规则决定被允许的请求能以多高的频率通过。规则是 Permit 的义务:被拒绝或无法决策的请求不消耗任何许可,因此永远不会占用正常调用方的配额。代码中的检查、simulate、explain 和影子求值同样不消耗许可。
规则
每个文档都可以在 rateLimits 中包含规则(字段):
{
"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 决定。
计数与后端
两种限流器使用同一个滑动窗口计数:估计值为上一个窗口的计数乘以其仍重叠的比例,再加上当前窗口的计数。估计值低于限制时请求被允许;被拒绝的请求不计数。
| 限流器 | 何时使用 | 范围 |
|---|---|---|
ReactiveRedisRateLimiter | classpath 中有 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:
{
"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。租户修改自己的规则,无论收紧还是放宽,都不需要第二个人:它的规则在自己的桶中计数,平台和应用的每条规则仍然作用于它的请求。对平台或应用文档的规则修改(包括删除)与那里的其他变更一样需要审批。
影子窗口比较的是决策,而规则从不改变决策,所以只涉及限流规则的变更在窗口中显示不出任何差异:窗口无法反映配额的情况。
下一步: 在错误进入生产环境之前发现它们,见测试策略。