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、或服务内部)。