1. 为什么我要自己写一个请求转发层做后端服务拆分的朋友大概都遇到过这种局面单体应用跑得好好的某天业务要求把用户中心、订单、支付拆成独立服务拆分本身不难难的是拆分之后那一堆跨服务的调用怎么管。最开始我的做法很朴素在每个服务里硬编码下游地址谁需要调谁就自己写一段 HTTP 客户端代码。项目小的时候没问题等到服务数量上了两位数这套玩法立刻崩盘——地址散落在十几个代码库里改一个下游端口要发十几个服务的版本测试环境跟生产环境的地址还得靠配置文件区分一出问题排查半天找不到是谁在调谁。后来我想过直接上一套成熟的反向代理方案但评估下来发现两个问题一是配置驱动的方案对动态路由的支持不够灵活我们的场景里经常需要根据请求头、路径前缀、查询参数做组合判断纯静态配置写起来很别扭二是有些方案体量偏大为了一个转发功能引入一堆用不上的模块运维同事看着头疼。于是我决定用 Go 自己撸一个轻量级的请求转发与 API 网关也就是后来我们内部叫开的 reroute-app。这个项目的定位很明确一个用 Go 实现的、配置简单、性能足够、支持动态路由规则的请求转发层。它能做的事情包括按路径前缀把请求分发到不同后端、根据请求头做灰度分流、统一加上跨域和鉴权头、记录每个请求的耗时和状态码。适合谁参考我觉得是那些正在做服务拆分、需要一个轻量网关但又不想引入重型组件的中小团队以及想搞明白反向代理底层原理、准备自己动手写一个的开发者。哪怕你之前没接触过网关只要会写基本的 Go 代码跟着思路走也能搭出一个能用的版本。我先把话说在前头这不是一个要跟成熟网关产品比功能全面性的东西它的价值在于可控、可改、够轻。你需要什么能力就加什么能力不用为了一个转发需求去啃几百页的配置文档。下面我把自己从设计到落地踩过的路完整拆一遍包括选型时的纠结、核心代码的关键点、以及那些文档里不会写的坑。2. 整体设计与技术选型拆解2.1 为什么是 Go而不是别的语言选 Go 做这个转发层不是我拍脑袋决定的。请求转发这个场景有几个硬性要求高并发下不能因为线程模型拖后腿、单次请求的额外开销要尽可能小、部署要简单最好一个二进制丢上去就能跑。Go 的 goroutine 模型天然适合处理大量并发连接标准库net/http自带的ReverseProxy已经把反向代理的核心逻辑封装好了我们只需要在它外面包一层路由和中间件。相比之下如果用 Java 写JVM 启动和内存占用对一个小网关来说偏重用 Node 写CPU 密集型的头部处理在高并发下容易成为瓶颈用 Rust 写性能最好但开发迭代速度跟不上我们频繁调整路由规则的需求。这里有个细节值得说Go 标准库的httputil.ReverseProxy其实已经处理了连接复用、请求体流式转发、响应头透传这些容易出错的地方。很多人自己从零写转发逻辑结果在请求体较大时出现内存暴涨就是因为把整个 body 读进内存再转发。ReverseProxy用的是流式拷贝边读边写内存占用跟请求体大小无关。所以我的策略是站在标准库肩膀上做增量而不是重复造轮子。2.2 路由匹配方案前缀树还是正则路由匹配是网关的核心。我评估过三种方案正则匹配、前缀树Trie、以及简单的字符串前缀判断。正则最灵活但每次匹配都要编译执行性能最差而且规则写多了之后可读性急剧下降前缀树性能好但实现复杂度高对于路由数量在几十到几百这个量级的场景属于过度设计字符串前缀判断最简单配合请求头等条件的二次过滤完全够用。最终我选的是分层匹配第一层按路径前缀做快速筛选第二层在候选规则里按优先级依次检查请求头、查询参数等附加条件。这样既保证了常见场景下的匹配速度又保留了组合条件的灵活性。规则优先级用显式的整数字段控制数字越小越优先避免依赖规则书写顺序这种隐式行为——这一点很重要隐式顺序在规则多了之后极难维护。2.3 配置热加载文件监听还是接口推送网关的配置变更频率其实不低灰度规则调整、后端地址迁移都需要改配置。如果每次改配置都要重启进程那转发层就成了整个系统的稳定性短板。我一开始用的是定时轮询配置文件的方式每隔几秒检查文件修改时间变了就重新加载。这个方案简单但有个问题轮询间隔内配置不生效而且频繁读文件在配置多的时候有 IO 开销。后来改成文件监听加接口推送双通道本地开发时用文件监听改完保存立刻生效生产环境通过一个内部管理接口推送新配置推送后原子替换内存中的路由表。原子替换的关键是用atomic.Value或者读写锁保护路由表指针保证替换过程中正在处理的请求不会读到半新半旧的规则。我实测下来用读写锁的方案在规则数量几百条时读锁竞争几乎可以忽略因为绝大多数请求走的是读路径。2.4 中间件链的设计取舍转发前后的处理逻辑鉴权、日志、限流、头部改写我统一抽象成中间件。这里有个设计选择中间件是包在ReverseProxy外面还是通过ReverseProxy的Director和ModifyResponse钩子实现我的做法是两者结合——请求进入时的通用处理日志、鉴权放在外层中间件跟具体后端相关的头部改写放在Director里响应侧的统计放在ModifyResponse里。这样职责清晰外层中间件不关心转发到哪Director只关心怎么改请求。注意Director里修改请求时要特别小心Host字段。默认情况下ReverseProxy会把原始请求的 Host 透传给后端很多后端框架依赖 Host 做虚拟主机判断如果你在Director里不小心覆盖了它后端可能返回 404 或者路由到错误的站点。我踩过这个坑排查了大半天才发现是 Host 被改掉了。3. 核心细节解析与实操要点3.1 路由规则的数据结构怎么定路由规则的结构直接决定了后续扩展的难易。我最初设计得很简单只有路径前缀和目标地址两个字段结果第一版上线一周就发现不够用了——需要按请求头做灰度、需要给某些路由单独配超时、需要标记哪些路由要跳过鉴权。于是重新设计成下面这样type Route struct { ID string // 规则唯一标识便于日志追踪 Prefix string // 路径前缀 Priority int // 优先级越小越优先 Headers map[string]string // 必须匹配的请求头 Target string // 后端基础地址 StripPrefix bool // 转发时是否去掉路径前缀 Timeout time.Duration // 单条路由的超时 SkipAuth bool // 是否跳过鉴权中间件 }StripPrefix这个字段看着不起眼实际非常关键。假设网关对外暴露/api/user/profile后端服务实际监听的是/profile那转发时就必须把/api/user这段前缀剥掉。如果不剥后端收到的是完整路径路由匹配不上直接 404。反过来如果后端本身就期望收到完整路径比如它自己也要做路径判断那就不能剥。这个开关必须做成每条路由可配不能全局一刀切。Priority字段的引入是因为前缀匹配存在包含关系。比如/api和/api/user两条规则一个请求/api/user/list两条都能匹配上这时候就得靠优先级决定用哪条。我的约定是更具体的前缀配更小的优先级数字这样长前缀优先命中符合直觉。3.2 请求转发的路径拼接逻辑路径拼接是转发层最容易出 bug 的地方我见过不少自己写的转发代码在这里翻车。核心问题是目标地址可能带路径请求路径也可能带前缀两者怎么拼才不重不漏。我的处理逻辑分三步先根据StripPrefix决定请求路径要不要去掉前缀检查目标地址是否以/结尾请求路径是否以/开头避免拼出//这种双斜杠用url.JoinPath或者手动规范化保证最终路径干净。举个具体例子目标地址是http://backend:8080/svc请求路径是/api/user/listStripPrefix为 true 且前缀是/api。第一步剥掉/api得到/user/list第二步拼接得到http://backend:8080/svc/user/list。如果目标地址写成http://backend:8080/svc/带尾斜杠直接字符串拼接就会变成svc//user/list虽然多数后端能容忍但日志里看着难受某些严格的框架还会报错。所以规范化这一步不能省。实操心得路径拼接写完一定要用表格把各种边界情况列出来测一遍——目标带不带尾斜杠、请求带不带前缀、前缀是不是根路径/、请求路径正好等于前缀这几种组合覆盖到了基本就不会出问题。3.3 超时控制的三层设置超时没设好一个慢后端能把整个网关拖垮。我在三个层面都设了超时连接超时建立 TCP 连接的时间、响应头超时从发完请求到收到响应头的时间、整体超时单次请求的总时长。Go 的http.Transport支持前两个整体超时用context.WithTimeout控制。参数怎么定连接超时我设 2 秒因为内网服务之间建连通常毫秒级2 秒还没连上基本说明后端挂了或者网络有问题。响应头超时设 10 秒覆盖绝大多数正常请求。整体超时按路由配置默认 30 秒个别慢接口比如导出报表单独放宽到 120 秒。这里有个计算逻辑整体超时必须大于响应头超时否则响应头还没回来 context 就取消了日志里会出现一堆context deadline exceeded但其实后端还在正常处理排查时容易误判。transport : http.Transport{ DialContext: (net.Dialer{ Timeout: 2 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ResponseHeaderTimeout: 10 * time.Second, MaxIdleConns: 200, MaxIdleConnsPerHost: 50, IdleConnTimeout: 90 * time.Second, }MaxIdleConnsPerHost这个参数值得单独说。默认值是 2意味着对同一个后端最多保持 2 个空闲连接高并发下连接频繁创建销毁性能损耗明显。我调到 50 之后压测时 QPS 提升了将近三成。但也不能无脑调大连接池太大占用后端资源要根据后端能承受的并发来定。3.4 请求头与响应头的处理边界转发时哪些头要透传、哪些要改写、哪些要丢弃这个边界必须想清楚。我的规则是透传Content-Type、Accept、Authorization、自定义业务头这些后端需要。改写X-Forwarded-For追加客户端 IPX-Forwarded-Proto标记原始协议X-Request-ID如果没有就生成一个。丢弃Connection、Keep-Alive这类逐跳头hop-by-hop header它们只对当前这一跳连接有意义转发给后端反而会干扰连接管理。X-Forwarded-For的追加逻辑要注意如果请求已经带了X-Forwarded-For说明前面还有一层代理要在后面追加而不是覆盖格式是逗号分隔。后端取客户端真实 IP 时通常取第一个值。这里有个安全隐患——客户端可以伪造这个头所以如果网关前面没有可信代理取 IP 时不能完全信任这个字段得结合连接的对端地址判断。响应侧我主要做两件事一是统一加跨域头Access-Control-Allow-Origin等二是记录响应状态码和耗时。跨域头放在网关统一加的好处是后端服务不用各自处理但要注意预检请求OPTIONS的处理——网关要能直接响应预检不能转发给后端否则后端没配跨域逻辑就会失败。4. 实操过程与核心环节实现4.1 从零搭起一个可运行的转发骨架先搭最小可运行版本把转发链路跑通再逐步加功能。第一步是初始化项目结构和依赖Go 的模块管理很省心标准库之外基本不需要额外依赖这也是我选 Go 的原因之一——依赖少意味着供应链风险小、构建快。mkdir reroute-app cd reroute-app go mod init reroute-app主程序的核心就是构造ReverseProxy并挂到 HTTP 服务器上。关键代码如下func buildProxy(route Route) *httputil.ReverseProxy { target, _ : url.Parse(route.Target) proxy : httputil.ReverseProxy{ Director: func(req *http.Request) { req.URL.Scheme target.Scheme req.URL.Host target.Host req.URL.Path joinPath(target.Path, stripIfNeeded(req.URL.Path, route)) req.Host target.Host }, Transport: sharedTransport, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { log.Printf(proxy error route%s err%v, route.ID, err) w.WriteHeader(http.StatusBadGateway) }, } return proxy }ErrorHandler必须自定义默认的错误处理会直接返回 502 但不带任何上下文线上排查时你根本不知道是哪个路由、什么原因失败的。我在里面加了路由 ID 和错误详情配合请求 ID 能快速定位。4.2 路由表的加载与原子替换路由表我用一个atomic.Pointer指向一个切片加载新配置时构造新切片再原子替换指针。为什么用切片而不是 map因为匹配时要按优先级顺序遍历切片天然有序map 还得额外排序。规则数量在几百条以内时线性遍历的开销完全可以接受实测单次匹配在微秒级。type RouteTable struct { routes []Route } var currentTable atomic.Pointer[RouteTable] func reload(newRoutes []Route) { sort.Slice(newRoutes, func(i, j int) bool { return newRoutes[i].Priority newRoutes[j].Priority }) currentTable.Store(RouteTable{routes: newRoutes}) }排序在加载时做一次匹配时直接顺序遍历第一个满足所有条件的规则就是命中规则。这里有个细节排序要稳定优先级相同的规则保持原有顺序用sort.SliceStable更稳妥避免相同优先级的规则在多次加载后顺序漂移导致行为不一致。4.3 中间件链的组装顺序中间件的顺序直接影响行为我定的顺序是请求 ID 生成 → 访问日志 → 限流 → 鉴权 → 转发。为什么是这个顺序请求 ID 最先保证后续所有日志都能带上它访问日志在限流之前这样被限流拒绝的请求也有记录限流在鉴权之前因为限流的判断成本低先挡掉超额请求能减轻鉴权环节的压力鉴权最后因为它可能涉及远程调用成本最高。handler : proxyHandler handler authMiddleware(handler) handler rateLimitMiddleware(handler) handler accessLogMiddleware(handler) handler requestIDMiddleware(handler)注意 Go 中间件的包装顺序跟执行顺序是反的最后包装的最先执行。上面代码里requestIDMiddleware最后包装所以它最先执行符合我定的顺序。这个反直觉的点新手很容易搞错写完一定要打日志验证实际执行顺序。4.4 灰度分流的具体实现灰度分流是我用得最多的功能。实现方式是在路由规则里配Headers条件比如要求X-Gray: true才命中灰度路由。匹配逻辑是请求头里所有配置的键值对都匹配才算命中。这样运营侧通过给特定用户加请求头就能把流量导到灰度后端不需要改任何代码。func matchHeaders(req *http.Request, expected map[string]string) bool { for k, v : range expected { if req.Header.Get(k) ! v { return false } } return true }实际用的时候有个坑请求头名称大小写不敏感但 map 的键是大小写敏感的。如果配置里写x-gray而客户端发的是X-Gray直接比较会失败。Go 的Header.Get内部做了规范化所以用Get取值没问题但配置的键最好统一转成规范格式每个单词首字母大写避免配置书写不一致导致规则不生效。4.5 压测数据与性能调优记录骨架跑通后我做了一轮压测用的是一个简单的压测工具对网关发请求后端是一个返回固定 JSON 的服务。初始版本在 4 核机器上跑到 8000 QPS 左右CPU 占用 70%。分析后发现两个瓶颈一是每次请求都新建ReverseProxy实例二是日志用了同步写。优化措施ReverseProxy按路由缓存复用不要每次请求都构造日志改成带缓冲的异步写批量刷盘。优化后同样的机器跑到 12000 QPSCPU 占用降到 55%。这个提升幅度说明对象复用和日志异步化是网关类服务性价比最高的两个优化点改动量小收益大。提示压测时一定要观察 GC 情况。如果GOGC默认值下 GC 频繁可以适当调大GOGC或者用GOMEMLIMIT控制内存上限。我这边把GOGC调到 200 后GC 次数明显减少延迟的 P99 更稳定。5. 常见问题与排查技巧实录5.1 转发后后端收到的路径不对这是最高频的问题表现是后端返回 404 或者路由到错误接口。排查顺序先看网关日志里记录的原始路径和目标路径确认拼接逻辑是否符合预期再确认StripPrefix配置是否正确最后检查目标地址有没有多余的尾斜杠。我整理了一个速查表现象可能原因处理方式后端 404路径多了一段前缀StripPrefix未开启开启并确认前缀配置路径出现双斜杠目标地址带尾斜杠且请求以斜杠开头拼接前做规范化后端收到空路径请求路径正好等于前缀且被完全剥掉剥前缀后为空则补/部分请求正常部分 404多条规则前缀重叠优先级配错检查优先级长前缀配小数字5.2 偶发的 502 错误怎么定位502 说明网关连不上后端或者后端提前断开。偶发意味着不是配置错误而是连接层面的问题。我的排查路径是先看ErrorHandler里记录的原始错误如果是connection refused说明后端实例挂了或者端口不对如果是EOF或connection reset说明后端主动断开了连接常见于后端超时时间比网关短后端先超时断开而网关还在等。解决办法是让网关的超时略大于后端超时给后端留出处理余量。另外检查连接池的IdleConnTimeout如果后端会主动关闭空闲连接而网关不知道复用这些连接就会报错。把IdleConnTimeout设得比后端的空闲关闭时间短一些能减少这类问题。5.3 配置热加载后规则没生效配置推了但行为没变先确认推送接口返回成功且新配置通过了校验。我遇到过几次是配置校验太宽松规则里目标地址写错了也能通过加载后请求全部 502。后来加了加载时的校验目标地址必须能解析、前缀必须以/开头、优先级不能重复冲突。校验不通过直接拒绝加载并保留旧配置保证网关始终处于可用状态。还有一种情况是规则生效了但被更高优先级的规则覆盖了。排查时把当前生效的路由表按优先级打印出来对照请求路径看实际命中的是哪条一目了然。5.4 大文件上传时内存暴涨前面提过自己写转发逻辑容易把 body 读进内存。用ReverseProxy默认是流式的但如果中间件里对 body 做了读取比如鉴权时要读 body 验签就会破坏流式特性。我的处理是需要读 body 的中间件必须把读出来的内容重新塞回req.Body用io.NopCloser包装并且限制可读的 body 大小超过阈值的请求跳过 body 校验直接转发。这样既满足了鉴权需求又不会因为大文件把内存打爆。5.5 客户端断开后后端还在处理用户关掉页面网关这边的请求 context 会被取消但后端可能还在跑。如果后端不感知取消就会白白消耗资源。解决办法是在Director里把原始请求的 context 传给转发请求ReverseProxy会自动处理取消传播。但要注意有些后端框架对 context 取消的处理不完善可能出现日志里一堆取消错误。这种情况要跟后端团队对齐确认他们正确处理了 context。6. 我在实际落地中的几点体会这套东西从第一版跑起来到现在中间迭代了十几轮最大的感受是网关这类基础设施简单可靠比功能丰富重要得多。我见过太多团队一上来就想做一个大而全的网关结果配置复杂度爆炸最后没人敢改。reroute-app 我刻意控制功能边界只做转发、路由、鉴权、日志这几件事每加一个功能都问自己不加会不会死不会就先不加。另一个体会是可观测性要从第一天就做。请求 ID、访问日志、错误详情这三样东西早期觉得麻烦懒得加等到线上出问题抓瞎的时候才后悔。现在我的习惯是任何转发层代码先把日志和追踪埋好再写业务逻辑这个顺序不能反。最后分享一个配置管理的小技巧路由规则我建议用版本化管理每次变更记录变更人和变更原因出问题时能快速回滚到上一个版本。规则文件本身用 YAML 或者 JSON 都行关键是变更要有迹可循。我们内部是把规则文件放在代码仓库里走评审流程虽然多了一道手续但避免了谁在什么时候改了什么导致线上挂了这种扯皮。这套方案后续还能往几个方向扩展比如加基于响应状态码的自动重试、按后端健康状态做负载均衡、把路由规则做成可视化配置界面。但这些都是锦上添花核心的转发链路稳定了加什么都是在这个骨架上长肉。你要是也在做类似的事情建议先把最小链路跑通压测过再考虑功能扩展顺序别搞反。
阅读完成 · 觉得有帮助?