Go 服务流量染色:灰度发布别再靠随机权重
一、线上事故报告:用户 A 看到新 UI,点击付款时跳回了旧页面
这次灰度发布的目标是 20% 流量。
负载均衡用随机权重分配,每 5 个请求有 1 个到新版本。
但用户的一次完整操作经历了 15 个请求。
每个请求独立判断灰度,导致同一用户的请求在旧版和新版间跳跃。
新版 UI 调整了订单确认页面结构。
旧版后端不认识新版前端发来的参数。
用户的第 14 个请求报错,订单创建失败。
根因不是代码 Bug,而是灰度策略缺陷。
随机权重灰度的问题是"无状态"。
它只知道请求比例,不知道请求属于谁。
流量染色解决的就是这个问题。
给请求打上标签,确保同一标签的请求路由一致。
二、流量染色的核心机制
流量染色让每个请求携带一个"染色标签"。
标签可以来自 HTTP Header、Cookie、用户 ID 哈希等。
网关根据染色标签,决定将流量路由到哪个版本。
flowchart TB A[客户端请求] --> B{提取染色标签} B -->|来源: Header X-Canary| C[直接使用标签值] B -->|来源: UserID 哈希| D[hash(UserID) % 100] B -->|来源: Cookie| E[读取 canary_group Cookie] C --> F{标签值 < 灰度比例?} D --> F E --> F F -->|是| G[路由到金丝雀版本] F -->|否| H[路由到稳定版本] G --> I[响应添加 Header: X-Version=canary] H --> J[响应添加 Header: X-Version=stable] I --> K[后续请求携带相同标签] J --> K三种染色策略:
白名单:指定用户 ID 列表必须走灰度。
比例染色:userID 哈希后按比例分流。
标签路由:前端传入特定标签值。
三、Go 实现流量染色网关
package main import ( "crypto/md5" "encoding/binary" "fmt" "net/http" "net/http/httputil" "net/url" "sync" ) // CanaryConfig 灰度配置 type CanaryConfig struct { mu sync.RWMutex StableBackend string `json:"stable_backend"` // 稳定版地址 CanaryBackend string `json:"canary_backend"` // 灰度版地址 CanaryPercent int `json:"canary_percent"` // 灰度比例 0-100 WhiteList []string `json:"white_list"` // 白名单用户ID HeaderName string `json:"header_name"` // 染色头名称 } // TrafficRouter 流量染色路由器 type TrafficRouter struct { config *CanaryConfig stableProxy *httputil.ReverseProxy canaryProxy *httputil.ReverseProxy } // NewTrafficRouter 创建路由器 func NewTrafficRouter(config *CanaryConfig) (*TrafficRouter, error) { stableURL, err := url.Parse(config.StableBackend) if err != nil { return nil, fmt.Errorf("解析稳定版地址失败: %w", err) } canaryURL, err := url.Parse(config.CanaryBackend) if err != nil { return nil, fmt.Errorf("解析灰度版地址失败: %w", err) } return &TrafficRouter{ config: config, stableProxy: httputil.NewSingleHostReverseProxy(stableURL), canaryProxy: httputil.NewSingleHostReverseProxy(canaryURL), }, nil } // ServeHTTP 路由入口 func (r *TrafficRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { if r.shouldRouteCanary(req) { // 染色:标记为灰度版本 req.Header.Set("X-Canary", "true") w.Header().Set("X-Version", "canary") w.Header().Set("X-Canary-Group", r.extractCanaryGroup(req)) r.canaryProxy.ServeHTTP(w, req) } else { req.Header.Set("X-Canary", "false") w.Header().Set("X-Version", "stable") r.stableProxy.ServeHTTP(w, req) } } // shouldRouteCanary 判断是否路由到灰度版本 func (r *TrafficRouter) shouldRouteCanary(req *http.Request) bool { r.config.mu.RLock() defer r.config.mu.RUnlock() // 策略1: 强制染色 — 请求头直接指定 if req.Header.Get(r.config.HeaderName) == "canary" { return true } if req.Header.Get(r.config.HeaderName) == "stable" { return false } // 策略2: 白名单 — 指定用户必走灰度 userID := r.extractUserID(req) for _, id := range r.config.WhiteList { if id == userID { return true } } // 策略3: 一致性哈希 — 保证同一用户路由一致 group := r.extractCanaryGroup(req) hashValue := r.hash(group) return hashValue < r.config.CanaryPercent } // extractUserID 从请求中提取用户标识 func (r *TrafficRouter) extractUserID(req *http.Request) string { // 优先级:Cookie > Header > Query if cookie, err := req.Cookie("user_id"); err == nil { return cookie.Value } if uid := req.Header.Get("X-User-ID"); uid != "" { return uid } return req.URL.Query().Get("user_id") } // extractCanaryGroup 提取染色分组标识 // 保证同一分组路由一致 func (r *TrafficRouter) extractCanaryGroup(req *http.Request) string { // 优先使用请求头中已有的染色分组 if group := req.Header.Get("X-Canary-Group"); group != "" { return group } userID := r.extractUserID(req) if userID != "" { return userID } // 兜底:使用 Session ID 或 IP if cookie, err := req.Cookie("session_id"); err == nil { return cookie.Value } return req.RemoteAddr } // hash 一致性哈希函数 func (r *TrafficRouter) hash(key string) int { h := md5.Sum([]byte(key)) // 取前 4 字节转为 uint32,然后 % 100 val := binary.BigEndian.Uint32(h[:4]) return int(val % 100) } // UpdateConfig 热更新灰度配置 func (r *TrafficRouter) UpdateConfig(newConfig *CanaryConfig) { r.config.mu.Lock() defer r.config.mu.Unlock() r.config.CanaryPercent = newConfig.CanaryPercent r.config.WhiteList = newConfig.WhiteList fmt.Printf( "灰度配置更新: 比例=%d%%, 白名单=%v\n", newConfig.CanaryPercent, newConfig.WhiteList, ) } // ---- 灰度管理 API ---- func (r *TrafficRouter) AdminHandler(w http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: r.config.mu.RLock() pct := r.config.CanaryPercent r.config.mu.RUnlock() fmt.Fprintf(w, `{"canary_percent": %d}`, pct) case http.MethodPost: newPct := 0 fmt.Sscanf(req.URL.Query().Get("percent"), "%d", &newPct) if newPct < 0 || newPct > 100 { http.Error(w, "灰度比例须在 0-100 之间", http.StatusBadRequest) return } newConfig := &CanaryConfig{CanaryPercent: newPct} r.UpdateConfig(newConfig) fmt.Fprintf(w, `{"status": "ok", "canary_percent": %d}`, newPct) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func main() { config := &CanaryConfig{ StableBackend: "http://localhost:9091", CanaryBackend: "http://localhost:9092", CanaryPercent: 10, WhiteList: []string{"test-user-001", "qa-team"}, HeaderName: "X-Canary", } router, err := NewTrafficRouter(config) if err != nil { panic(err) } mux := http.NewServeMux() mux.Handle("/admin/canary", http.HandlerFunc(router.AdminHandler)) mux.Handle("/", router) fmt.Println("流量染色网关启动 :8080") http.ListenAndServe(":8080", mux) }四、流量染色的工程权衡
一致性哈希保证用户路由稳定。
但如果灰度版本有 Bug,同一用户会持续受影响。
需要配合熔断或手动踢出机制。
染色标签需要跨服务传播。
如果下游服务也做了灰度,标签必须传递。
建议在网关层统一注入 Header,全链路透传。
不适合的场景:
会话极短的 API(每次都是独立请求,染色无意义);
需要 AB 测试而非金丝雀发布的场景;
用户量太少无法形成统计意义的情况(< 1000 用户)。
五、总结
流量染色比随机权重更适合生产灰度发布。
一致性哈希保证同一用户始终路由到同一版本。
染色标签通过 HTTP Header 全链路传播。
白名单机制让内部测试人员始终访问灰度版本。
灰度比例可通过管理 API 动态调整,无需重启。