先别急着背词表,咱把那些看一眼就发懵的 301、302、308 单独拎出来聊一聊。HTTP 状态码里,2xx 是"一切正常",4xx 是"客户端你错了",5xx 是"服务器我崩了",唯独 3xx 这批状态码最容易被忽略——因为它们不报错、不拒绝,反而是服务器在给客户端指路:"你要的资源不在这了,去那儿找吧。"这个"指路"的动作,就是重定向,而承载指路信息的语言,就是 3xx 重定向类状态码。
这篇文章是 HTTP 状态码系列的第三部分,重点剖析 3xx 家族里每一个成员:300、301、302、303、304、307、308。我会结合客户端开发、服务端配置、接口联调这些真实场景,讲清楚它们各自该在什么情况下用、浏览器会怎么处理、搜索引擎和客户端 SDK 会有什么反应,再配上 Nginx 配置和 curl 调试经验。适合后端开发、前端开发、客户端开发,以及所有被重定向循环折磨过的技术人。
1. 为什么会有 3xx:重定向机制的底层逻辑
1.1 资源位置变了,协议怎么通知客户端
HTTP 协议把互联网上的每个资源都抽象成一个 URL,相当于给每个资源发了一个门牌号。但门牌号是会变的:网站从 http:// 升级到 https://,接口从旧域名迁到新域名,某个下载文件挪了一个路径,甚至某个页面因为活动结束被合并到其他页面。这时候服务器不可能让客户端直接得到一个 404 了事,而是需要告诉客户端:"你原本想访问的资源还在,但地址已经不是这个了,你去新的地址请求。"
这个"通知"动作,在协议层面就是服务器在响应头里带上一个 Location 字段,写上最新的 URL,同时把状态码置为某个 3xx。浏览器或客户端收到后,根据状态码语义决定是自己直接跟着跳,还是把选择权交给用户。整个过程就是一次"客户端问路、服务器指路"的对话,而 Location 头就是那张写着新地址的便签。
有人会问:为什么不直接返回 200 然后把内容从新地址返回回来?这就要说到 URL 作为资源定位符的"身份属性"了。如果服务器默默返回内容,浏览器地址栏里还是旧地址,用户收藏夹里存的也是旧地址,下次访问还是得绕一圈,搜索引擎也无法把旧链接的权重转移给新链接。3xx 重定向看起来多了一次网络往返,但它换来了链接语义的清晰:浏览器地址栏更新了、客户端的 baseURL 改了、搜索爬虫也知道该把旧链接的收录转移到新链接上。
1.2 客户端面临的选择:自动跟进还是交给用户
客户端收到 3xx 之后,并不是所有场景都会乖乖自动跳。对于 GET 请求,浏览器几乎总是会自动跟随重定向,用户通常只会在地址栏看到 URL 变了一下,甚至都注意不到。但面对 POST 或者其他非幂等请求,浏览器就必须小心翼翼。
这里的关键在于"语义是否需要保留"。如果用户提交了一个 POST 表单,服务器返回 302 让客户端跳转到另一个地址,浏览器如果自动用 POST 再发一次新请求,新接口可能又被执行一次"写入操作",导致订单重复提交、数据重复创建。为了安全,浏览器内部对 302、303 有约定俗成的处理:把原本的 POST 转成 GET 再访问新地址。这个"方法改写"行为,正是衍生出 303 和 307/308 两个特殊状态码的直接原因——有的场景我们希望它改方法,有的场景我们死也不希望它改方法。
理解了这一点,3xx 家族里的核心分类就清晰了:一类是"位置变了,方法跟着变",一类是"位置变了,方法不许动",还有一类是 304 这种"位置没变,内容也没变,你去用缓存"。下面逐个拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 容易混淆的"永久/临时":301、302、303、307、308 逐一拆解
2.1 301 Moved Permanently:永久迁移的黄金标准
301 的语义是"资源已经永久搬迁了"。它背后传达的意思是:以后所有对该 URL 的访问,都不该再落到老地址,客户端、搜索引擎应直接用新地址替代旧地址。
实际使用中 301 最常见的场景,一个是域名更换,另一个是 http:// 跳 https://。比如旧域名 example.com 整体迁到 newexample.com,最合理的做法就是把旧域名的所有请求 301 到新域名的对应路径,让浏览器直接把地址栏换掉,让搜索引擎把旧域名的页面权重合并到新域名。我自己在维护一个个人博客时做过一次域名迁移,当时就是把 Nginx 里所有旧域名请求统一 return 301 到新域名,一周后搜索引擎的收录就开始转移到新域名上了。
但 301 有一个极其隐蔽的坑:它会改变请求方法。按 RFC 标准,301 既然暗示"永久移动",客户端通常会把原请求改写为 GET 再去访问新地址。也就是说,如果你对一个 POST 接口返回 301,客户端几乎一定会用 GET 再请求 Location 指向的新地址,新接口收到的可能就是 GET 而不是 POST。这在 GET 只读接口上问题不大,但如果是写接口,请务必避开 301,改用后面要说的 308。
另一个容易被忽略的坑是浏览器缓存 301。浏览器在收到 301 响应后,不只会跳转这一次,还会把"旧地址 = 新地址"这个映射关系缓存下来。以后用户再直接输入旧地址,浏览器可能连服务器都不请求,直接在本地完成跳转。这带来了一个调试噩梦:你改了服务端重定向规则,但本地浏览器仍然固守旧的 301 缓存,表现为"怎么改都没反应"。出现这种情况时,一般要强制刷新、清缓存或者用无痕窗口才能看到真实的重定向结果。
2.2 302 Found 和 307 Temporary Redirect:临时重定向的双胞胎
302 的语义是"资源临时在别处",它表示老地址仍然有效,只是这次临时让你去别的地方拿资源。典型的应用场景包括:未登录用户访问需要登录的页面时,临时跳转到登录页;某些 AB 实验里把一部分流量临时切到新页面;还有临时维护页面的切换。
但 302 有个历史遗留问题,它也是 HTTP 协议发展史上著名的"语义和实现分裂"。最初的 RFC 1945 和 2616 对 302 的描述不够精确,没明确规定客户端到底该不该把 POST 改写为 GET。实际结果是,几乎所有浏览器都心照不宣地做了同样的事:收到 302 后,不管原始方法是 POST 还是 PUT,几乎一律转成 GET 再访问新地址。这个行为在表单提交场景下是有意义的:用户 POST 提交表单后页面刷新成新页面,再点浏览器刷新也不会重复提交表单。所以后来规范干脆"追认"了这种用法,但为了把"临时重定向且允许改方法"和"临时重定向但不允许改方法"彻底区分开,RFC 7231 里重新定义了 302,并额外引入 307。
307 的语义和 302 几乎一模一样,唯一的本质区别就是:307 必须保持原始请求方法和请求体,不允许改写为 GET。一个登录跳转场景,如果用户刚好是 POST 某个接口时未登录,你用 302 可能就把 POST 弄丢了,用 307 则能保留原始 POST 请求体,在跳转后的新地址上用同样的方法和数据继续请求。因此,在接口设计中,凡是"临时跳转但必须保持原请求方式"的契约,都应该使用 307 而不是 302。
选 302 还是 307 的判断标准,可以简化成一句话:临时跳转且希望浏览器帮你把方法改成 GET,用 302;临时跳转且必须原封不动地把方法、请求体带到新地址,用 307。我见过不少团队在 API 网关里配置回源跳转时随手用了 302,结果上游收到的 POST 变成了 GET,排查问题排查到怀疑人生,所以这个细节值得你单独记一笔。
2.3 308 Permanent Redirect:非 GET 请求的永久重定向
如果说 307 是"不许改方法的临时跳转",那 308 就是"不许改方法的永久跳转"。它的语义和 301 一模一样,表示资源永久迁移,唯一区别仍然是:301 允许浏览器改写为 GET,308 则严格要求保持原始请求方法和请求体。
过去很长一段时间 308 都是个冷门状态码,因为大多数开发者只在 GET 页面跳转场景里混着用 301 就够用了。但近几年它越来越受重视,原因是前后端分离和微服务架构普及后,接口层面的重定向越来越多,而这些接口大量使用 POST/PUT/DELETE。比如你在网关层把 /api/v1/order 永久迁移到 /api/v2/order,如果直接返回 301,单体客户端 SDK 一旦发的是 POST,就会莫名变成 GET 请求,直接报 method not allowed。
我建议在服务端框架和网关里统一确立一个约定:页面跳转、GET 资源迁移用 301;非 GET 接口的永久迁移一律用 308。很多现代基础设施已经默认这么做了,比如部分云厂商的对象存储签名 URL 失效后会返回 307 让客户端用当前凭证重新获取,一些 API 网关的 redirect 插件也会按方法自动区分 301 和 308。说到底,308 不是怪癖,它是把 301 的"永久移动"语义从 GET 世界扩展到了所有 HTTP 方法上。
有一点要注意:308 的客户端适配度不如 301 那么普及。老一点的客户端库、代理服务器、甚至是某些测试工具,可能只认识 301/302,遇到 308 会傻眼。不过随着 HTTP 语义被越来越严格地实现,主流的浏览器、curl、Java/Go/Python 的 HTTP 库现在都支持 308 了,实际踩坑概率已经低了很多。
2.4 303 See Other:定位与业务流转的标准答案
303 的语义非常特殊:它告诉客户端"你要的资源在另一个 URI 上,请用 GET 方法去访问那个新地址"。注意,303 不像 301 那样暗示资源永久移动,也不像 302 那样暗示资源临时移动,它更强调"当前这个响应的结果请你换个地方看",而且不管原始请求方法是什么,一律用 GET 去新地址。
教科书级别的 303 用法是表单提交后的 Post/Redirect/Get 模式。用户 POST 提交下单请求,服务器处理成功后不直接返回一个静态确认页面,而是返回 303 指向"订单详情页"的 GET 地址。这样用户看到的是订单详情页 URL,按 F5 刷新也只是重新 GET 这个详情页,不会把上次的 POST 重新提交一遍,天然避免了重复下单。当年很多注册表单被用户狂点刷新,导致注册邮件轰炸的用户,就是没做 PRG 模式。
从开发者视角看,303 是一个"业务流转信号"而不是"资源迁移信号"。在设计后端接口时,如果某个 POST 接口执行完写操作后要让客户端去看一个新的 GET 资源,优先考虑返回 303 而不是 302。因为 302 在不同客户端上对方法的处理并不完全一致,虽然现代浏览器基本都改成 GET,但严谨起见,语义明确、行为统一的 303 更值得依赖。
顺带一提,300 和 305 这两个成员在实际场景中几乎见不到了。300 Multiple Choices 表示服务器有多种返回形式,客户端可以选一个,但现代 Web 已经不太用这种方式做内容协商,更多的用 Accept 请求头直接协商,所以 300 基本停留在 RFC 里。305 Use Proxy 因为代理语义在现实网络中会造成歧义,后来被废弃了。这两个状态码不用花太多心思,面试时知道它们"存在过"就够了。
3. 300 与 304:3xx 里的非典型成员
3.1 300 Multiple Choices:内容协商的博物馆展品
300 的本意是服务器有多个资源表示形式,比如同一篇文档的中文版、英文版、PDF 版、HTML 版,服务器不知道客户端想要哪个,干脆列出所有选项让客户端自己挑。在早期 HTTP 内容协商设计里,它可以配合响应体里的选项列表使用。
但实际开发中,几乎没有任何主流框架会真的返回 300。原因很简单:现代 HTTP 已经有了更精细的内容协商机制,客户端通过 Accept、Accept-Language、Accept-Encoding 等请求头告诉服务器自己想要什么,服务器直接选择匹配的内容返回 200 即可。与其让客户端在 300 选项里做二次选择,不如把选择过程前移到请求阶段。所以你在 Nginx、Spring MVC、Express 这些框架里几乎找不到主动 return 300 的用法。
我唯一一次在主流程里见到 300,是在某个冷门 API 的 SDK 文档里:那个接口支持 JSON、XML、Protobuf 三种返回格式,设计者想让客户端在不确定返回格式时先 GET 一次拿到可选格式列表,再带上 Accept 重放请求。这种设计不能说错,但对调用方很不友好,增加了请求次数和状态分支。后续他们还是改成了默认返回 JSON,其他格式用 Accept 协商,300 成了历史包袱。如果你的接口不是特殊的资源发现场景,建议别用 300 增加调用方的认知负担。
3.2 304 Not Modified:缓存验证中的关键角色
304 是 3xx 家族里最特别的一个,它甚至不伴随任何 Location 头,也不会让浏览器发生地址跳转,而是响应对缓存请求的验证——它告诉客户端:"你本地缓存的副本依然新鲜,直接用,不需要重新下载响应体。"
理解 304 要从 HTTP 缓存的"再验证"机制说起。客户端第一次请求资源 http://example.com/pic.png 时,服务器返回 200 和响应体,同时带上验证头,最常见的是 ETag(实体标签,相当于资源的版本号)和 Last-Modified(最后修改时间)。浏览器把响应体存进本地缓存。等客户端再次请求同一个 URL 时,浏览器会带上 If-None-Match: <上次的ETag> 或 If-Modified-Since: <上次的Last-Modified> 去问服务器:"我这个版本还行吗?"如果服务器确认资源没变,就返回一个 304 响应,响应体为空,浏览器直接使用本地缓存。
这个机制节省的流量非常可观。尤其对于图片、CSS、JS 这类静态资源,304 验证只需要几百字节的响应头,而不需要把整个资源再下载一遍。我在维护一个图片服务时统计过,热门图片的请求里 304 占比能到六成以上,正因为如此,Nginx 默认对静态资源开启的 etag on 和 if_modified_since 配置才那么重要。
要理解 304 和"服务器不返回状态码直接用浏览器本地过期时间"的区别,可以把缓存流程分为两种:强缓存和协商缓存。强缓存用 Cache-Control 和 Expires 控制,在缓存有效期内浏览器根本不发请求;协商缓存则用 ETag/Last-Modified 控制,每次都要发请求验证,但验证结果是 304 时响应体为空。如果你打开 DevTools 的 Network 面板,看到某个请求状态码是 304,Size 列显示 from disk cache,就说明协商缓存生效了。
3.3 304 实操:验证头和缓存策略怎么配
日常开发里真正需要手写 304 的机会不多,因为你用的框架和网关基本都会帮你处理静态资源的缓存验证。但一旦你写了后端接口,又希望让客户端对接口响应做协商缓存,就必须自己生成 ETag 或者 Last-Modified。这里给一个简单的判断逻辑:
- 如果响应体是动态生成的,每次内容都可能变,直接不要加缓存头,否则客户端拿到 304 后会用一份过期的 body。
- 如果响应体依赖某个数据库表或者文件,可以把数据版本号、MongoDB 的
_id生成策略、或者文件修改时间作为ETag。客户端带If-None-Match来请求时,比对一致就返回 304。
一个常见的坑是 ETag 的引号格式。规范里 ETag 的值必须用双引号包裹,比如 ETag: "abc123",但不少开发者自定义 header 时忘了加引号,或者用随机 UUID 当 ETag 导致每次请求都不一致,缓存完全失效。另外,Nginx 对静态资源默认生成的 ETag 里包含了 inode、文件大小和修改时间,如果你在集群里有多个 Web 服务器但文件不一致,可能会出现 A 服务器返回 200、B 服务器返回 304 的问题,跨机器部署时要注意同步文件。
还有一点,304 响应本身是不允许带响应体的,但可以带缓存控制头。比如服务器可以在 304 里更新 Cache-Control 或 ETag,让客户端调整后续的缓存策略。用 Go 的 http.ServeContent 或者 Spring 的 ResponseEntity 时,框架会自动处理这些细节;但如果你手写 Response,一定要确保 Content-Length: 0,不然客户端可能以为还有 body 要读,导致连接挂起或者解析错误。
4. 重定向实操:从 Nginx 到客户端的完整链路配置
4.1 Nginx 里的 return 和 rewrite 该怎么选
Nginx 几乎是最常配置重定向的服务端软件,但它同时提供 return 和 rewrite 指令,很多人搞不清区别,乱用之后就会出现"跳着跳着路径就没了"的怪问题。
return 指令是立即返回一个状态码和可选的 Location 头。比如:
nginx复制server {
listen 80;
server_name example.com;
return 301 https://newexample.com$request_uri;
}
这行配置会把 http://example.com/path?query=1 永久重定向到 https://newexample.com/path?query=1。return 的执行时机非常早,Nginx 不会再解析这个 server 块里后续的 location 规则,适合做域名级、HTTP 到 HTTPS 的整体跳转。
而 rewrite 指令是改写到内部请求的 URI,再配合 permanent 或 redirect 标志才产生重定向。比如:
nginx复制location /old/ {
rewrite ^/old/(.*)$ /new/$1 permanent;
}
rewrite ... permanent 会返回 301,rewrite ... redirect 会返回 302。这里有一个我踩过很深的坑:rewrite 指令如果写在 server 层,会因为 Nginx 内部的 URI 重写顺序,导致后续的 location 匹配发生意想不到的变化。例如 rewrite ^/api/(.*)$ /v2/$1 last; 会让 location /v2/ 块重新匹配,如果 v2 的 location 里又写了 rewrite,很容易转进死循环。相比之下,return 更简单、更省 Nginx CPU,因为它省去了解析正则和重新匹配 location 的过程。
我的建议是:能不用 rewrite 做跳转就不用,服务端重定向一律用 return,真正要改内部请求路径才用 rewrite。 你可以在 Nginx 配置里这样组织:
nginx复制server {
listen 80;
server_name legacy.example.com;
# 老域名所有请求 301 到新域名
return 301 https://www.newexample.com$request_uri;
}
server {
listen 443 ssl;
server_name www.newexample.com;
# 路径迁移用 return,配精确 location
location = /old/index.html {
return 301 /new/index.html;
}
location /files/ {
return 308 /downloads/$request_uri;
}
}
4.2 用 curl 和浏览器 DevTools 排查重定向链路
重定向排查最实用的工具是 curl,它的 -I 参数发送 HEAD 请求,配合 -L 参数可以让 curl 自动跟随重定向,并把每一跳的响应头打印出来。
bash复制curl -I -L https://newexample.com/old/path
执行后你会看到类似这样的输出:
code复制HTTP/1.1 301 Moved Permanently
Location: https://newexample.com/new/path
HTTP/1.1 200 OK
Content-Type: text/html
这就是一次重定向链路的完整视图。注意 -L 默认的最大跳转次数是 50,如果超过 50 次 curl 会报错,这本身就是发现重定向循环的一种方式。调试时我通常还会加上 --max-redirs 10 限制跳转次数,避免某个循环配置让 curl 一直跳到超时。
单看第一跳而不自动跟随可以用:
bash复制curl -I http://example.com/old
如果你的重点是看 POST 请求在重定向中各跳的方法变化,可以用 -X POST -d 'key=value' -v 打开详细模式,观察请求行里的 method 是不是变成了 GET:
bash复制curl -v -X POST -d 'a=1' -L http://example.com/submit
浏览器端则建议直接开 DevTools 的 Network 面板,勾选 Preserve log,然后刷新页面。所有 3xx 响应会显示在请求列表里,点击某一条查看 Headers 里的 Location 字段,并且在 Response 预览里看到实际请求顺序。Chrome DevTools 还会在重定向循环时直接提示 ERR_TOO_MANY_REDIRECTS,基本定位到有两条规则互相指。
还有一种常见的客户端问题是 304 被认为"请求失败"。很多前端代码里会写 if (response.status === 200) { ... },结果接口走协商缓存返回了 304,走到 else 分支被当成错误处理,页面出现间歇性空白。这种问题从服务端日志很难看出名堂,因为 304 本身不是错误,真正容易踩的是业务代码对 304 的处理缺失。排查时先确认服务端确实配置了 ETag,再在客户端责任代码里对 304 做显式处理,通常问题就解决了。
4.3 HTTPS 跳转、目录斜杠和签名 URL 的隐藏细节
HTTP 到 HTTPS 的跳转是每个站点几乎都有的配置,但这里有一个常年让人困惑的细节:要不要保留 $request_uri。用 return 301 https://$host$request_uri; 是最标准的写法,它会保留原始路径和查询参数。如果你写成 return 301 https://$host/;,那所有请求都跳转到根路径了,接口调用全都会失效。
另外要注意目录自动加斜杠的行为。当客户端请求 http://example.com/dir(没有结尾斜杠)而服务器上确实存在一个 dir 目录时,Nginx 默认会返回一个 301,跳转到 http://example.com/dir/。这个行为是静态文件服务器内置的,一般问题不大,但有坑:如果你后面又配置了全局 return 301 https://...,这两层跳转叠加,可能造成多余的跳转链。比如客户端请求 http://example.com/dir 先 301 到 http://example.com/dir/,再 301 到 https://example.com/dir/。要想一步到位,要么配置里把 80 端口的目录请求和 HTTPS 跳转都处理好,要么接受多一跳。
签名 URL 和临时凭证的场景则要特别小心状态码选择。比如对象存储的预签名 URL 过期后,服务器如果返回 301 或 302,签名信息会因为跳转而被浏览器带到新地址,可能泄露在服务器日志里。更合理的设计是返回 307,让客户端把原始请求方法和头带到新地址,同时配合 Location 使用新的签名参数。同样地,OAuth 授权流程里的重定向,通常也用 302 或 303 配合 Location 把授权码传给客户端,因为这是临时行为,不能用 301 把长期缓存给客户端。
5. 常见问题与排查技巧实录
5.1 重定向循环:分不清谁在指谁
重定向循环是最常见也最折磨人的问题,症状就是浏览器报 ERR_TOO_MANY_REDIRECTS。循环的成因无非几种:重定向规则里把请求指回原路径;两个规则互相指向;或者代理和源站对路径加斜杠的逻辑冲突。
排查思路很简单:先看日志里最后一跳的请求 URL 和 Location,然后顺着链路边往回复盘。我印象很深的一次事故是,Nginx 配置里 / 路径做了 HTTPS 强制跳转,同时 /app 路径又有一个 location rewrite 回 /,结果 http://example.com/app 在 HTTP 层跳到 HTTPS,又在 HTTPS 层被 rewrite 回 /app,前后互相指,客户端直接死循环。最终是靠 curl 限制 --max-redirs 打印出跳转序列,一眼就看到了两条规则互相指。
防止循环的核心是别写重叠的 rewrite 和 return 规则,尤其避免同一个请求在 rewrite 后被其他 location 再重写一次。如果非要同时使用,至少要在规则里加防循环的标记,例如 Nginx 里 max_redirects 或应用层的跳转计数。
5.2 老客户端只认 301/302,308 兼容性翻车
308 虽然语义严谨,但某些老旧的 HTTP 库、嵌入式客户端、或者自研代理并不认识它,遇到 308 时不会自动跳转,而是直接把 308 当错误返回。这种问题在浏览器端几乎遇不到,但在 STM32、RTOS 这类嵌入式 HTTP 库里很常见,因为很多轻量 HTTP 实现只会处理 200、301、302 这类最常规的状态码。
如果你的服务面向的是这种受限客户端,又确实需要保持 POST 方法的重定向,有两条路:一是放弃 308,退回到自定义响应体或者 307;二是把重定向逻辑放在客户端由业务代码做。实际操作中,我碰到过某个设备固件里的 HTTP 库连 307 都不支持,最后我们直接让接口返回 302,同时客户端 SDK 里对 302 加了个特殊标记,根据 Location 手动改发 POST 到新地址。说白了,协议是理想,兼容是现实,在覆盖老客户端时,状态码的选择要先想想对方认不认识。
5.3 301 被浏览器缓存,规则改了却还在跳旧地址
前面提过 301 会被浏览器缓存,这里单独拿出来说是因为它极其隐蔽。场景一般是这样:你在 Nginx 里配了一条 return 301 /new /latest,调试时发现路径不对,改成 /new /post 后重新加载,但在浏览器里访问 /new 还是跳到 /latest,清理了 Nginx 缓存也没用——其实问题不在服务端,而在浏览器本地缓存了 301 映射。
遇到这个情况,第一招是开无痕窗口验证服务端规则是否正确,如果正确就去掉浏览器缓存再看。Chrome 可以在 Network 面板里右键清空缓存,或者用 curl -I 直接看服务端返回到底是不是你改过的 Location。另外,如果你在浏览器控制台输入 location.href = '/new' 访问,也可能因为定向访问而直接被缓存命中,这时候不要怀疑后端配置,先清缓存。
5.4 304 和 206 混在一起,断点续传接口的缓存冲突
有一个 3xx 相关的进阶坑:当接口支持 Range 请求并且返回 206 Partial Content 时,如果你又在响应里配置了 ETag,客户端第二次请求带上多个 Range 头,服务器可能会不知道该返回 206 还是 304,表现就是某些文件下载时中途变 304,导致客户端读到空 body。
这种情况一般出现在文件下载、视频拖拽播放这类场景。解决办法是,在支持 Range 的接口上,要么不使用 ETag,要么让 ETag 能对资源内容做精确定位,并且在处理 If-None-Match 时区分是不是带 Range 的请求。Spring 的 ResourceHttpRequestHandler 默认处理得比较完善,但如果自己写的下载接口,最好在收到 If-None-Match 且没有 Range 时才返回 304,有 Range 时仍返回 206。
5.5 内网网关跳转后丢端口、丢路径问题
微服务场景下,客户端请求先打到网关,再由网关转发给下游服务。如果网关层配置了重定向,最容易出现的问题是 Location 里的端口和路径被改了。举例来说,网关对外暴露在 8080,下游服务内部监听 8081,下游返回一个 Location: http://servicea:8081/api/callback,如果网关不处理直接透传,客户端拿到 servicea:8081 这种内网地址就完全不可达。
处理办法很简单:网关层要在重写 Location 时把它对外暴露的 host、端口和路径拼回去。Spring Cloud Gateway 的 RewriteLocationResponseFilter 就是干这个的,Nginx 做反向代理时也要注意 proxy_redirect 指令。配置 proxy_redirect http://servicea:8081/ /; 可以把内网地址改写成相对路径或者代理服务器的前缀路径。这个坑在前后端联调时非常常见,尤其是用 Docker Compose 起一套服务,客户端反馈重定向地址是 localhost:8081 而不是 localhost:8080,多半就是缺了 proxy_redirect。
结尾:重定向状态码选择的本质,是语义契约的选择
写到这里,3xx 家族的每个成员基本都过了一遍。回头看,这一组状态码并不是简单的"服务器让你跳一下",而是服务器在跟客户端、搜索引擎、代理服务器定契约:301 和 308 说"以后别来了,去新地方",302 和 307 说"这次先到这,下次可能还回老地方",303 说"去新地方,但必须用 GET",304 说"你这缓存还能用,不用重新下载"。它们之间的细微差别,决定了你的接口能不能被正确调用、下载功能会不会偶发失败、搜索引擎能不能顺利把权重迁走。
我个人在实际操作中的体会是:把状态码当成接口文档的一部分来对待,别把它当纯协议细节。每写一个重定向,先问三个问题:这个跳转是永久的还是临时的?原请求方法还能不能保留?客户端是浏览器还是 SDK?三个问题的答案组合,基本就能锁死该用哪一个 3xx 状态码。配好规则之后,再顺手用 curl 打一遍重定向链,确认 Location 是完整的、没有循环、没有丢参数,这一套下来,大部分重定向相关的生产事故都能在发布前挡住。
最后再分享一个小技巧:很多 CI 流水线里可以加一个"重定向探测"步骤,用脚本批量请求所有配置了跳转的 URL,校验状态码和 Location 是否符合预期。这比所有跳转都等着人工点一遍要靠谱得多。HTTP 状态码是好东西,但只有对每一类状态码的语义都抠到细节,你才能真正把它变成客户端和服务器之间顺畅沟通的语言,而不是互相指责的口水仗。
