
网关项目最容易被写成一锅粥的地方就是各种自定义过滤器。上周帮一个团队梳理代码看到他们用一个 200 行的自定义过滤器往请求头里塞环境标识我建议直接换成AddRequestHeaderX-Env, prod一行搞定对方看我的眼神像看一个骗子。这种情况见过太多次了大家一边吐槽网关逻辑臃肿、重复代码遍地一边不知道 Spring Cloud Gateway 自带的那 30 多个内置过滤器已经把大部分脏活干完了。这篇东西不聊源码深挖就是把我在实战里反复用到的内置过滤器按场景拆开讲明白配置套路、适用边界和踩过的坑目标是让你看完之后先翻官方文档找现成方案再决定要不要自己动手写过滤器把网关里的重复代码压缩掉八成。1. 先弄懂这批过滤器的共同套路GatewayFilterFactory 与 YAML 的关系1.1 名字不是乱起的后缀暴露了它的身份所有内置过滤器都有一个完整的类名比如AddRequestHeaderGatewayFilterFactory、RewritePathGatewayFilterFactory。Spring 的自动装配会识别GatewayFilterFactory这个后缀把前面的AddRequestHeader、RewritePath这些短名字作为 YAML 里可用的过滤器标识。所以你在配置文件里看到的每一个“过滤器”背后的本质都是一个工厂类它负责解析参数并生成真正的过滤器实例。这意味着什么意味着你不需要背 30 个类名只需要对着官方文档里那张xxx GatewayFilter Factory列表把后缀去掉就是 YAML 里能直接写的名字。后续自己写自定义过滤器的时候规范命名XxxGatewayFilterFactory也能享受同样的自动装配待遇。这个命名规律是理解整套过滤器体系的钥匙很多人在教程里抄配置抄了半天换一个过滤器就不会写了大概率就是没搞懂这一层映射关系。1.2 YAML 里 filters 列表的顺序就是过滤器的执行顺序同一个路由下可以挂多个过滤器它们会按 YAML 里写的先后顺序依次执行。前一个过滤器加工完请求后一个拿到的是加工之后的结果。举个例子spring: cloud: gateway: routes: - id: demo uri: http://127.0.0.1:8081 predicates: - Path/demo/** filters: - AddRequestHeaderX-Env, prod - AddRequestParameterfrom, gateway请求先被加上X-Env: prod请求头随后又多了fromgateway这个查询参数。这个顺序特性在实际生产里有大用途后面讲安全头清理时你会看到“先删后加”和“先加后删”的结果是完全不同的。另一个容易踩的坑是有人以为过滤器执行顺序靠数字控制内置过滤器确实可以在定义时通过ordered参数指定顺序但绝大多数情况下你不需要它按列表顺序摆放就够了强行指定顺序反而会让配置变得难以阅读。1.3 内置过滤器与 GlobalFilter 的分工要区分开内置过滤器是绑定在具体路由上的粒度细只对匹配到这个路由的请求生效。GlobalFilter 则对所有经过网关的请求生效适合做全局限流、统一鉴权、访问日志这类横切逻辑。这两类过滤器在执行模型上也不在一个队列里后面第五节我会专门讲它们的执行顺序问题这里你只需要记住一个大原则能用内置过滤器解决的需求不要用 GlobalFilter 手写。内置过滤器是官方维护的配置放在配置中心里改连重新编译都不用自定义 GlobalFilter 写起来一时爽维护起来就是给自己埋雷。2. 请求加工四件套RewritePath、StripPrefix、AddRequestParameter、AddRequestHeader 的实战微服务拆分的多了之后最头疼的一件事就是路径对不上。前端调网关的路径是/api/order/xxx但订单服务内部的真实路径可能是/order/xxx或者/v1/order/xxx。如果让每个下游服务都去兼容网关的路径等于把网关的活摊到了所有服务头上改一个路径恨不得联动十几个项目。正确的做法是在网关层用内置过滤器统一做路径适配。2.1 RewritePath 的正则替换改的是路径不是完整 URLRewritePath的作用是用正则表达式匹配请求路径然后替换成目标路径。filters: - RewritePath/api/order/(?segment.*), /$\{segment}这段配置的含义是把/api/order/后面的所有内容捕获到名为segment的组里然后在转发时替换到根路径下。比如请求/api/order/list下游拿到的就是/list。两个细节值得注意。第一(?segment.*)是正则命名分组替换串里用$\{segment}引用分组内容。在 YAML 文件里${segment}这种写法会被 Spring 的占位符解析机制当作外部配置项去查找查不到就直接报错所以必须写成$\{segment}做转义。这个坑我见过不止一次尤其是从官方文档直接复制配置到项目里时格式稍微一乱就启动失败。第二RewritePath只改写路径部分查询参数会原样透传。所以如果你脑子里的需求是“把路径和参数一起改掉”那你要找的其实是多个过滤器的组合而不是一个正则搞定一切。2.2 StripPrefix 的层级计数数错了就是事故StripPrefix用起来极其简单但特别容易数错。它的作用是去掉路径开头的 N 层前缀。filters: - StripPrefix2假设上游请求路径是/api/order-service/order/list网关把请求转发给lb://order-service服务时StripPrefix1→ 下游路径变成/order-service/order/listStripPrefix2→ 下游路径变成/order/list一个很典型的反面案例有人想把这个路径转发成/order/list脑子里想着“把项目前缀去掉”顺手写了StripPrefix1结果下游拿到的是/order-service/order/list整个请求 404。原因就是对“层级”这个概念没数清楚。我的经验是配StripPrefix之前先把下游接口的完整路径摊在纸上从第一个斜杠开始数数到跟接口文档一致为止而不是凭感觉。另外要提醒一点StripPrefix和RewritePath都能改路径但适用场景不同。前者是“固定砍掉前 N 段”规则简单粗暴后者依赖正则适合路径结构混乱、需要精细匹配的场景。2.3 AddRequestHeader 与 AddRequestParameter网关层补全上下文网关最常见的职责之一是统一鉴权鉴权解析出来的用户信息要往下游传。没有内置过滤器的时候大家会在每个服务里写一个拦截器解析 Token这种代码写一次没问题写在五个服务里就是纯负担。用AddRequestHeader可以在请求转发前把用户信息塞进请求头filters: - AddRequestHeaderX-User-Id, 10086 - AddRequestHeaderX-Tenant-Id, t_1024下游服务只要定义对应的请求头参数就能拿GetMapping(/order/list) public Result list(RequestHeader(X-User-Id) String userId, RequestHeader(X-Tenant-Id) String tenantId) { // 直接用不用再解析 Token }AddRequestParameter也是一样的套路只不过加工目标从请求头变成了查询参数适合给下游追加一些约定的公共参数比如渠道号、来源标识。这里有个真实的调试事故值得说。某次项目里下游服务一直拿不到X-Tenant-Id排查了半天最后发现路由上同时配了RemoveRequestHeader和AddRequestHeader而且顺序写反了——刚加上的头又被后面的RemoveRequestHeader给删掉了。这个经历让我在配置过滤器的顺序时养成了习惯先在脑子里把请求从头到尾走一遍每个过滤器加工完的结果是什么下一个过滤器又对它做了什么确认无误再发布。3. 投票防刷与幂等防重用 RequestRateLimiter 在网关拦住大部分重复请求最近看到一版“多人投票 浏览器本地防重复投票”的代码做法是在localStorage里存一个已投票的标记点击后直接禁用按钮。这种方案应付“用户手滑多点了一下”还行但本质上不设防刷新页面、清除缓存、换个浏览器标记就没了恶意脚本更是可以直接绕过前端逻辑。真正靠得住的防重与防刷必须放到服务端而网关是天然的第一道关卡。3.1 浏览器本地防重为什么不够网关限流补的是哪块本地标记最大的问题是“不可信”。用户侧的存储和逻辑都是可以篡改的前端禁按钮只是体验优化不是安全措施。服务端必须对同一用户或同一 IP 的请求频率做约束才能挡住脚本刷票和接口盗刷。把限流逻辑放到网关层的好处是不侵入业务代码不需要每个投票接口都写一遍改动成本最低。但要分清楚内置的RequestRateLimiter做的是限流——控制请求速率它不等同于“同一个用户重复投票直接拒绝”的防重。两者目标不同很多时候需要配合使用。下面先讲限流防重方案在 3.4 中展开。3.2 RequestRateLimiter 的令牌桶参数别照抄RequestRateLimiter底层用的是 Redis 令牌桶算法配置参数一共有三个replenishRate表示每秒补充多少令牌相当于平均允许的速率burstCapacity表示桶的容量决定瞬时突发能放到多大requestedTokens表示每次请求消耗几个令牌默认是 1。filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 redis-rate-limiter.requestedTokens: 1 key-resolver: #{clientKeyResolver}一个常见的误区是“照抄别人项目的参数”这两个数字必须结合业务场景算。比如投票接口预期正常用户一分钟操作 10 次那replenishRate设置成 0.2 都够了但如果是商品详情页这种高频读接口每秒 10 个令牌可能直接把自己用户给误伤了。我习惯先做一次简单的容量评估预估峰值 QPS 是多少允许的单用户频率是多少再反推这组参数。另外要特别注意RequestRateLimiter依赖spring-boot-starter-data-redis-reactive项目里如果没有这个依赖配置了限流过滤器启动时就要报错。还有一个容易被忽略的点key-resolver的值是一个 SpEL 表达式指向容器里一个实现了KeyResolver接口的 Bean。这个 Bean 没有配置或者返回的 key 为空限流就起不到预期效果而且网关默认在限流触发时返回 429。3.3 用 KeyResolver 把限流维度从 IP 升级到 userIdKeyResolver就是决定“按什么维度计数”的关键。默认大家会写按 IP 限流但实际生产里 IP 维度有很明显的缺陷NAT 环境下整个公司可能共享一个出口 IP一个正常的办公网用户群体合计请求几次就可能集体触发限流而对刷量的人来说换 IP 的成本太低了。更靠谱的思路是优先按用户身份限流。Bean public KeyResolver clientKeyResolver() { return exchange - { String userId exchange.getRequest().getHeaders().getFirst(X-User-Id); if (userId null || userId.isEmpty()) { return Mono.just(exchange.getRequest().getRemoteAddress() .getAddress().getHostAddress()); } return Mono.just(userId); }; }这个 KeyResolver 的逻辑是请求头里有用户 ID 就用用户 ID 限流没有就退回 IP。这样登录用户互不影响匿名用户也有兜底约束。注意KeyResolver的返回值是MonoString如果有多个维度组合比如用户 ID 接口路径直接把两个值拼成一个字符串返回就行。我见过有人在里面做耗时的远程调用这会让整个网关链路变慢能避免尽量避免。3.4 限流不等于防重真正防重还要靠请求指纹去重限流是把整体请求速率压在阈值内防重是“同一个操作第二次来的时候直接拒绝”。以投票为例用户连点两次第一次成功第二次要拒绝。RequestRateLimiter管不了这个它只关心一秒钟允许多少次两次间隔可能在阈值内就都放进去了。真正做服务端防重我的经验是走“请求指纹 Redis SETNX”的套路。在网关层自定义一个轻量过滤器把用户标识、接口路径、请求体摘要拼接起来算个哈希然后往 Redis 里写键String fingerprint userId : exchange.getRequest().getURI().getPath() : DigestUtils.md5DigestAsHex(bodyBytes); Boolean first redisTemplate.opsForValue() .setIfAbsent(fingerprint, 1, Duration.ofSeconds(10)); if (Boolean.FALSE.equals(first)) { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS); return exchange.getResponse().setComplete(); }这个方案对“同一用户短时间内重复投票同一操作”的场景几乎是无敌的窗口期过了键自动过期不影响后续再次正常操作。看到这里你应该明白了要少写重复代码不是不写代码而是把代码写在该写的地方。内置过滤器能搞定 80% 的通用需求剩下 20% 的业务语义比如防重指纹用自定义滤波器补齐这套组合下来你已经比大多数项目干净太多了。4. 安全与鉴权过滤器的降本增效清理伪造头、统一跳转、一键安全头网关对外暴露后所有请求头都是用户可控的。真实环境里客户端随便伪造一个X-User-Id想冒充管理员如果你不做清理直接透传给下游下游服务一旦信任这个头就是严重事故。这种事情不能依赖每个服务自己防网关这一层必须统一兜住。4.1 RemoveRequestHeader 和 RemoveRequestParameter转发前先消毒内置过滤器RemoveRequestHeader可以按名字删除请求头RemoveRequestParameter可以删除查询参数。在开放 API 场景下需要清理的黑名单头至少有这几个X-Forwarded-For、X-User-Id、X-Trace-Id。filters: - RemoveRequestHeaderX-Forwarded-For - RemoveRequestHeaderX-User-Id - RemoveRequestHeaderX-Trace-Id - AddRequestHeaderX-User-Id, 10086顺序很重要。上面的配置先删掉了客户端传入的伪造头随后AddRequestHeader注入网关自己解析出来的可信值。反过来如果先加后删你辛苦注入的可信值会被当垃圾清掉等于白费工夫。这是很多项目里“安全头配置形同虚设”的经典原因配置看起来有实际链路里根本没生效。4.2 SetStatus 与 RedirectTo把错误出口收口到网关下游服务返回的 401、403、500 页面格式五花八门靠每个服务统一风格是一件低性价比的事。内置过滤器SetStatus可以直接把响应状态码改掉RedirectTo则可以把请求重定向到一个固定地址。filters: - name: RedirectTo args: status: 302 url: https://login.example.com/login配置RedirectTo有两个前提容易忽略url必须带 schemahttps://或http://不然启动时不报错但运行时跳转异常状态码通常用 302 这类重定向标准码不要随口写个 200 跳转浏览器不会认的。如果只是想改状态码而不跳转直接用SetStatus简化处理。4.3 SecureHeaders 一行配置补齐安全响应头服务端返回的响应里应该包含X-Content-Type-Options、X-Frame-Options、Strict-Transport-Security这类安全头。只在一个服务里加还好微服务规模上来之后每个服务都配一遍就太蠢了。内置过滤器SecureHeaders就是干这个的一行配置给所有经过网关的响应自动补上默认的安全头集合。filters: - SecureHeaders它默认加上的头包括X-XSS-Protection: 1; modeblock、X-Frame-Options: DENY、X-Content-Type-Options: nosniff等。需要注意默认值不一定符合你公司的安全规范比如前端页面有跨域资源加载时Content-Security-Policy的默认策略可能过严这时候需要自定义SecureHeaders的配置项去覆盖默认值。我也看到过有人把这个过滤器加到所有路由上导致管理后台页面被 CSP 拦截排查半天才发现是安全头的锅。所以用SecureHeaders之前先跟安全团队对一遍默认值清单别盲目开启。5. 稳定性组合拳Retry、CircuitBreaker 与全局过滤器的协作顺序网关是流量的总入口稳定性比单个服务更敏感。一个下游抖动如果网关不做防护所有调用方会一起超时。内置的Retry、CircuitBreaker过滤器能帮你在网关层解决相当一部分稳定性问题但它们也有各自的脾气。5.1 Retry 过滤器能省的重试代码和它踩过的坑很多项目的重试逻辑写在业务代码或者 Feign 层但有些请求在网关转发阶段就失败了直接在网关重试最省事。内置Retry过滤器支持按状态码、按异常类型、按 HTTP 方法组合配置filters: - name: Retry args: retries: 3 statuses: BAD_GATEWAY methods: GET series: SERVER_ERROR exceptions: IOException.class参数含义retries是重试次数statuses需要重试的 HTTP 状态码methods限定哪些请求方法参与重试series表示按状态码系列匹配比如SERVER_ERROR匹配 5xxexceptions指定触发重试的异常类型要写全限定类名。必须强调一个原则重试 GET 请求基本安全重试 POST/PUT 有重复提交风险。如果下游接口不是幂等的POST 重试可能导致重复下单、重复扣款、重复投票。我在生产环境里的习惯是methods: GET或methods: GET, HEAD实在需要重试写操作先把下游接口改成幂等的否则一旦出事故重试帮的忙全都会变成事故倍数放大。5.2 CircuitBreaker 过滤器把 Resilience4j 接到网关的关键配置内置的CircuitBreaker过滤器底层走 Spring Cloud CircuitBreaker默认实现是 Resilience4j。它比手写熔断逻辑强在状态机、半开试探、滑动窗口这些都帮你实现了你要做的只是把它接到路由上filters: - name: CircuitBreaker args: name: orderBreaker fallbackUri: forward:/fallbackname是熔断器实例名字fallbackUri是指定降级后的跳转地址通常是一个网关自己处理的本地forward路径。配置熔断器参数的时候读/actuator/configprops或者直接看 Resilience4j 的配置项不要凭记忆写timeoutDuration这种不存在的属性经常有人在这里踩坑配了半天没效果才发现属性名写错了。生产里我一般会先给熔断器配置一个保守的失败率阈值比如 50%运行一段时间观察数据再逐步收紧。5.3 内置过滤器和 GlobalFilter 的执行顺序问题这是网关开发里最隐蔽的坑之一。很多人以为“内置过滤器先执行GlobalFilter 后执行”实际上GlobalFilter默认是排在所有路由级内置过滤器之后的。带来的问题很致命如果你把限流配置成路由级过滤器而鉴权写成了GlobalFilter那么未登录的请求会先经过RequestRateLimiter占用限流令牌然后才被鉴权拦截。恶意用户可以用一堆无效请求把限流额度消耗光导致正常用户无令牌可用。解决方式有两种。第一种让鉴权GlobalFilter实现Ordered接口并返回负数比如-100这样它就能排在内置过滤器之前执行。第二种把鉴权也做成路由级过滤器直接配置在filters列表里。我个人倾向于用第一种因为鉴权是全局逻辑绑定到每个路由上太啰嗦用负的Order值控制优先级最干净。排查这类问题的时候打开网关的 debug 日志看过滤器执行顺序一目了然比自己猜效率高得多。6. 30 个内置过滤器的场景分类速查表与三个高频坑看完前面几个核心场景最后给一张按场景分类的速查表。按官方文档数下来 3.x 版本内置过滤器大约 30 多个名字可能随版本有增减但核心的都在表里。这张表是我日常配置时翻得最勤的东西每次接到新需求先对着表找有没有现成方案。6.1 场景分类速查表场景典型内置过滤器一句话说明路径改写RewritePath、StripPrefix、PrefixPath、SetPath调整转发路径的前缀、后缀与整体结构请求头加工AddRequestHeader、RemoveRequestHeader、SetRequestHeader、MapRequestHeader增删改请求头MapRequestHeader可以做头值拷贝请求参数AddRequestParameter、RemoveRequestParameter注入或清理查询参数响应头加工AddResponseHeader、RemoveResponseHeader、SetResponseHeader、RewriteResponseHeader、DedupeResponseHeader统一加工响应头DedupeResponseHeader专治重复头安全SecureHeaders、RedirectTo、SetStatus、SetRequestHost安全头、跳转、状态码与 Host 重写限流与容错RequestRateLimiter、Retry、CircuitBreaker稳定性三件套会话与传输细节SaveSession、PreserveHostHeader、RemoveHopByHopHeaders处理 Session 与传输层头信息这张表里比较容易忽略的是SetRequestHost和PreserveHostHeader。前者可以在转发前重写请求的 Host 头后者则保留最原始请求的 Host 头具体场景取决于下游服务是否强依赖 Host 做虚拟主机路由。6.2 三个容易忽略的高频坑第一个坑是 CORS 响应头重复。多个下游服务各自配了Access-Control-Allow-Origin浏览器收到的响应里出现两个相同的 CORS 头直接判定请求失败。用DedupeResponseHeader在网关统一去重filters: - DedupeResponseHeaderAccess-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST第二个坑是RemoveHopByHopHeaders默认会清理Connection、Keep-Alive、Transfer-Encoding这类逐跳头。大多数情况下这是正确的但如果下游有特殊的长连接校验逻辑把希望保留的头显式列出来别等出问题了才去翻默认行为。第三个坑是SaveSession的使用时机。网关层一旦需要做 Session 保持必须显式调用SaveSession过滤器来确保 Session 状态被正确保存否则会话状态可能在转发后丢失。它是那种平时用不上、一旦用到忘了加就很致命的存在。6.3 什么时候该放弃内置过滤器自己写 GlobalFilter内置过滤器覆盖面虽广也不是万能的。需要读数据库做动态路由和黑白名单、需要跨多个路由做全局限流与审计、需要强烈的业务语义比如防重指纹这些场景就该考虑自定义。判断标准其实很简单如果逻辑只影响某一个路由优先查官方文档找内置过滤器如果逻辑影响所有路由且没法用配置组合实现再写GlobalFilter。我现在的习惯是接到需求先在文档里找现成过滤器找不到再动手写。这话听起来没什么技术含量但真的能让网关代码瘦掉一大圈。如果你手里也有一堆手写过滤器先不要急着重构按今天这张表把内置过滤器能覆盖的部分标出来删掉重复逻辑你会发现网关变干净的速度比想象中快得多。