APISIX
收录于 APISIX 多租户网关治理
APISIX 进阶实战:复杂限流矩阵的最终落地方案
当限流开始同时受套餐、接口和租户影响,问题已经不再是插件挂哪一层,而是请求进来后该命中哪条规则。本文拆解控制面编译与数据面决策的最终落地方案。
前两篇先把入口防护和套餐分层这两件事收住了:Global Rule 负责入口默认防护,Consumer Group 负责承载套餐层的共享策略。可真往下落时,新的问题很快就冒出来了:同一个套餐下,不同 API 需要不同额度;同一个 API 下,不同租户又要独立计数。到了这一步,限流问题已经不再是“把插件挂在哪里”,而是“请求进来之后,到底该命中哪条规则”。
这也是第三篇要继续往下推的地方。APISIX 的 Consumer、Consumer Group 和插件机制本身就支持继续往下拆:Consumer 用来识别调用方,Consumer Group 用来承载共享插件配置,而插件则用于补足组织或业务自己的扩展能力。最终落地时,没有继续把复杂规则堆在 Route、Consumer 和 Consumer Group 上,而是把整套方案拆成了两层:控制面负责把高层策略编译成稳定的 APISIX 静态配置,数据面负责在请求进入时完成套餐、路由、租户三维度的规则选择与计数。
这套拆法就是明确分工。APISIX 的 standalone 模式本来就支持 file-driven 配置管理,适合用完整配置文件作为声明式来源;Lua 自定义插件则可以通过 extra_lua_path 加载,并在插件链路里参与请求处理。
思路就是这个思路。
为什么没有继续堆 APISIX 内置对象
没有继续把矩阵规则硬塞进内置对象,原因很直接:APISIX 的对象模型适合表达“分层职责”,不适合表达“多维组合决策”。
官方文档已经把几个关键边界说得很清楚。Consumer 用来表示调用方,认证成功后,绑定在 Consumer 上的插件和上游配置会生效;Consumer Group 用来抽取常用插件配置并绑定给多个 Consumer;同时,当同一个插件同时配置在 Consumer、Route、Plugin Config 和 Service 上时,Consumer 的插件配置优先。也就是说,这套模型天然适合解决“身份”和“共享策略”的问题,只是并不天然等于“套餐 × Route × 租户”的矩阵叠加。
一旦继续往 Consumer、Consumer Group、Route 上叠同名限流插件,系统就会很快进入一个状态:套餐规则写在组里,租户特例写在 Consumer 里,接口差异又想写在 Route 里。最后很难回答一个最基础的问题: 当前这次请求真正生效的到底是哪条限流规则。
这类问题不是配置量太大,而是规则选择本身已经独立成了一个运行时问题。它需要一层明确的决策逻辑,靠多加对象走不通。
最终方案:控制面编译静态规则,数据面执行动态决策
复杂限流矩阵最终采用的是两层架构。
第一层是控制面编译。平台侧转而维护一份更高层的策略模型,不再直接拼散装的 APISIX 资源,再用 Python 之类的工具把它编译成 APISIX 能直接加载的静态配置。之所以这么做,是因为 APISIX 的 standalone file-driven 模式本来就支持以整份配置文件作为配置源,并在文件变化后热更新。这个能力非常适合接收“编译后的结果”,而不是人工手写的大量低层 YAML。
第二层是数据面决策。复杂的地方,也就是套餐归属、当前命中的 Route、请求里的租户标识,全部放到 Lua 自定义插件里处理。APISIX 官方插件开发文档明确支持通过 extra_lua_path 加载自定义代码,并在 conf/config.yaml 里把插件加入启用列表。这样,请求到达网关时,就可以先由插件完成“规则选择”和“计数 key 生成”,再去执行限流。
这两层分别解决两个不同的问题:
- 控制面解决配置如何规模化管理。
- 数据面解决请求如何在运行时正确命中规则。
边界一旦划开,整套方案就稳定了。两层的职责和数据流向大致是这样:
flowchart TD
subgraph 控制面["控制面 · 配置规模化管理"]
A[平台侧] -->|"维护套餐 × 路由规则"| B[高层策略 YAML]
B --> C[Python 编译器]
C -->|"展开为 Consumer Group 等资源"| D[APISIX 静态配置]
end
subgraph 数据面["数据面 · 运行时规则命中"]
D -->|"standalone 模式热加载"| E[APISIX 网关]
F[请求] --> E
E --> G[Lua 自定义插件]
G -->|"规则选择 + 计数 key 生成"| H[限流决策]
end
控制面落地:不直接写 APISIX 资源,先写高层策略模型
控制面没有直接维护 Route、Consumer Group、Plugin Config 的最终形态,而是先维护一份更接近业务的高层策略文件。它表达的是套餐和接口之间的关系,而不是 APISIX 的底层资源细节。
例如:
plans:
free_plan:
default:
count: 20
time_window: 60
routes:
route_a:
count: 5
time_window: 60
route_b:
count: 50
time_window: 60
paid_plan:
default:
count: 100
time_window: 60
routes:
route_a:
count: 60
time_window: 60
route_b:
count: 300
time_window: 60
这类文件不会直接下发给 APISIX。它先经过一层 Python 编译器,展开成最终的静态资源,例如:
Consumer Group的默认套餐规则Route/Plugin Config级别的通用配置- 环境差异化参数
- 默认兜底策略
这样做并非因为 APISIX 不支持直接写 YAML——standalone file-driven 模式本身就是围绕完整配置文件工作的。既然最终配置天然要以整份文件下发,那么中间就很适合插入一层“高层 DSL 到低层资源”的编译过程。
一个极简的编译器大致可以写成这样:
import yaml
from pathlib import Path
src = yaml.safe_load(Path("plan_matrix.yaml").read_text())
apisix = {
"consumer_groups": []
}
for plan_name, plan_conf in src["plans"].items():
default_rule = plan_conf.get("default", {})
apisix["consumer_groups"].append({
"id": plan_name,
"plugins": {
"limit-count": {
"count": default_rule["count"],
"time_window": default_rule["time_window"],
"rejected_code": 429,
"group": f"{plan_name}_quota"
}
}
})
Path("apisix.generated.yaml").write_text(
yaml.safe_dump(apisix, sort_keys=False, allow_unicode=True)
)
数据面落地:Lua 插件负责三维规则选择
决定请求命中哪条规则的是数据面,不是控制面。
请求进入 APISIX 之后,需要立刻回答三个问题:
- 当前请求属于哪个套餐。
- 当前命中了哪个
Route。 - 当前租户是谁。
只有这三个问题都回答完,才能拿到最终适用的限流规则,并构造出独立的计数桶。这个过程最终落在一个 Lua 自定义插件中。APISIX 官方插件开发方式已经把路径留好了:自定义插件可以放在额外目录,通过 extra_lua_path 加载,并加入 plugins 列表启用。需要特别注意的是,一旦显式定义 plugins 列表,它会替换默认插件列表,而不是自动合并。
最终采用的插件抽象不是“重写一个 limit-count”,而是实现一个 Rule Selector + Key Builder。它只做两件事:先选规则,再造 key。
一个最小可用的骨架如下:
local core = require("apisix.core")
local plugin_name = "tenant-plan-limit"
local schema = {
type = "object",
properties = {
matrix = {
type = "object"
}
},
required = {"matrix"}
}
local _M = {
version = 0.1,
priority = 2500,
name = plugin_name,
schema = schema
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
local function resolve_plan(ctx)
if ctx.consumer and ctx.consumer.group_id then
return ctx.consumer.group_id
end
return "default"
end
local function resolve_route_id(ctx)
return ctx.route_id or "default"
end
local function resolve_tenant_id(ctx)
return core.request.header(ctx, "X-Tenant-Id")
end
function _M.access(conf, ctx)
local plan = resolve_plan(ctx)
local route_id = resolve_route_id(ctx)
local tenant_id = resolve_tenant_id(ctx)
if not tenant_id then
return 400, { message = "missing tenant id" }
end
local plan_conf = conf.matrix[plan] or conf.matrix["default"]
if not plan_conf then
return
end
local rule = plan_conf[route_id] or plan_conf["default"]
if not rule then
return
end
local limit_key = tenant_id .. ":" .. plan .. ":" .. route_id
-- 这里接 Redis / shared dict / 计数服务
-- pseudo code:
-- local current = incr(limit_key, 1, rule.time_window)
-- if current > rule.count then
-- return 429, { message = "rate limit exceeded" }
-- end
end
return _M
这段代码里,plan 负责定位规则簇,route_id 负责定位子规则,tenant_id 负责形成真正的独立配额桶。只要这三层职责不混,后面的存储介质、告警、指标上报都只是工程细节。
为什么 tenant_id 不进入规则表,而进入计数 key
落地时我们专门把这个边界卡死了:租户标识不写进规则表,只写进计数 key。
规则表表达的是共享策略,租户标识表达的是独立配额桶。如果把 tenant_id 也直接写进规则矩阵,系统会迅速退化成一张巨大的租户特例表:套餐层被打散,规则复用消失,最终变成“每个租户一套私有配置”。这样做短期灵活,长期一定失控。
早晚的事。
因此最终的模型是:
- 套餐决定规则簇。
Route决定具体子规则。tenant_id只负责分桶。
这样,套餐层仍然是共享策略,租户层仍然只是共享策略下的独立计数实体。前两篇建立起来的“共享策略归组、个体差异归身份”的边界,到第三篇仍然保持住了。这个拆法与 Consumer Group 的官方定位是一致的:组承载共享插件配置,而不是每个个体的私有逻辑。
为什么 Python 重写 YAML 不能替代这套插件方案
Python 重写 YAML 最终被保留下来,但它没有替代插件,只承担控制面角色。
原因很简单:APISIX 的 standalone file-driven 模式再适合声明式配置,它也仍然是在处理“配置变更”和“热更新”;它不是面向“每个请求实时决策”的机制。官方文档描述的重点就是:配置来自文件,APISIX 加载配置并在更新后热更新生效。这个模式非常适合承接“编译好的静态结果”,不适合承接“请求到来时再推理一遍规则”。
所以控制面和数据面的分工是明确的:
- Python 重写 YAML,解决的是配置复杂度。
- Lua 自定义插件,解决的是运行时决策复杂度。
落地后的收益是什么
这套方案落地之后,最明显的收益有三个。
第一,配置终于收敛了。高层策略模型比直接维护 APISIX 底层资源稳定得多,平台侧可以做统一校验、生成、发布和回滚,避免各业务方手写大量重复配置。standalone file-driven 模式本身就适合围绕整份配置文件做管理,这让“编译后再下发”的流程非常自然。
第二,数据面的职责终于清楚了。插件不再负责承载整个业务模型,只负责在请求到达时完成规则选择和计数 key 生成。APISIX 的插件机制本来就是为组织自定义能力准备的,这种用法正好踩在它的长项上。
第三,后续演进空间被保留下来了。只要三层职责不变,后面无论是接 Redis、接策略中心、加租户灰度,还是做特殊客户临时加权,都不需要再把系统退回到“堆对象拼规则”的状态。Consumer 继续负责身份,Consumer Group 继续负责共享策略,插件继续负责运行时决策,边界不需要重画。
结语
复杂限流矩阵最终没有继续依赖 APISIX 内置对象去拼装,而是明确拆成了两层:
- 控制面:Python 编译高层策略 YAML,生成稳定的 APISIX 静态配置。
- 数据面:Lua 自定义插件在请求进入时完成套餐、路由、租户三维度的规则选择与计数。
这是第三篇的核心结论。
当问题还只是“给某个接口限流”时,内置对象已经足够。当问题变成“套餐 × 路由 × 租户”的组合决策时,要补的是一层明确的运行时决策能力,而不是更多对象。APISIX 官方已经把这两条路径都留了出来:standalone 模式适合承接声明式配置,Lua 插件机制适合承接请求时扩展。把两者分层用好,复杂限流矩阵才算稳定跑起来。