前两篇先把入口防护和套餐分层这两件事收住了:Global Rule 负责入口默认防护,Consumer Group 负责承载套餐层的共享策略。可真往下落时,新的问题很快就冒出来了:同一个套餐下,不同 API 需要不同额度;同一个 API 下,不同租户又要独立计数。到了这一步,限流问题已经不再是“把插件挂在哪里”,而是“请求进来之后,到底该命中哪条规则”。

这也是第三篇要继续往下推的地方。APISIX 的 ConsumerConsumer Group 和插件机制本身就支持继续往下拆:Consumer 用来识别调用方,Consumer Group 用来承载共享插件配置,而插件则用于补足组织或业务自己的扩展能力。最终落地时,没有继续把复杂规则堆在 RouteConsumerConsumer Group 上,而是把整套方案拆成了两层:控制面负责把高层策略编译成稳定的 APISIX 静态配置,数据面负责在请求进入时完成套餐、路由、租户三维度的规则选择与计数。

这套拆法就是明确分工。APISIX 的 standalone 模式本来就支持 file-driven 配置管理,适合用完整配置文件作为声明式来源;Lua 自定义插件则可以通过 extra_lua_path 加载,并在插件链路里参与请求处理。

思路就是这个思路。

为什么没有继续堆 APISIX 内置对象

没有继续把矩阵规则硬塞进内置对象,原因很直接:APISIX 的对象模型适合表达“分层职责”,不适合表达“多维组合决策”。

官方文档已经把几个关键边界说得很清楚。Consumer 用来表示调用方,认证成功后,绑定在 Consumer 上的插件和上游配置会生效;Consumer Group 用来抽取常用插件配置并绑定给多个 Consumer;同时,当同一个插件同时配置在 ConsumerRoutePlugin ConfigService 上时,Consumer 的插件配置优先。也就是说,这套模型天然适合解决“身份”和“共享策略”的问题,只是并不天然等于“套餐 × Route × 租户”的矩阵叠加。

一旦继续往 ConsumerConsumer GroupRoute 上叠同名限流插件,系统就会很快进入一个状态:套餐规则写在组里,租户特例写在 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 资源,先写高层策略模型

控制面没有直接维护 RouteConsumer GroupPlugin 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 插件机制适合承接请求时扩展。把两者分层用好,复杂限流矩阵才算稳定跑起来。