极简 API 网关:手写一个支持限流和断路器的轻量反向代理
极简 API 网关:手写一个支持限流和断路器的轻量反向代理
一、网关的肥胖症:为什么 Nginx + 插件不足以说服你另起炉灶
Nginx 是优秀的反向代理,Kong 是强大的 API 网关。但有一种场景会让你动摇:你的后端只有三个微服务,每天几万次调用。这时候引入 Kong(需要 PostgreSQL + 配置管理 + 插件生态),运维复杂度超过了业务价值。
极简主义的回答是:如果需求足够明确,200 行 Go 代码写一个网关比配 Nginx 插件更快、更可控、更易维护。
核心需求通常就三个:反向代理(把请求路由到正确的后端)、限流(防止某个客户端打爆服务)、断路器(后端挂了就不再转发,避免雪崩)。加上健康检查和基本的日志,这就是一个够用的网关。
graph LR C1[客户端A] --> GW[API 网关] C2[客户端B] --> GW C3[客户端C] --> GW GW -->|限流器检查| RL[令牌桶] GW -->|断路器检查| CB[熔断器] CB -->|闭合| S1[用户服务 :8081] CB -->|熔断| FB[快速失败 503] CB -->|闭合| S2[订单服务 :8082] S1 -->|失败计数| CB style FB fill:#ff6b6b,color:#fff style GW fill:#4dabf7,color:#fff本文将实现一个不到 300 行的 Go API 网关,覆盖反向代理、令牌桶限流和断路器三个核心能力。
二、反向代理的深层机制:ReverseProxy 的请求改写与错误传播
Go 标准库的net/http/httputil.ReverseProxy是网关的基础。它的核心逻辑是:接收*http.Request,构造一个新的*http.Request发送到后端,然后将后端的*http.Response写回给客户端。
在使用ReverseProxy时,有三个容易出错的点:
请求头改写:默认情况下,ReverseProxy会复制客户端的 Host 头到后端。如果后端服务依赖 Host 头做路由,这会导致请求被路由到错误的服务。用Director函数可以覆盖。
SSE/流式响应:ReverseProxy默认会缓冲后端的响应体。对于 SSE(Server-Sent Events)这种流式响应,需要设置FlushInterval为负数或较小正数以关闭缓冲。
错误响应格式统一:后端返回 4xx/5xx 时,网关不应该原样透传。应该用ModifyResponse统一错误响应格式,把后端的内部错误信息替换为对客户端友好的提示。
三、完整实现:限流器 + 断路器 + 反向代理的组合
package gateway import ( "context" "fmt" "log" "net/http" "net/http/httputil" "net/url" "sync" "time" ) // ========== 令牌桶限流器 ========== type TokenBucket struct { rate float64 // 每秒生成的令牌数 burst int // 桶容量 tokens float64 lastRefill time.Time mu sync.Mutex } func NewTokenBucket(rate float64, burst int) *TokenBucket { return &TokenBucket{ rate: rate, burst: burst, tokens: float64(burst), lastRefill: time.Now(), } } func (tb *TokenBucket) Allow() bool { tb.mu.Lock() defer tb.mu.Unlock() now := time.Now() elapsed := now.Sub(tb.lastRefill).Seconds() tb.tokens += elapsed * tb.rate if tb.tokens > float64(tb.burst) { tb.tokens = float64(tb.burst) } tb.lastRefill = now if tb.tokens >= 1.0 { tb.tokens -= 1.0 return true } return false } // ========== 断路器 ========== type CircuitState int const ( CircuitClosed CircuitState = iota CircuitOpen CircuitHalfOpen ) type CircuitBreaker struct { failureThreshold int cooldownPeriod time.Duration failureCount int state CircuitState lastFailureTime time.Time mu sync.Mutex } func NewCircuitBreaker(threshold int, cooldown time.Duration) *CircuitBreaker { return &CircuitBreaker{ failureThreshold: threshold, cooldownPeriod: cooldown, state: CircuitClosed, } } func (cb *CircuitBreaker) Allow() bool { cb.mu.Lock() defer cb.mu.Unlock() switch cb.state { case CircuitClosed: return true case CircuitOpen: if time.Since(cb.lastFailureTime) > cb.cooldownPeriod { cb.state = CircuitHalfOpen return true } return false case CircuitHalfOpen: return true } return false } func (cb *CircuitBreaker) RecordSuccess() { cb.mu.Lock() defer cb.mu.Unlock() cb.failureCount = 0 cb.state = CircuitClosed } func (cb *CircuitBreaker) RecordFailure() { cb.mu.Lock() defer cb.mu.Unlock() cb.failureCount++ cb.lastFailureTime = time.Now() if cb.failureCount >= cb.failureThreshold { cb.state = CircuitOpen } } // ========== 网关核心 ========== type Gateway struct { routes map[string]*Route mu sync.RWMutex } type Route struct { path string target *url.URL proxy *httputil.ReverseProxy limiter *TokenBucket breaker *CircuitBreaker } func NewGateway() *Gateway { return &Gateway{ routes: make(map[string]*Route), } } // AddRoute 注册一个后端服务 func (g *Gateway) AddRoute(pathPrefix, targetURL string, rate float64, burst int, cbThreshold int, cbCooldown time.Duration) error { target, err := url.Parse(targetURL) if err != nil { return fmt.Errorf("parse target URL: %w", err) } proxy := httputil.NewSingleHostReverseProxy(target) // 自定义 Director:修改请求路径 originalDirector := proxy.Director proxy.Director = func(req *http.Request) { originalDirector(req) // 去掉前缀后转发 req.URL.Path = req.URL.Path[len(pathPrefix):] } // 统一的错误处理 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { log.Printf("proxy error for %s: %v", r.URL.Path, err) http.Error(w, `{"error":"backend_unavailable"}`, http.StatusBadGateway) } g.mu.Lock() defer g.mu.Unlock() g.routes[pathPrefix] = &Route{ path: pathPrefix, target: target, proxy: proxy, limiter: NewTokenBucket(rate, burst), breaker: NewCircuitBreaker(cbThreshold, cbCooldown), } return nil } func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { route := g.matchRoute(r.URL.Path) if route == nil { http.Error(w, `{"error":"route_not_found"}`, http.StatusNotFound) return } // Step 1: 限流检查 if !route.limiter.Allow() { http.Error(w, `{"error":"rate_limit_exceeded"}`, http.StatusTooManyRequests) return } // Step 2: 断路器检查 if !route.breaker.Allow() { http.Error(w, `{"error":"circuit_open"}`, http.StatusServiceUnavailable) return } // Step 3: 记录响应用于断路器状态更新 recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK} route.proxy.ServeHTTP(recorder, r) // Step 4: 根据响应状态更新断路器 if recorder.statusCode >= 500 { route.breaker.RecordFailure() } else { route.breaker.RecordSuccess() } } func (g *Gateway) matchRoute(path string) *Route { g.mu.RLock() defer g.mu.RUnlock() // 最长前缀匹配 var bestMatch *Route for prefix, route := range g.routes { if len(path) >= len(prefix) && path[:len(prefix)] == prefix { if bestMatch == nil || len(prefix) > len(bestMatch.path) { bestMatch = route } } } return bestMatch } type statusRecorder struct { http.ResponseWriter statusCode int } func (r *statusRecorder) WriteHeader(code int) { r.statusCode = code r.ResponseWriter.WriteHeader(code) } // 使用示例 func main() { gw := NewGateway() gw.AddRoute("/api/users", "http://localhost:8081", 100, 20, 5, 30*time.Second) gw.AddRoute("/api/orders", "http://localhost:8082", 50, 10, 3, 20*time.Second) log.Println("Gateway listening on :8080") http.ListenAndServe(":8080", gw) }关键设计:statusRecorder拦截 WriteHeader 调用记录状态码,用于断路器状态更新。路由匹配使用最长前缀匹配,保证/api/users/profile优先匹配/api/users而非/api。
四、边界分析:这个网关不能做什么
不支持动态路由:路由在启动时注册,不能运行时热更新。如果你的路由需要频繁变更,考虑引入 etcd/Consul 做服务发现。
不支持 TLS 终止:本实现只处理 HTTP。生产环境建议在网关前面加一层 Nginx 或 Caddy 处理 TLS,网关本身处理业务逻辑。
没有请求体改写能力:如果需要在网关层修改请求体(如注入认证信息),需要实现ModifyResponse。
断路器的简单假设:本实现使用简单的失败计数模式。更精细的做法是监控错误率而非绝对计数,但复杂度会翻倍。在微服务数量 < 5 的场景下,简单方案已经够用。
性能上限:单进程 Go 网关可以支撑数万 QPS。但如果后端的延迟 > 100ms,网关的并发连接数会快速膨胀。配置合理的连接池和超时可以缓解这个问题。
五、总结
当你只有三个微服务时,300 行 Go 代码的网关比任何"全家桶"方案都更值得。核心就三个组件:令牌桶限流(防过度调用)、断路器(防雪崩)、反向代理(请求路由)。
每个组件的代码不超过 50 行,阅读和维护成本极低。以后需求增长时,你可以选择逐步替换组件(如把令牌桶换成 Redis 分布式限流),而不是上来就引入整个 Kong 生态。
少即是多。工程化的本质不是堆砌工具,而是在合适的时候,用合适的复杂度解决问题。