高性能微服务网关
一 动态路由匹配
Setup
go
// Setup 初始化路由引擎并配置路由规则,包括 gRPC 和 WebSocket 代理
func Setup(protected gin.IRouter, httpProxy *proxy.HTTPProxy, cfg *config.Config) {
logger.Info("Loading routing rules from configuration",
zap.Any("rules", cfg.Routing.Rules))
validateRules(cfg)
// 根据配置选择并初始化适当的路由引擎
var router internalrouter.Router
switch cfg.Routing.Engine {
case "trie":
router = internalrouter.NewTrieRouter()
logger.Info("Initialized TrieRegexp routing engine")
case "trie-regexp", "trie_regexp": // 支持连字符和下划线两种变体
router = internalrouter.NewTrieRegexpRouter()
logger.Info("Initialized TrieRegexp-Regexp routing engine")
case "regexp":
router = internalrouter.NewRegexpRouter(cfg)
logger.Info("Initialized Regexp routing engine")
case "gin":
router = internalrouter.NewGinRouter()
logger.Info("Initialized Gin routing engine")
default:
logger.Warn("Unknown routing engine specified, defaulting to Gin",
zap.String("engine", cfg.Routing.Engine))
router = internalrouter.NewGinRouter()
}
// 为 gRPC 和 WebSocket 路由创建分组,使用配置中的前缀
grpcGroup := protected.Group(cfg.GRPC.Prefix)
wsGroup := protected.Group(cfg.WebSocket.Prefix)
// 配置 HTTP 路由
router.Setup(protected, httpProxy, cfg)
// 如果启用且存在规则,配置 gRPC 代理
if cfg.GRPC.Enabled && len(cfg.Routing.GetGrpcRules()) > 0 {
proxy.SetupGRPCProxy(cfg, grpcGroup)
}
// 如果启用且存在规则,配置 WebSocket 代理
if cfg.WebSocket.Enabled && len(cfg.Routing.GetWebSocketRules()) > 0 {
wsProxy := proxy.NewWebSocketProxy(cfg)
wsProxy.SetupWebSocketProxy(wsGroup, cfg)
logger.Info("WebSocket proxy configured successfully")
}
// **************** 这下面可以删除 ****************
// **************** 这下面可以删除 ****************
// 为特定引擎中的动态路由注册空处理器
switch cfg.Routing.Engine {
case "trie", "trie_regexp", "regexp":
for p := range cfg.Routing.GetHTTPRules() {
// 空处理器依赖特定 Router 实现中的中间件
protected.Any(p, func(c *gin.Context) {})
}
}
}
特殊逻辑:为自定义路由注册"占位符"
这是这段代码中最晦涩、最值得注意的部分:
go
switch cfg.Routing.Engine {
case "trie", "trie_regexp", "regexp":
for p := range cfg.Routing.GetHTTPRules() {
// 空处理器依赖特定 Router 实现中的中间件
protected.Any(p, func(c *gin.Context) {})
}
}
为什么要这么做?
- 背景 :
- 如果使用的是 "gin" 引擎,
router.Setup内部会直接写r.Any(path, httpProxy.Handler)。 - 但是,如果是 "trie" 或 "regexp" 这种自定义高级路由,它们的匹配逻辑通常是在一个**全局中间件(Middleware)**里完成的。中间件会拦截请求,自己算一下"这个 URL 该去哪里",然后直接调用代理逻辑。
- 如果使用的是 "gin" 引擎,
- Gin 的机制限制 :
- Gin 的路由器有一个特点:如果一个 URL 没有被注册过,Gin 会直接返回 404,根本不会走到业务中间件里(或者中断后续流程)。
- 解决方案(Hack) :
- 为了让 Gin "放行"这些请求,不报 404,我们需要显式地告诉 Gin:"嘿,这个路径是存在的。"
- 于是代码遍历了所有规则,注册了一个 空处理器 (func© {})。
- 真实流程 :请求进来 -> 自定义路由中间件(拦截并代理转发) -> (如果中间件没截获) -> 空处理器(什么都不做)。
- 实际上,自定义路由中间件通常会在内部调用 c.Abort() 或直接处理完响应,请求根本不会真正执行到这个空 func。这个 func 只是为了骗过 Gin 的 404 检查,充当一个占位符。
但是,这是一种"笨"办法 (Anti-Pattern)
虽然这个逻辑是通的,但它被称为 "Hack"(权宜之计),因为它有两个明显的缺点:
- 双重维护:当请求到达时,Gin 会先在自己的 Radix Tree(基数树)里查找路径。所以你不仅要在 Trie 里存一份路由,还要在 Gin 里存一份。
- 丧失动态性:如果你想在程序运行时动态添加一个新路由,你光插入 Trie 没用,还得去 Gin 里注册这个占位符,否则 Gin 不认。这就失去了使用 Trie 做动态网关的意义。
新方案(通配符路由):
你告诉 Gin:"凡是进来的请求,不管它是谁,全部直接交给我处理。 "
在 Gin 中,这个指令就是 /*path。
- * 代表通配符,匹配任何字符。
- path 是一个变量名(你可以随便取,比如 *any),Gin 会把匹配到的路径存到这个变量里。
我们需要做两件事:
不再使用 r.Use,而是直接注册一个统领全局的路由。
删除外部的 switch 循环。
尝试改动:
- 删除了占位符循环。
- 改为注册一个根路径通配符 r.Any("/*path", gatewayHandler),试图接管所有流量。
遭遇报错:
- Panic:wildcard route ... conflicts with existing children。
原因分析:
- Gin 的底层路由树(Radix Tree)严禁在同一层级同时存在"通配符(/*path)"和"具体前缀(/grpc, /ws)"。
- 因为 gRPC 和 WebSocket 已经占用了 /grpc 和 /ws,导致根路径无法注册 /*path。
最终改动:
- 修改 Setup 函数签名,明确要求传入 *gin.Engine。
- 用 r.NoRoute() 作为兜底逻辑。
达成效果:
-
分层注册:
- gRPC/WebSocket:使用明确的前缀(/grpc),Gin 优先匹配。
- HTTP 网关:使用 engine.NoRoute(gatewayHandler)。
-
无冲突:NoRoute 不占用路由树节点,不会和 /grpc 打架。
-
完美兜底:凡是 gRPC 和 WS 没匹配上的,全部掉进 Trie 网关处理。
-
动态性:Trie 树可以随意动态增删节点,无需重启 Gin
修改后的代码
router.setup
go
// Setup 初始化路由引擎并配置路由规则,包括 gRPC 和 WebSocket 代理
func Setup(engine *gin.Engine, httpProxy *proxy.HTTPProxy, cfg *config.Config) {
logger.Info("Loading routing rules from configuration",
zap.Any("rules", cfg.Routing.Rules))
validateRules(cfg)
// 根据配置选择并初始化适当的路由引擎
var router internalrouter.Router
switch cfg.Routing.Engine {
case "trie":
router = internalrouter.NewTrieRouter()
logger.Info("Initialized TrieRegexp routing engine")
case "trie-regexp", "trie_regexp": // 支持连字符和下划线两种变体
router = internalrouter.NewTrieRegexpRouter()
logger.Info("Initialized TrieRegexp-Regexp routing engine")
case "regexp":
router = internalrouter.NewRegexpRouter(cfg)
logger.Info("Initialized Regexp routing engine")
case "gin":
router = internalrouter.NewGinRouter()
logger.Info("Initialized Gin routing engine")
default:
logger.Warn("Unknown routing engine specified, defaulting to Gin",
zap.String("engine", cfg.Routing.Engine))
router = internalrouter.NewGinRouter()
}
// 为 gRPC 和 WebSocket 路由创建分组,使用配置中的前缀
grpcGroup := engine.Group(cfg.GRPC.Prefix)
wsGroup := engine.Group(cfg.WebSocket.Prefix)
// 配置 HTTP 路由
router.Setup(engine, httpProxy, cfg)
// 如果启用且存在规则,配置 gRPC 代理
if cfg.GRPC.Enabled && len(cfg.Routing.GetGrpcRules()) > 0 {
proxy.SetupGRPCProxy(cfg, grpcGroup)
}
// 如果启用且存在规则,配置 WebSocket 代理
if cfg.WebSocket.Enabled && len(cfg.Routing.GetWebSocketRules()) > 0 {
wsProxy := proxy.NewWebSocketProxy(cfg)
wsProxy.SetupWebSocketProxy(wsGroup, cfg)
logger.Info("WebSocket proxy configured successfully")
}
//// 为特定引擎中的动态路由注册空处理器
//switch cfg.Routing.Engine {
//case "trie", "trie_regexp", "regexp":
// for p := range cfg.Routing.GetHTTPRules() {
// // 空处理器依赖特定 Router 实现中的中间件
// protected.Any(p, func(c *gin.Context) {})
// }
//}
}
main.setupRoutes
go
// setupRoutes 配置所有路由,简洁调用独立处理函数
func (s *Server) setupRoutes(cfg *config.Config) {
// 基本路由
s.Router.GET("/health", s.handleHealth) // 健康检查路由
s.Router.GET("/status", s.handleStatus) // 状态检查路由
s.Router.POST("/login", s.handleLogin) // 登录路由
// 添加 pprof 调试路由
if cfg.Server.PprofEnabled { // 假设在 config 中添加了 PprofEnabled 字段
s.Router.GET("/debug/pprof/*profile", gin.WrapH(http.DefaultServeMux))
logger.Info("pprof endpoints enabled at /debug/pprof")
}
// 添加关闭熔断器的 API
s.Router.POST("/breaker/disable", traffic.DisableBreakerHandler)
// Prometheus 监控路由
if cfg.Observability.Prometheus.Enabled {
s.Router.GET(cfg.Observability.Prometheus.Path, gin.WrapH(promhttp.Handler()))
}
// 文件服务路由
fileServerRouter := routing.NewFileServerRouter(cfg)
fileServerRouter.Setup(s.Router, cfg)
// 路由管理 API
routeGroup := s.Router.Group("/api/routes")
{
routeGroup.POST("/add", s.handleAddRoute) // 添加路由
routeGroup.PUT("/update", s.handleUpdateRoute) // 更新路由
routeGroup.DELETE("/delete", s.handleDeleteRoute) // 删除路由
routeGroup.GET("/list", s.handleListRoutes) // 列出所有路由
}
// 保存配置 API
s.Router.POST("/api/config/save", s.handleSaveConfig)
// 动态路由
logger.Info("设置动态路由", zap.Any("routing_rules", cfg.Routing.Rules))
//protected := s.Router.Group("/")
//if cfg.Middleware.Auth {
// protected.Use(auth.Auth()) // 应用认证中间件
//}
//s.Router.Use(auth.Auth())
routing.Setup(s.Router, s.HTTPProxy, cfg)
logger.Info("动态路由设置完成")
}
几种路由匹配结构
1.gin 原生
go
// GinRouter 管理 Gin 框架的 HTTP 路由设置
type GinRouter struct {
}
// NewGinRouter 创建并初始化 GinRouter 实例
func NewGinRouter() *GinRouter {
logger.Info("GinRouter initialized")
return &GinRouter{}
}
// Setup 在提供的 Gin 路由器中配置 HTTP 路由规则
func (gr *GinRouter) Setup(r gin.IRouter, httpProxy *proxy.HTTPProxy, cfg *config.Config) {
rules := cfg.Routing.GetHTTPRules()
if len(rules) == 0 {
logger.Warn("No HTTP routing rules found in configuration")
return
}
// 为每个路径注册路由规则
for path, targetRules := range rules {
logger.Info("Registering HTTP route",
zap.String("path", path),
zap.Any("targets", targetRules))
r.Any(path, httpProxy.CreateHTTPHandler(targetRules))
}
}
2.Trie
go
// TrieRouter 使用 Trie 数据结构管理 HTTP 路由
type TrieRouter struct {
Trie *Trie // Trie 数据结构实例
}
// Trie 表示用于高效前缀匹配路由的 Trie 数据结构
type Trie struct {
Root *TrieNode // Trie 的根节点
}
// TrieNode 表示 Trie 中的一个节点,包含子节点和路由规则
type TrieNode struct {
Children map[rune]*TrieNode // 子节点映射
Rules config.RoutingRules // 路由规则
IsEnd bool // 标记此节点是否为有效路由的终点
}
// NewTrieRouter 创建并初始化 TrieRouter 实例
func NewTrieRouter() *TrieRouter {
return &TrieRouter{
Trie: &Trie{
Root: &TrieNode{Children: make(map[rune]*TrieNode)},
},
}
}
// Setup 根据配置在 Gin 路由器中设置 TrieRouter 的 HTTP 路由规则
func (tr *TrieRouter) Setup(r gin.IRouter, httpProxy *proxy.HTTPProxy, cfg *config.Config) {
// 1. 初始化:把配置里的规则全部 Insert 进树里
rules := cfg.Routing.GetHTTPRules()
for path, targetRules := range rules {
tr.Trie.Insert(path, targetRules)
}
/* 注册 Gin 中间件(旧的方法)
// r.Use 表示这是一个拦截所有请求的全局中间件
r.Use(func(c *gin.Context) {
// ... 开启追踪 ...
path := c.Request.URL.Path
// 在 Trie 树中查找当前请求路径
targetRules, found := tr.Trie.Search(ctx, path)
// 情况 A:没找到路由
if !found {
// 返回 404
c.JSON(http.StatusNotFound, gin.H{"error": "Route not found"})
// Abort 非常重要!它阻止 Gin 继续执行后续的 Handler 或中间件
c.Abort()
return
}
// 情况 B:找到了路由
// 记录追踪信息
span.SetAttributes(attribute.String("matched_target", targetRules[0].Target))
// *关键步骤*:直接调用 httpProxy 来处理请求
// 这里实际上把中间件变成了终点处理器(Handler)
httpProxy.CreateHTTPHandler(targetRules)(c)
}) */
// 定义新的处理函数 (为了复用)
gatewayHandler := func(c *gin.Context) {
// 开始追踪路由匹配过程
ctx, span := trieTracer.Start(c.Request.Context(), "Routing.Match",
trace.WithAttributes(attribute.String("type", "Trie")),
trace.WithAttributes(attribute.String("path", c.Request.URL.Path)))
defer span.End()
logger.Debug("Processing request in Trie routing handler",
zap.String("path", c.Request.URL.Path))
path := c.Request.URL.Path
targetRules, found := tr.Trie.Search(ctx, path)
if !found {
// 既然都在 NoRoute 里了,这里也没找到,那就是真的 404
span.SetStatus(codes.Error, "Route not found")
logger.Warn("No matching route found in Gateway Trie",
zap.String("path", path),
zap.String("method", c.Request.Method))
c.JSON(http.StatusNotFound, gin.H{"error": "Route not found"})
// 不需要 c.Abort(),因为已经在末端了
return
}
// 记录和追踪成功匹配的路由
span.SetAttributes(attribute.String("matched_target", targetRules[0].Target))
span.SetStatus(codes.Ok, "Route matched successfully")
logger.Info("Successfully matched route in Trie",
zap.String("path", path),
zap.Any("rules", targetRules))
// 将追踪上下文传递下游并处理请求
c.Request = c.Request.WithContext(ctx)
httpProxy.CreateHTTPHandler(targetRules)(c)
}
if engine, ok := r.(*gin.Engine); ok {
// 只有是 Engine 的时候才能注册全局的 NoRoute
engine.NoRoute(gatewayHandler)
engine.NoMethod(gatewayHandler)
logger.Info("Registered global fallback handler (NoRoute) for Gateway")
} else {
// 如果传入的是一个 RouterGroup(分组),而不是 Engine
// 我们无法使用 NoRoute,只能退回到使用通配符(但这可能会引起冲突)
// 或者提示错误
logger.Warn("Setup received a RouterGroup, not *gin.Engine. Cannot register NoRoute handler. " +
"If you are using a Group, consider registering the gateway handler on the Engine instead.")
// 备选方案:如果必须在 Group 下工作,且没有冲突,可以尝试通配符
// 但如果和 /grpc 冲突,这里会 Panic。
// r.Any("/*path", gatewayHandler)
}
}
路由构建
go
// Insert 将路径及其关联的路由规则插入 Trie
func (t *Trie) Insert(path string, rules config.RoutingRules) {
node := t.Root
path = strings.TrimPrefix(path, "/") // 规范化路径,去除前导斜杠
for _, ch := range path {
if node.Children[ch] == nil {
node.Children[ch] = &TrieNode{Children: make(map[rune]*TrieNode)}
}
node = node.Children[ch]
}
node.Rules = rules
node.IsEnd = true
logger.Info("Successfully inserted route into TrieRegexp",
zap.String("path", "/"+path),
zap.Any("rules", rules))
}
路由搜索
go
// Search 在 Trie 中查找给定路径的路由规则
func (t *Trie) Search(ctx context.Context, path string) (config.RoutingRules, bool) {
ctx, span := trieTracer.Start(ctx, "TrieRegexp.Search",
trace.WithAttributes(attribute.String("path", path)))
defer span.End()
node := t.Root
path = strings.TrimPrefix(path, "/") // 规范化路径,去除前导斜杠
path = strings.TrimSuffix(path, "/") // 去除尾部斜杠以保持一致性
for _, ch := range path {
if node.Children[ch] == nil {
return nil, false
}
node = node.Children[ch]
}
if node.IsEnd {
return node.Rules, true
}
return nil, false
}
3.Trie+regexp
go
type TrieRegexpRouter struct {
TrieRegexp *TrieRegexp
}
type TrieRegexp struct {
Root *TrieRegexpNode
}
type TrieRegexpNode struct {
Children map[rune]*TrieRegexpNode
Rules config.RoutingRules
IsEnd bool
// 优化:RegexRules 不再只存在于 Root,而是分散在树的各个节点
// 存储的是"静态前缀匹配到当前节点后,需要进一步检查的正则规则"
RegexRules []RegexRule
}
type RegexRule struct {
Regex *regexp.Regexp
Pattern string
Rules config.RoutingRules
}
func NewTrieRegexpRouter() *TrieRegexpRouter {
return &TrieRegexpRouter{
TrieRegexp: &TrieRegexp{
Root: &TrieRegexpNode{Children: make(map[rune]*TrieRegexpNode)},
},
}
}
// Setup 和其他的类似
func (tr *TrieRegexpRouter) Setup(r gin.IRouter, httpProxy *proxy.HTTPProxy, cfg *config.Config) {
rules := cfg.Routing.GetHTTPRules()
if len(rules) == 0 {
logger.Warn("No HTTP routing rules found in configuration")
return
}
for path, targetRules := range rules {
tr.TrieRegexp.Insert(path, targetRules)
}
/* r.Use(func(c *gin.Context) {
ctx, span := trieRegexpTracer.Start(c.Request.Context(), "Routing.Match",
trace.WithAttributes(attribute.String("type", "TrieRegexp")),
trace.WithAttributes(attribute.String("path", c.Request.URL.Path)))
defer span.End()
path := c.Request.URL.Path
targetRules, found := tr.TrieRegexp.Search(ctx, path)
if !found {
logger.Warn("No matching route found",
zap.String("path", path),
zap.String("method", c.Request.Method))
c.JSON(http.StatusNotFound, gin.H{"error": "Route not found"})
c.Abort()
span.SetStatus(codes.Error, "Route not found")
return
}
span.SetAttributes(attribute.String("matched_target", targetRules[0].Target))
span.SetStatus(codes.Ok, "Route matched successfully")
logger.Info("Successfully matched route in TrieRegexp",
zap.String("path", path),
zap.Any("rules", targetRules))
c.Request = c.Request.WithContext(ctx)
httpProxy.CreateHTTPHandler(targetRules)(c)
}) */
gatewayHandler := func(c *gin.Context) {
ctx, span := trieRegexpTracer.Start(c.Request.Context(), "Routing.Match",
trace.WithAttributes(attribute.String("type", "TrieRegexp")),
trace.WithAttributes(attribute.String("path", c.Request.URL.Path)))
defer span.End()
path := c.Request.URL.Path
// 执行查找
targetRules, found := tr.TrieRegexp.Search(ctx, path)
if !found {
// 没找到路由,记录日志并返回 404
logger.Warn("No matching route found",
zap.String("path", path),
zap.String("method", c.Request.Method))
span.SetStatus(codes.Error, "Route not found")
c.JSON(http.StatusNotFound, gin.H{"error": "Route not found"})
// NoRoute 是处理链的终点,直接 return 即可
return
}
// 找到路由,记录追踪信息
span.SetAttributes(attribute.String("matched_target", targetRules[0].Target))
span.SetStatus(codes.Ok, "Route matched successfully")
logger.Info("Successfully matched route in TrieRegexp",
zap.String("path", path),
zap.Any("rules", targetRules))
// 更新上下文并执行代理转发
c.Request = c.Request.WithContext(ctx)
httpProxy.CreateHTTPHandler(targetRules)(c)
}
// 3. 注册到 Gin Engine 的 NoRoute (兜底逻辑)
if engine, ok := r.(*gin.Engine); ok {
// 注册 NoRoute 和 NoMethod
// 这样 gRPC 和 WebSocket 的路由不会受影响,其他所有请求都会进这里
engine.NoRoute(handlers...)
engine.NoMethod(handlers...)
logger.Info("TrieRegexp gateway handler registered via NoRoute")
} else {
// 如果传入的是 Group,无法注册 NoRoute
logger.Error("TrieRegexpRouter requires *gin.Engine to register NoRoute handler, but received *gin.RouterGroup")
}
}
路由构建
go
// 辅助函数:提取正则中的静态前缀
// 例: "^/api/v1/.*" -> "/api/v1/"
// 例: "^/user/\d+" -> "/user/"
func getStaticPrefix(pattern string) string {
clean := strings.TrimPrefix(pattern, "^")
// 遇到这些特殊字符就停止
specialChars := ".*+?()|[]{\\}"
idx := strings.IndexAny(clean, specialChars)
if idx == -1 {
return clean
}
return clean[:idx]
}
func (t *TrieRegexp) Insert(path string, rules config.RoutingRules) {
node := t.Root
originalPath := path
// 1. 识别并处理正则路由
if strings.ContainsAny(path, ".*+?()|[]^$\\") {
regexPattern := path
// 规范化正则,确保包含锚点
if !strings.HasPrefix(regexPattern, "^") {
regexPattern = "^" + regexPattern
}
if !strings.HasSuffix(regexPattern, "$") {
regexPattern = regexPattern + "$"
}
re, err := regexp.Compile(regexPattern)
if err != nil {
logger.Error("Failed to compile regex", zap.String("path", originalPath), zap.Error(err))
return
}
// --- 核心优化点:计算静态前缀 ---
staticPrefix := getStaticPrefix(path)
cleanPrefix := strings.TrimPrefix(staticPrefix, "/")
// 根据静态前缀,将节点移动到 Trie 树的深处
// 这样只有前缀匹配了,才会去跑正则
for _, ch := range cleanPrefix {
if node.Children[ch] == nil {
node.Children[ch] = &TrieRegexpNode{Children: make(map[rune]*TrieRegexpNode)}
}
node = node.Children[ch]
}
node.RegexRules = append(node.RegexRules, RegexRule{
Regex: re,
Pattern: path,
Rules: rules,
})
logger.Info("Inserted regex route",
zap.String("pattern", originalPath),
zap.String("prefix_optimization", staticPrefix))
return
}
// 2. 处理普通静态路由
cleanPath := strings.TrimPrefix(path, "/")
for _, ch := range cleanPath {
if node.Children[ch] == nil {
node.Children[ch] = &TrieRegexpNode{Children: make(map[rune]*TrieRegexpNode)}
}
node = node.Children[ch]
}
node.Rules = rules
node.IsEnd = true
logger.Info("Inserted static route", zap.String("path", originalPath))
}
路由搜索
go
func (t *TrieRegexp) Search(ctx context.Context, path string) (config.RoutingRules, bool) {
ctx, span := trieRegexpTracer.Start(ctx, "TrieRegexp.Search",
trace.WithAttributes(attribute.String("path", path)))
defer span.End()
node := t.Root
cleanPath := strings.TrimPrefix(path, "/")
// --- 核心优化点:收集路径上的正则 ---
// 我们不用一开始就遍历正则,而是在遍历 Trie 时,把路过的节点上的正则收集起来
// 为了减少内存分配,这里预估深度为 4,实际可视情况调整
// 如果实际运行时超过了 4 层(比如有 5 层),代码依然能正常工作,Go 会自动扩容,只是稍微多消耗一点点性能。
potentialMatches := make([][]RegexRule, 0, 4)
// 先收集 Root 节点的正则(如果有的话,比如全通配符 .*)
if len(node.RegexRules) > 0 {
potentialMatches = append(potentialMatches, node.RegexRules)
}
matchStatic := true
for _, ch := range cleanPath {
if node.Children[ch] == nil {
matchStatic = false
break // 静态路径走不通了,停止深入
}
node = node.Children[ch]
// 收集当前节点挂载的正则规则
if len(node.RegexRules) > 0 {
potentialMatches = append(potentialMatches, node.RegexRules)
}
}
// 1. 优先:完全匹配的静态路由
if matchStatic && node != nil && node.IsEnd {
return node.Rules, true
}
// 2. 兜底:回溯检查沿途收集的正则
// 从最后收集到的(也就是树最深处的)开始检查 -> 实现"最长前缀优先"
for i := len(potentialMatches) - 1; i >= 0; i-- {
rules := potentialMatches[i]
for _, regexRule := range rules {
if regexRule.Regex.MatchString(path) {
return regexRule.Rules, true
}
}
}
return nil, false
}
后续交付Handler
go
// CreateHTTPHandler 创建 HTTP 请求处理函数
func (hp *HTTPProxy) CreateHTTPHandler(rules config.RoutingRules) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, span := httpTracer.Start(c.Request.Context(), "HTTPProxy.Handle",
trace.WithAttributes(
attribute.String("http.method", c.Request.Method),
attribute.String("http.path", c.Request.URL.Path),
))
defer span.End()
c.Request = c.Request.WithContext(ctx)
target, selectedEnv := hp.getSelectTarget(c, rules) //调用 selectWithLoadBalancer 进行负载均衡
if target == "" {
handleNoTarget(c, span, c.Request.URL.Path, getEnvFromHeader(c))
return
}
span.SetAttributes(attribute.String("proxy.target", target))
if hp.httpPoolEnabled {
hp.getProxyWithPool(c, target, selectedEnv)
} else {
hp.proxyDirect(c, target, selectedEnv)
}
}
}
1.为什么要用trie的结构去匹配?有什么优缺点?
2.为什么找到后要调用HttpHandler进行转发?
3.trie+regexp详细的流程?
4.gin的原生路由转发?Any函数?
二 负载均衡
httpProxy 结构
go
// HTTPProxy 管理 HTTP 代理功能
type HTTPProxy struct {
httpPool *HTTPConnectionPool // HTTP 连接池
loadBalancer loadbalancer.LoadBalancer // 负载均衡器
objectPool *util.ObjectPoolManager // 对象池管理器
httpPoolEnabled bool // 是否启用 HTTP 连接池
selectTargetFunc func(c *gin.Context, rules config.RoutingRules) (string, string)
proxyWithPoolFunc func(c *gin.Context, target, env string)
}
调用负载均衡流程:
请求经过中间件->hp.CreateHTTPHandler->hp.getSelectTarget->hp.SelectTarget->hp.selectWithLoadBalancer
go
// selectWithLoadBalancer 使用负载均衡器选择目标
func (hp *HTTPProxy) selectWithLoadBalancer(c *gin.Context, rules config.RoutingRules, envOverride ...string) (string, string) {
ctx, span := httpTracer.Start(c.Request.Context(), "HTTPProxy.selectWithLoadBalancer",
trace.WithAttributes(
attribute.String("http.method", c.Request.Method),
attribute.String("http.path", c.Request.URL.Path),
))
defer span.End()
var targets []string
targets = hp.extractTargets(rules)
defer hp.objectPool.PutTargets(targets)
c.Request = c.Request.WithContext(ctx)
target := hp.loadBalancer.SelectTarget(targets, c.Request)
if target == "" {
return "", ""
}
selectedEnv := defaultEnv
if len(envOverride) > 0 {
selectedEnv = envOverride[0]
} else {
for _, rule := range rules {
if rule.Target == target {
selectedEnv = rule.Env
break
}
}
}
logger.Info("Target selected via load balancer",
zap.String("path", c.Request.URL.Path),
zap.String("target", target),
zap.String("env", selectedEnv))
return target, selectedEnv
}
加权轮询
go
// TargetWeight 定义目标及其关联权重
type TargetWeight struct {
Target string //目标地址
Weight int //权重值
}
func (cb *WeightedRoundRobin) Type() string {
return "weighted-round-robin"
}
// WeightedRoundRobin 实现加权轮询负载均衡算法
type WeightedRoundRobin struct {
rules map[string][]TargetWeight // 预定义的路径到加权目标的映射规则
states map[string]*wrrState // 每个路径的运行时状态
mu sync.Mutex // 确保状态更新的线程安全
}
// wrrState 保存加权轮询选择的状态
type wrrState struct {
targets []string // 目标地址列表
weights []int // 每个目标对应的权重
totalWeight int // 所有权重的总和
currentCount int // 请求分发的计数器
}
// NewWeightedRoundRobin 创建并初始化 WeightedRoundRobin 实例
func NewWeightedRoundRobin(rules map[string][]TargetWeight) *WeightedRoundRobin {
wrr := &WeightedRoundRobin{
rules: rules,
states: make(map[string]*wrrState),
}
// 根据预定义规则初始化状态
for path, targetRules := range rules {
targets := make([]string, len(targetRules))
weights := make([]int, len(targetRules))
totalWeight := 0
for i, rule := range targetRules {
targets[i] = rule.Target
weights[i] = rule.Weight
totalWeight += rule.Weight
}
wrr.states[path] = &wrrState{
targets: targets,
weights: weights,
totalWeight: totalWeight,
currentCount: -1, // 从 -1 开始,第一次递增后选择索引 0
}
}
logger.Info("WeightedRoundRobin load balancer initialized",
zap.Int("ruleCount", len(rules)))
return wrr
}
// SelectTarget 根据加权轮询选择目标,或回退到简单轮询
func (wrr *WeightedRoundRobin) SelectTarget(targets []string, req *http.Request) string {
wrr.mu.Lock()
defer wrr.mu.Unlock()
// 开始追踪负载均衡选择过程
_, span := wrrTracer.Start(req.Context(), "LoadBalancer.Select",
trace.WithAttributes(attribute.String("type", wrr.Type())),
trace.WithAttributes(attribute.Int("target_count", len(targets))))
defer span.End()
// 处理边缘情况
if len(targets) == 0 {
logger.Warn("No targets available for weighted round-robin selection")
span.SetAttributes(attribute.String("result", "no targets"))
return ""
}
if len(targets) == 1 {
target := targets[0]
span.SetAttributes(attribute.String("selected_target", target))
logger.Debug("Selected single available target",
zap.String("target", target))
return target
}
// 尝试使用预定义的加权规则
path := req.URL.Path
state, ok := wrr.states[path]
if !ok || len(state.targets) == 0 {
// 如果没有预定义规则,回退到简单轮询
count := 0
if state != nil {
count = state.currentCount
state.currentCount = (state.currentCount + 1) % len(targets)
}
target := targets[count%len(targets)]
span.SetAttributes(attribute.String("selected_target", target))
logger.Debug("Selected target using simple round-robin fallback",
zap.String("path", path),
zap.String("target", target))
return target
}
// 加权轮询选择
if state.totalWeight == 0 {
logger.Warn("Total weight is zero, unable to select target",
zap.String("path", path))
return ""
}
// 递增计数器并计算在总权重中的位置
state.currentCount++
current := state.currentCount % state.totalWeight
cumulativeWeight := 0
// 根据累计权重选择目标
for i, weight := range state.weights {
cumulativeWeight += weight
if current < cumulativeWeight {
target := state.targets[i]
span.SetAttributes(attribute.String("selected_target", target))
logger.Debug("Selected target using weighted round-robin",
zap.String("path", path),
zap.String("target", target),
zap.Int("weight", weight))
return target
}
}
/**
target1: 1
target2: 2
target3: 3
totalWeight: 6 ( 1 + 2 + 3 )
currentCount | -1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 ...
current | 0 1 2 3 4 5 0 1 2 3 4 5 0 1 2 ...
cumulativeWeight | 1 3 3 6 6 6 1 3 3 6 6 6 1 3 3
选择对象 | 1 2 2 3 3 3 1 2 2 3 3 3 1 2 2 ...
*/
// 回退到第一个目标(正常情况下不会发生,除非权重配置错误)
target := state.targets[0]
span.SetAttributes(attribute.String("selected_target", target))
logger.Debug("Selected fallback target due to weight calculation",
zap.String("path", path),
zap.String("target", target))
return target
}
一致性哈希
go
// kTracer 为 Ketama 负载均衡模块初始化追踪器
var kTracer = otel.Tracer("loadbalancer:ketama")
// Ketama 使用 Ketama 一致性哈希算法实现的负载均衡器
type Ketama struct {
nodes []string // 目标节点列表
hashRing []uint32 // 排序后的哈希环
hashMap map[uint32]string // 哈希值到节点的映射
replicas int // 每个物理节点的虚拟节点数
mu sync.RWMutex // 保护哈希环的并发访问
}
// NewKetama 创建并初始化 Ketama 负载均衡器
func NewKetama(replicas int) *Ketama {
k := &Ketama{
replicas: replicas,
hashMap: make(map[uint32]string),
mu: sync.RWMutex{},
}
logger.Info("Ketama load balancer initialized", zap.Int("replicas", replicas))
return k
}
func (cb *Ketama) Type() string {
return "ketama"
}
// SelectTarget 根据客户端 IP 使用一致性哈希选择目标节点
func (k *Ketama) SelectTarget(targets []string, req *http.Request) string {
// 开始追踪负载均衡选择过程
_, span := kTracer.Start(req.Context(), "LoadBalancer.Select",
trace.WithAttributes(attribute.String("type", k.Type())),
trace.WithAttributes(attribute.Int("target_count", len(targets))))
defer span.End()
if len(targets) == 0 {
span.SetAttributes(attribute.String("result", "no targets"))
logger.Warn("No targets available for selection")
return ""
}
k.mu.RLock()
// 检查目标列表是否变化,需要重建哈希环
needRebuild := len(k.nodes) != len(targets) || !equalSlice(k.nodes, targets)
k.mu.RUnlock()
if needRebuild {
k.mu.Lock()
// 双重检查以避免并发情况下的重复构建
if len(k.nodes) != len(targets) || !equalSlice(k.nodes, targets) {
k.buildRing(targets)
}
k.mu.Unlock()
}
k.mu.RLock()
defer k.mu.RUnlock()
if len(k.hashRing) == 0 {
// 如果哈希环为空,回退到第一个目标
target := targets[0]
span.SetAttributes(attribute.String("selected_target", target))
logger.Debug("Selected fallback target due to empty hash ring",
zap.String("target", target))
return target
}
// 使用客户端 IP 作为哈希键进行一致性选择
key := k.hashKey(req.RemoteAddr)
index := k.findNearest(key)
target := k.hashMap[k.hashRing[index]]
span.SetAttributes(attribute.String("selected_target", target))
logger.Debug("Selected target using Ketama consistent hashing",
zap.String("clientIP", req.RemoteAddr),
zap.String("target", target))
return target
}
// buildRing 根据目标列表构建 Ketama 哈希环
func (k *Ketama) buildRing(targets []string) {
k.nodes = targets
k.hashRing = nil // 重置哈希环
k.hashMap = make(map[uint32]string)
totalSlots := len(targets) * k.replicas
k.hashRing = make([]uint32, 0, totalSlots)
// 遍历每一个真实的物理节点(比如 "192.168.1.10")
for _, node := range targets {
// 循环 k.replicas 次(比如 100 次)
for j := 0; j < k.replicas; j++ {
// 构造虚拟节点名称,例如 "192.168.1.10-0", "192.168.1.10-1", ...
hash := k.hash(node + "-" + strconv.Itoa(j))
// 将计算出的哈希值放入环中
k.hashRing = append(k.hashRing, hash)
// 记录这个哈希值对应的是哪台真实机器
k.hashMap[hash] = node
}
}
// 对哈希环进行排序以支持二分查找
sort.Slice(k.hashRing, func(i, j int) bool {
return k.hashRing[i] < k.hashRing[j]
})
logger.Info("Ketama hash ring rebuilt",
zap.Int("nodes", len(targets)),
zap.Int("totalSlots", totalSlots))
}
// hash 使用 MD5 生成 32 位哈希值
func (k *Ketama) hash(key string) uint32 {
h := md5.Sum([]byte(key))
return binary.BigEndian.Uint32(h[0:4]) // 使用前 4 字节作为哈希值
}
// hashKey 从客户端地址计算哈希键
func (k *Ketama) hashKey(clientAddr string) uint32 {
return k.hash(clientAddr)
}
// findNearest 查找哈希环中大于等于给定哈希值的最近节点索引
func (k *Ketama) findNearest(hash uint32) int {
index := sort.Search(len(k.hashRing), func(i int) bool {
return k.hashRing[i] >= hash
})
if index == len(k.hashRing) {
return 0 // 如果哈希值超出所有节点,环绕到第一个节点
}
return index
}
// equalSlice 比较两个字符串切片是否相等
func equalSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
consul服务发现
三 安全控制
巨大优化:允许指定路由加载自定义中间件
main函数处理:在main函数中添加一个注册中间件的map,加载到路由中
go
registry := map[string]gin.HandlerFunc{
"jwt_auth": auth.Auth(),
}
// 2. 调用 Setup,把中间件传进去
// 注意:这里我们修改了 Setup 的签名,增加了最后一个变长参数
routing.Setup(s.Router, s.HTTPProxy, cfg, registry)
config.go中添加相应的内容,拿路由规则的时候顺便拿到对应路由需要加载的中间件
go
// RoutingRule 路由规则定义
type RoutingRule struct {
Target string `mapstructure:"target"`
Weight int `mapstructure:"weight"`
Env string `mapstructure:"env"`
Protocol string `mapstructure:"protocol"`
HealthCheckPath string `mapstructure:"healthCheckPath"`
// 注意:虽然这是一个列表,但通常对于同一个 Path 的所有 Target,这个配置应该是一样的
Middlewares []string `mapstructure:"middlewares"`
}
router.setup:修改一下入参,把map传进去
go
// Setup 初始化路由引擎并配置路由规则,包括 gRPC 和 WebSocket 代理
func Setup(engine *gin.Engine, httpProxy *proxy.HTTPProxy, cfg *config.Config, mwRegistry map[string]gin.HandlerFunc) {
var router internalrouter.Router
// ... 省略
// 配置 HTTP 路由
router.Setup(engine, httpProxy, cfg, mwRegistry)
// ... 省略
}
路由绑定中间件------这里以gin模式为例
go
// Setup 在提供的 Gin 路由器中配置 HTTP 路由规则
func (gr *GinRouter) Setup(r gin.IRouter, httpProxy *proxy.HTTPProxy, cfg *config.Config, mwRegistry map[string]gin.HandlerFunc) {
rules := cfg.Routing.GetHTTPRules()
if len(rules) == 0 {
logger.Warn("No HTTP routing rules found in configuration")
return
}
// 为每个路径注册路由规则
for path, targetRules := range rules {
logger.Info("Registering HTTP route",
zap.String("path", path),
zap.Any("targets", targetRules))
// 1. 定义处理函数切片
var handlers []gin.HandlerFunc
// 2. 解析中间件:既然不考虑 Method,我们默认取该路径下第一个规则的中间件配置
if len(targetRules) > 0 {
// 遍历配置中的中间件名字 (例如 ["auth", "logger"])
for _, name := range targetRules[0].Middlewares {
// 从注册表中查找对应的 HandlerFunc
if mwFunc, ok := mwRegistry[name]; ok {
handlers = append(handlers, mwFunc)
} else {
logger.Warn("Middleware configured but not found in registry",
zap.String("path", path),
zap.String("middleware", name))
}
}
}
// 3. 将原本的代理 Handler 追加到切片末尾
handlers = append(handlers, httpProxy.CreateHTTPHandler(targetRules))
// 4. 注册路由 (展开 handlers 切片)
r.Any(path, handlers...)
}
}
更改后yaml是这样的
yaml
/api/v1/user:
- target: http://127.0.0.1:8381
weight: 50
env: stable
protocol: http
healthcheckpath: /status
middlewares: ["jwt_auth"]
targetRules[0]拿到rules里面第1个target下的中间件列表(target对应不同负载均衡的节点,实际上也只用配置一个target)
把这个hander注册进去,最后再注册httpProxy的Handler
四 流量治理
单路由限流中间件配置,类似上面的jwt
go
var rateLimitHandler gin.HandlerFunc
if cfg.Middleware.RateLimit {
switch cfg.Traffic.RateLimit.Algorithm {
case "token_bucket":
// 这里调用你现有的函数,获取一个 HandlerFunc
rateLimitHandler = traffic.TokenBucketRateLimit()
case "leaky_bucket":
rateLimitHandler = traffic.LeakyBucketRateLimit()
default:
logger.Error("未知的限流算法", zap.String("algorithm", cfg.Traffic.RateLimit.Algorithm))
os.Exit(1)
}
} else {
// 如果总开关没开,可以给一个空的 Handler,或者在注册表里就不放这个 key
rateLimitHandler = func(c *gin.Context) { c.Next() }
}
// =================================================================
// 2. 准备熔断中间件
// =================================================================
var breakerHandler gin.HandlerFunc
if cfg.Middleware.Breaker {
breakerHandler = traffic.Breaker()
} else {
breakerHandler = func(c *gin.Context) { c.Next() }
}
// 1. 准备中间件列表
// 准备中间件注册表
registry := map[string]gin.HandlerFunc{
"rate_limit": rateLimitHandler,
"breaker": breakerHandler,
}
// 2. 调用 Setup,把中间件传进去
// 注意:这里我们修改了 Setup 的签名,增加了最后一个变长参数
routing.Setup(s.Router, s.HTTPProxy, cfg, registry)
令牌桶算法
使用uberRatelimit实现,主要传参调接口
go
type TokenBucketLimiter struct {
limiter uberRatelimit.Limiter
}
func NewMultiDimensionalTokenBucket(cfg *config.Config) *MultiDimensionalTokenBucket {
mdt := &MultiDimensionalTokenBucket{
config: cfg,
}
if cfg.Traffic.RateLimit.Enabled {
mdt.globalLimiter = NewTokenBucketLimiter(cfg.Traffic.RateLimit.QPS, cfg.Traffic.RateLimit.Burst)
}
return mdt
}
func (tbl *TokenBucketLimiter) Take() time.Time {
return tbl.limiter.Take()
}
func NewTokenBucketLimiter(qps, burst int) *TokenBucketLimiter {
l := &TokenBucketLimiter{
limiter: uberRatelimit.New(qps, uberRatelimit.WithSlack(burst)),
}
logger.Info("TokenBucketLimiter initialized",
zap.Int("qps", qps),
zap.Int("burst", burst))
return l
}
// 令牌桶中间件入口
func TokenBucketRateLimit() gin.HandlerFunc {
cfg := config.GetConfig()
mdt := NewMultiDimensionalTokenBucket(cfg)
return func(c *gin.Context) {
if !cfg.Traffic.RateLimit.Enabled {
c.Next()
return
}
_, span := tokenBucketTracer.Start(c.Request.Context(), "RateLimit.TokenBucket",
trace.WithAttributes(attribute.String("path", c.Request.URL.Path)))
defer span.End()
// 检查全局限流
if mdt.globalLimiter != nil {
if !checkLimit(mdt.globalLimiter, c, span, "global", "") {
return
}
}
// 检查IP限流
clientIP := c.ClientIP()
ipQPS := cfg.Traffic.RateLimit.QPS / 2
ipBurst := cfg.Traffic.RateLimit.Burst / 2
ipLimiter := mdt.getOrCreateLimiter("ip", clientIP, ipQPS, ipBurst)
if !checkLimit(ipLimiter, c, span, "ip", clientIP) {
return
}
// 检查路由限流
route := c.Request.URL.Path
routeQPS := cfg.Traffic.RateLimit.QPS
routeBurst := cfg.Traffic.RateLimit.Burst
routeLimiter := mdt.getOrCreateLimiter("route", route, routeQPS, routeBurst)
if !checkLimit(routeLimiter, c, span, "route", route) {
return
}
span.SetStatus(codes.Ok, "Request allowed by token bucket")
c.Next()
}
}
为啥要做double check
代码分析:
go
// 第一次检查(无锁)
if limiter, ok := limiterMap.Load(key); ok {
return limiter.(*TokenBucketLimiter)
}
// 加锁
mdt.mutex.Lock()
defer mdt.mutex.Unlock()
// 第二次检查(有锁)
if limiter, ok := limiterMap.Load(key); ok {
return limiter.(*TokenBucketLimiter)
}
// 创建新对象
limiter := NewTokenBucketLimiter(qps, burst)
limiterMap.Store(key, limiter)
return limiter
为什么要双重检查?
- 性能优化
- 第一次检查(无锁):如果限流器已经存在,直接返回,避免加锁开销
- 在大多数情况下,限流器已经存在,这样可以显著提高性能
- 线程安全
- 多个goroutine可能同时检查到限流器不存在
- 如果没有第二次检查,每个goroutine都会创建新的限流器,导致:
- 内存泄漏(创建多个相同key的限流器)
- 逻辑错误(相同的key对应不同的限流器实例)
- 竞态条件防止
考虑这样的时序:
text
Goroutine A: 第一次检查 → 未找到 → 获取锁
Goroutine B: 第一次检查 → 未找到 → 等待锁
Goroutine A: 创建限流器 → 释放锁
Goroutine B: 获取锁 → 如果没有第二次检查,会重复创建
漏桶算法
go
func (l *LeakyBucketLimiter) startLeak() {
ticker := time.NewTicker(time.Second / time.Duration(l.rate))
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.mutex.Lock()
if len(l.queue) > 0 {
select {
case <-l.queue: // 漏出一个请求
default:
}
}
l.mutex.Unlock()
case <-l.stopChan:
logger.Info("LeakyBucketLimiter leak routine stopped")
return
}
}
}
<-l.queue 从 channel 中接收一个元素并丢弃,在这个限流器语境下就相当于"漏掉"了一个请求配额。之所以直接丢弃,是因为在这个漏桶限流器的设计中,channel 里存储的不是实际的请求数据,而是请求的"占位符" 。这个限流器只关心 "能否处理",不关心 **"处理什么"**业务在别处处理。
- 内存效率 :
struct{}不占内存 - 解耦:限流器不依赖具体业务逻辑
- 简单:实现和维护更简单
go
func (l *LeakyBucketLimiter) Allow() bool {
l.mutex.Lock()
defer l.mutex.Unlock()
select {
case l.queue <- struct{}{}: // 尝试放入一个占位符
return true
default:
return false
}
}
熔断
时间滑动窗口------过期丢出窗口------计算窗口内的错误率和延迟率
Hystrix 的核心机制
Hystrix 主要通过以下几种机制来提供保护:
- 断路器模式
这是 Hystrix 最核心的思想。它就像一个电路中的保险丝。
- 闭合状态: 正常情况下,请求可以正常通过,调用远程服务。
- 打开状态: 当调用失败的次数达到一个阈值时,断路器会"跳闸"(打开)。在接下来的一个时间窗口内,所有对此服务的请求都会立即失败,而不会真正地去调用那个已经故障的服务。这给了故障服务恢复的时间,也避免了资源的浪费。
- 半开状态: 在断路器打开一段时间后,它会自动进入半开状态,尝试放行一个请求。如果这个请求成功了,断路器就闭合,恢复流程;如果还是失败,就继续保持打开状态。
- 服务降级
当某个服务调用失败(比如超时、异常,或断路器打开)时,Hystrix 允许你提供一个 "降级方案"。
- 例子: 在电商系统中,查询商品详情需要调用"用户评价服务"来获取评论。如果"用户评价服务"挂了,Hystrix 可以触发降级,返回一个预设的默认值(比如一个空列表,或者从本地缓存中获取的静态提示信息),而不是让整个商品详情页都打不开。这样保证了核心流程的可用性。
- 资源隔离
Hystrix 将对外部服务的调用包装在一个所谓的 "命令" 中,并将每个命令分配到一个独立的线程池中执行。
- 好处: 这样,即使某个服务(比如服务B)的调用非常慢,占满了自己的线程池,也不会影响到其他服务(比如服务D)的调用,因为它们使用不同的线程池。这就实现了故障隔离。
- 请求缓存与请求合并
- 请求缓存: 在同一个请求上下文中,对同一个依赖服务的重复调用,Hystrix 可以只执行第一次调用,后续直接返回缓存的结果。
- 请求合并: 可以将短时间内对同一个依赖服务的多个请求合并为一个批处理请求,减少网络开销
项目里的应用
熔断测试
| 指标 | /api/v1/order(开启限流) |
/health(未开启限流) |
|---|---|---|
| QPS (Requests/sec) | ~4,237 | ~7,775 |
| 平均延迟 (Latency) | 23.70 ms | 14.17 ms |
| 最大延迟 (Max) | 135.98 ms | 106.22 ms |
| 吞吐量 (Transfer/sec) | 798.63 KB/s | 1.02 MB/s |
✅ 结论先行:
限流显著降低了 QPS、增加了延迟,但提升了系统稳定性(避免过载)------这正是限流的设计目的。
怎么联动普罗米修斯的
go
// init 注册 Prometheus 指标
func init() {
prometheus.MustRegister(errorRateGauge, latencyGauge)
}
// Breaker 返回用于熔断和降级的 Gin 中间件
func Breaker() gin.HandlerFunc {
// ...
// 初始化时间滑动窗口用于请求统计
window := NewTimeSlidingWindow(time.Duration(cfg.Traffic.Breaker.WindowDuration) * time.Second)
return func(c *gin.Context) {
// ...
// 更新 Prometheus 指标
errorRate := window.ErrorRate()
avgLatency := window.AvgLatency()
errorRateGauge.WithLabelValues(path).Set(errorRate)
latencyGauge.WithLabelValues(path).Set(float64(avgLatency) / float64(time.Second))
span.SetStatus(codes.Ok, "Request processed successfully")
// 记录请求统计用于调试
logger.Debug("Updated request statistics",
zap.String("path", path),
zap.Bool("success", success),
zap.Duration("latency", latency),
zap.Float64("errorRate", errorRate),
zap.Duration("avgLatency", avgLatency))
}
}
五 流量染色与灰度发布
**流量染色:**给请求打上标识,以便在复杂的调用链中对其进行识别和路由控制。
具体作用包括:
- 标识流量身份 :这是最根本的作用。通过HTTP Header等元数据,标记出流量的来源、用途或身份,例如:
X-Env: canary(标记为金丝雀测试流量)X-User-Id: 12345(标记来自特定内部测试用户)X-Traffic-Source: stress-test(标记为压测流量)
- 构建逻辑环境:无需搭建多套完整的物理环境。通过流量染色,可以让同一套物理集群同时承载不同环境的流量(如测试环境、预发布环境),它们逻辑隔离但物理共享,极大节约资源。
- 实现精准路由的基础:它是实现灰度发布、A/B测试等技术的前提。只有先给流量打上标记,网关和服务网格才能根据标记将流量引导到正确的目标服务实例。
- 全链路追踪与调试:带有唯一标识的染色流量可以在整个分布式系统中被追踪,方便开发人员排查问题或观察特定用户的行为路径。
**灰度发布:**一种平滑的应用程序发布策略,通过逐步将流量从旧版本切换到新版本,来降低发布风险。
具体作用包括:
- 降低发布风险:这是最主要的作用。避免直接将一个有潜在Bug的新版本暴露给所有用户,一旦新版本有问题,可以快速回滚,影响范围极小。
- 小范围验证:先让一小部分用户(如内部员工、特定地区用户、随机1%的用户)使用新版本,验证其功能、性能和稳定性是否达到预期。
- 渐进式交付:根据验证结果,逐步扩大新版本的流量比例(例如1% → 5% → 20% → 50% → 100%),实现平稳过渡。
- 实时回滚:在灰度过程中,如果监控到新版本的错误率升高或性能下降,可以立即修改路由规则,将流量全部切回稳定的旧版本,实现秒级回滚。
一个典型的协作流程:
- 染色 :在网关层,根据业务规则(如用户ID尾号、特定Header)给一部分请求打上
version: v2的标签。 - 发布:部署服务的新版本 V2,并与旧版本 V1 同时在线。
- 路由 :配置服务网格的路由规则:"所有带有
version: v2标签的请求,都路由到 V2 版本的服务实例;其他请求一律路由到 V1 版本。" - 监控与调整:监控 V2 版本的运行状态。如果一切正常,就逐步扩大染色的范围(例如,将规则改为"用户ID尾号为0,1,2,3的请求都染色"),直到所有流量都流向 V2,完成发布。
六 协议转化
http -- gRPC
客户端与服务端对比
| 项目 | gRPC 服务端 (hello-service) |
gRPC 客户端 (mini-gateway) |
|---|---|---|
是否需要 .proto |
✅ 是 | ✅ 是(必须和服务端一致) |
是否生成 \*.pb.go |
✅ 是 | ✅ 是 |
是否生成 \*_grpc.pb.go |
✅ 是 | ✅ 是 |
是否生成 \*.pb.gw.go |
❌ 否 | ✅ 是(gRPC-Gateway 特有) |
是否手写 ServiceImpl |
✅ 是 | ❌ 否 |
是否调用 grpc.NewServer() |
✅ 是 | ❌ 否 |
是否调用 grpc.Dial() |
❌ 否 | ✅ 是 |
是否注册 RegisterXXXServer |
✅ 是 | ❌ 否 |
是否注册 RegisterXXXHandler |
❌ 否 | ✅ 是 |
| 核心角色 | 提供者(Provider) | 消费者/代理(Consumer/Proxy) |
| 监听端口 | 如 :50051 |
如 :8080(HTTP) |
| 依赖对方吗? | 不依赖网关 | 必须能连上服务端 |
代码解析
注册gRPC服务
go
// grpcTracer 为 gRPC 代理初始化追踪器
var grpcTracer = otel.Tracer("proxy:grpc")
// 新增:允许测试时注入自定义 gRPC 连接创建和处理器注册逻辑
var newGRPCClient = func(target string, dialOpts ...grpc.DialOption) (*grpc.ClientConn, error) {
return grpc.Dial(target, dialOpts...)
}
var registerHelloServiceHandlerFunc = proto.RegisterHelloServiceHandler
// SetupGRPCProxy 配置 HTTP 到 gRPC 的反向代理
func SetupGRPCProxy(cfg *config.Config, r gin.IRouter) {
mux := runtime.NewServeMux(
/* 🔍 详细解释
什么是 runtime.ServeMux?
它是 gRPC-Gateway 提供的一个 HTTP 路由器(multiplexer)
功能类似 Gin 的 *gin.Engine 或 Go 原生的 http.ServeMux
但它专门用于将 HTTP/JSON 请求 → 转发给 gRPC 服务 */
runtime.WithErrorHandler(httpErrorHandler()),
runtime.WithForwardResponseOption(httpResponseModifier),
)
dialOpts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()), // 本地测试用,生产环境需启用 TLS
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
}
// 遍历 gRPC 路由规则
for route, rules := range cfg.Routing.GetGrpcRules() {
for _, rule := range rules {
if rule.Protocol != "grpc" {
continue
}
conn, err := newGRPCClient(rule.Target, dialOpts...)
if err != nil {
logger.Error("Failed to establish gRPC connection",
zap.String("target", rule.Target),
zap.Error(err))
continue
}
// 在设置期间注册 gRPC 服务处理器
if err := registerHelloServiceHandlerFunc(context.Background(), mux, conn); err != nil {
logger.Error("Failed to register gRPC service handler",
zap.String("target", rule.Target),
zap.Error(err))
conn.Close()
continue
}
logger.Info("Successfully registered gRPC service handler",
zap.String("path", route),
zap.String("target", rule.Target))
}
// ...
}
}
将传入的http请求转发到gRPC
go
// 处理带有上下文传播的传入请求
r.Any(route, func(c *gin.Context) {
ctx, span := grpcTracer.Start(c.Request.Context(), "GRPCProxy.Handle",
trace.WithAttributes(
attribute.String("http.method", c.Request.Method),
attribute.String("http.path", c.Request.URL.Path),
attribute.String("grpc.prefix", cfg.GRPC.Prefix),
))
defer span.End()
span.SetAttributes(attribute.String("grpc.routing.path", c.Request.URL.Path))
req := c.Request
// 规范化 URL,避免不必要的重定向
if strings.HasSuffix(req.URL.Path, "/") {
req.URL.Path = strings.TrimSuffix(req.URL.Path, "/")
}
originalPath := req.URL.Path
grpcPrefix := cfg.GRPC.Prefix
adjustedPath := strings.TrimPrefix(originalPath, grpcPrefix) // 去掉 /grpc 前缀
if adjustedPath == originalPath {
logger.Warn("Request path lacks gRPC prefix, no adjustment applied",
zap.String("path", originalPath),
zap.String("prefix", grpcPrefix))
} else {
logger.Info("Adjusted gRPC request path by removing prefix",
zap.String("originalPath", originalPath),
zap.String("adjustedPath", adjustedPath),
zap.String("prefix", grpcPrefix))
req.URL.Path = adjustedPath
}
// 将元数据传播到请求上下文中其实,gRPC-gateway 会自动获取,这里只是改个名字
ctx = metadata.NewIncomingContext(ctx, metadata.Pairs("request-id", c.GetHeader("X-Request-ID")))
req = req.WithContext(ctx) // 直接用 req := c.Request.WithContext(c.Request.Context()) 也可以
recorder := &statusRecorder{ResponseWriter: c.Writer, Status: http.StatusOK}
mux.ServeHTTP(recorder, req)
// 从路由规则中识别目标
target := ""
for _, rule := range cfg.Routing.GetGrpcRules()[route] {
if rule.Protocol == "grpc" {
target = rule.Target
break
}
}
// statusRecorder 捕获 HTTP 响应状态码
type statusRecorder struct {
gin.ResponseWriter
Status int
}
// WriteHeader 重写 WriteHeader 以捕获状态码
func (r *statusRecorder) WriteHeader(code int) {
r.Status = code
r.ResponseWriter.WriteHeader(code)
}
自定义错误处理和返回头
go
// httpErrorHandler 自定义 gRPC 请求的错误处理
func httpErrorHandler() runtime.ErrorHandlerFunc {
return func(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) {
st, _ := status.FromError(err)
statusCode := fmt.Sprintf("%d", st.Code())
path := r.URL.Path
observability.GRPCCallsTotal.WithLabelValues(path, statusCode).Inc()
logger.Error("gRPC request processing failed",
zap.String("path", path),
zap.String("statusCode", statusCode),
zap.String("error", st.Message()))
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
}
// httpResponseModifier 为 HTTP 响应添加自定义头部
func httpResponseModifier(ctx context.Context, w http.ResponseWriter, _ gproto.Message) error {
if md, ok := metadata.FromIncomingContext(ctx); ok {
logger.Info("Metadata found in request context",
zap.Any("metadata", md))
} else {
logger.Warn("No metadata found in request context")
}
w.Header().Set("X-Proxy-Type", "grpc-gateway")
w.Header().Set("X-Powered-By", "mini-gateway")
return nil
}
七 实时监控
Grafana+Prometheus
看相应文章
Prometheus 自动注册机制 (promauto)
在标准的 Prometheus Go 客户端用法中,通常需要两步:
- 创建指标:使用 prometheus.NewCounter 等。
- 注册指标:手动调用 prometheus.MustRegister(myMetric) 将其加入注册表。
但在我们的代码中使用了 github.com/prometheus/client_golang/prometheus/promauto 这个包,而不是基础的 prometheus 包。
go
// 你的代码
RequestsTotal = promauto.NewCounterVec( ... )
promauto 的作用是将这两步合并了。
当代码运行时,promauto.NewCounterVec(以及其他 promauto.New... 函数)会做两件事:
- 创建一个新的指标实例。
- 立即 将其注册到 Prometheus 的默认全局注册表 (prometheus.DefaultRegisterer) 中。
初始化时机 (Global Variables)
这些变量是在 var (...) 块中定义的全局变量。
go
var (
RequestsTotal = promauto.NewCounterVec(...)
// ... 其他指标
)
注入时机 :
当你的 Go 程序启动,加载 observability 包时,Go 运行时会初始化这些全局变量。就在这一瞬间,promauto 就会执行,将这些指标"注入"到内存中的 Prometheus 默认注册表中。
暴露给外界
虽然指标已经"注入/注册"到了程序内存中,但普罗米修斯服务器(Prometheus Server)要能抓取到它们,你还需要在 HTTP 服务端(通常在 main.go 或路由配置中)暴露 /metrics 接口。
是这样写的:
go
// Prometheus 监控路由
if cfg.Observability.Prometheus.Enabled {
s.Router.GET(cfg.Observability.Prometheus.Path, gin.WrapH(promhttp.Handler()))
}
gin.WrapH(...)------ 关键适配器 :- 背景 :Prometheus 官方库提供的
promhttp.Handler()是标准的 Go net/http.Handler 接口。 - 问题 :Gin 框架使用的是自己定义的
gin.HandlerFunc(即 func(*gin.Context)),两者签名不兼容,直接放进去会报错。 - 解决:gin.WrapH 是 Gin 提供的一个包装函数,它把标准的 http.Handler 转换(Wrap)成 Gin 可以识别的 Handler。
- 背景 :Prometheus 官方库提供的
promhttp.Handler()------ 核心处理逻辑 :- 作用:这是 Prometheus 官方提供的"搬运工"。
- 当请求到达时,这个 Handler 会去默认注册表(Default Gatherer)里把所有注册的指标(就是你上一个代码里 RequestsTotal 等变量)取出来。
- 它将这些数据格式化成 Prometheus 能够识别的纯文本格式,并写回 HTTP 响应体
Jaeger
函数签名与开关控制
go
func InitTracing(cfg *config.Config) func(context.Context) error {
if !cfg.Observability.Jaeger.Enabled {
// ... 如果配置未启用,直接返回一个空的关闭函数
return func(ctx context.Context) error { return nil }
}
// ...
}
- 功能:这是一个初始化函数,接收配置对象 cfg。
- 返回值 :返回一个 func(context.Context) error。这是一个典型的 Cleanup/Shutdown 模式。调用者(通常是 main 函数)需要在服务退出前调用这个返回的函数,以确保内存中缓存的追踪数据能被刷新(Flush)并发送出去,防止数据丢失。
- 配置开关:代码首先检查配置是否启用了 Jaeger。如果没启用,它不做任何初始化,直接返回一个"空操作"的清理函数。这是一个很好的工程实践(Feature Flag)。
创建 OTLP 导出器 (Exporter)
go
exporter, err := otlptracehttp.New(context.Background(),
otlptracehttp.WithEndpoint(cfg.Observability.Jaeger.Endpoint), // 例如 "localhost:4318"
otlptracehttp.WithURLPath("/v1/traces"),
otlptracehttp.WithInsecure(), // 生产环境通常需要 TLS
)
配置采样策略 (Sampler)
go
switch cfg.Observability.Jaeger.Sampler {
case "always":
sampler = sdktrace.AlwaysSample()
case "ratio":
sampler = sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.Observability.Jaeger.SampleRatio))
// ...
}
这是分布式追踪中非常关键的一环,决定了保留多少请求数据:
- always:记录 100% 的请求。开发环境常用,但生产环境流量大时会消耗大量存储和带宽。
- ratio (配合 ParentBased) :
- TraceIDRatioBased:按比例采样(例如 0.1 代表 10%)。
- ParentBasedWrapper :这是一个很智能的包装器。它的逻辑是:"如果上游服务已经决定对这个请求进行采样(即请求头里带了采样标记),那么我也必须采样,不管比例是多少;只有当我是链路的第一站时,才按比例采样"。这保证了整个微服务调用链的完整性,不会出现"断链"。
初始化 TracerProvider
go
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter), // 批处理模式
sdktrace.WithResource(res),
sdktrace.WithSampler(sampler),
)
- TracerProvider:这是 OTel 的核心管理对象。
- WithBatcher:非常重要。它不会每产生一条日志就发一次 HTTP 请求(那样太慢了),而是会在内存中积攒一批(Batch),或者每隔几秒钟统一发送一次。这大大降低了对网关性能的影响。
设置全局传播器 (Propagator)
go
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
logger.Info("Distributed tracing initialized successfully",
zap.String("endpoint", cfg.Observability.Jaeger.Endpoint),
zap.String("sampler", cfg.Observability.Jaeger.Sampler),
zap.Float64("sampleRatio", cfg.Observability.Jaeger.SampleRatio))
return tp.Shutdown // 返回清理函数以释放资源
- otel.SetTracerProvider:设置全局单例,这样在代码的其他地方只需要调用 otel.Tracer("name") 就能开始记录。
- TraceContext:这是 W3C 标准。它负责在 HTTP Header 中注入 traceparent 字段,让下游服务知道当前的 TraceID 是什么。
- Baggage:允许你在链路中透传一些自定义的键值对(比如 user_id=123),整个链路的所有服务都能读到这个值。
关键:为什么在服务关闭时才调用
因为使用了"批处理(Batching)"
在 InitTracing 代码中,配置了:
go
sdktrace.WithBatcher(exporter)
这意味着:
- 日常运行中 :当你代码里记录一个 Span(比如 tracer.Start(...))时,这个数据不会立刻通过网络发给 Jaeger。
- 缓存机制:数据会被暂时存放在内存的一个**缓冲区(Buffer)**里。
- 自动发送:OpenTelemetry 的后台协程(Goroutine)会每隔一段时间(比如 5 秒)或者当缓冲区满了(比如攒够 512 条)时,才打包通过网络发送一次。
为什么要这样?
如果每处理一个 HTTP 请求就发一次 Jaeger 请求,网关的性能会大打折扣(网络 IO 太慢了)。批处理可以把网络开销降到最低。
关闭时的风险:内存数据丢失
因为有了上面的"缓存机制",就带来了一个问题:
场景模拟:
- 服务正常运行,内存缓冲区里攒了 30 条刚才发生的请求追踪数据。
- 还没有到 5 秒的发送时间点。
- 运维人员按下了 Ctrl+C,或者 Kubernetes 发送了 SIGTERM 信号要重启服务。
- 如果不调用 Shutdown :Go 程序直接退出,内存被操作系统回收。那 30 条还在缓冲区里的数据瞬间消失了。
tp.Shutdown(ctx) 的具体逻辑是:
- 停止接收新数据:告诉 Tracer 不要再记录新的 Span 了。
- 强制发送(Flush):把内存缓冲区里所有现存的数据,不管有没有攒够,立刻打包发给 Jaeger。
- 等待完成:阻塞当前线程,直到数据发送成功,或者直到 ctx 超时。
- 释放资源:关闭底层的 HTTP/TCP 连接。
闭包:在初始化时已经启动
go
// setupMiddleware 配置中间件
func (s *Server) setupMiddleware(cfg *config.Config) {
// ...
if cfg.Middleware.Tracing {
cleanup := observability.InitTracing(cfg)
s.TracingCleanup = cleanup
s.Router.Use(middleware.Tracing()) // 分布式追踪
}
}
这个写法叫"闭包(Closure)"或"回调"
在 Go 语言中,函数是一等公民,可以像变量一样传递。
- tp.Shutdown() <--- 加了括号,代表现在立刻执行关机。
- tp.Shutdown <--- 没加括号,代表把这个函数作为一个对象返回。
当你调用 InitTracing 时,Tracer 立刻开始收集数据。
函数里面,返回给你的那个 func,就是一个 Shutdown 的开关。
Batcher 已经在后台跑了。
当你最后想结束的时候,你才调用返回的 Shutdown 函数。
调用实例:以 breaker 为例
go
var breakerTimeSlidingTracer = otel.Tracer("breaker:time-sliding")
func Breaker() gin.HandlerFunc {
// ...
_, span := breakerTimeSlidingTracer.Start(c.Request.Context(), "Breaker.Check",
trace.WithAttributes(attribute.String("path", c.Request.URL.Path)))
defer span.End()
// ...
span.SetStatus(codes.Error, "Circuit breaker open")
span.SetAttributes(attribute.String("breakerState", "open"))
observability.BreakerTrips.WithLabelValues(path).Inc() // 向 Prometheus 上报一个 Counter 指标,记录"熔断触发次数"。
// ...
}
1. breakerTimeSlidingTracer
- 这是一个 OpenTelemetry Tracer 实例 ,它标识了这个 Span 属于 "基于时间滑动窗口的熔断器" 组件。
2. .Start(parentCtx, spanName, opts...)
- 启动一个新的 Span ,作为当前请求上下文(
c.Request.Context())的子 Span。 - 参数说明:
c.Request.Context():父上下文,用于传递 TraceID、SpanID,保证链路连贯。"Breaker.Check":Span 的名称,表示"熔断器检查"操作。trace.WithAttributes(...):附加结构化属性(标签),便于后续过滤和分析。
3. trace.WithAttributes(attribute.String("path", ...))
- 为当前 Span 添加一个键值对标签:
path = "/api/users"。 - 在可视化工具(如 Jaeger、Grafana Tempo)中,你可以:
- 按
path筛选所有/api/order的熔断检查 - 分析特定接口的熔断触发频率
- 按
4. defer span.End()
- 必须调用!用于标记 Span 的结束时间。
- OpenTelemetry 会自动记录:
- Span 的开始时间
- Span 的持续时间(Duration)
- 是否发生错误(需手动设置状态)
八 对象池化
fasthttp 连接池
为什么使用连接池?
A. 时间成本:三次握手(Latency)
HTTP 是建立在 TCP 之上的。每次发起请求,如果新建连接,都要经历:
- TCP 三次握手(SYN, SYN-ACK, ACK):需要在客户端和服务端之间来回通信 3 次。
- TLS/SSL 握手(如果是 HTTPS):还需要额外的 4+ 次来回通信来交换密钥。
- 发送数据。
如果没有连接池 ,每个请求都要经历 1 和 2。比如网络延迟是 20ms,建立连接可能就要 60ms~100ms,而真正处理业务可能只要 10ms。大部分时间在"空转"。
有了连接池 ,第一次建立后,后续请求直接发送数据,延迟瞬间降低。
B. 资源成本:端口耗尽(Port Exhaustion)
这是网关最容易遇到的问题。
- TCP 连接关闭时,并不会立即消失,而是会进入一个叫 TIME_WAIT 的状态(通常持续 60秒)。
- 系统可用的端口号是有限的(通常 65535 个,可用约 3-5万)。
- 如果网关 QPS(每秒请求数)是 2000,且没有连接池,几秒钟内就会耗尽所有端口。后续请求会因为"无法分配端口"而报错(cannot assign requested address)。
连接池通过复用连接,极大地减少了连接的创建和关闭次数,避免了 TIME_WAIT 堆积。
详细流程(生命周期)
假设你的网关收到一个请求,要转发给后端 127.0.0.1:8080。
第一步:借(Acquire)
代码调用 client.Do(req, resp) 时:
- 查看池子:HostClient 会看内部维护的空闲链表(Idle List)。
- 情况 A(有空闲) :发现有一根之前用过的 TCP 连接正闲着,直接**"捞"**出来给这个请求用。(最快,0 耗时)
- 情况 B(无空闲) :池子是空的(或者都在忙),且当前连接数没达到上限(MaxConns),于是新建 一根 TCP 连接给它用。(耗时:三次握手)
- 情况 C(已满) :池子空了,且连接数达到上限(比如设置了 1000),这个请求就会排队等待,或者直接报错"连接池已满"。
第二步:用(Use)
- 请求占用这根 TCP 连接,把数据发给后端。
- 注意 :在等待后端响应的这段时间里,这根连接是被当前请求独占的,别的请求不能用它。
第三步:还(Release)
- 后端返回数据,网关接收完毕。
- 关键点 :网关不会关闭这根 TCP 连接(不发送 FIN 包)。
- 网关把它擦干净,重新放回池子的空闲链表里。
- 这根连接进入"待机"状态,等待被下一个请求"捞"走。
初始化
结构体
go
type HTTPConnectionPool struct {
// 核心存储:使用 sync.Map 存储目标地址到 *fasthttp.HostClient 的映射
// sync.Map 是 Go 标准库提供的并发安全的 Map,适合读多写少的场景(网关路由配置通常是稳定的)
clients sync.Map
cfg *config.Config
cleanupCh chan struct{} // 用于通知清理资源的信号通道
}
在程序初始化时会调用http_proxy中的
go
// NewHTTPProxy 创建并初始化 HTTPProxy 实例
func NewHTTPProxy(cfg *config.Config) *HTTPProxy {
lb := initializeLoadBalancer(cfg)
logPoolStatus(cfg.Performance.HttpPoolEnabled)
logGrayscaleStatus(cfg.Routing.Grayscale)
return &HTTPProxy{
httpPool: NewHTTPConnectionPool(cfg),
loadBalancer: lb,
objectPool: util.NewPoolManager(cfg),
httpPoolEnabled: cfg.Performance.HttpPoolEnabled,
}
}
调用NewHTTPConnectionPool(cfg)
go
// NewHTTPConnectionPool 创建并初始化连接池实例
func NewHTTPConnectionPool(cfg *config.Config) *HTTPConnectionPool {
pool := &HTTPConnectionPool{
cfg: cfg,
cleanupCh: make(chan struct{}),
}
if cfg.Performance.HttpPoolEnabled {
pool.initializePool(cfg)
} else {
logger.Info("HTTP connection pool disabled in configuration")
}
return pool
}
如果开启了连接池,进行init
go
// initializePool 根据配置初始化连接池中的目标
func (p *HTTPConnectionPool) initializePool(cfg *config.Config) {
var initializedCount int
rules := cfg.Routing.GetHTTPRules()
for _, targetRules := range rules {
for _, rule := range targetRules {
if host, err := normalizeTarget(rule.Target); err != nil {
// normalizeTarget 从目标 URL 中提取 host:port
// func normalizeTarget(target string) (string, error) {
// u, err := url.Parse(target)
// if err != nil {
// return "", err
// }
// if u.Host == "" {
// return target, nil // 处理目标已是 host:port 的情况
// }
// return u.Host, nil
//}
logger.Error("Invalid target address detected",
zap.String("target", rule.Target),
zap.Error(err))
} else if _, loaded := p.clients.LoadOrStore(host, p.newHostClient(host)); !loaded {
// 如果 map 中不存在该 host,则创建并存入
// 这里实现了连接池的"预热",在网关启动时就准备好 Client 对象
initializedCount++
logger.Info("Initialized HostClient for target",
zap.String("host", host))
}
}
}
logger.Info("HTTP connection pool initialized successfully",
zap.Int("initializedTargets", initializedCount))
}
创建新的Clinet
记录host,其他调用默认配置
go
// newHostClient 创建新的 HostClient 并应用配置设置
func (p *HTTPConnectionPool) newHostClient(addr string) *fasthttp.HostClient {
return &fasthttp.HostClient{
Addr: addr,
MaxConns: p.cfg.Performance.MaxConnsPerHost,
MaxIdleConnDuration: defaultMaxIdleConnDuration,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
}
}
获取客户端
go
func (p *HTTPConnectionPool) GetClient(target string) (*fasthttp.HostClient, error) {
// 1. 规范化地址
host, err := normalizeTarget(target)
if err != nil {
return nil, err
}
// 2. 尝试直接从缓存获取
if client, ok := p.clients.Load(host); ok {
return client.(*fasthttp.HostClient), nil
}
// 3. 如果不存在(可能是动态添加的路由,或者是配置中未预热的地址),则动态创建
client, _ := p.clients.LoadOrStore(host, p.newHostClient(host))
logger.Info("Dynamically created new HostClient", zap.String("host", host))
return client.(*fasthttp.HostClient), nil
}
这段代码在http_proxy中被调用
go
// proxyWithPool 使用连接池代理转发请求
func (hp *HTTPProxy) proxyWithPool(c *gin.Context, target, env string) {
_, span := httpTracer.Start(c.Request.Context(), "HTTPProxy.Handle.Pool",
trace.WithAttributes(
attribute.String("http.method", c.Request.Method),
attribute.String("http.path", c.Request.URL.Path),
))
defer span.End()
client, err := hp.httpPool.GetClient(target)。
if err != nil {
handleProxyError(c, span, target, "Failed to get HTTP client", err)
return
}
req, resp := fasthttp.AcquireRequest(), fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
hp.prepareFastHTTPRequest(c, req, target, env)
if err := client.Do(req, resp); err != nil {
// 当你调用 client.Do(req, resp) 时:
// 1. fasthttp 会先去池子里看有没有"空闲且存活"的连接。
// 2. 如果有,直接拿来用(免去三次握手)。
// 3. 用完后,不关闭 TCP 连接,而是把它放回池子,标记为"空闲"。
handleProxyError(c, span, target, "Backend service unavailable", err)
return
}
hp.writeFastHTTPResponse(c, resp)
span.SetStatus(codes.Ok, "HTTP proxy completed successfully")
health.GetGlobalHealthChecker().UpdateRequestCount(target, true)
}
关闭
go
// Close 关闭连接池
func (p *HTTPConnectionPool) Close() {
close(p.cleanupCh)
p.clients.Range(func(key, value interface{}) bool {
p.clients.Delete(key)
return true
})
logger.Info("HTTP connection pool closed")
}
请求转换
hp.prepareFastHTTPRequest(c, req, target, env)
- 背景:c *gin.Context 里的请求是基于标准库 net/http 的格式,而 client 是 fasthttp 的。两者的结构体不兼容。
- 作用 :这个辅助函数负责"搬运数据"。
- 把 Gin 里的 Header 复制到 req 对象里。
- 把 Gin 里的 Body 读出来塞给 req。
- 设置 req 的 URL 和 Host。
- 如果 env 是金丝雀,加上 X-Env: canary 头。
具体代码
go
// prepareFastHTTPRequest 准备 FastHTTP 请求
func (hp *HTTPProxy) prepareFastHTTPRequest(c *gin.Context, req *fasthttp.Request, target, env string) {
reqURI := "http://" + target + c.Request.URL.Path
if c.Request.URL.RawQuery != "" {
reqURI += "?" + c.Request.URL.RawQuery
}
req.SetRequestURI(reqURI)
req.Header.SetMethod(c.Request.Method)
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
if env == canaryEnv {
req.Header.Set("X-Env", canaryEnv)
}
if c.Request.Body != nil {
if body, err := c.GetRawData(); err == nil {
req.SetBody(body)
}
}
}
hp.writeFastHTTPResponse(c, resp)
- 作用:数据搬运的逆过程。
对象池
go
// ObjectPoolManager 管理可重用资源的对象池逻辑
type ObjectPoolManager struct {
cfg *config.Config // 配置信息
targetsPool sync.Pool // 目标地址切片池
rulesPool sync.Pool // 路由规则切片池
}
// NewPoolManager 创建并初始化对象池管理器实例
func NewPoolManager(cfg *config.Config) *ObjectPoolManager {
pm := &ObjectPoolManager{
cfg: cfg,
}
// 仅当配置中启用内存池时初始化池
if cfg.Performance.MemoryPool.Enabled {
pm.targetsPool = sync.Pool{
New: func() interface{} {
return make([]string, 0, cfg.Performance.MemoryPool.RulesCapacity)
},
}
pm.rulesPool = sync.Pool{
New: func() interface{} {
return make(config.RoutingRules, 0, cfg.Performance.MemoryPool.TargetsCapacity)
},
}
} else {
// 未启用池时使用空池,避免空引用
pm.targetsPool = sync.Pool{}
pm.rulesPool = sync.Pool{}
}
return pm
}
// GetTargets 从池中获取可重用的目标切片或创建新切片
func (pm *ObjectPoolManager) GetTargets(capacity int) []string {
if pm.cfg.Performance.MemoryPool.Enabled {
targets := pm.targetsPool.Get().([]string)
return targets[:0] // 重置长度以重用切片
}
return make([]string, 0, capacity) // 未启用池时分配新切片
}
// PutTargets 将目标切片归还到池中以供重用
func (pm *ObjectPoolManager) PutTargets(targets []string) {
if pm.cfg.Performance.MemoryPool.Enabled {
pm.targetsPool.Put(targets)
}
// 未启用池时,切片将通过垃圾回收自动丢弃
}
// GetRules 从池中获取可重用的路由规则切片或创建新切片
func (pm *ObjectPoolManager) GetRules(capacity int) config.RoutingRules {
if pm.cfg.Performance.MemoryPool.Enabled {
rules := pm.rulesPool.Get().(config.RoutingRules)
return rules[:0] // 重置长度以重用切片
}
return make(config.RoutingRules, 0, capacity) // 未启用池时分配新切片
}
// PutRules 将路由规则切片归还到池中以供重用
func (pm *ObjectPoolManager) PutRules(rules config.RoutingRules) {
if pm.cfg.Performance.MemoryPool.Enabled {
pm.rulesPool.Put(rules)
}
// 未启用池时,切片将通过垃圾回收自动丢弃
}
九 动态配置更新
原本的动态配置更新存在问题
日志显示"配置已更新",内存里的 server.Router 变量也更新了,但实际请求时,服务依然表现为旧的路由和旧的中间件逻辑。
**原因:**服务是这样启动的
go
// start 启动服务
func (s *Server) start() {
cfg := s.ConfigMgr.GetConfig()
logStartupInfo(cfg)
listenAddr := ":" + cfg.Server.Port
logger.Info("服务开始监听", zap.String("address", listenAddr))
go func() {
if err := s.Router.Run(listenAddr); err != nil { // s.Router 是一个 gin.Engine !!
logger.Error("启动服务失败", zap.Error(err))
os.Exit(1)
}
}()
go StartMemoryMonitoring()
s.gracefulShutdown()
}
// refreshConfig 刷新配置
func refreshConfig(server *Server, configMgr *config.ConfigManager) {
for newCfg := range configMgr.ConfigChan {
logger.Info("正在刷新服务配置")
server.setupMiddleware(newCfg)
server.setupRoutes(newCfg)
server.HTTPProxy.RefreshLoadBalancer(newCfg)
health.GetGlobalHealthChecker().RefreshTargets(newCfg)
logger.Info("服务配置刷新成功")
}
}
Go 的 http.Server 在启动瞬间(调用 ListenAndServe 时),就"锁死"了传入的那个 Handler(即旧的 Gin Engine)。
虽然你在代码逻辑里把 server.Router 指向了一个新的 Gin 实例,但底层的 HTTP 服务并不知道,它依然在死循环里使用启动时拿到的那个旧对象。
架构改造(引入"原子壳")
目标:
我们需要在不中断 TCP 连接(不重启端口监听)的情况下,让 HTTP Server 能够"切换"它的处理逻辑。
方案:
利用 gin.Engine 本身就是一个 http.Handler 的特性,设计一个中间层(壳)。
- 设计 AtomicHandler :
这是一个实现了 ServeHTTP 接口的结构体。它的作用仅仅是充当"代理人"。 - 工作原理 :
http.Server -> 永远只调用 AtomicHandler -> AtomicHandler 从原子变量里取出当前的 gin.Engine -> 处理请求。 - 切换机制 :
当配置更新时,我们在后台构建一个新的 Gin Engine,然后通过原子操作(atomic.Store)瞬间替换掉 AtomicHandler 内部持有的指针。
需要注意:
"先组装,后上线"。 必须保证 Router 对象已经装载了所有的中间件和路由规则后,才能把它交给 Handler 对外服务。
为什么要使用原子操作
如果不用原子操作:
- 正在处理
/api/v1/user的请求可能突然跳到新引擎 → 404 - 或者读到损坏的指针 → 整个服务 crash
- 或者部分用户看到新 API,部分看到旧 API → 不一致
用 atomic.Value:
- 所有新请求立即使用新路由
- 老请求继续用旧路由直到结束
- 零停机、零错误、强一致性
代码
结构体变化:
go
// AtomicHandler 是一个线程安全的 HTTP Handler 容器
type AtomicHandler struct {
handler atomic.Value // 存储 *gin.Engine
}
// ServeHTTP 实现 http.Handler 接口
// 每次请求进来,都从 atomic.Value 加载最新的 Router 处理
func (h *AtomicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if v := h.handler.Load(); v != nil {
v.(http.Handler).ServeHTTP(w, r) // 这里会得到并调用 gin.Engine
}
}
// Set 更新内部的 Handler
func (h *AtomicHandler) Set(handler http.Handler) {
h.handler.Store(handler)
}
// Server 结构体封装服务相关组件
type Server struct {
Router *gin.Engine // 仅用于构建路由配置,不直接给 http.Server 用
GatewayHandler *AtomicHandler // 新增:给 http.Server 用的动态 Handler
ConfigMgr *config.ConfigManager
TracingCleanup func(context.Context) error
LoadBalancer loadbalancer.LoadBalancer
HTTPProxy *proxy.HTTPProxy
}
这里自定义的AtomicHandler因为实现了ServeHTTP接口,所以可以被当成一个handler
初始化(initServer):
- 准备容器:先初始化 AtomicHandler,但暂时不给它具体的 Router,或者给它也无所谓,反正稍后要覆盖。
- 构建内核 :
- 调用 setupMiddleware:此时 s.Router 被创建(这是真身),加载全局中间件。
- 调用 setupRoutes:此时 s.Router 被填充了 API,对指定路由加载相应中间件
- 原子上线(关键一步) :
- 执行
s.GatewayHandler.Set(s.Router)。 - 此时,满载逻辑的 Router 才正式接管流量
- 执行
go
// initServer 初始化服务实例
func initServer(configMgr *config.ConfigManager) *Server {
cfg := configMgr.GetConfig() // 获取当前配置
// 初始化日志
logger.Init(logger.Config{
Level: cfg.Logger.Level,
FilePath: cfg.Logger.FilePath,
MaxSize: cfg.Logger.MaxSize,
MaxBackups: cfg.Logger.MaxBackups,
MaxAge: cfg.Logger.MaxAge,
Compress: cfg.Logger.Compress,
})
validateConfig(cfg) // 验证配置有效性
cache.Init(cfg) // 初始化缓存
observability.InitMetrics() // 初始化监控指标
health.InitHealthChecker(cfg) // 初始化健康检查
s := &Server{
ConfigMgr: configMgr,
GatewayHandler: &AtomicHandler{},
}
// 如果启用了 RBAC 认证,则初始化 RBAC
if cfg.Security.AuthMode == "rbac" && cfg.Security.RBAC.Enabled {
security.InitRBAC(cfg)
}
s.setupMiddleware(cfg) // 配置中间件
s.setupHTTPProxy(cfg) // 配置 HTTP 代理
s.setupRoutes(cfg) // 配置路由
s.GatewayHandler.Set(s.Router) // 初始化后保存当前的 handler
return s
}
服务启动(setup):
go
// start 启动服务
func (s *Server) start() {
cfg := s.ConfigMgr.GetConfig()
logStartupInfo(cfg)
listenAddr := ":" + cfg.Server.Port
logger.Info("服务开始监听", zap.String("address", listenAddr))
svr := &http.Server{
Addr: listenAddr,
Handler: s.GatewayHandler,
}
go func() {
if err := svr.ListenAndServe(); err != nil {
logger.Error("启动服务失败", zap.Error(err))
os.Exit(1)
}
}()
go StartMemoryMonitoring()
s.gracefulShutdown()
}
修正后的热更逻辑(refreshConfig):
逻辑与初始化完全一致:
- 后台构建新 Router。
- 注册新路由。
- Set 替换旧 Router。
go
// refreshConfig 刷新配置
func refreshConfig(server *Server, configMgr *config.ConfigManager) {
for newCfg := range configMgr.ConfigChan { // 检测到热更新 channel 中会获取到值,其余时间阻塞
logger.Info("正在刷新服务配置")
server.setupMiddleware(newCfg)
server.setupRoutes(newCfg)
server.HTTPProxy.RefreshLoadBalancer(newCfg)
health.GetGlobalHealthChecker().RefreshTargets(newCfg)
server.GatewayHandler.Set(server.Router) // 这里拿到修改后的 gin.Engine
logger.Info("服务配置刷新成功")
}
}
viper监听config文件变化
go
// 监听配置文件变化以实现热更新
v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
logger.Info("Configuration file changed", zap.String("file", e.Name))
newCfg := &Config{}
newV := viper.New()
newV.SetConfigFile(e.Name)
newV.SetConfigType("yaml")
setDefaultValues(newV)
configMgr.mutex.Lock() // 变更设置时加锁
configMgr.config = newCfg
configMgr.mutex.Unlock()
// 通知配置变更
select {
case configMgr.ConfigChan <- newCfg:
logger.Info("Configuration reload notification sent")
default:
logger.Warn("Config channel full, skipping notification")
}
})
十 缓存
缓存中间件 (CacheMiddleware)。
它的核心逻辑非常独特:它实现了一种基于热度阈值的缓存策略(Hotspot Caching),而不是常见的"见请求就缓存"。
下面我为你详细解析这段中间件的工作流程、核心逻辑以及它的设计意图。
1. 核心工作流程
中间件的执行顺序如下:
- 全局开关检查:首先检查配置 Caching.Enabled,如果关闭直接跳过。
- 规则匹配:根据当前请求路径 (path) 查找是否有对应的缓存规则 (GetCacheRuleByPath)。如果没规则或 HTTP 方法不匹配,跳过。
- 解析目标主机:解析路由规则获取 upstream 目标地址(用于监控标签或缓存键区分)。
- 请求计数 (Key Step):调用 IncrementRequestCount 增加该路径的访问计数。
- 检查缓存命中 :尝试读取缓存。
- 命中:记录 CacheHits 指标,直接返回缓存内容,Abort 终止后续处理。
- 未命中:继续向下执行。
- 阈值判断 (Unique Logic) :
- 关键逻辑:if count < int64(rule.Threshold)
- 如果当前请求次数未达到 配置的阈值,直接放行到后端,但不缓存响应。
- 捕获并建立缓存 :
- 如果达到了阈值,使用自定义的 responseWriter 劫持 c.Writer。
- 执行 c.Next() 让后端处理请求。
- 记录 CacheMisses 指标。
- 如果后端返回 200 OK,将捕获的响应内容写入 Redis 缓存。
2. 关键设计点解析
A. "热度阈值"策略 (Threshold)
go
// 增加计数
count := health.GetGlobalHealthChecker().IncrementRequestCount(..., rule.TTL)
// ... 检查缓存命中 ...
// 如果计数小于阈值,直接放行,不进行缓存操作
if count < int64(rule.Threshold) {
c.Next()
return
}
设计意图 :
这是这段代码最聪明的地方。通常的缓存策略是"第一次请求就缓存"。但这个中间件要求一个接口在 TTL 周期内被访问了 N 次(Threshold)之后,才会被视为"热点接口"并开始缓存。
- 优点 :
- 节省 Redis 内存:长尾流量(很少被访问的 URL)不会占用缓存空间。
- 防止缓存污染:避免一次性的爬虫请求或随机参数请求填满缓存。
- 场景:适用于 URL 参数非常多,但只有部分参数组合是热门查询的系统。
B. 响应劫持 (Response Hijacking)
go
writer := &responseWriter{ResponseWriter: c.Writer}
c.Writer = writer
c.Next()
// ...
content := writer.body.String()
技术细节 :
Gin 的默认 c.Writer 直接把数据写入网络连接,中间件无法回头读取已发送的数据。
- 实现方式:必须实现一个装饰器(responseWriter),重写 Write 方法。在将数据发给客户端的同时,把数据拷贝一份到内存(writer.body)中。
- 目的:为了在请求结束后,能够拿到完整的响应内容并调用 SetCache 存入 Redis。
C. 指标监控 (Observability)
go
observability.CacheHits.WithLabelValues(method, path, target).Inc()
observability.CacheMisses.WithLabelValues(method, path, target).Inc()
- 维度:监控不仅包含了 Method 和 Path,还包含了 target(上游主机)。这对于网关来说很重要,可以分析是哪个后端服务的接口命中率低
总结:该缓存机制的优劣势分析
优势 (Pros):
- 极高的资源利用率:通过 count < threshold 策略,确保每一字节的 Redis 内存都用在了真正的热点数据上。
- 高性能:底层使用 Redis 原子操作,上层逻辑简单直接,能够应对高并发。
- 可观测性好:集成了详细的日志(Zap)和监控指标(Prometheus),便于运维排查命中率低的问题。
劣势/风险 (Cons):
- 首个周期穿透:由于需要累积计数到阈值,在流量突增的初期(冷启动),会有一定量的请求直接打到后端,起不到瞬间保护作用(预热过程)。
- 内存开销(应用层):中间件在缓存写入时需要将 Response Body 完整拷贝到内存(writer.body),如果接口返回数据量巨大(如大文件下载),可能会导致网关服务 OOM(内存溢出)。
重点:代码问题
-
代码现状:
gopath := c.Request.URL.Path // 这里只获取路径,不包含查询参数 (?id=1)在 Go 的 net/http 和 Gin 中,
.Path只返回/api/v1/user,而丢弃 了?id=1。此外如果提交的是 Post 表单,也无法根据表单内容生成缓存
-
后果:
- 计数器共享(误判热度):用户 A 访问 ?id=1,用户 B 访问 ?id=2,计数器都在累加同一个 Key (/api/v1/user)。这可能还能接受(视作该接口整体热度)。
- 缓存键共享(严重数据泄露) :这是最致命的。Redis Key 是 mg:cache:GET:/api/v1/user。
- 第 N 次请求(触发阈值):用户访问 ?id=99(管理员信息)。网关缓存了管理员的 JSON。
- 第 N+1 次请求 :普通用户访问 ?id=1(普通用户信息)。网关发现 Key 存在,直接返回缓存中管理员的信息!
这被称为 缓存投毒 (Cache Poisoning) 或 数据串扰。
3.如何修复?
你需要 "区分规则匹配路径" 与 "缓存唯一键" 同时 只处理 Get 请求。
- 规则匹配路径 (path):保持使用 c.Request.URL.Path。因为你在配置文件里配置规则时,肯定是配 /api/v1/user,不可能把所有 ID 都配进去。
- 缓存/计数唯一键 (cacheKey):使用 c.Request.RequestURI,它包含路径和查询参数(例如 /api/v1/user?id=1)。
- 只处理 Get 请求:使用c.Request.Method = http.MethodGet 判断是否为 Get 请求,如果不是则不记录缓存
修正后的代码
go
func CacheMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !config.GetConfig().Caching.Enabled {
c.Next()
return
}
// 1. 用于查找配置规则的 Path (纯路径: /api/v1/user)
path := c.Request.URL.Path
// 2. 用于 Redis 缓存和计数的 Key (包含参数: /api/v1/user?id=1)
// RequestURI 包含了 Query String
requestURI := generateCacheKey(c) // 相当于 requestURI := c.Request.RequestURI 但排序
method := c.Request.Method
// [修改点]:强制只处理 GET 请求
// 如果不是 GET 请求,直接放行,不走缓存逻辑
// -------------------------------------------------------
if c.Request.Method != http.MethodGet {
c.Next()
return
}
// ... 省略 target 获取逻辑 ...
// -----------------------------------------------------------
// 关键修改:在操作 Redis 时,全部把 path 换成 requestURI
// -----------------------------------------------------------
// 3. 计数器:使用 requestURI,这样不同 ID 会分开计数
// (如果你希望所有 ID 共享计数但分开缓存,这里可以用 configPath,但下面必须用 requestURI 代替 path)
count := health.GetGlobalHealthChecker().IncrementRequestCount(c.Request.Context(), requestURI, rule.TTL)
logger.Debug("Request count", zap.String("uri", requestURI), zap.Int64("count", count))
// 4. 查缓存:必须使用 requestURI 代替 path,否则会发生数据串扰
if content, found := health.GetGlobalHealthChecker().CheckCache(c.Request.Context(), method, requestURI, target); found {
observability.CacheHits.WithLabelValues(method, configPath, target).Inc() // 监控依然可以用 configPath 聚合
c.String(http.StatusOK, content)
c.Abort()
return
}
if count < int64(rule.Threshold) {
c.Next()
return
}
writer := &responseWriter{ResponseWriter: c.Writer}
c.Writer = writer
c.Next()
observability.CacheMisses.WithLabelValues(method, configPath, target).Inc()
if c.Writer.Status() == http.StatusOK {
content := writer.body.String()
// 5. 写缓存:必须使用 requestURI 代替 path
err := health.GetGlobalHealthChecker().SetCache(c.Request.Context(), method, requestURI, content, rule.TTL)
if err != nil {
logger.Error("Failed to cache response", zap.Error(err))
}
}
}
}
// 辅助函数(可用):排序参数,生成规范化的 CacheKey
func generateCacheKey(c *gin.Context) string {
// 获取所有参数
params := c.Request.URL.Query()
// 这里的 Encode 方法默认会按 Key 字母顺序排序
sortedQuery := params.Encode()
if sortedQuery == "" {
return c.Request.URL.Path
}
return c.Request.URL.Path + "?" + sortedQuery
}