47.4 鉴权与限流应放在哪一层

一句话

鉴权要在信任边界上做,限流要在资源瓶颈的前一跳做;两者都不是「选一层」,而是「每层只做它唯一能做的那件事」——网关认身份、服务校权限、资源层兜容量,且沿请求路径阈值必须逐层收紧。

先分清三种「鉴权」

概念 回答的问题 谁最该做 做错的后果
认证(Authentication) 你是谁 信任边界(网关 / mesh) 每个服务各解一次 token,密钥散落
授权(Authorization) 你能对这个资源做什么 服务(资源 owner) 网关不知道资源归属,只能做粗粒度
审计(Audit) 你做了什么 服务 + 日志/审计系统 出事后无法追责(见 3.5 审计与取证)

把它们混成一句"网关做鉴权",就会出现两种典型翻车:网关做了资源级授权(它不认识业务对象),或服务完全信任网关传来的 user_id(任何能进内网的人都能伪造)。

五层各自能拿到什么

南北向与东西向的差别见 47.5 南北向与东西向流量。

层 能拿到 适合做 不适合做
CDN / WAF IP、UA、路径、粗略频率、攻击指纹 抗 DDoS、IP 黑名单、SQL 注入/XSS 规则 用户级授权(看不到业务身份)
入口网关 完整 HTTP 请求、API key、JWT、OAuth2 token 认证、scope 级授权、租户配额、路由级限流 资源级授权(不知道订单属于谁)
Mesh sidecar 服务身份(mTLS/SPIFFE)、调用方服务名 服务间认证、mTLS、粗粒度服务授权、重试/超时 终端用户身份(除非透传并校验)
服务 业务上下文、资源归属、事务边界 资源级授权、幂等、业务规则 抗 DDoS、全局限流(信息不全)
数据层 连接、查询、行级策略 最后一道防线(行级权限、连接数上限) 认证(太晚,成本高)

🔑 判断一句话:越靠近用户,越只能做"粗"的鉴权;越靠近资源,越能做"细"的授权。 网关做不了"这个用户能不能退这个订单",服务做不了"这个 IP 是不是僵尸网络"。

决策矩阵

需求 放哪层 理由
拦截 SQL 注入 / CC 攻击 CDN / WAF 只有它能在流量进入系统前丢弃
API key 校验、JWT 签名校验 入口网关 统一密钥管理、统一拒绝口径
OAuth2 scope(order:read) 入口网关 与 API 路由一一对应,属于 API 契约
"只能操作自己的订单" 服务 需要资源归属,网关不知道
租户级配额(每月 10 万次) 网关 按 consumer/tenant 计数,跨服务统一
单接口并发保护(≤ 200 并发) 服务(或 sidecar) 只有服务知道自己的容量与耗时
服务间调用鉴权 Mesh(mTLS + 授权策略) 身份是工作负载,不是终端用户
数据库连接数上限 数据层 / 连接池 最终瓶颈在这里,前面放多少都白搭
防重放(nonce + 时间窗) 网关(或服务) 需要看签名与时间戳;跨服务统一更省事

身份怎么往下传:三种方式,三种成本

方式 每次校验成本 撤销延迟 适用
JWT 本地验签 微秒级(对称)/ 几十~百微秒(非对称) ≤ token TTL 绝大多数 API
远程内省(token introspection) 一次 RTT(毫秒级) 实时 高敏操作、令牌不可自包含
内省 + 缓存 命中时 ≈ 0 ≤ 缓存 TTL 折中:要快速撤销又不想打爆鉴权服务
package main

import "fmt"

const (
	qps       = 10000.0 // 入口总请求
	tokenTTL  = 900.0   // JWT 有效期(秒)
	cacheTTL  = 60.0    // 内省结果缓存(秒)
	rttMs     = 2.0     // 到鉴权服务的一次往返
	jwtVerify = 0.08    // 本地验签,单位 ms(量级:ES256 约 50–100µs)
)

func main() {
	fmt.Println("| 方案 | 每请求额外延迟 | 鉴权服务 QPS | 撤销生效延迟 | 适用 |")
	fmt.Println("|---|---|---|---|---|")
	fmt.Printf("| 每请求远程内省 | %.1fms | %.0f | 0(实时) | 高敏操作、令牌不可自包含 |\n", rttMs, qps)
	fmt.Printf("| 本地 JWT 验签 | %.2fms | 0 | ≤ %.0fmin(令牌过期前一直有效) | 绝大多数 API |\n", jwtVerify, tokenTTL/60)
	fmt.Printf("| 内省 + 缓存 %.0fs | %.3fms(命中时) | %.0f | ≤ %.0fs | 需要快速撤销但要控制成本 |\n",
		cacheTTL, rttMs/cacheTTL, qps/cacheTTL, cacheTTL)

	fmt.Println("\n| 层 | 每 1 万 QPS 的鉴权调用量 | 说明 |")
	fmt.Println("|---|---|---|")
	fmt.Println("| 入口网关(远程内省) | 10000 QPS | 鉴权服务必须能扛住全站流量 |")
	fmt.Println("| 入口网关(JWT) | 0 | 只验签,无远程调用 |")
	fmt.Println("| 网关 + 每跳服务都远程校验 | 10000 × 跳数 | 5 跳就是 5 万 QPS,且每跳多 2ms |")
}

输出(本机实测):

| 方案 | 每请求额外延迟 | 鉴权服务 QPS | 撤销生效延迟 | 适用 |
|---|---|---|---|---|
| 每请求远程内省 | 2.0ms | 10000 | 0(实时) | 高敏操作、令牌不可自包含 |
| 本地 JWT 验签 | 0.08ms | 0 | ≤ 15min(令牌过期前一直有效) | 绝大多数 API |
| 内省 + 缓存 60s | 0.033ms(命中时) | 167 | ≤ 60s | 需要快速撤销但要控制成本 |

| 层 | 每 1 万 QPS 的鉴权调用量 | 说明 |
|---|---|---|
| 入口网关(远程内省) | 10000 QPS | 鉴权服务必须能扛住全站流量 |
| 入口网关(JWT) | 0 | 只验签,无远程调用 |
| 网关 + 每跳服务都远程校验 | 10000 × 跳数 | 5 跳就是 5 万 QPS,且每跳多 2ms |

读法:"每跳都远程校验"是成本与延迟的双重放大器。零信任要求的不是"每跳都调鉴权服务",而是"每跳都验证一个可离线验证的凭证"(mTLS 服务身份 + 短 TTL 令牌 + 本地验签),把 RTT 换成密码学。

限流分层:每层限的不是同一个东西

算法与参数(桶容量、窗口、突发)见 35.7 限流 (漏桶 / 令牌桶 / 滑动窗口 / 自适应);到顶之后的熔断与降级见 35.6 熔断与降级。

层 限什么 键 超限行为 漏了会怎样
CDN / WAF 连接数/请求速率(粗) IP、ASN、地域 拒绝连接 / JS 挑战 DDoS 直接打到源站
入口网关 API 配额(商业语义) consumer、tenant、route 429 + Retry-After 单客户打爆全站;套餐无法计费
服务 并发数、单接口速率 路由、用户、资源 ID 429 / 排队 / 快速失败 慢依赖拖垮线程/协程池
资源层 连接数、队列长度、磁盘写入 连接池、分区 阻塞/拒绝 数据库连接被打满,全站雪崩

阈值必须逐层收紧(灰度期还要按新旧两套分别设,见 47.3 路由与灰度):

CDN 允许 50k QPS  ─▶  网关按租户 20k  ─▶  服务单实例 2k  ─▶  DB 连接池 200

如果外层放行的量 > 内层能承受的量,内层就会被"合法流量"打穿——此时外层限流形同虚设,只是一个延迟生效的统计报表。反过来,内层阈值高于外层也没有意义(永远达不到)。

⚠️ 还有一个隐蔽的放大路径:重试。网关限流 1000 QPS,服务内部对失败请求重试 2 次,实际到达下游的是 3000 QPS。限流阈值必须按"含重试的放大系数"折算(见 35.5 超时、重试与退避)。

三层各一段配置

粗限流在 Nginx(认证之前就能算),细配额在认证之后,并发闸门在服务里。认证 → 配额 → 路由的插件顺序见 47.2 API 网关。

入口网关(Nginx):按 IP 粗限流 + 认证子请求 + 身份透传。

limit_req_zone  $binary_remote_addr zone=ip:10m rate=50r/s;   # 每 IP 50 r/s
limit_conn_zone $binary_remote_addr zone=ipconn:10m;

location /api/ {
    limit_req  zone=ip burst=100 nodelay;   # 超限直接 429,不排队
    limit_conn ipconn 20;                   # 单 IP 最多 20 个在途连接

    auth_request /_auth;                    # 认证失败直接 401/403,不进上游
    auth_request_set $user_id $upstream_http_x_user_id;
    proxy_set_header X-User-Id $user_id;    # 身份由认证服务注入,客户端伪造无效
    proxy_pass http://api_backend;
}

location = /_auth {
    internal;
    proxy_pass http://auth:8081/verify;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
}

⚠️ 这里不能按 $user_id 限流:limit_req 在 preaccess 阶段执行,auth_request 在 access 阶段——限流跑在认证前面,那时 $user_id 还是空的,键全部落空,全站挤进同一个桶。按 consumer/租户的配额必须放到认证之后(网关插件或服务内部)。

服务层(Go):并发闸门,满了立刻 429,不排队。

package main

import (
	"context"
	"errors"
	"net/http"
)

var errBusy = errors.New("too many in-flight requests")

// Guard 保护“这一层”的并发:满了立刻拒绝,不排队——排队等于把超时转嫁给调用方。
type Guard struct{ sem chan struct{} }

func NewGuard(n int) *Guard { return &Guard{sem: make(chan struct{}, n)} }

func (g *Guard) Do(ctx context.Context, fn func() error) error {
	select {
	case g.sem <- struct{}{}:
		defer func() { <-g.sem }()
		return fn()
	case <-ctx.Done():
		return ctx.Err()
	default:
		return errBusy
	}
}

func handle(g *Guard) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := g.Do(r.Context(), func() error {
			_, _ = w.Write([]byte("ok")) // 真实业务:查库 / 调下游
			return nil
		})
		if errors.Is(err, errBusy) {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "busy", http.StatusTooManyRequests)
		}
	}
}

func main() {
	g := NewGuard(200) // 单实例并发上限:由压测定,不是拍的
	http.HandleFunc("/api/order", handle(g))
	_ = http.ListenAndServe(":8080", nil)
}

资源层:db.SetMaxOpenConns(200) 是最后的物理闸门。池子满了,前面的限流再准也只能排队或失败——所以它必须和上面的阈值一起算,不能两拨人各配各的。

三个公式,别拍脑袋:

单实例并发上限 ≈ 单实例 QPS × P99(s) × 1.5      # 用于设服务并发闸门
总入口 QPS 上限 ≈ 实例数 × 单实例 QPS × 0.8     # 用于设网关配额,留故障/发布余量
数据库连接数   ≈ 总 QPS × 单次查询耗时(s)       # 用于设连接池上限

算例:单实例 2k QPS、P99 200ms → 并发闸门约 2000 × 0.2 × 1.5 = 600;6 个实例 → 网关阈值 ≤ 6 × 2000 × 0.8 = 9600 QPS;每请求一次 10ms 查询 → DB 连接约 12000 × 0.01 = 120,池子给 150–200。

验证两件事:超限要快(429 立刻返回),不要排队(排队会把 P99 拉高,最后变成 504)。

# 网关:并发 100 打 500 次,看有多少 429、有没有 504
seq 1 500 | xargs -P 100 -I{} curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/api/order | sort | uniq -c

# 服务:并发打满时 429 立刻返回;若 P99 同时飙升,说明在排队而不是快速失败

五个常见错误

错误 后果 正确做法
只在网关鉴权,内网信任一切 内网横向移动即可绕过;SSRF 可直接打内部 API 每跳 mTLS + 服务级授权(38.8 零信任)
每个服务各写一套鉴权 策略不一致、密钥散落、审计口径不同 认证集中,授权下放(网关给身份,服务判权限)
用可伪造的 header 传身份(x-user-id) 任意伪造身份 传签名令牌或经 mTLS 通道,并在服务侧校验
限流只做在网关 内网调用、重试、异步任务全部绕过 服务侧按并发/接口再限一层
WAF 当鉴权用 规则可被绕过,且无法表达业务权限 WAF 只做攻击面收敛,不做授权

反模式

反模式 后果
在网关做资源级授权("这个订单属于你吗") 网关被迫了解业务模型,变成分布式单体
所有请求都远程内省 鉴权服务成为全站瓶颈与单点,延迟 +1 RTT
限流阈值不随容量调整 扩容后限流成了瓶颈;缩容后限流形同虚设
限流键用 IP NAT 后整栋楼连坐;IPv6 下几乎无效
在认证前按身份限流(Nginx 用 auth 变量做键) 键为空、全站共用一个桶,配额形同虚设
鉴权失败返回 500 客户端当成服务故障并重试 → 放大
审计日志里记 token 明文 合规事故,且日志成了凭据泄漏源
每层都做"完整鉴权" 5 层 × 每层一次校验,成本翻倍、策略却仍可能不一致

自检

Q1 为什么"认证放网关、授权放服务"是合理的分工?反过来会怎样?

▸ 答案:认证只需要"令牌 + 密钥",网关天然具备统一入口与密钥管理能力;授权需要"资源归属"(这个订单是不是你的、这个项目你有没有权限),只有持有业务数据的服务才知道。反过来(网关做授权)会迫使网关理解每个服务的资源模型,任何权限变更都要改网关配置;服务做认证则会在 N 个服务里重复实现密钥校验与撤销逻辑,策略必然不一致。

Q2 每请求远程内省,10 万 QPS 的入口意味着什么?怎么降到可接受?

▸ 答案:意味着鉴权服务要扛 10 万 QPS、且每个请求多 1 个 RTT(如 2ms)。两条降本路径:① 改为 JWT 本地验签(成本降到微秒级,代价是撤销延迟 ≤ token TTL);② 内省 + 短 TTL 缓存(如 60s,鉴权服务 QPS 降到 1/60,撤销延迟 ≤ 60s)。高敏操作可以保留"实时内省"作为例外通道。

Q3 网关限流 1000 QPS,服务能扛 2000 QPS,需要再在服务上限流吗?

▸ 答案:需要。原因不是容量而是路径:① 内网其他服务/异步任务可能绕过网关直接调它;② 网关的重试或客户端重试会让实际到达量 > 1000;③ 网关限的是"总入口",服务可能被单个热点接口打满(其他接口空闲)。服务侧应按"并发数 + 单接口速率"限流,保护自己的线程/协程池与依赖。

Q4 为什么限流阈值必须"外层 ≥ 内层"?举一个违反后的故障链路。

▸ 答案:外层负责"不让过多流量进入系统",内层负责"保护具体资源"。若外层 20k、服务只能扛 2k,一次营销活动把 20k 合法流量放进系统,服务先被打满 → 队列堆积 → 超时 → 客户端重试 → 实际请求量更高,形成雪崩。此时网关的 20k 限流完全没起作用,因为它比瓶颈宽 10 倍。正确做法是让网关的 per-route 阈值 ≤ 下游容量(含重试放大系数)。

Q5 零信任要求"每跳都验证",是不是意味着每跳都要调鉴权服务?

▸ 答案:不是。零信任要求的是"每跳都不默认信任上游",实现手段可以是离线可验证的凭证:mTLS 证明工作负载身份(每跳一次握手,连接复用后摊薄到接近 0)、短 TTL 的 JWT 携带用户与 scope(本地验签微秒级)、必要时再对高危操作做远程内省。把"每跳验证"等同于"每跳远程调用",会把安全设计变成性能灾难。

Q6 为什么 Nginx 的 limit_req 不能直接按 auth_request 拿到的 user_id 限流?

▸ 答案:limit_req 在 preaccess 阶段执行,auth_request 在 access 阶段,限流先于认证——那时 $user_id 还没有值,zone 的键全部落空,所有请求挤进同一个桶,等于全站共用一个配额。所以 Nginx 只能做基于 IP/连接的粗限流;按 consumer/租户的配额必须在认证之后做(网关插件、OpenResty/Lua、或服务内部)。

关联