
HTTP 这玩意儿干我们这行的每天都在跟它打交道但真要把它聊透很多人脑子里其实是一团浆糊。面试的时候背几句“三次握手”“状态码 200 是成功”实际抓包一看一堆 304、206、Transfer-Encoding 混在一起立马就懵了。我写这篇东西不是要给你复述教科书而是想把我这些年调接口、排故障、做性能优化时踩过的 HTTP 相关的坑一个一个摊开来讲。不管你是刚入行的新手还是写了几年业务代码但一直没系统梳理过网络层的老手看完应该都能有点收获。核心就一件事把 HTTP 从“知道”变成“会用”从“背概念”变成“能排错”。1. 从一个诡异的 504 说起为什么超时排查总在绕圈子1.1 一次真实的线上超时排查经历前阵子有个朋友找我说他们线上一个接口偶尔报 504但后端服务的日志里完全看不到请求进来。他查了半天怀疑是网关的问题又怀疑是数据库慢查询折腾了一整天没结果。我让他先把完整的请求链路画出来从客户端到网关到服务再到下游依赖每一跳都标上超时配置。画完之后问题就很明显了网关到服务的读超时设了 3 秒但服务内部调下游的读超时设了 5 秒。也就是说网关 3 秒就断了服务还在那儿傻等下游返回等它拿到结果想往回写的时候连接早没了。这个案例特别典型。很多人排查 HTTP 超时问题习惯性地从“谁报错就查谁”入手但 HTTP 是链式调用上游的超时一定不能大于下游的超时否则上游断了下游还在跑白白浪费资源不说日志还特别难看——上游报 504下游报成功对不上账。1.2 超时配置的层级关系与常见误区HTTP 调用链上的超时配置本质上是一个漏斗模型。最外层是客户端超时往里是网关超时再往里是服务自身的处理超时最里面是服务调下游的超时。这个漏斗必须从外到内逐层递减每一层都要比它里面那层大但又要比它外面那层小。我见过太多项目在这块翻车。有的是所有层级都设成一样的 30 秒结果一出问题就是雪崩式超时因为没有任何一层能提前熔断。有的是外层设了 60 秒内层设了 5 秒看起来内层更严格应该没问题但实际上外层等 60 秒才放弃这 60 秒里连接一直占着并发一高连接池就爆了。注意超时配置不是拍脑袋定的要根据接口的 P99 响应时间来设。一般来说外层超时可以设成内层超时的 1.5 到 2 倍留出网络传输和序列化的时间。1.3 用 curl 和抓包工具定位超时断点排查超时问题最直接的办法就是用 curl 加上-w参数把各个阶段的时间打出来。比如curl -o /dev/null -s -w DNS解析: %{time_namelookup}s\nTCP连接: %{time_connect}s\nTLS握手: %{time_appconnect}s\n首字节: %{time_starttransfer}s\n总耗时: %{time_total}s\n https://example.com/api这一串输出能直接告诉你时间花在哪了。如果 DNS 解析就花了好几秒那问题在域名解析如果 TCP 连接慢可能是网络链路或者服务端 backlog 满了如果首字节慢但连接快那就是服务端处理逻辑的问题。再配合 tcpdump 抓包看 TCP 层的重传和窗口变化基本就能定位到是网络问题还是应用问题。我个人的习惯是先用 curl 看时间分布再用 tcpdump 确认有没有丢包重传最后才去翻应用日志。这个顺序能帮你省很多时间因为大部分“服务慢”的锅其实在网络层或者配置层。2. 状态码背后的潜台词别只看 200 和 4042.1 3xx 家族里最容易被忽视的 304 和 307大部分人看到 3xx 就想到 301 和 302 重定向但 304 和 307 在实际项目里出现的频率一点都不低。304 是协商缓存命中服务端告诉客户端“你本地那份还能用别重新下载了”。这个状态码如果出现在 API 接口上通常意味着你的缓存策略配错了——API 返回的数据应该是动态的不应该走协商缓存。307 和 302 的区别在于307 会保持原有的请求方法。302 在历史上很多客户端实现会把 POST 改成 GET虽然规范说不应该这样但现实就是这么混乱。所以如果你做的是 POST 请求的重定向用 307 更安全能保证方法不变、body 不丢。2.2 4xx 错误里藏着的客户端行为线索401 和 403 的区别经常被搞混。401 是“你没认证”403 是“你认证了但没权限”。这个区别在排查问题时很关键如果用户反馈“登录了还是打不开”先看是 401 还是 403。401 说明 token 没带上或者过期了403 说明 token 有效但权限不够。429 是限流状态码这个在微服务架构里特别常见。看到 429 不要急着去调大限流阈值先看看是不是有客户端在疯狂重试。我遇到过一次某个客户端在收到 429 之后没有退避而是立即重试结果把限流阈值打满正常请求全被拒了。后来在客户端加了指数退避才解决。2.3 5xx 不一定是服务端的锅502 和 504 是网关层最常报的两个错。502 是网关连不上后端504 是网关连上了但后端没在规定时间内返回。这两个错误看起来都是服务端的问题但有时候根因在客户端——比如客户端发了一个超大 body网关转发的时候超时了报 504但后端其实还在处理。还有一种情况是 500 错误里包含了敏感信息。有些框架默认会把异常堆栈直接返回给客户端这在生产环境是绝对不能接受的。我建议在网关层做一层统一的错误响应处理把所有 5xx 的 body 替换成通用的错误提示详细的堆栈只记在服务端日志里。状态码常见含义排查方向301/302永久/临时重定向检查 Location 头是否正确304协商缓存命中检查 Cache-Control 和 ETag 配置401未认证检查 Authorization 头是否携带403无权限检查 token 权限范围429限流检查客户端重试策略502网关连不上后端检查后端服务是否存活504网关等待后端超时检查后端处理时间和超时配置3. 请求头和响应头那些你天天见但未必真懂的字段3.1 Content-Type 里的 charset 到底该不该写Content-Type: application/json; charsetutf-8这个写法太常见了但 JSON 的默认编码就是 UTF-8规范里明确说了 charset 参数对 application/json 没有意义。写了不会出错但属于冗余信息。真正需要关注 charset 的是text/html和text/plain这类文本类型如果不指定浏览器可能会用本地默认编码去解析导致中文乱码。我在实际项目里遇到过一个问题服务端返回Content-Type: text/plain没带 charset客户端用 GBK 解码结果中文全乱了。后来统一在网关层给所有文本类型的响应加上charsetutf-8问题就没了。所以我的建议是JSON 接口不用写 charset但文本类接口一定要写。3.2 Transfer-Encoding 和 Content-Length 的互斥关系HTTP 响应里Transfer-Encoding: chunked和Content-Length不能同时出现。chunked 编码意味着 body 长度不确定服务端边生成边发送每个 chunk 前面带上长度。这种模式适合流式响应比如大文件下载或者 SSE。但有些老旧的客户端不支持 chunked这时候服务端就得把整个 body 生成完再算 Content-Length。如果 body 特别大内存就爆了。我见过一个导出接口数据量大的时候服务端 OOM就是因为框架默认用了 chunked但中间有个代理不支持代理把整个响应缓冲下来了。提示如果你的服务前面有 Nginx 或者类似的代理注意检查代理对 chunked 的支持情况。有些代理会强制把 chunked 转成带 Content-Length 的响应这时候大响应就会出问题。3.3 Cache-Control 的 max-age 和 s-maxage 有什么区别max-age是给客户端用的s-maxage是给共享缓存比如 CDN、代理用的。如果响应里同时有这两个共享缓存会优先用s-maxage。这个区别在做 CDN 缓存策略的时候特别重要。举个例子你有一个接口返回用户个人信息希望浏览器缓存 60 秒但 CDN 不要缓存。这时候可以设Cache-Control: max-age60, s-maxage0。这样浏览器会缓存 60 秒CDN 拿到 s-maxage0 就不缓存了。如果只写 max-age60CDN 也会跟着缓存 60 秒用户 A 的个人信息可能被返回给用户 B这就出大事了。3.4 Vary 头对缓存命中率的影响Vary头告诉缓存同一个 URL 根据哪些请求头来区分不同版本。最常见的是Vary: Accept-Encoding表示压缩和未压缩的版本要分开缓存。如果不写这个缓存可能把 gzip 压缩的响应返回给不支持 gzip 的客户端客户端解压失败。但 Vary 也不能乱写。我见过一个项目写了Vary: User-Agent结果每个浏览器的 UA 都不一样缓存命中率直接降到接近零。所以 Vary 的原则是只写那些真正影响响应内容的请求头不要为了保险把所有头都写进去。4. 连接管理Keep-Alive 不是万能药4.1 长连接什么时候该关什么时候该留HTTP/1.1 默认开启 Keep-Alive一个 TCP 连接可以复用多次请求。这听起来很美好但实际用起来有很多坑。首先长连接会占用服务端的文件描述符和内存如果客户端开了连接不发请求服务端资源就被白白占着。其次中间的网络设备比如负载均衡、防火墙可能有空闲超时连接看起来还在实际上已经被断了这时候发请求就会报错。我的经验是对于内部服务之间的调用长连接可以开但要设置合理的空闲超时比如 60 秒。对于面向公网的接口长连接要谨慎因为客户端的网络环境不可控连接可能随时断掉。可以在响应头里加Connection: close让客户端每次请求完就关连接虽然效率低一点但稳定性更好。4.2 连接池配置不当引发的血案连接池是长连接的进阶用法客户端维护一组到服务端的连接请求来了就从池子里拿一个。连接池的核心参数是最大连接数和最大空闲连接数。这两个参数设不好要么连接不够用导致请求排队要么连接太多把服务端压垮。我调过一个连接池最大连接数设了 200但服务端每个实例只能处理 50 个并发连接。结果就是客户端这边连接池里 200 个连接全建上了服务端那边有 150 个连接在排队等处理响应时间飙升。后来把最大连接数降到 40反而吞吐量上去了因为服务端不用在连接切换上浪费时间。注意连接池的最大连接数不是越大越好要根据服务端的处理能力来定。一个简单的估算方法是服务端实例数 × 每实例并发处理数 × 0.8就是客户端连接池的上限。4.3 HTTP/2 多路复用对连接数的改变HTTP/2 的多路复用让一个连接可以同时跑多个请求理论上客户端到同一个域名只需要一个连接就够了。这确实减少了很多连接建立的开销但也带来了新的问题如果这个连接出问题所有请求都受影响。而且 HTTP/2 的流控和优先级机制比较复杂配置不当反而会导致某些请求被饿死。我在一个项目里把 HTTP/2 打开之后发现大文件下载会阻塞小接口的请求。原因是 HTTP/2 的流控窗口是连接级别的大文件把窗口占满了小请求拿不到窗口。后来调整了流控窗口大小并且给不同优先级的请求设置了不同的权重才解决这个问题。5. 缓存机制用好了是性能利器用错了是事故源头5.1 强缓存和协商缓存的配合使用强缓存是Cache-Control: max-age或者Expires在有效期内客户端直接用本地副本根本不发请求。协商缓存是ETag或者Last-Modified客户端每次都要发请求问服务端“这个还能用吗”服务端说 304 就用本地的。这两种缓存要配合使用。对于不经常变的静态资源比如带 hash 的 JS 和 CSS 文件可以用强缓存设一年。对于 HTML 文件用协商缓存保证用户能及时拿到新版本。对于 API 接口一般不用缓存但如果有些接口数据变化不频繁可以用协商缓存减少数据传输量。5.2 ETag 生成策略对性能的影响ETag 的生成方式有两种一种是基于文件内容算 hash一种是基于文件的修改时间和大小。基于内容的 ETag 更准确但每次都要读文件算 hash对大文件来说开销很大。基于修改时间的 ETag 性能好但如果文件内容变了但修改时间没变比如从备份恢复就会出问题。Nginx 默认用的是“修改时间内容长度”的 ETag这个在大多数场景下够用了。但如果你的文件是通过 CI/CD 部署的每次部署都会更新修改时间那 ETag 就会变协商缓存就失效了。这时候可以考虑用内容 hash 作为 ETag或者在部署脚本里保持修改时间不变。5.3 缓存穿透和缓存雪崩在 HTTP 层的表现缓存穿透是指请求一个不存在的资源缓存层和存储层都没有每次请求都打到存储层。在 HTTP 层这表现为大量 404 响应而且这些 404 没有被缓存。解决办法是在缓存层也缓存空结果比如返回 404 的时候带上Cache-Control: max-age60这样短时间内相同的请求就不会再打到后端了。缓存雪崩是指大量缓存同时过期请求全部打到后端。在 HTTP 层这表现为某个时间点后端流量突然飙升。解决办法是给缓存过期时间加随机值比如原本 300 秒过期改成 300 秒加减 60 秒的随机值避免同时失效。6. 安全相关的头别等出事了才想起来加6.1 CORS 预检请求为什么总是失败CORS 是跨域资源共享浏览器在发跨域请求之前如果请求方法不是简单方法GET、HEAD、POST或者带了自定义头会先发一个 OPTIONS 预检请求。这个预检请求经常失败原因通常是服务端没有正确处理 OPTIONS 方法或者Access-Control-Allow-Origin没有正确设置。我见过一个项目服务端只处理了 GET 和 POSTOPTIONS 请求直接返回 405。浏览器看到预检失败就不发实际请求了。解决办法是在网关或者框架层面统一处理 OPTIONS 请求返回 204 并带上正确的 CORS 头。还有一个坑是Access-Control-Allow-Credentials: true和Access-Control-Allow-Origin: *不能同时使用。如果前端要带 cookie服务端必须指定具体的 Origin不能用通配符。6.2 CSP 头配置不当导致页面白屏CSP 是内容安全策略用来限制页面可以加载哪些资源。配置太松起不到安全作用配置太紧会导致页面白屏。我遇到过一次CSP 里写了default-src self结果页面引用的第三方字体和 CDN 上的 JS 全被拦了页面直接白屏。配置 CSP 的正确姿势是先设一个宽松的策略然后用Content-Security-Policy-Report-Only模式观察一段时间看看有哪些资源被拦了再逐步收紧。不要一上来就设最严格的策略那样很容易把正常功能搞挂。6.3 HSTS 的 max-age 设多长才合适HSTS 是强制客户端使用 HTTPS 的机制通过Strict-Transport-Security头来设置。max-age参数决定浏览器在多长时间内强制使用 HTTPS。设太短起不到作用设太长如果 HTTPS 出问题了想回退都回不去。我的建议是第一次上线 HSTS 的时候设一个比较短的时间比如 300 秒观察一段时间没问题再逐步加长。最终可以设到一年甚至更长。但一定要确保你的 HTTPS 配置是稳定的证书不会过期否则 HSTS 会把用户挡在门外。安全头作用常见配置Strict-Transport-Security强制 HTTPSmax-age31536000; includeSubDomainsContent-Security-Policy限制资源加载default-src self; script-src self unsafe-inlineX-Content-Type-Options禁止 MIME 嗅探nosniffX-Frame-Options防止点击劫持DENY 或 SAMEORIGINReferrer-Policy控制 Referer 发送strict-origin-when-cross-origin7. 调试工具链抓包、模拟、重放7.1 用 curl 模拟各种异常场景curl 不只是用来发请求的它还能模拟很多异常场景。比如用--max-time模拟超时用--limit-rate模拟慢速网络用-H模拟各种奇怪的请求头。我经常用 curl 来复现客户端报的问题因为 curl 的输出最干净没有浏览器那些额外的行为。# 模拟 3 秒超时 curl --max-time 3 https://example.com/api # 模拟 10KB/s 的慢速网络 curl --limit-rate 10k https://example.com/bigfile # 发送自定义头 curl -H X-Custom-Header: test https://example.com/api7.2 用抓包工具看 TCP 层的细节curl 能看到 HTTP 层的时间分布但看不到 TCP 层的细节。这时候就需要抓包工具了。我一般用 tcpdump 抓包然后用 Wireshark 分析。重点看几个东西TCP 重传、窗口大小变化、RST 包。如果看到大量重传说明网络质量有问题。如果窗口大小一直是零说明接收方处理不过来。如果看到 RST说明连接被强制断开了可能是服务端主动断的也可能是中间设备断的。7.3 用重放工具做回归测试重放工具可以把线上真实的请求录下来然后在测试环境重放。这对于排查那些“只在生产环境出现”的问题特别有用。我常用的做法是在网关层把出问题的请求的完整信息方法、URL、头、body记下来然后在测试环境用脚本重放看能不能复现。重放的时候要注意有些请求带的时间戳或者签名是有时效性的重放的时候需要重新生成。还有就是重放可能会产生副作用比如下单请求重放会重复下单所以重放之前一定要确认接口是幂等的或者在测试环境做好隔离。8. 性能优化从协议层到应用层的几个抓手8.1 压缩算法的选择gzip 还是 brotligzip 是标配几乎所有服务端和客户端都支持。brotli 是后来者压缩率比 gzip 高 15% 到 25%但需要客户端支持。现在主流浏览器都支持 brotli 了所以我的建议是同时开启 gzip 和 brotli让客户端通过Accept-Encoding头来选择。配置的时候要注意brotli 压缩级别不要设太高否则 CPU 开销会很大。一般来说静态资源可以用高一点的级别比如 9 到 11动态内容用低一点的级别比如 4 到 6。因为静态资源压缩一次可以缓存起来动态内容每次都要压缩CPU 开销更敏感。8.2 减少请求数的几种手段减少请求数是最直接的性能优化手段。常见的方法有合并 CSS 和 JS 文件、使用雪碧图、内联小图片为 base64。但这些方法都有代价合并文件会导致缓存粒度变粗改一个文件整个包都要重新下载base64 内联会增加 HTML 体积而且不能被单独缓存。我的经验是对于不经常变的第三方库可以合并成一个 vendor 包设长缓存。对于业务代码按路由拆分每个路由一个包这样改一个路由不会影响其他路由。图片的话小图标用雪碧图或者字体图标大图单独加载。8.3 预加载和预连接的使用时机link relpreload可以让浏览器提前加载关键资源link relpreconnect可以提前建立连接。这两个用好了能明显提升首屏速度但用不好会浪费带宽。预加载的原则是只预加载当前页面一定会用到的资源不要预加载可能用到的。比如首屏的 CSS 和字体可以预加载下面的图片就不要预加载了等滚动到了再加载。预连接的原则是只对确定会请求的域名做预连接不要对所有第三方域名都做否则会建立一堆没用的连接。9. 那些年我踩过的 HTTP 坑9.1 请求体太大导致 413 错误有一次前端上传图片一直报 413。查了半天发现是 Nginx 的client_max_body_size默认只有 1MB而图片有 2MB。改大这个配置就好了。但这个问题的坑在于Nginx 返回 413 的时候前端拿到的错误信息很不明确只说是请求失败没有具体原因。后来我们在网关层加了一个统一的错误处理把 413 转换成更友好的提示。9.2 重定向次数过多导致循环配置 HTTPS 跳转的时候如果不小心把 HTTP 到 HTTPS 的跳转和 HTTPS 到 HTTP 的跳转同时配上了就会形成死循环浏览器报“重定向次数过多”。这个问题的排查方法是看浏览器的网络面板看每次重定向的 Location 头找到循环的起点。9.3 时间戳精度问题导致的缓存失效有些服务端用秒级时间戳生成 ETag 或者 Last-Modified如果同一秒内文件被修改了两次第二次修改就不会被识别到。解决办法是用毫秒级时间戳或者直接用内容 hash。这个问题在自动化部署的场景下特别容易出现因为部署脚本执行很快同一秒内可能改多个文件。9.4 代理层对头部的修改导致的诡异问题代理层比如 Nginx、CDN有时候会修改请求头或响应头导致一些诡异的问题。比如代理把Accept-Encoding头去掉了服务端就不压缩了代理把Host头改了服务端就路由到错误的实例了。排查这类问题的关键是在每一层都打印完整的请求头和响应头对比看哪一层改了。10. 从 HTTP/1.1 到 HTTP/3升级之前要想清楚的事10.1 HTTP/2 的坑队头阻塞并没有完全解决HTTP/2 的多路复用解决了 HTTP/1.1 的应用层队头阻塞但 TCP 层的队头阻塞还在。如果一个 TCP 包丢了后面的包都要等重传所有流都被阻塞。这就是为什么在弱网环境下HTTP/2 的表现有时候还不如 HTTP/1.1。10.2 HTTP/3 的 QUIC 协议解决了什么HTTP/3 基于 QUIC 协议QUIC 是基于 UDP 的把流控和重传都放到了用户态。这样每个流独立处理丢包一个流丢包不影响其他流。而且 QUIC 把 TLS 握手和连接建立合并了建连速度更快。但 HTTP/3 的部署成本不低。首先需要服务端和客户端都支持其次有些网络设备会屏蔽 UDP 流量导致 QUIC 连不上需要回退到 TCP。我的建议是如果你的用户主要在移动端而且对弱网体验要求高可以尝试 HTTP/3。如果是内部服务HTTP/2 已经够用了。10.3 升级策略灰度、回退、监控升级 HTTP 版本一定要灰度。先让一小部分流量走新版本观察错误率和延迟指标。如果没问题再逐步扩大比例。同时要准备好回退方案一旦出问题能快速切回旧版本。监控要覆盖连接建立时间、首字节时间、错误率这几个关键指标这样才能及时发现异常。我个人在实际操作中的体会是HTTP 这东西光看文档是学不会的。你得真的去抓包、去调超时、去配缓存踩过几次坑之后那些概念才会变成你自己的东西。遇到问题不要慌按照“先看时间分布再看状态码最后看头部”的顺序排查大部分问题都能定位到。