三个端直连之后,网关先要决定“先问谁”
arch-lab 的订单系统最初让 Web、移动端和合作方分别直连 Order、Inventory、Payment。上线两个月后出现三件事:合作方把 token 误配到了内部管理接口;一个批量对账租户在凌晨把库存查询打到限流阈值;值班同学只能按 IP 段临时封禁。团队决定把入口收敛到一个边缘网关,但第一次评审就卡在顺序上:限流放在认证前可以少查一次令牌,认证放在限流前可以避免无效流量消耗租户配额。两种顺序在正常流量下看不出差别,在攻击或故障时决定了谁能先被保护。
这一节只讨论边缘网关的四道关卡:路由匹配、令牌认证、作用域校验、租户限流。它们看起来都是“拦截”,但失败语义不同:404 是路径不存在,401 是身份不明,403 是身份明确但无权,429 是身份和权限都成立但配额耗尽。顺序一旦写错,调用方会收到误导性的错误,排障也会找错方向。更完整的网关形态可以对照 API 网关与 BFF ;本节把其中与租户隔离最相关的部分做成可测试的实现。
路由匹配:模板比正则更适合做契约
路由表是网关对外承诺的路径集合,不是一段可以随手加规则的脚本。arch-lab 选择“按 / 分段 + {name} 单段占位”的模板匹配,而不是正则或前缀匹配:模板能列出所有合法路径,评审时能逐条对照;正则的表达力更强,但两个正则是否重叠很难在评审阶段看出来;前缀匹配则会让 /v1/orders 意外吞掉 /v1/orders-admin。
| 匹配方式 | 表达力 | 冲突可读性 | 参数提取 | 适用场景 |
|---|---|---|---|---|
| 精确路径 | 低 | 高 | 无 | 健康检查、固定回调 |
| 分段模板 | 中 | 高 | 单段 | REST 资源入口 |
| 前缀匹配 | 中 | 低 | 无 | 静态资源、整段代理 |
| 正则 | 高 | 低 | 需额外约定 | 历史系统兼容层 |
实现上有三个容易忽略的细节。第一,方法必须参与匹配,GET /v1/orders/{id} 和 DELETE /v1/orders/{id} 是两条不同的契约。第二,占位段不能为空,/v1/orders//items 不应被解释成 orderID=""。第三,路径大小写敏感,上游把 Latin 字符小写化时,网关不能跟着做隐式转换,否则签名和缓存键会不一致。路由表的变更应当像 API 契约一样走评审,而不是在值班时热加一条前缀。
认证、作用域与租户:三个不同的问题
很多网关把“认证”和“授权”合成一个 check(),结果所有失败都返回 403。arch-lab 把它拆成三层:令牌解析回答“你是谁”,作用域回答“这个身份能做什么”,租户声明回答“这次操作属于哪个租户”。三层各自有独立的失败码,调用方才能区分是换 token、申请权限,还是补租户绑定。
租户 ID 必须来自凭证,不能来自 X-Tenant-ID 请求头。否则一个持有 tenant-a token 的调用方只要改一个头,就能读写 tenant-b 的数据,这是典型的困惑代理(confused deputy)。如果确实需要平台管理员跨租户操作,应当签发带 tenant:admin 作用域、且目标租户显式出现在审计日志里的专用凭证,而不是让普通 token 信任请求头。
| 阶段 | 输入 | 失败码 | 常见错误 |
|---|---|---|---|
| 路由 | method + path | 404 | 用前缀匹配吞掉相邻路径 |
| 认证 | bearer token | 401 | 把过期 token 当成 403 |
| 作用域 | principal.scopes | 403 | 只校验登录,不校验动作 |
| 租户 | principal.tenant_id | 403 | 信任 X-Tenant-ID |
| 配额 | tenant + cost | 429 | 全局限流,租户互相挤占 |
这里的顺序是:路由 → 认证 → 作用域 → 租户 → 配额。路由最先,因为不存在的路径不值得查令牌;认证在配额前,因为无效 token 不应该消耗任何租户的额度。这个顺序也让 401 和 429 的监控口径稳定:401 上升说明凭证或客户端配置有问题,429 上升才是容量或滥用问题。
租户限流:配额是隔离合同,不是全局阀门
限流算法本身不是本节重点,限流算法与分布式实现 已经比较过固定窗口、令牌桶和漏桶。边缘网关真正要回答的是“按什么键限”和“状态放在哪”。按租户建令牌桶,能让 tenant-a 的批量任务耗尽自己的 burst,而不影响 tenant-b 的交互流量;按全局建桶,则一个租户可以瞬间吃掉所有额度,隔离名存实亡。
| 维度 | 全局桶 | 每租户桶 | 每租户每路由桶 |
|---|---|---|---|
| 隔离性 | 无 | 租户间隔离 | 租户内也能分路由 |
| 状态量 | 1 | 租户数 | 租户数 × 路由数 |
| 突发公平性 | 先到先得 | 各自 burst | 各自 burst |
| 适用 | 单租户内部 | SaaS 入口 | 读写配额不同 |
本节的实现把桶放在进程内,这是有意的简化,也是必须写清楚的边界:网关有 N 个副本时,每个副本都有一套独立的桶,实际总配额接近 N × burst。生产上要么把桶放进共享存储做原子扣减,要么把单副本配额设为 总配额 / N 并接受副本间的不均匀。前者多一次网络往返,后者在扩缩容时会漂移;两种都不是免费的,不能把进程内限流直接当成分布式配额。
可运行实现
下面的 gateway 包只依赖标准库。Router 在构造时冻结路由表;TenantRateLimiter 用令牌桶按租户计费;Gateway.Serve 按固定顺序执行五步,并返回一个可直接映射到 HTTP 状态码的 Decision。测试用注入的时钟,避免依赖真实时间。
// file: internal/platform/gateway/gateway.go
// tier: L1
// verify: go test ./internal/platform/gateway/ -count=1
package gateway
import (
"errors"
"fmt"
"strings"
"sync"
"time"
)
type Route struct {
ID, Method, Path, Scope string
TenantScoped bool
Cost int
}
type Router struct{ routes []Route }
func NewRouter(routes []Route) (*Router, error) {
seen := map[string]bool{}
for _, r := range routes {
if r.ID == "" || r.Method == "" || r.Path == "" {
return nil, errors.New("gateway: route id, method and path are required")
}
if seen[r.ID] {
return nil, fmt.Errorf("gateway: duplicate route id %q", r.ID)
}
seen[r.ID] = true
if !strings.HasPrefix(r.Path, "/") {
return nil, fmt.Errorf("gateway: route %q path must start with /", r.ID)
}
}
return &Router{routes: routes}, nil
}
func splitPath(p string) []string {
if p = strings.Trim(p, "/"); p == "" {
return nil
}
return strings.Split(p, "/")
}
func (r *Router) Match(method, path string) (Route, map[string]string, bool) {
segs := splitPath(path)
for _, route := range r.routes {
want := splitPath(route.Path)
if route.Method != method || len(want) != len(segs) {
continue
}
params, ok := map[string]string{}, true
for i, w := range want {
if name, capture := strings.CutPrefix(w, "{"); capture && strings.HasSuffix(name, "}") {
name = strings.TrimSuffix(name, "}")
if name == "" || segs[i] == "" {
ok = false
break
}
params[name] = segs[i]
} else if w != segs[i] {
ok = false
break
}
}
if ok {
return route, params, true
}
}
return Route{}, nil, false
}
// Principal is the authenticated caller. TenantID comes from the credential,
// never from a request header.
type Principal struct {
TenantID string
Scopes map[string]bool
}
type TokenResolver func(token string) (Principal, bool)
type TenantRateLimiter struct {
mu sync.Mutex
rate float64
burst float64
buckets map[string]*bucket
now func() time.Time
}
type bucket struct {
tokens float64
last time.Time
}
func NewTenantRateLimiter(ratePerSecond float64, burst int) (*TenantRateLimiter, error) {
return newTenantRateLimiter(ratePerSecond, burst, time.Now)
}
func newTenantRateLimiter(rate float64, burst int, now func() time.Time) (*TenantRateLimiter, error) {
if rate <= 0 || burst < 1 {
return nil, errors.New("gateway: rate must be positive and burst at least 1")
}
return &TenantRateLimiter{rate: rate, burst: float64(burst), buckets: map[string]*bucket{}, now: now}, nil
}
func (l *TenantRateLimiter) Allow(tenant string, cost int) (bool, int) {
if cost <= 0 {
cost = 1
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
b := l.buckets[tenant]
if b == nil {
b = &bucket{tokens: l.burst, last: now}
l.buckets[tenant] = b
}
if e := now.Sub(b.last).Seconds(); e > 0 {
b.tokens = min(l.burst, b.tokens+e*l.rate)
b.last = now
}
if b.tokens < float64(cost) {
return false, int(b.tokens)
}
b.tokens -= float64(cost)
return true, int(b.tokens)
}
// Request has no tenant field on purpose: the tenant is derived from Token.
type Request struct{ Method, Path, Token string }
type Decision struct {
Status int
RouteID string
Tenant string
Remaining int
Reason string
}
type Gateway struct {
router *Router
resolve TokenResolver
limiter *TenantRateLimiter
}
func NewGateway(router *Router, resolve TokenResolver, limiter *TenantRateLimiter) *Gateway {
return &Gateway{router, resolve, limiter}
}
func (g *Gateway) Serve(req Request) Decision {
route, _, ok := g.router.Match(req.Method, req.Path)
if !ok {
return Decision{Status: 404, Reason: "no route"}
}
p, ok := g.resolve(req.Token)
if !ok {
return Decision{Status: 401, RouteID: route.ID, Reason: "invalid token"}
}
if route.Scope != "" && !p.Scopes[route.Scope] {
return Decision{Status: 403, RouteID: route.ID, Tenant: p.TenantID, Reason: "missing scope"}
}
if route.TenantScoped && p.TenantID == "" {
return Decision{Status: 403, RouteID: route.ID, Reason: "tenant claim required"}
}
ok, remaining := g.limiter.Allow(p.TenantID, route.Cost)
if !ok {
return Decision{Status: 429, RouteID: route.ID, Tenant: p.TenantID, Remaining: remaining, Reason: "tenant quota exhausted"}
}
return Decision{Status: 200, RouteID: route.ID, Tenant: p.TenantID, Remaining: remaining}
}
// file: internal/platform/gateway/gateway_test.go
// tier: L1
// verify: go test ./internal/platform/gateway/ -count=1
package gateway
import (
"testing"
"time"
)
func gwRouter(t *testing.T) *Router {
r, err := NewRouter([]Route{
{ID: "create-order", Method: "POST", Path: "/v1/orders", Scope: "orders:write", TenantScoped: true},
{ID: "get-order", Method: "GET", Path: "/v1/orders/{orderID}", Scope: "orders:read", TenantScoped: true},
{ID: "admin-metrics", Method: "GET", Path: "/v1/admin/metrics", Scope: "admin:read"},
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return r
}
func gwGateway(t *testing.T, burst int) *Gateway {
tokens := map[string]Principal{
"read-a": {TenantID: "tenant-a", Scopes: map[string]bool{"orders:read": true}},
"write-a": {TenantID: "tenant-a", Scopes: map[string]bool{"orders:write": true}},
"write-b": {TenantID: "tenant-b", Scopes: map[string]bool{"orders:write": true}},
"no-tenant": {Scopes: map[string]bool{"orders:write": true, "admin:read": true}},
}
resolve := func(token string) (Principal, bool) { p, ok := tokens[token]; return p, ok }
lim, err := NewTenantRateLimiter(1, burst)
if err != nil {
t.Fatalf("NewTenantRateLimiter: %v", err)
}
return NewGateway(gwRouter(t), resolve, lim)
}
func TestRouterMatch(t *testing.T) {
r := gwRouter(t)
cases := []struct {
method, path, want, param string
ok bool
}{
{"POST", "/v1/orders", "create-order", "", true},
{"POST", "/v1/orders/", "create-order", "", true},
{"GET", "/v1/orders/o-42", "get-order", "o-42", true},
{"DELETE", "/v1/orders", "", "", false},
{"GET", "/v1/orders/o-42/items", "", "", false},
{"GET", "/V1/orders/o-42", "", "", false},
}
for _, tc := range cases {
route, params, ok := r.Match(tc.method, tc.path)
if ok != tc.ok || (ok && (route.ID != tc.want || (tc.param != "" && params["orderID"] != tc.param))) {
t.Fatalf("%s %s: ok=%v route=%s params=%v", tc.method, tc.path, ok, route.ID, params)
}
}
}
func TestGatewayStageOrder(t *testing.T) {
g := gwGateway(t, 100)
cases := []struct {
name string
req Request
status int
tenant string
}{
{"unknown route", Request{Method: "GET", Path: "/v1/nope", Token: "read-a"}, 404, ""},
{"missing token", Request{Method: "POST", Path: "/v1/orders"}, 401, ""},
{"missing scope", Request{Method: "POST", Path: "/v1/orders", Token: "read-a"}, 403, "tenant-a"},
{"tenant required", Request{Method: "POST", Path: "/v1/orders", Token: "no-tenant"}, 403, ""},
{"allowed", Request{Method: "GET", Path: "/v1/orders/o-1", Token: "read-a"}, 200, "tenant-a"},
{"not tenant scoped", Request{Method: "GET", Path: "/v1/admin/metrics", Token: "no-tenant"}, 200, ""},
}
for _, tc := range cases {
d := g.Serve(tc.req)
if d.Status != tc.status || d.Tenant != tc.tenant {
t.Fatalf("%s: status=%d tenant=%q want %d/%q (%s)", tc.name, d.Status, d.Tenant, tc.status, tc.tenant, d.Reason)
}
}
}
func TestTenantLimiterIsolation(t *testing.T) {
now := time.Date(2026, 10, 28, 9, 30, 0, 0, time.UTC)
lim, err := newTenantRateLimiter(1, 2, func() time.Time { return now })
if err != nil {
t.Fatalf("newTenantRateLimiter: %v", err)
}
for i := 0; i < 2; i++ {
if ok, _ := lim.Allow("tenant-a", 1); !ok {
t.Fatalf("tenant-a request %d should pass", i+1)
}
}
if ok, remaining := lim.Allow("tenant-a", 1); ok || remaining != 0 {
t.Fatalf("third tenant-a: ok=%v remaining=%d want false/0", ok, remaining)
}
if ok, remaining := lim.Allow("tenant-b", 1); !ok || remaining != 1 {
t.Fatalf("tenant-b must be isolated: ok=%v remaining=%d", ok, remaining)
}
now = now.Add(time.Second)
if ok, remaining := lim.Allow("tenant-a", 1); !ok || remaining != 0 {
t.Fatalf("after refill: ok=%v remaining=%d", ok, remaining)
}
}
func TestGatewayQuotaIsPerTenant(t *testing.T) {
g := gwGateway(t, 1)
if d := g.Serve(Request{Method: "POST", Path: "/v1/orders", Token: "write-a"}); d.Status != 200 {
t.Fatalf("first request status=%d want 200", d.Status)
}
if d := g.Serve(Request{Method: "POST", Path: "/v1/orders", Token: "write-a"}); d.Status != 429 || d.Tenant != "tenant-a" {
t.Fatalf("second request status=%d tenant=%q want 429 tenant-a", d.Status, d.Tenant)
}
if d := g.Serve(Request{Method: "POST", Path: "/v1/orders", Token: "write-b"}); d.Status != 200 {
t.Fatalf("tenant-b status=%d want 200", d.Status)
}
}
本机实测
命令与环境:macOS darwin/arm64、Go 1.26.0。先运行 gofmt -l . 与 go vet ./...,两者均无输出;再运行:
$ go test ./internal/platform/gateway/ -run 'TestRouterMatch|TestGateway|TestTenantLimiter' -count=1 -v
=== RUN TestRouterMatch
--- PASS: TestRouterMatch (0.00s)
=== RUN TestGatewayStageOrder
--- PASS: TestGatewayStageOrder (0.00s)
=== RUN TestTenantLimiterIsolation
--- PASS: TestTenantLimiterIsolation (0.00s)
=== RUN TestGatewayQuotaIsPerTenant
--- PASS: TestGatewayQuotaIsPerTenant (0.00s)
PASS
ok archlab/internal/platform/gateway 0.289s
四个测试覆盖了路由模板的精确/占位/方法/大小写边界,五步顺序的 404/401/403/200 分界,固定时钟下的租户桶隔离与补充,以及租户 A 耗尽 burst 后租户 B 仍能通过。输出末尾 ok 行里的耗时只是本机单进程、内存路由表的运行时间,不代表生产网关的匹配或限流开销;跨副本配额、令牌解析的远程调用都没有在这个实验里。
常见错误与边界
- 先限流后认证。无效 token 会消耗真实租户的配额,攻击者可以用垃圾请求把某个租户锁死。
- 从请求头取租户。
X-Tenant-ID只能用于日志关联,不能作为授权依据;租户必须来自凭证。 - 把进程内桶当分布式配额。N 个副本会把配额放大到约 N 倍,扩缩容时还会漂移。
- 路由模板允许空段或大小写不敏感。空段会让参数校验形同虚设,大小写折叠会让签名、缓存和审计记录对不上。
- 本节没有实现令牌撤销、密钥轮换和审计日志;这些属于身份与密钥治理,不能由网关的一个 map 代替。
小结
边缘网关的价值不在于“多一层”,而在于把入口契约固定下来:哪些路径存在、谁可以调用、调用属于哪个租户、每个租户能用多少。路由模板让契约可评审,凭证派生租户让隔离不可绕过,按租户令牌桶让配额成为租户之间的合同而不是全局阀门。
实现顺序比算法更值得先确定:路由 → 认证 → 作用域 → 租户 → 配额。这个顺序让 401、403、429 各自对应明确的原因,也让监控能区分凭证问题、权限问题和容量问题。进程内实现只适合作为单副本基线;一旦网关横向扩展,配额状态必须外置或按副本折算,否则“隔离”只存在于单机。
阅读导航:上一节:3.3 服务网格的收益边界:Sidecar、xDS 与运维成本 · 下一节:4.2 BFF:按端聚合与契约裁剪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。