《后端架构设计·演进与治理》4.1 边缘网关:路由、认证与租户限流

从 arch-lab 订单系统把三个端的入口收敛到边缘网关的决策出发,拆解路由匹配、令牌认证、作用域校验和租户限流四道关卡的顺序与失败语义,给出一个只用标准库的 Go 实现,并用固定时钟验证跨租户配额隔离、401/403/429 的分界以及租户声明缺失时的拒绝路径。

三个端直连之后,网关先要决定“先问谁”

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 + path404用前缀匹配吞掉相邻路径
认证bearer token401把过期 token 当成 403
作用域principal.scopes403只校验登录,不校验动作
租户principal.tenant_id403信任 X-Tenant-ID
配额tenant + cost429全局限流,租户互相挤占

这里的顺序是:路由 → 认证 → 作用域 → 租户 → 配额。路由最先,因为不存在的路径不值得查令牌;认证在配额前,因为无效 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:按端聚合与契约裁剪 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查