1. 从一次线上404说起:context-path与网关路由的碰撞
事情发生在某个再普通不过的周三下午。我们团队维护的一套微服务系统刚完成一轮迭代,把其中一个核心业务服务从单体拆成了独立服务,部署到测试环境后,前端同学反馈:所有经过网关访问这个服务的接口,全部返回404,但绕过网关直接访问服务实例的端口,接口又是通的。
这个现象非常典型——直连能通、过网关就404,几乎可以断定问题出在网关的转发环节,而不是业务服务本身。我们这套系统用的是 Spring Cloud Gateway 做统一入口,服务注册发现走的是注册中心,路由配置一部分是静态的 YAML,一部分是动态下发的。出问题的这个服务,恰好是唯一一个在application.yml里配置了server.servlet.context-path的服务。
排查过程不算复杂,但背后牵扯到的知识点却相当密集:网关的断言(Predicate)怎么匹配路径、StripPrefix过滤器怎么裁剪前缀、context-path在服务端是怎么参与路径解析的、注册中心里注册的到底是哪个路径。这几个环节只要有一个对不上,404 就会如约而至。我把这次排查的完整链路、每个环节的判断依据、以及最后落地的几种修复方案整理出来,希望能帮到正在被同类问题折磨的同学。
这篇文章适合三类人看:一是刚接触 Spring Cloud Gateway、对路由匹配机制还不太熟的同学;二是正在做服务拆分、被路径问题卡住的开发者;三是想系统梳理网关路径转发链路的架构同学。我会尽量把每个"为什么"讲透,而不是只丢一个结论让你抄。
2. 先搞清楚404到底是谁返回的
遇到404,第一反应不应该是改配置,而是先确认这个404是谁吐出来的。是网关自己没匹配到路由返回的?还是网关匹配到了路由、转发给下游服务后,下游服务返回的?这两种情况的排查方向完全不同。
2.1 网关层404与下游404的区分方法
最直接的办法是看响应体和响应头。Spring Cloud Gateway 在找不到匹配路由时,默认返回的是一个比较"干净"的404,响应体通常为空或者只有极简内容,而且不会带有下游服务特有的响应头。而下游服务返回的404,往往会带上该服务自己的错误结构,比如统一的 JSON 错误码、Content-Type是application/json、甚至带有服务名标识。
另一个更可靠的手段是打开网关的日志。把org.springframework.cloud.gateway这个包的日志级别调到DEBUG,你会看到类似Route matched或者No RoutePredicateHandlerMapping found这样的关键信息。如果日志里压根没有路由匹配成功的记录,那就是网关层没匹配上;如果匹配上了、转发了,但下游返回404,那问题就在下游的路径解析上。
我们当时的情况是:网关日志显示路由匹配成功了,请求也确实转发到了下游服务,但下游返回了404。这就把问题范围一下子缩小到了"转发过去的路径"和"下游期望的路径"不一致上。
2.2 用直连对比法快速定位
我习惯用一个笨但极其有效的办法:拿到网关转发时实际使用的目标 URL,然后手动用这个 URL 去直连下游服务。怎么拿到实际转发的 URL?在网关日志里,DEBUG级别会打印出转发目标地址;或者临时加一个全局过滤器,把ServerWebExchange里的GATEWAY_REQUEST_URL_ATTR打出来。
假设网关配置的路由是/api/order/**转发到order-service,而order-service配置了context-path: /order。那么网关转发过去的路径可能是/api/order/list,但下游服务实际期望的是/order/api/order/list或者/order/list——具体是哪个,取决于StripPrefix有没有配、配了几位。直连测试时,你分别拼出这几种路径去请求,哪个返回200,哪个就是下游真正期望的路径。这一步做完,问题基本就水落石出了。
提示:直连测试时记得带上和网关转发时一致的请求头,尤其是
Host和Content-Type,有些服务对Host有校验,会干扰判断。
3. context-path在服务端到底做了什么
很多同学对context-path的理解停留在"给接口加个前缀",但它在 Servlet 容器层面到底怎么参与路径解析,其实直接决定了网关该怎么配。这里必须把原理讲清楚,否则你只是记住了"要加个 StripPrefix",换个场景又会懵。
3.1 Servlet容器中的contextPath与servletPath
在 Servlet 规范里,一个请求的路径被拆成三部分:contextPath、servletPath、pathInfo。假设请求是/order/api/list,而应用配置了context-path: /order,那么容器解析结果是:contextPath = /order,servletPath = /api/list(在 Spring MVC 里通常整体作为映射路径处理),pathInfo一般为空。
关键点在于:Spring MVC 的DispatcherServlet在做 HandlerMapping 时,用的是去掉contextPath之后的路径。也就是说,你的 Controller 上写@RequestMapping("/api/list"),它匹配的是/api/list,而不是/order/api/list。context-path是容器层面的前缀,不参与 Controller 的映射匹配。
这就解释了一个常见困惑:为什么我配了context-path,Controller 里却不用写这个前缀?因为容器已经帮你剥掉了。
3.2 网关转发时路径是怎么拼的
网关这边,路由配置里的uri指向的是服务实例地址,比如lb://order-service。当请求/api/order/list进来,网关匹配到路由后,默认会把完整的原始路径转发给下游,除非你配置了StripPrefix之类的过滤器去裁剪。
所以默认情况下,下游收到的是/api/order/list。但下游服务配了context-path: /order,容器会认为contextPath应该是/order,可实际请求路径开头是/api,对不上。这时候容器会怎么处理?它会认为这个请求不属于本应用的 context,直接返回404。这就是我们那次问题的根因——网关转发的路径没有包含下游的 context-path 前缀,导致下游容器拒绝处理。
3.3 一个容易混淆的等价关系
这里有个等价关系必须记住:下游服务配了context-path: /order,等价于"所有接口的访问路径前面都要加/order"。网关要做的,就是保证转发过去的路径以/order开头。
有两种达成方式:一是路由的Path断言本身就带/order前缀,转发时原样带过去;二是断言不带,但通过RewritePath或SetPath过滤器在转发前把前缀补上。选哪种,取决于你的路由规划风格,后面会详细对比。
4. 路由断言、StripPrefix与context-path的三方博弈
把这三个东西放在一起看,很多404的成因就一目了然了。它们之间的关系可以用一句话概括:断言决定"什么样的请求进来",StripPrefix 决定"转发前砍掉几段",context-path 决定"下游要求路径以什么开头"。三者必须自洽。
4.1 StripPrefix的工作原理与计数陷阱
StripPrefix=1的意思是:把请求路径按/分割后,砍掉最前面的1段。注意,是段,不是字符。/api/order/list按/分割后是["", "api", "order", "list"](开头的空串不算有效段),StripPrefix=1砍掉api,剩下/order/list。
计数陷阱在于:很多人以为StripPrefix=1是砍掉第一个斜杠前的内容,其实它砍的是第一个有效路径段。如果你的断言是/api/**,那StripPrefix=1正好砍掉api;但如果断言是/api/v1/**,你想砍掉api/v1两段,就得写StripPrefix=2。数错段数,转发路径就会多一段或少一段,404 随之而来。
4.2 三种典型配置组合的对照
我把常见的配置组合和结果整理成表,方便你对照自己的场景:
| 断言 Path | StripPrefix | 下游 context-path | 下游收到路径 | 结果 |
|---|---|---|---|---|
/api/order/** | 0(不配) | /order | /api/order/list | 404,前缀不对 |
/api/order/** | 1 | /order | /order/list | 200,正确 |
/api/order/** | 2 | /order | /list | 404,砍多了 |
/order/** | 0 | /order | /order/list | 200,正确 |
/api/** | 1 | /order | /order/list(若原路径为/api/order/list) | 200,正确 |
看这张表你会发现,核心不是 StripPrefix 配几,而是"转发后的路径是否以 context-path 开头"。只要满足这个条件,怎么配都行。理解了这一点,你就不会再死记"context-path 要配 StripPrefix=1"这种口诀了。
4.3 为什么"直连能通"会误导人
直连测试时,你手动输入的路径往往已经带了/order前缀(因为你从服务日志或接口文档里看到的就是这个),所以能通。但网关转发时用的是另一套路径拼接逻辑,两者不一致,就出现了"直连通、过网关404"的诡异现象。这不是矛盾,而是你测试时用的路径和网关实际转发的路径根本不是一个。排查时一定要以网关实际转发的 URL为准,而不是以你脑子里以为的 URL 为准。
5. 完整排查链路:从404到定位根因
这一节我把当时的排查步骤完整还原,你可以照着这个顺序走一遍,基本能覆盖90%以上的同类问题。
5.1 第一步:确认404来源
打开网关 DEBUG 日志,搜索路由匹配记录。如果没匹配上,检查断言的Path是否写对、路由是否真的加载了(动态路由的话检查配置中心有没有下发成功)。如果匹配上了,进入下一步。
5.2 第二步:打印实际转发URL
临时加一个全局过滤器,优先级设低一点(Ordered.LOWEST_PRECEDENCE),在filter方法里打印exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)。这个属性里就是网关最终要请求的完整 URL。把它记下来。
5.3 第三步:直连验证下游期望路径
用上一步拿到的 URL 的 path 部分,直连下游服务实例。如果404,说明下游期望的路径和这个不一样。这时候去看下游服务的context-path配置,以及 Controller 的实际映射路径,反推出下游真正期望的完整路径。
5.4 第四步:反推网关配置该怎么改
对比"网关实际转发路径"和"下游期望路径",差异在哪就改哪。如果是少了 context-path 前缀,要么给断言加上这个前缀,要么用RewritePath补上;如果是多了前缀,用StripPrefix砍掉。改完重启网关,再走一遍验证。
5.5 第五步:回归测试所有相关路由
改完一个服务的路由后,别忘了检查同一网关下其他服务的路由有没有受影响。尤其是当你调整了全局过滤器或公共路由配置时,很容易误伤。我们当时就因为改了一个StripPrefix的默认值,导致另一个服务的路径被多砍了一段,又排查了半小时。
注意:动态路由场景下,改完配置后要确认配置中心推送成功、网关确实刷新了路由。有些团队的路由刷新有缓存或延迟,改完立刻测可能还是旧配置,白白浪费时间。
6. 几种修复方案的取舍与落地
定位到根因后,修复方案不止一种。选哪种,取决于你的路由规划风格和团队约定。我把几种方案摆出来,说说各自的适用场景和坑。
6.1 方案一:断言带context-path前缀,不裁剪
这是最直观的方案。路由断言直接写成/order/**,转发时不裁剪,下游收到/order/list,正好匹配 context-path。优点是配置简单、路径一目了然;缺点是网关对外暴露的路径带上了服务内部的前缀,如果这个前缀是内部命名、对外不友好,就不太合适。另外,如果多个服务都想用统一的对外前缀(比如都走/api),这个方案就不好统一了。
6.2 方案二:断言用对外前缀,StripPrefix裁剪后补context-path
这是最常见的方案。断言写/api/order/**,StripPrefix=1砍掉api,剩下/order/list,正好匹配。但如果对外前缀有多段,比如/api/v1/order/**,就要StripPrefix=2。这个方案的坑在于段数容易数错,而且一旦对外前缀调整,所有相关路由的 StripPrefix 都要跟着改,维护成本偏高。
6.3 方案三:用RewritePath精确重写
RewritePath用正则做路径重写,最灵活也最精确。比如:
filters: - RewritePath=/api/order/(?<segment>.*), /order/$\{segment}这个配置把/api/order/xxx重写成/order/xxx,不管后面跟什么都能正确拼接。优点是意图明确、不受段数影响;缺点是要写正则,对不熟悉正则的同学有一定门槛,而且正则写错会导致更难排查的问题。
6.4 方案四:统一在网关层剥离context-path
如果团队约定所有服务都不配context-path,把前缀统一交给网关管理,那问题从根上就不存在了。这是最"干净"的方案,但需要推动所有服务改造,存量系统迁移成本高。新项目建议优先考虑这种约定,能省掉大量路径对齐的麻烦。
我个人在项目里的选择是:新服务一律不配 context-path,对外路径由网关统一规划;存量服务如果已经配了,用 RewritePath 精确重写,而不是用 StripPrefix 数段数。这样既避免了段数陷阱,也让路径映射关系在配置里一眼可见。
7. 几个容易忽略的细节与踩坑记录
排查完这次问题后,我又陆续遇到过几个相关的坑,一并记下来,都是文档里不太会写、但实际很常见的。
7.1 注册中心里注册的路径带不带context-path
有些注册中心的健康检查或服务元数据里会带上 context-path,有些不会。如果你的网关是基于服务实例的元数据来拼路径的(比如自定义了负载均衡策略),那注册的路径信息就会影响转发结果。我们有一次就是因为服务注册时上报的路径带了 context-path,而网关又额外补了一次,导致路径里出现了两个/order,直接404。排查时记得看一眼注册中心里这个服务的元数据。
7.2 大小写与末尾斜杠的隐形差异
/Order和/order在默认配置下是不等价的,/order/list和/order/list/也可能被下游区别对待。网关转发时如果路径末尾多了或少了一个斜杠,下游的路径匹配就可能失败。这类问题在日志里看起来"路径明明一样",但就是404,非常折磨人。建议在网关层加一个规范化过滤器,统一处理末尾斜杠和大小写。
7.3 全局过滤器顺序对路径的影响
如果你同时用了StripPrefix和RewritePath,它们的执行顺序会影响最终路径。过滤器是按Order排序执行的,顺序不对,可能先重写再裁剪,结果完全不是你想要的。排查时把相关过滤器的Order值打出来,确认执行顺序符合预期。
7.4 本地能通、测试环境不通的环境差异
本地开发时,很多人图省事不配 context-path,或者用不同的路由配置,导致本地一切正常,一上测试环境就404。这类问题的根源是环境间配置不一致。建议把路由配置纳入版本管理,各环境用同一份基础配置,只覆盖必要的差异项,减少这类"环境玄学"。
8. 把路径对齐这件事做成规范
踩过几次坑之后,我在团队里推了一套简单的路径规范,执行下来同类问题基本绝迹了。核心就三条:第一,对外路径统一由网关规划,服务内部不感知对外前缀;第二,新服务不配 context-path,如果非要配,必须在服务文档里显式标注,并在网关路由配置里加注释说明;第三,所有涉及路径裁剪或重写的路由,必须写清楚"转发前路径"和"转发后路径"的对照注释,方便后来人一眼看懂。
这三条看起来简单,但真正落地后,路径相关的404从每月好几次降到了几乎为零。路径问题本质上不是技术难题,而是约定和沟通问题——只要转发前后的路径关系在配置里写得明明白白,谁来看都不会懵。
最后分享一个我常用的小技巧:在网关里加一个只在测试环境生效的调试端点,输入任意路径,返回它会匹配到哪条路由、最终转发成什么 URL。这个端点帮我们省下了大量"改配置-重启-测试"的循环时间,定位路径问题的效率提升非常明显。如果你也在维护网关,强烈建议做一个类似的工具,投入很小,回报很大。