网关这个词听起来挺唬人,很多刚接触后端或者基础设施的朋友一看到"从零写一个网关"就觉得这是大厂中间件团队才干的活。其实把外壳剥掉,网关的本质就是一个HTTP服务器加上一堆可插拔的过滤器,核心逻辑并不比写一个CRUD接口复杂多少。我自己第一次动手写网关是在一个内部服务治理的小项目里,当时的需求很朴素:把散落在各个服务里的鉴权、限流、日志埋点统一收口到一个入口层,避免每个业务服务都重复实现一遍。做完之后最大的感受是,网关真正的难点不在"写出来",而在"想清楚每一层该放什么、不该放什么"。
这篇内容适合有一定后端基础、想理解网关内部运转机制的开发者,也适合正在做服务治理、需要自研轻量网关的团队参考。我会从最核心的请求转发讲起,一路拆到过滤器链、路由匹配、负载均衡、限流熔断这些模块,每个部分都给出可落地的设计思路和关键代码骨架。不会堆砌框架API,而是把"为什么这么设计"讲透,让你看完能自己动手搭一个能跑起来的版本。
1. 先把网关的职责边界划清楚
1.1 网关到底该管什么
很多人一上来就想着把网关做成万能层,结果越写越臃肿,最后变成一个谁都不敢改的巨石。我在动手之前做的第一件事,是拿一张纸列出"请求从客户端到业务服务之间,哪些事情是横切的、每个服务都要做但又不想重复做的"。列完之后你会发现,真正属于网关的职责其实就那么几类。
第一类是流量入口相关的:路由转发、负载均衡、协议转换。这是网关最原始也最核心的职能,客户端只需要知道网关的地址,后面的服务拓扑对它是透明的。第二类是安全与访问控制:身份认证、权限校验、IP黑白名单。这些逻辑如果下沉到每个业务服务,一旦认证规则变更就要改几十个地方,放在网关统一处理最划算。第三类是稳定性保障:限流、熔断、降级、超时控制。网关是流量的必经之路,在这里做保护能挡住绝大部分异常流量,避免打垮后端。第四类是可观测性:访问日志、链路追踪埋点、指标采集。请求都经过网关,在这里统一记录比在每个服务里埋点要省事得多。
反过来,有些东西不该放进网关。比如业务逻辑编排、复杂的数据聚合、跟具体业务强相关的参数校验,这些放在网关里会让网关和业务耦合,违背了它作为基础设施层的定位。我见过有团队把订单状态机都塞进网关,后来业务一改网关就得跟着发版,非常痛苦。记住一个原则:网关只处理"跟具体业务无关的横切关注点"。
1.2 一个最小可用网关的组成
把职责理清之后,一个最小可用的网关其实就四个部分。监听层负责接收客户端连接、解析HTTP请求;路由层根据请求的路径、域名、Header等信息决定转发到哪个后端;过滤器链在转发前后执行一系列处理逻辑,鉴权、限流、日志都在这里;转发层把请求真正发到后端服务并把响应回传给客户端。
这四层的关系是串行的:请求进来先过监听层解析,然后进路由层匹配目标,匹配到了就依次执行过滤器链的pre阶段,再转发到后端,拿到响应后执行过滤器的post阶段,最后返回给客户端。理解了这条主链路,后面所有的扩展都是往这条链上挂东西。
提示:不要一开始就追求功能齐全。先把"能转发"这条最短路径跑通,再逐个往过滤器链里加能力,这样每一步都有可验证的结果,出问题也容易定位。
2. 请求转发这条主链路怎么搭
2.1 选一个靠谱的HTTP底层
写网关绕不开HTTP协议的处理,自己从Socket开始解析HTTP报文不是不行,但生产环境里没必要重复造这个轮子。主流做法是基于成熟的网络库来做,比如Java生态里的Netty,Go生态里的net/http或者fasthttp。选型的核心考量是异步非阻塞能力和背压处理,因为网关要扛的并发量通常远高于普通业务服务。
我当时的项目用的是Netty,原因是它对连接的管理足够细粒度,可以方便地做连接池、超时控制,而且事件驱动的模型在高并发下表现稳定。如果你用Go,标准库的net/http其实已经够用,配合goroutine天然就能处理高并发,开发效率更高。这里没有绝对的对错,关键看团队的技术栈和运维能力。
底层库选定之后,网关的监听层要做的事情就很清晰了:绑定端口、注册请求处理器、把接收到的HTTP请求解析成一个内部统一的数据结构。这个内部结构很重要,它要能承载方法、路径、Header、Query参数、Body等所有信息,后续的路由和过滤器都基于它来操作。
2.2 把请求抽象成统一的上下文对象
网关内部流转的不应该是一个原始的HTTP请求对象,而是一个请求上下文。这个上下文里除了请求本身,还要能挂载路由匹配结果、过滤器之间传递的数据、响应信息、以及一些元数据比如请求ID、开始时间戳。
为什么要做这层抽象?因为过滤器之间需要共享数据。比如鉴权过滤器解析出了用户ID,后面的限流过滤器可能要根据用户维度限流,日志过滤器要把用户ID记进日志。如果每个过滤器都去重新解析请求,既浪费性能又容易出错。有了上下文对象,前一个过滤器往里面塞数据,后一个直接取就行。
public class GatewayContext { private String requestId; private long startTime; private HttpRequest request; private HttpResponse response; private Route matchedRoute; private Map<String, Object> attributes = new HashMap<>(); public void setAttribute(String key, Object value) { attributes.put(key, value); } public Object getAttribute(String key) { return attributes.get(key); } }这个上下文对象会贯穿整个请求生命周期,从进入网关到返回响应,所有模块都围绕它工作。设计的时候要注意线程安全,如果底层是异步模型,同一个请求的处理可能跨多个线程,attributes用普通的HashMap就可能有并发问题,需要根据实际情况换成并发容器或者保证单线程处理。
2.3 转发时最容易踩的坑
转发看起来简单,就是把请求发到后端再把响应拿回来,但实际做的时候有几个坑特别容易踩。第一个是Header的处理。HTTP的Header里有不少是逐跳(hop-by-hop)的,比如Connection、Keep-Alive、Transfer-Encoding这些,它们只对当前这一跳连接有意义,转发的时候必须去掉,否则会干扰后端服务的连接管理。我一开始没处理这个,结果后端偶尔出现连接异常,排查了很久才发现是Transfer-Encoding被透传导致的。
第二个是Body的读取。如果过滤器需要读取Body内容(比如做参数校验或者签名验证),读完一次之后流就到底了,转发的时候Body就空了。解决办法是在读取时把Body缓存下来,转发时用缓存的内容重新构造请求。但缓存Body有内存风险,大文件上传场景要特别小心,通常的做法是设置一个阈值,超过阈值就不缓存直接透传。
第三个是超时控制。网关到后端的连接超时和读取超时必须分别设置,而且要和客户端的超时协调好。如果客户端超时是30秒,网关到后端超时设了60秒,那客户端早就断了网关还在等,白白占用资源。合理的做法是网关的超时略小于客户端超时,留出处理余量。
3. 路由匹配:网关的导航系统
3.1 路由规则的数据结构设计
路由的本质是"根据请求特征找到目标服务",所以核心是一个匹配规则加上一个目标地址。规则可以基于路径、域名、Header、Query参数等维度,目标地址则包含后端服务的地址列表和负载均衡策略。
设计路由数据结构的时候,我建议把匹配条件和目标配置分开。匹配条件是一组断言(Predicate),每个断言判断请求的某个维度是否满足;目标配置包含后端地址列表、负载均衡算法、超时参数等。这样设计的好处是路由规则可以灵活组合,比如"路径以/api/order开头 且 Header里带version=v2"这样的复合条件,用一组断言就能表达。
public class RouteDefinition { private String id; private List<Predicate> predicates; // 匹配条件 private String targetService; // 目标服务名 private List<String> targetUrls; // 后端地址列表 private String loadBalanceStrategy; // 负载均衡策略 private int connectTimeout; private int readTimeout; }路由配置通常放在配置中心或者数据库里,网关启动时加载,运行中支持动态刷新。动态刷新这一点很关键,否则每次改路由都要重启网关,可用性没法保证。
3.2 路径匹配的几种模式与性能取舍
路径匹配是最常用的路由维度,实现方式有好几种,性能和灵活性各不相同。最简单的是精确匹配,请求路径和配置路径完全相等才命中,用HashMap存的话查找是O(1),速度最快。但实际场景里精确匹配往往不够用,因为路径里经常带动态参数,比如/user/123/profile。
于是就有了前缀匹配和通配符匹配。前缀匹配是判断请求路径是否以某个前缀开头,实现简单,但要注意匹配顺序,否则短前缀会"吃掉"长前缀的请求。通配符匹配支持/user/*/profile这种模式,灵活性最高,但需要把模式编译成正则或者构建成前缀树来加速匹配。
我在项目里用的是**前缀树(Trie)**方案,把路径按斜杠分段构建成树,匹配时逐段下降。这样既能支持通配符,查找效率也接近O(路径段数),比逐个正则匹配快很多。如果路由数量不大(几百条以内),其实用有序列表加正则也能接受,不必过度设计。
| 匹配方式 | 实现复杂度 | 查找性能 | 适用场景 |
|---|---|---|---|
| 精确匹配 | 低 | O(1) | 路径固定的接口 |
| 前缀匹配 | 低 | O(n) | 按业务模块划分 |
| 通配符匹配 | 中 | O(n) | 路径含动态参数 |
| 前缀树 | 高 | O(段数) | 路由量大、性能敏感 |
3.3 路由匹配的优先级问题
当多条路由都能匹配同一个请求时,谁优先?这个问题不处理好会出现很诡异的现象。比如配置了/api/**转发到服务A,又配置了/api/order/**转发到服务B,一个请求/api/order/list到底走哪个?直觉上应该走更具体的B,但程序如果按配置顺序匹配,先遇到A就转发到A了。
解决办法是给路由定义优先级,匹配时按优先级排序,优先级高的先匹配。优先级的计算可以基于匹配条件的精确程度,比如精确匹配高于前缀匹配,长前缀高于短前缀。也可以让配置方显式指定优先级数字,简单直接。我倾向于自动计算加手动覆盖结合的方式,大部分情况自动算就够了,特殊情况允许手动指定。
注意:路由优先级一定要有明确的规则并且写进文档,否则运维改配置的时候很容易踩坑。我见过因为优先级不明确导致流量走错服务的线上事故,排查起来非常费劲。
4. 过滤器链:网关的能力扩展点
4.1 过滤器链的执行模型
过滤器链是网关最核心的扩展机制,鉴权、限流、日志、改写请求这些能力全部以过滤器的形式挂载。执行模型上,每个过滤器有两个执行时机:pre阶段在转发到后端之前执行,post阶段在拿到后端响应之后执行。所有过滤器的pre按顺序执行,post按逆序执行,形成一个类似栈的结构。
这个模型的好处是资源的申请和释放能对称。比如一个计时过滤器在pre阶段记录开始时间,在post阶段计算耗时并上报,天然配对。再比如一个连接管理过滤器在pre阶段从池里取连接,post阶段归还,也不会乱。
public interface GatewayFilter { // 返回true继续执行下一个过滤器,返回false中断 boolean pre(GatewayContext ctx); void post(GatewayContext ctx); }过滤器链的执行要支持中断。比如鉴权失败,就没必要继续往下走了,直接返回错误响应。这时候pre返回false,链停止执行,网关直接构造错误响应返回给客户端。同时要注意,已经执行过的过滤器的post阶段是否还要执行?我的做法是中断时不再执行后续post,但已经进入的过滤器要保证资源释放,所以更稳妥的设计是用try-finally包裹。
4.2 过滤器的排序与依赖管理
过滤器之间有先后顺序的要求。鉴权必须在限流之前吗?不一定,但通常鉴权在前更合理,因为未认证的请求直接拒绝,不用浪费限流的计算资源。日志过滤器通常要在最外层,这样能记录到所有请求,包括被其他过滤器拒绝的。
排序的实现方式有两种:一种是给过滤器打上order值,按值排序;另一种是用注解声明依赖,比如"我必须在某某过滤器之后"。order值简单直观,但过滤器多了之后数字容易冲突;依赖声明更灵活,但实现复杂。我建议中小规模用order值就够了,给每个过滤器预留足够的间隔(比如10、20、30),方便中间插入新的。
这里有个容易忽略的点:过滤器的异常处理。任何一个过滤器抛异常,都不能让整个网关崩掉。要在过滤器链的执行外层包一层异常捕获,把异常转成标准的错误响应,同时记录日志。我一开始没做这个,结果某个过滤器的一个空指针直接把整个网关进程搞挂了,教训很深刻。
4.3 内置过滤器的实现要点
鉴权过滤器的核心是从请求里提取凭证(通常是Header里的Token),验证有效性,解析出用户身份塞进上下文。验证逻辑可以本地校验(比如JWT验签),也可以远程调用认证服务。本地校验性能好但撤销困难,远程校验实时性好但有网络开销。常见的折中方案是本地校验加短过期时间,配合刷新机制。
限流过滤器要解决的是"单位时间内允许多少请求通过"。算法上令牌桶和漏桶是最常用的两种。令牌桶允许一定程度的突发流量,漏桶则严格平滑。实现上单机限流用内存计数器就行,分布式限流需要借助Redis之类的共享存储。限流的维度可以是IP、用户、接口路径,通常组合使用。
日志过滤器要记录请求的关键信息:请求ID、路径、方法、客户端IP、响应状态码、耗时。注意不要记录敏感信息比如密码、完整Token。日志的写入要异步,否则会拖慢请求处理。我一般用异步队列加批量写入的方式,对主链路的影响降到最低。
5. 负载均衡与后端健康检查
5.1 常见负载均衡算法的选择
路由匹配到目标服务后,如果后端有多个实例,就要决定把请求发给哪一个。轮询是最简单的,按顺序依次分发,适合后端实例性能相近的场景。随机实现也简单,但流量分布可能不均匀。加权轮询允许给性能强的实例更高权重,适合实例配置不一致的情况。最少连接把请求发给当前连接数最少的实例,适合请求处理时间差异大的场景。一致性哈希则用于需要会话保持的场景,同一用户的请求尽量落到同一实例。
选哪种取决于业务特点。无状态的服务用轮询或加权轮询就够了;有本地缓存或者会话状态的服务,一致性哈希更合适。我项目里默认用加权轮询,因为大部分服务是无状态的,加权能应对实例规格不一致的情况。
public interface LoadBalancer { String select(List<String> instances, GatewayContext ctx); } public class WeightedRoundRobin implements LoadBalancer { private AtomicInteger counter = new AtomicInteger(0); public String select(List<String> instances, GatewayContext ctx) { // 简化版:按权重展开后轮询 int index = counter.getAndIncrement() % instances.size(); return instances.get(Math.abs(index)); } }5.2 健康检查不能只靠被动发现
后端实例挂掉是常态,网关必须能感知到并把流量从故障实例上摘除。健康检查分主动和被动两种。主动检查是网关定期给后端发探测请求(比如访问一个健康检查接口),连续失败N次就标记为不健康。被动检查是根据实际转发的请求结果来判断,比如连续出现连接超时或5xx就临时摘除。
只做被动检查的问题是,故障实例在被摘除之前已经接收了一批失败请求,用户体验受损。只做主动检查的问题是,探测间隔内实例挂掉,这段时间的请求还是会失败。所以生产环境通常是两者结合:主动检查做基础的健康状态维护,被动检查做快速熔断。
摘除的实例不能永久拉黑,要有恢复机制。通常是标记为不健康后,隔一段时间放少量探测流量过去,成功了就恢复。这个"半开"状态的设计能避免实例恢复后流量一下子全压过来。
5.3 重试策略的边界
后端偶发失败时,网关自动重试能提升成功率。但重试不是无脑加就行,有几个边界必须守住。第一,只对幂等请求重试。GET、HEAD这类天然幂等,重试安全;POST如果后端没做幂等处理,重试可能导致重复下单,绝对不能随便重试。第二,重试要有次数上限和退避。连续重试3次还没成功基本可以放弃了,而且每次重试之间要间隔递增,避免给已经过载的后端雪上加霜。第三,重试要换实例。如果重试还发给同一个故障实例,那重试就没意义了,要从负载均衡器里重新选一个。
提示:重试次数和超时要一起算总账。如果单次超时1秒,重试3次,那最坏情况客户端要等3秒多。这个总时间必须小于客户端的超时,否则客户端早断了网关还在重试。
6. 限流熔断这些稳定性手段怎么落地
6.1 限流算法的实现细节
限流的核心是控制单位时间内的请求量。固定窗口最简单,每秒一个计数器,超过阈值就拒绝,但存在临界问题:窗口切换的瞬间可能放过两倍流量。滑动窗口把时间切成小格,统计最近一个窗口内的总量,平滑了临界问题,代价是要维护多个计数器。令牌桶以固定速率往桶里放令牌,请求来了取令牌,取不到就拒绝,桶的容量决定了能容忍多少突发。漏桶则是请求先进队列,以固定速率流出,超出队列容量就拒绝,输出绝对平滑。
我项目里用的是令牌桶,因为它对突发流量的容忍度更符合实际业务。实现上可以用一个原子变量记录当前令牌数,配合上次填充时间戳,每次请求时先根据时间差补充令牌再尝试扣减。
public class TokenBucket { private final long capacity; private final long ratePerSecond; private double tokens; private long lastRefillTime; public synchronized boolean tryAcquire() { refill(); if (tokens >= 1) { tokens -= 1; return true; } return false; } private void refill() { long now = System.currentTimeMillis(); double elapsed = (now - lastRefillTime) / 1000.0; tokens = Math.min(capacity, tokens + elapsed * ratePerSecond); lastRefillTime = now; } }单机限流用这个就够了,分布式限流要把令牌数放到Redis里,用Lua脚本保证原子性。分布式限流的性能开销比单机大不少,如果对精度要求不高,可以用"单机限流乘以实例数"的近似方案,简单且性能好。
6.2 熔断器的状态机
熔断是为了防止故障扩散。当某个后端服务的错误率超过阈值,网关直接拒绝发往它的请求,快速失败,避免请求堆积拖垮整个链路。熔断器有三个状态:关闭、打开、半开。关闭状态下正常放行,同时统计错误率;错误率超阈值就切到打开状态,所有请求直接拒绝;打开状态持续一段时间后切到半开,放少量请求试探,成功就回到关闭,失败就继续打开。
状态切换的阈值和时间窗口要调好。错误率阈值设太低会误熔断,正常波动就被切了;设太高又起不到保护作用。我一般从50%错误率、10秒统计窗口、30秒熔断时长开始调,根据实际表现微调。半开状态放行的请求数也要控制,通常几个就够了,太多会冲击还没恢复的服务。
6.3 降级与兜底响应
熔断之后请求被拒绝了,但用户不能看到一个空白页或者莫名其妙的错误。这时候需要降级,返回一个兜底的响应。降级响应可以是缓存里的旧数据、一个默认值、或者一个友好的提示。降级策略要针对具体接口配置,因为不同接口能接受的兜底方式不一样。
比如商品详情接口,后端挂了可以返回缓存里的商品信息,用户至少能看到东西;但下单接口就没法降级,只能明确告诉用户稍后再试。降级配置最好支持动态调整,出故障的时候能快速开启或关闭。
7. 配置管理与动态生效
7.1 配置的存储与加载
网关的配置包括路由规则、限流阈值、熔断参数、过滤器开关等,这些都不应该硬编码在代码里。常见的存储方案有配置文件、数据库、配置中心。配置文件简单但改配置要重启;数据库能动态读取但每次请求都查库不现实;配置中心(比如基于发布订阅的配置服务)能推送变更,是最适合网关的方案。
不管用哪种存储,网关内部都要有一份内存中的配置快照,请求处理时读内存,保证性能。配置变更时更新这份快照,用原子替换的方式避免读写冲突。我一般用一个volatile引用的配置对象,变更时构造新对象再整体替换,读的时候直接读引用,简单且线程安全。
7.2 动态刷新的实现方式
动态刷新有两种模式:推和拉。推是配置中心主动通知网关,实时性好但需要网关暴露接收接口;拉是网关定期去配置中心查询,实现简单但有延迟。实际项目里常用推拉结合:配置中心推送变更通知,网关收到通知后再去拉取完整配置,兼顾实时性和可靠性。
刷新的时候要注意平滑过渡。比如路由规则变更,正在处理中的请求应该继续用旧规则走完,新请求用新规则。如果直接替换配置,正在处理的请求可能读到一半配置变了,导致行为不一致。解决办法是请求开始时把用到的配置快照引用保存到上下文里,整个处理过程都用这个快照。
7.3 配置变更的灰度与回滚
配置变更也是变更,也可能出问题。一个错误的路由规则可能让所有流量走错服务。所以配置变更要支持灰度,先在一小部分实例上生效,观察没问题再全量。还要支持快速回滚,保留历史版本,出问题一键切回。
我踩过一个坑:有次改限流阈值,手抖少写了一个零,结果限流形同虚设,后端被打挂。从那以后我给关键配置加了范围校验,超出合理范围直接拒绝加载并告警。这个校验逻辑虽然简单,但能挡住很多低级错误。
8. 可观测性:出了问题怎么查
8.1 请求链路的完整记录
网关作为流量入口,是记录请求链路的最佳位置。每个请求进来时生成一个唯一的请求ID,这个ID要透传到后端服务,这样从网关日志到后端日志能串起来。请求ID的生成可以用UUID,也可以用雪花算法保证有序。透传的方式通常是放在Header里,后端服务从Header里取出来放进自己的日志上下文。
日志要记录的信息包括:请求ID、时间戳、客户端IP、请求方法、路径、目标服务、响应状态码、耗时、请求体大小、响应体大小。这些字段足够定位大部分问题。注意日志的格式要结构化,方便后续用日志系统检索分析,不要用纯文本拼接。
8.2 关键指标的采集
除了日志,还要采集指标用于监控告警。网关层面最关键的指标有:QPS、响应时间分布(P50、P95、P99)、错误率、各后端服务的健康状态、限流触发次数、熔断触发次数。这些指标按服务、按接口维度聚合,能快速发现异常。
指标的采集要注意基数控制。如果把请求路径里的动态参数也作为标签,指标基数会爆炸,监控系统扛不住。通常的做法是把路径归一化,比如/user/123/profile归一成/user/{id}/profile再作为标签。
8.3 排查问题的常用思路
线上出问题时,排查顺序一般是:先看网关的整体QPS和错误率,判断是全局问题还是局部问题;再看具体是哪个后端服务异常,看它的健康状态和响应时间;然后看是不是限流或熔断被触发了;最后结合请求ID去查具体请求的完整链路日志。
我遇到过一个典型问题:某个接口偶发超时,但后端服务的监控指标都正常。后来通过网关日志发现,超时的请求都集中在某几个后端实例上,进一步查发现这几个实例所在的机器网络有抖动。如果没有网关这层的细粒度日志,这个问题很难定位。所以网关的可观测性做得好,能省下大量排查时间。
9. 性能优化与压测
9.1 网关自身的性能瓶颈在哪
网关作为所有流量的必经之路,自身性能至关重要。常见的瓶颈有几个:线程模型,如果用同步阻塞模型,并发一高线程就被占满;内存分配,每个请求都创建大量临时对象会加重GC压力;序列化,如果网关要做协议转换,序列化的开销不可忽视;日志写入,同步写日志会阻塞请求处理。
优化的方向对应着来:用异步非阻塞的IO模型,减少线程等待;复用对象,用对象池减少分配;选择高效的序列化方式;日志异步化。我项目里把日志改成异步批量写入后,P99响应时间下降了将近30%,效果很明显。
9.2 压测该关注哪些指标
压测网关不能只看QPS,要综合看几个指标。吞吐量是每秒能处理的请求数,反映处理能力。响应时间要看P50、P95、P99,平均值会掩盖长尾问题。错误率在高压下是否上升,反映稳定性。资源占用包括CPU、内存、网络IO,看是否有瓶颈。GC情况如果用的是带GC的语言,要关注GC频率和停顿时间。
压测要逐步加压,从低并发开始,每档稳定运行一段时间再往上加,观察各项指标的变化曲线。找到性能拐点,也就是响应时间开始急剧上升、错误率开始上升的那个点,那个并发量就是网关的实际承载上限。留出安全余量,生产环境的容量规划不要贴着上限来。
9.3 几个实用的优化技巧
连接池复用:网关到后端的连接要复用,避免每个请求都新建连接。连接池的大小要根据后端处理能力和网络延迟来调,太小会成为瓶颈,太大又会给后端压力。
零拷贝:转发大文件或者大Body时,尽量避免数据在用户态和内核态之间多次拷贝。很多网络库提供了零拷贝的API,用好了能显著降低CPU占用。
本地缓存:路由匹配结果、鉴权结果这类可以缓存的数据,缓存在本地能减少重复计算。但要注意缓存的失效策略,配置变更时要及时清理。
预热:网关刚启动时,JIT还没编译、连接池还没建立、缓存还是空的,这时候性能最差。生产环境要做预热,启动后先跑一批请求把热点路径跑热,再接入真实流量。
10. 从能跑到能用还差什么
10.1 高可用部署
单点网关是致命的,网关挂了整个系统就不可用了。所以网关必须多实例部署,前面挂负载均衡。多实例之间要保证配置一致,通过配置中心统一下发。实例要能优雅上下线,下线时先把流量摘除,等在处理的请求处理完再关闭,避免请求丢失。
网关的部署位置也有讲究。可以部署在靠近客户端的地方做接入网关,也可以部署在服务集群内部做内部网关。大型系统往往是多级网关,边缘网关负责接入和安全,内部网关负责服务间的路由和治理。
10.2 安全加固
网关是暴露在外面的入口,安全加固不能马虎。请求大小限制要设置,防止超大请求打满内存。请求频率限制前面讲过了,是防刷的重要手段。Header校验要严格,防止请求头注入。TLS终止通常在网关做,证书管理要规范。访问控制要细化到接口级别,不同来源的请求权限不同。
还有一个容易被忽略的点是错误信息的暴露。网关返回的错误信息不要包含后端服务的内部细节,比如堆栈、内网地址,这些信息泄露出去会给攻击者提供线索。统一返回友好的错误码和提示即可。
10.3 版本演进与兼容
网关作为基础设施,一旦上线就有大量服务依赖它,所以向后兼容非常重要。新增功能要保证不影响现有行为,配置格式的变更要支持新旧共存一段时间。废弃某个功能要提前通知,给出迁移窗口。
我建议网关的配置和API都要有版本号,不同版本的行为明确区分。升级时先在小范围验证,确认兼容后再全量。回滚方案要提前准备好,出问题能快速退回旧版本。
写网关这件事,从零到能跑可能几天就够了,但从能跑到能用、好用,需要持续打磨。我最大的体会是,网关的复杂度不在于单个功能有多难,而在于各个模块之间的配合和边界划分。把职责边界划清楚,把扩展点设计好,剩下的就是往框架里填内容。每次加新功能前先问自己:这个逻辑该不该放在网关?放进来会不会让网关和业务耦合?想清楚这两个问题,能避免很多后期的返工。