流量治理中的同步与异步权衡:为什么我们需要 Register-Only 模式?
在分布式系统中,限流(Rate Limiting)的标准做法看起来很直观:收到请求时,向配额中心发起同步查询,得到许可后再处理业务。但这种做法有个隐藏的代价——每个请求都要等待远程配额服务的往返延迟。
本文讨论的是一种反直觉的方案:异步注册模式(Register-Only Mode)。先放行请求,后异步上报使用情况。这是一次明确的权衡——用强一致性换取低延迟和高可用。
同步限流的隐形代价
最直观的限流实现是同步的。网关收到请求时,必须先向配额服务发起 RPC 调用:
请求 -> 网关 -> RPC 至配额服务 -> 网关 -> 后端
网关必须等待配额服务的响应。如果配额服务跨机房甚至跨地域,这会给每一个请求增加显著的往返延迟(RTT)。
- 延迟代价:10ms 的 RTT 意味着网关的基础延迟已经是 10ms + 处理时间。对于目标延迟为个位数毫秒的系统,这样的开销是难以接受的。
- 可用性耦合:如果配额服务出现问题,网关也会受影响。这样我们在数据平面引入了一个控制平面的硬依赖。
异步注册模式:先处理,后上报
在这个模式下,网关的逻辑反过来了——不求权限,先行动再报告:
- 立即处理业务:网关不等待配额检查,直接放行请求。
- 异步上报:同时启动一个后台任务(Goroutine 或缓冲通道),向配额服务发送"使用事件"。
配额服务收集这些异步事件,计算当前消费速率。一旦发现超限,它向网关发送"节流"信号。网关随后在短时间内开启本地拒绝模式。
权衡分析
这种架构用一致性换取低延迟和高可用。
- 一致性(精确度):接受一定的误差范围。因为请求不被阻塞,突发流量可能在节流信号返回前就已超过限额。例如在 100 RPS 的限制下,可能短时间内允许 105 个请求。
- 延迟(性能):消除了配额服务的 RTT 对关键路径的影响。网关的响应时间与配额服务的距离无关。
Go 语言演示:同步 vs 异步
我们用 Go 对两种模式进行对比。严格模式要求先得到配额许可再放行;异步模式先放行请求再异步上报使用。
package main
import (
"context"
"fmt"
"sync"
"time"
)
// QuotaClient 模拟远端配额中心,包含模拟的网络延迟
type QuotaClient struct {
mu sync.Mutex
}
// QuotaResult 模拟配额申请结果
type QuotaResult struct {
Allowed bool
Latency time.Duration
}
func (c *QuotaClient) Acquire(ctx context.Context, id string) QuotaResult {
start := time.Now()
// 模拟网络 IO:配额中心响应需要 100ms
select {
case <-time.After(100 * time.Millisecond):
return QuotaResult{Allowed: true, Latency: time.Since(start)}
case <-ctx.Done():
return QuotaResult{Allowed: false, Latency: time.Since(start)}
}
}
// Balancer 模拟负载均衡器
type Balancer struct {
quotaClient *QuotaClient
// 模式开关:true 为异步注册模式,false 为同步检查模式
registerOnly bool
}
func (b *Balancer) HandleRequest(id string) {
if b.registerOnly {
b.handleAsync(id)
} else {
b.handleSync(id)
}
}
// 同步模式:请求必须等待配额中心返回
// 缺点:用户感知延迟增加,强依赖配额中心稳定性
func (b *Balancer) handleSync(id string) {
start := time.Now()
// 设置超时,防止配额中心拖死网关
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
result := b.quotaClient.Acquire(ctx, id)
if result.Allowed {
fmt.Printf("[Sync] 请求 %s: 拿到配额 (耗时 %v), 执行业务...\n", id, time.Since(start))
} else {
fmt.Printf("[Sync] 请求 %s: 被限流或超时, 拒绝请求.\n", id)
}
}
// 异步模式:直接执行业务,后台异步通知配额中心
// 优点:零额外延迟,用户体验极佳
func (b *Balancer) handleAsync(id string) {
start := time.Now()
// 1. 先斩后奏:直接执行核心业务
fmt.Printf("[Async] 请求 %s: 直接放行 (非阻塞), 开始业务处理...\n", id)
// 2. 异步发射:启动协程上报配额使用情况
// 注意:在生产环境中,这里应该使用 Worker Pool 或 Channel 缓冲,避免无限创建 Goroutine
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_ = b.quotaClient.Acquire(ctx, id)
// 实际上报逻辑通常是批量的,这里简化为单次调用
}()
// 业务处理几乎瞬间完成,不受配额中心 100ms 延迟的影响
fmt.Printf("[Async] 请求 %s: 处理完成 (主路径耗时 %v)\n", id, time.Since(start))
}
func main() {
client := &QuotaClient{}
fmt.Println("--- 场景 A: 同步限流模式 (追求安全性) ---")
balancerSync := &Balancer{quotaClient: client, registerOnly: false}
balancerSync.HandleRequest("REQ-001")
fmt.Println("\n--- 场景 B: 异步注册模式 (追求极致性能) ---")
balancerAsync := &Balancer{quotaClient: client, registerOnly: true}
balancerAsync.HandleRequest("REQ-002")
// 等待异步任务完成以便观察输出
time.Sleep(200 * time.Millisecond)
fmt.Println("\n演示结束。")
}
性能对比
--- 场景 A: 同步限流模式 (追求安全性) ---
[Sync] 请求 REQ-001: 拿到配额 (耗时 100.12ms), 执行业务...
--- 场景 B: 异步注册模式 (追求极致性能) ---
[Async] 请求 REQ-002: 直接放行 (非阻塞), 开始业务处理...
[Async] 请求 REQ-002: 处理完成 (主路径耗时 45µs)
基准测试清晰地展示了差异。同步模式下,每个请求都要承担 100ms 的代价。异步模式仅耗时 45µs——性能提升了约 2000 倍。
实际应用场景
不同系统对一致性的容忍度不同。
- 支付网关:强一致性不可协商。超限交易未被拦截的代价很高。必须接受延迟的开销。
- 高频 API 网关:短时间内允许少量超限流量,通常比为了精确计数而阻塞用户请求更可接受。异步注册模式既保护了后端免受持续过载,也保持了快速路径的畅通。
选择哪一种,取决于后端的容错能力和用户的期望。