做后端开发这几年,HTTP协议是我每天都要照面的东西。你可能会写接口、会调接口,但HTTP协议的理解如果停留在“发请求、收响应”这个层面,遇到真正的线上问题就特别容易卡壳。502是上游挂了还是网关超时?缓存明明设置了为什么还回源?Cookie莫名其妙的丢了一半?这些问题表面看五花八门,根子上都在HTTP协议的细节里。
前段时间我被几类问题反复折腾,干脆把HTTP从报文格式、连接管理、缓存机制、HTTPS握手、HTTP/2多路复用这些内容从头梳理了一遍。这篇文章不打算讲教科书式的定义,而是把我在实际开发和排查里对HTTP协议的全部理解做一个系统整理。从报文长什么样,到连接怎么复用,再到缓存和HTTPS的坑,一步步往后铺。适合刚接触网络协议的后端新人,也适合已经写了好几年接口但遇到协议细节还是有点懵的同学。
1. HTTP协议演进:每个版本都在解决特定问题
1.1 从HTTP/0.9到HTTP/3:协议版本背后的设计动机
HTTP协议现在有多个版本,很多人只知道“2比1快,3比2快”,但不知道快在哪里、为什么不直接一步到位。理解演进不是为了背历史,而是为了知道每个设计取舍到底在解决什么痛。
HTTP/0.9是最早的雏形,只有GET请求,响应也只是一段HTML文本,没有头部字段、没有状态码。那个年代的场景就是把文档从服务器拖下来展示,协议能做的事极少,但够用。
HTTP/1.0开始引入HEAD、POST这些方法,状态码、头部字段、Content-Type都是这个版本加入的。这时候协议才真正有了“表达语义”的能力,能告诉客户端我返回的是什么类型的资源、资源是否存在、是否被移动过。
HTTP/1.1是目前部署最广泛的版本,也是问题最多但大家最熟悉的版本。它带来了Host头字段(一台服务器可以挂多个域名)、持久连接(默认keep-alive,不用每次请求都重新握手)、分块传输编码、缓存控制、Range断点续传、管线化尝试。这个版本已经非常完备,但性能上有个致命问题:同一连接上的请求必须按顺序返回,这就是队头阻塞。
HTTP/2换了一套底层机制:二进制分帧、多路复用、头部压缩、服务端推送。它解决的是HTTP/1.1的应用层性能瓶颈,但依然跑在TCP之上,TCP的丢包重传仍然会阻塞所有数据流。
HTTP/3干脆踢开了TCP,基于UDP实现了QUIC协议,把传输层和应用层一起重做,目标是彻底解决TCP层面的队头阻塞,同时带来更快的连接建立和连接迁移能力。
1.2 版本演进的知识在排查里到底怎么用
很多人觉得了解协议演进没什么实际用处,其实恰恰相反。排查慢接口的时候,如果你知道HTTP/1.1一个域名只能开6个左右并发连接,就能理解为什么页面上图片一多就被排队阻塞。如果你知道HTTP/2可以把多路请求复用在一个连接上,就明白为什么某些场景下合并域名反而适得其反。
我遇到过最典型的例子:某个图片服务在同一域名下挂了上百张小图,页面加载极慢。在HTTP/1.1下,浏览器对同一域名最多开6个连接,所有图片只能挤在这6条链路里排队下载,这就是队头阻塞的宏观表现。当时把资源切到HTTP/2之后,所有图片在一个TCP连接上并行传输,加载时间直接砍了一半以上。
版本演进的知识不是一个孤立的知识点,它是一张地图。知道每个版本解决什么问题,就能在遇到对应瓶颈时快速定位到正确的层级。比如首屏加载慢,先看是连接建立慢、TLS握手慢、还是响应体下载慢,每一段都对应不同的协议机制和排查手段。
2. 报文拆解:请求与响应的完整面貌
2.1 请求报文的结构与真实案例
HTTP请求报文的结构看起来很简单,就四部分:请求行、请求头部、空行、请求体。但实际调试时,很多人连请求行里包含什么都说不全,更别说理解每个头部字段的作用。
一个典型的POST请求报文长这样:
POST /api/login HTTP/1.1 Host: api.example.com Content-Type: application/json User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Accept: application/json Authorization: Bearer eyJhbGciOi... Content-Length: 36 {"username":"demo","password":"123456"}请求行由三部分组成:方法、URI、协议版本。这个例子是POST请求,URI是/api/login,协议版本是HTTP/1.1。注意Host头是HTTP/1.1规范里唯一必须存在的请求头,因为一台服务器可能托管多个域名,服务器需要靠Host来区分请求属于哪个虚拟主机。
Content-Type和Content-Length在带请求体的POST里特别关键。Content-Type告诉服务器正文的格式,比如application/json、application/x-www-form-urlencoded、multipart/form-data。Content-Length标识正文长度,服务器读够这个字节数就知道请求体结束。如果这两个字段不匹配,服务器端就会出现解析错误或请求体截断。
我曾经调一个上传接口时怎么都报400,检查了半天发现是Content-Length比实际请求体多了两个字节。原因是在代码里手动拼接请求体时,字符串里多了两个不可见字符。这种问题靠肉眼根本看不出来,后来用抓包工具对比原始请求才发现。
2.2 响应报文与状态码的实用语义
响应报文结构和请求报文对称:状态行、响应头、空行、响应体。状态行的格式是协议版本、状态码、原因短语,比如HTTP/1.1 200 OK。
状态码是排查问题的第一信号灯,每个码的含义直接决定下一步排查方向。我先列一张高频速查表:
| 状态码 | 含义 | 实际场景与排查方向 |
|---|---|---|
| 200 | 请求成功 | 响应体有效 |
| 201 | 创建成功 | POST接口创建资源成功 |
| 204 | 无内容 | 删除成功、预检请求通过,响应体为空 |
| 301 | 永久重定向 | 旧域名跳新域名,搜索引擎会更新索引 |
| 302 | 临时重定向 | 临时跳转,常用于登录跳转 |
| 304 | 协商缓存生效 | 服务器判定客户端缓存仍有效,返回空响应体 |
| 307/308 | 临时/永久重定向 | 保持原始请求方法不改变,和301/302区别在这 |
| 400 | 请求语法错误 | 参数校验失败、JSON格式不对 |
| 401 | 未认证 | 没有登录或Token过期,需要重新认证 |
| 403 | 无权限 | 已登录但被拒绝访问,和401是本质区别 |
| 404 | 资源不存在 | URL路径错误、服务未部署 |
| 405 | 方法不允许 | GET接口被发了POST请求 |
| 408 | 请求超时 | 客户端长时间未发送完整请求 |
| 409 | 资源冲突 | 更新了已删除的数据、唯一约束冲突 |
| 413 | 请求体过大 | 上传文件超过服务端限制 |
| 415 | 媒体类型不支持 | Content-Type和服务端解析器不匹配 |
| 429 | 请求太频繁 | 触发限流,需要检查限流规则 |
| 500 | 服务器内部错误 | 后端代码异常,需要看应用日志 |
| 502 | 网关收到无效响应 | 上游服务挂了、连接被重置 |
| 503 | 服务不可用 | 服务在启动中、过载、主动熔断 |
| 504 | 网关超时 | 上游在限时内没返回响应 |
401和403的区分是面试常客,也是实际排错时最容易犯迷糊的地方。401表示“你是谁我都不知道”,认证环节就挂了,常见于未携带Token或Token过期;403表示“我知道你是谁,但你不允许干这事”,常见于权限不足、IP被拉黑。如果你看到的是403,说明认证已经通过了,问题出在授权层,去查权限配置而不是重登。
2.3 容易被忽略但影响巨大的头部字段
有几个头部字段平时不起眼,出了问题时才发现它们一直在起作用。
Accept-Encoding和Content-Encoding这对字段决定了资源是否压缩传输。客户端发送Accept-Encoding: gzip, br表示支持压缩格式,服务器返回Content-Encoding: gzip表示响应体被压缩过。如果中间某个环节把Content-Encoding去掉了但响应体依然是压缩数据,客户端就会解码失败,表现为页面出现乱码或JSON解析直接报错。
Transfer-Encoding: chunked是HTTP/1.1的分块传输,服务器不知道响应体总长度时使用,数据被切成一块块发送。它和Content-Length是互斥的,如果响应头里同时出现两者,通常是配置错误,客户端会优先按Transfer-Encoding处理,可能导致解析异常。
Via头是代理链路的路由标记,每次经过代理都会追加信息。X-Forwarded-For是一个非标准但广泛使用的字段,用来记录原始客户端IP。现在推荐用Forwarded标准字段替代,但实际生产环境还是X-Forwarded-For用得最多。服务器要取真实用户IP时,得从X-Forwarded-For的最后一个非可信代理IP开始取,否则容易被伪造。
引出一个排查场景:应用日志里看到用户IP全是同一个内网IP,原因就是服务部署在反向代理后面,程序直接读socket里的IP而不是去读X-Forwarded-For。这类问题在架构里加了负载均衡后特别常见。
3. 连接管理:从三次握手到队头阻塞
3.1 短连接、持久连接与连接池的底层逻辑
HTTP协议底层的传输依赖TCP。一次TCP连接的建立需要三次握手,先发SYN,对方回SYN+ACK,再回ACK。在极端网络环境下,这个往返过程可能几十毫秒甚至几百毫秒。
HTTP/1.0时代每请求一次就建一个新连接,请求完立刻断开。如果页面要加载几十个资源,光建连接的时间就够喝一壶。HTTP/1.1引入持久连接,默认Connection: keep-alive,同一连接可以连续发送多个请求,大幅减少握手次数。
持久连接不是无限期的,服务端和客户端各有空闲超时时间。Nginx默认的keepalive_timeout一般是65秒或75秒,超过这个时间连接就被服务端关闭。如果客户端不知道连接已关闭,复用旧连接发送请求,就会收到一条TCP RST报文,表现就是请求直接报错。
连接池就是基于持久连接做的一层复用管理。以Go的net/http为例,默认Transport维护一个连接池,空闲连接会在MaxIdleConnsPerHost限制内保留复用。Java的OkHttp也有类似机制。我在压测时见过一个典型案例:后端服务设置了极短的连接空闲超时,而代理层复用了大量空闲连接,高峰期批量报502,就是因为代理拿到的连接已经被后端悄悄关掉了,数据发过去直接被RST。这个坑的解法是让代理的连接空闲超时小于后端的keepalive_timeout,并且配置合适的keepalive连接数。
TIME_WAIT是TCP连接主动关闭方会进入的状态,等待2MSL(最长报文段寿命)时间后才真正释放。高并发短连接场景下,主动关闭连接的一方会产生大量TIME_WAIT连接,端口被占满直接导致新连接失败。解决办法有两个方向:一是加大本地端口范围和调整时间等待参数,最根本的还是开启连接复用,减少短连接数量。
3.2 队头阻塞的成因与两代协议的解决路线
HTTP/1.1在持久连接基础上想了一个优化方案叫管线化:客户端一次发出多个请求,不用等上一个响应完成。但服务器端必须按照请求到达的顺序返回响应,如果第一个请求的响应特别慢,后面所有已经准备好的响应都得排在它后面发送。这个队头阻塞问题直接导致管线化在现实中基本没有发挥作用,各家浏览器和代理都默认关闭。
为了缓解HTTP/1.1并发不足的问题,浏览器选择对同一域名建立多个TCP连接,通常是6个左右。但连接数是有限的,请求一旦超过连接数,超出的部分就得排队。这也是为什么HTTP/1.1时代网页优化会把资源拆分到多个域名来提升并行度,代价是连接数变多、握手开销变大。
HTTP/2多路复用的思路是完全不同的:在一个TCP连接上同时跑多个流,每个流独立传输请求和响应,不需要按顺序等。应用层的队头阻塞被解决了,但TCP层的问题还在。TCP是字节流协议,如果传输过程中一个数据包丢了,接收方会缓存后续所有数据包,等待重传之后才能按顺序交给上层。这意味着HTTP/2下某个流的丢包会影响这个连接上的所有流。弱网环境、高丢包率的移动网络里,HTTP/2的表现可能比HTTP/1.1还差。
HTTP/3选择彻底绕开这个局面,用UDP实现了QUIC协议。QUIC把每个流独立传输,一个流的丢包只影响它自己,不影响其他流。这是HTTP/3最核心的动机,后面第6章展开讲。
4. 缓存与Cookie:性能倍增和安全细节两手抓
4.1 强缓存与协商缓存的配合思路
HTTP缓存机制直接决定页面响应速度,也最容易在配置时踩坑。整个机制可以分成强缓存和协商缓存两部分。
强缓存是指浏览器在缓存有效期内直接使用本地副本,不发请求到服务器。通过Cache-Control的max-age来控制,比如max-age=3600表示一小时内直接用缓存,状态是200 from disk cache或者200 from memory cache。Expires是HTTP/1.0的字段,指定一个绝对过期时间,但由于客户端时钟可能不准,现在基本用Cache-Control替代,两者都存在时Cache-Control优先。
协商缓存是本地有缓存但不确定是否最新的,带着条件请求头去询问服务器。服务器返回304 Not Modified表示继续用本地缓存,返回200就带新响应体。条件请求有两个维度:Last-Modified/If-Modified-Since基于文件修改时间,ETag/If-None-Match基于内容标识。
ETag精度更高,因为文件修改时间变了但内容没变时,Last-Modified也会变导致重复下载,ETag则能精确判断内容是否真的修改。优先级上ETag高于Last-Modified,但两者可以同时配置,作为双重校验。
实际项目里最经典的配合是:HTML页面用no-cache(每次都要回源验证,保证发布后用户立刻看到新页面),静态资源如JS、CSS、图片用max-age=31536000(一年)配合文件名hash(如app.8f3d4a.js)。文件名一旦变化,浏览器把它当成全新资源去下载,旧资源就算缓存一年也不会被引用。这个方案是静态资源缓存的最佳实践,我见到的几乎所有生产项目都在用。
列一张Cache-Control常用指令速查表:
| 指令 | 含义 | 典型使用场景 |
|---|---|---|
| max-age=秒 | 客户端缓存时长 | 静态资源长缓存 |
| no-cache | 缓存但要先验证 | HTML页面、接口响应 |
| no-store | 绝不缓存 | 包含隐私数据的响应 |
| public | 任何节点可缓存 | CDN缓存静态资源 |
| private | 只能客户端本地缓存 | 个性化内容 |
| s-maxage=秒 | 代理/CDN缓存时长 | 控制CDN层缓存 |
| must-revalidate | 过期后必须回源验证 | 避免过期脏数据 |
一个常见认知误区:no-cache不是不缓存,而是“可以缓存但每次使用前必须去服务器验证”;no-store才是真正的不缓存。很多人在接口里设置no-cache以为就不缓存了,实际上是每次都发请求去做304验证而已,性能开销比直接no-store更大。
4.2 Cookie到底该怎么设置才不背锅
Cookie是HTTP头里最容易被忽略细节的部分。服务端通过Set-Cookie下发,浏览器在后续请求中通过Cookie头部原样携带。
Set-Cookie可以携带多个属性,每个属性都有明确作用域。Domain决定哪些域名会带这个Cookie,不设置的话默认只对当前主机生效,子域拿不到。如果需要在example.com下的所有子域共享登录态,就要设置Domain=.example.com。Path限定路径范围,比如Path=/admin只会在访问/admin下的页面时携带。实际踩坑经常出现在这里:登录后跳转到另一个子域,发现Cookie不见了,一看就是Domain没设置对。
HttpOnly是安全属性,设置后JavaScript的document.cookie读取不到,能有效防止XSS脚本偷取Cookie。项目中凡是涉及登录态、用户身份的Cookie都应该加上HttpOnly。Secure属性要求仅在HTTPS连接下携带,如果是HTTP明文访问,浏览器不会发送这个Cookie。开发环境用http调试时经常出现Cookie发不出去的情况,先查是不是这个属性。
SameSite属性是近年防范CSRF的关键。Lax是浏览器的默认值,允许顶级导航的GET请求带Cookie,但跨站请求如AJAX、表单POST默认不带。Strict更严格,任何跨站场景都不带Cookie。None允许所有跨站请求携带,但浏览器要求必须同时设置Secure才能生效。曾经有个管理系统嵌在第三方平台iframe里,登录状态一直保持不住,排查半天发现是浏览器默认SameSite=Lax策略,导致iframe跨站请求不带Cookie。解决办法就是在关键Cookie上显式配置SameSite和Secure,并确保场景符合跨站携带的安全预期。
还有一类问题与大小有关:Cookie有4KB左右的长度限制,单域名Cookie数量也有限制。如果把用户信息、购物车内容全都塞进Cookie,很容易导致请求头过长,服务器解析失败报400。正确做法是Cookie只存会话标识或轻量偏好,重数据放服务端存储。
5. HTTPS与TLS:加密握手的内部逻辑
5.1 TLS握手流程拆解
HTTPS等于HTTP加TLS,TLS负责加密和身份验证。很多人问为什么HTTPS不直接用非对称加密传输所有数据?因为非对称加密计算开销远大于对称加密。实际方案是混合加密:用非对称加密安全地交换出对称密钥,之后用对称密钥加密业务数据,兼顾安全和性能。
以TLS 1.2为例,完整的握手流程大致如下:客户端发送ClientHello,附带支持的加密套件列表和随机数;服务器回复ServerHello选择加密套件,同时下发自己的证书链,并携带密钥交换参数;客户端验证证书合法性,然后生成预主密钥,通过ClientKeyExchange发送给服务器;之后双方用预主密钥派生出主密钥,并通过ChangeCipherSpec通知对方后面开始加密通信,最后发送Finished消息做完整性校验。
TLS 1.2的握手需要两个RTT才能建立,再加上TCP三次握手的一个RTT,首次请求至少三个往返。在移动网络下这个时间相当可观。TLS 1.3大幅优化了握手过程,把握手消息合并成一次往返,即1-RTT建立连接。如果是之前连接过的客户端,可以使用PSK预共享密钥实现0-RTT,客户端在第一个数据包就能携带应用数据。0-RTT有重放攻击风险,所以只适合幂等请求,浏览器实际实现里对此做了限制。
关于密钥交换算法,TLS 1.2时代常见的有RSA和ECDHE。RSA方式下客户端生成预主密钥,用服务器公钥加密传过去,服务器用私钥解密。这个方案的弱点是一旦服务器私钥泄露,历史上所有流量都能被解密,没有前向安全性。ECDHE是双方各自生成临时密钥对,通过椭圆曲线运算协商出共享密钥,即使服务器长期私钥泄露,也无法还原之前的会话密钥。TLS 1.3直接废弃了RSA密钥交换,只保留前向安全的算法,这个设计理念我强烈建议每个做安全建设的人都理解一下。
5.2 证书链、会话复用与线上常见坑
证书体系是HTTPS信任的基石。服务器下发的证书需要能被客户端验证到可信根,信任关系是这样的:客户端内置了一批根证书,根证书签发中间证书,中间证书再签发服务器证书,构成一条证书链。
线上最常见的证书问题不是证书过期,而是证书链不完整。服务器只配置了叶子证书,没有把中间证书一起下发,导致客户端无法拼出完整的信任链,验证失败。手机端尤其容易复现这个问题,因为移动端的信任库和桌面不完全一致。排查方法是用openssl命令查看服务器返回的证书链:
openssl s_client -connect api.example.com:443 -servername api.example.com如果输出里只有一层证书,大概率就是中间证书没配全。
SNI(Server Name Indication)解决的是多域名共用IP时的证书选择问题。客户端在TLS握手时通过SNI字段告诉服务器它访问的域名,服务器据此返回对应的证书。旧的客户端SDK如果不支持SNI,连到多证书服务器时会拿到默认证书,导致证书验证失败。现在新框架基本都默认支持了,但遇到老设备、老客户端时还是要留意。
ALPN(应用层协议协商)让TLS握手过程中顺便确定上层使用什么协议。浏览器在握手时声明支持h2和http/1.1,服务器选择自己启用的协议返回。如果你开启了HTTP/2但抓包发现请求还是HTTP/1.1,优先检查ALPN配置。
TLS握手成本在移动端尤其明显,会话复用机制是优化关键。Session ID方式是服务器缓存会话状态,Session Ticket方式是把票据加密后发给客户端,下次连直接凭票据恢复会话,省掉一轮完整握手。生产环境性能优化时,除了坐等网络变好,配置好会话复用是最直接的握手耗时优化手段。
6. HTTP/2与HTTP/3:多路复用、HPACK与QUIC实战
6.1 HTTP/2的三大特性:二进制分帧、多路复用、头部压缩
HTTP/2仍然保持和HTTP/1.1相同的语义,GET、POST、状态码、头部字段这些概念都在,只是传输机制变了。它把HTTP消息在底层拆分成一个个帧,帧是二进制格式,替代了HTTP/1.1纯文本的报文格式。二进制分帧让解析更高效,也更容易扩展新帧类型。
多路复用是HTTP/2最核心的性能特性。每条消息的帧都标有Stream ID,属于不同请求的帧可以交错发送,接收方按照Stream ID重组。这样单个TCP连接上可以同时承载几十上百个请求,彻底告别HTTP/1.1的请求排队问题。服务端还有一个SETTINGS_MAX_CONCURRENT_STREAMS参数控制并发流数量,常见默认值是一百。
头部压缩用的HPACK算法包含三个机制:静态表预定义61个常见头部字段的索引,动态表记录连接过程中反复出现的头部内容,Huffman编码压缩字符串。静态表的好处是GET请求的方法和路径等常用字段只传输一个索引数字,而不是逐字传输整个字符串。动态表是在连接内维护的,首次传输完整头部后在表中建立映射,后续只需传一个索引。这也是为什么HTTP/2对头部重复多的场景优化效果极其明显。
关于服务端推送,HTTP/2允许服务器在客户端请求HTML时主动推送相关资源,省去客户端后续请求的RTT。但在实际业务中服务端推送的使用率并不高,因为推送出去的资源很可能客户端缓存里已经有了,白白浪费带宽。相比之下,用Link: rel=preload配合服务端推送的特点做精确控制更现实。我现在做的项目里,前端预加载主要靠preload标签,没有用HTTP/2推送,因为推送的粒度太难控制。
6.2 HTTP/3为什么要换掉TCP
HTTP/2的问题在前一章提过:TCP层的队头阻塞。本质原因是TCP是可靠的有序字节流协议,任何一个包丢了,接收方都必须等待重传才能继续向上层递交数据。HTTP/2的所有流都复用在同一个TCP连接上,一个流的丢包直接影响所有流。
HTTP/3换用基于UDP的QUIC协议,把每个流独立出来,每个流有自己的重传机制,丢包只影响当前流,其他流继续传输。在网络质量差的场景中,这个差异体验是能直接感知的。我做过一次弱网模拟对比,模拟10%丢包率,HTTP/2的整页加载时间明显劣化到数倍,而HTTP/3的加载时间变化小得多。
QUIC还实现了两个很实用的特性。一是连接迁移:因为QUIC不再依赖四元组(源IP、源端口、目的IP、目的端口)标识连接,而是用Connection ID,当手机从WiFi切到4G导致IP变化时,已有连接可以继续使用,不用重新握手。另一个是0-RTT快速重连:复用之前协商过的连接参数,在第一个包就发送数据,虽然存在重放风险,但幂等请求场景下收益明显。
目前HTTP/3的部署还依赖端到端支持,CDN、负载均衡、客户端网络库都要跟上。实际项目中常见的做法是HTTP/1.1和HTTP/2先做好,HTTP/3作为渐进增强项,通过Alt-Svc头向支持的客户端广播可用性,不支持的客户端自然降级到HTTP/2。这个演进思路在实际迁移过程中很重要,不要指望一步到位。
7. 抓包与排查:我排查HTTP问题的实战记录
7.1 工具链与抓包姿势
排查HTTP问题,工具是第一生产力。浏览器DevTools的Network面板是我最优先用的,它把所有关键指标都可视化呈现了。打开一个请求的Timing,能看到完整的阶段划分:
| 阶段 | 说明 | 常见慢因 |
|---|---|---|
| Queueing | 请求排队时间 | 并发超过限制、优先级调整 |
| Stalled | 等待连接池分配连接 | 连接池占满、代理配置过重 |
| DNS | DNS解析耗时 | DNS服务器响应慢、解析链路长 |
| Initial connection | TCP握手耗时 | 网络RTT大、服务器负载高 |
| SSL/TLS | TLS握手耗时 | 会话未复用、证书链过长 |
| Request sent | 发送请求数据时间 | 请求体过大、上传速率低 |
| Waiting (TTFB) | 服务端处理到返回首字节 | 后端业务逻辑慢、数据库查询慢 |
| Content Download | 接收响应体耗时 | 响应体积大、网络带宽受限 |
这个瀑布图直接告诉我们慢在哪一段。如果TTFB抖动明显,问题大概率在服务端;如果Content Download时间过长,优先压缩响应体积而不是优化后端。
命令行下我常用curl来做快速验证。一个很有用的模板是把各阶段耗时打出来写:
curl -w "dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" -o /dev/null -s https://api.example.com/health输出里一眼就能看出哪段耗时高,适合脚本化和批量检测耗时异常。
7.2 超时、502、缓存不生效的排查实录
二次定位502的经验值得单独写。一次线上故障,某接口突然大面积报502,当时第一反应是后端服务是不是挂了,上去看进程还活着,端口也在监听。后来抓包发现网关收到连接建立成功,但在发起HTTP请求后有大量RST重置包。排查到最后发现是网关连接池里存的空闲连接被后端提前断开了,连接复用时就像打了死电话,后端直接把网关的请求打回去。位置在后端的keepalive_timeout设置太短,网关没感知连接已失效,复用旧连接就报错。调整配置让两者超时时间对齐后,502数量归零。
504和502的排查方向完全不一样。504是网关把请求转给了上游,但上游在超时时间内没返回任何数据,通常是上游慢查询、下游服务雪崩。遇到504先去查上游应用日志,看是否有慢接口堆积、线程池耗尽、数据库连接数打满这类问题。
缓存不生效现象也很常见。有次同事反馈静态资源明明设置了Cache-Control: max-age=31536000,强制刷新后还是每次都回源。排查后发现浏览器DevTools在勾选了Disable cache时会自动给请求加Cache-Control: no-cache头,所有请求都必须回源验证。这个是调试状态下的正常行为,不是线上问题。
真正线上的缓存不生效,查这几个方向:一是Nginx的add_header指令作用域问题,add_header在某个location里启用后,如果不加always,则只在200和201等特定状态码下生效,304响应不会带上Cache-Control头;二是响应头里同时存在Cache-Control和老的Pragma、Expires字段,Pragma: no-cache会导致部分代理忽略Cache-Control;三是CDN层缓存时间没设置,源站配置生效了但CDN层默认不缓存。
7.3 排查思路与经验总结
我排查HTTP问题有一套固定套路,按照这个顺序能少走很多弯路。第一步看状态码和响应头;第二步看具体是哪个阶段慢;第三步再去看应用日志和代码。很多人在第二步都没做完整,就直接开始翻代码,最后发现根本不是代码问题。
响应头是一份直接可读的排错报告。Cache-Control告诉你缓存策略,Content-Encoding告诉你压缩方式,Set-Cookie带着安全属性,Via和X-Cache标记了经过的代理链路和缓存命中状态。把响应头完整读一遍再行动,能排除一大半服务端代码问题。
关于跨域请求也补充一句:浏览器跨域报错本质是浏览器的同源策略,不是服务器被攻击。跨域请求分为简单请求和预检请求,简单请求直接发出,非简单请求先发OPTIONS预检。如果看到跨域失败,先看OPTIONS请求是否正常返回,再看CORS响应头里的Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers是否和实际预检请求匹配。我见过很多后端只在主请求里加了CORS头,没处理OPTIONS请求,跨域就一直报错。这种问题在加了新的自定义请求头、改了content-type之后特别容易出现。
最后分享一个小习惯:我不管排查什么问题,总是先把请求头和响应头完整展开看一眼,再往下走。很多人调接口只关心状态码和返回体,但是从头部能看到的信息量往往比返回体更大。HTTP协议看起来平淡,实际上每个字段都有它的位置和价值。把报文读熟练了,排查问题就会有一种按图索骥的从容感。