上周五晚上八点多,一个老客户给我发消息,说他们那个文件上传接口从当天中午开始就一直很慢,"以前 5MB 的包 3 秒就传完了,现在 30 秒都传不完"。第一反应我以为是磁盘 IO 满了或者源站带宽被打满,登上服务器一看:CPU 12%,内存正常,网卡流量连端口带宽的十分之一都不到,磁盘队列也是空的。业务方着急,催得紧,我本来准备重装一台机器的,后来多问了一句"是不是所有人都慢",得到的回答是:大部分人正常,只有几个用手机网络、用老旧浏览器的用户卡到不行。这个现象一下子把我拉到了另一个方向问题不在服务器,在 HTTP/2 的流控机制上。
如果你现在也在做高并发的接口服务、想把 Nginx 反代换成 HTTP/2 h2c、或者正在排查"明明带宽没跑满速度却上不去"这类怪问题,那这台机器用什么配置其实是次要的事,先看看 mikucloud(自有自治系统的电信级网络) 这种底层线路比较扎实的商家;需要多地节点做分发和容灾的,可以再对比一下 moack(全球网络与快速交付)。机器的链路质量是地基,但今天真正要聊的东西在协议层HTTP/2 的流控窗口。

一、先说清楚:HTTP/2 的流控到底是个什么东西
要理解这个坑,得先理解 HTTP/1.1 和 HTTP/2 在"谁来控制发送速度"这件事上的根本区别。HTTP/1.1 是串行的:一条 TCP 连接上,同一时刻只能有一个请求在传输,响应发完了才轮到下一个。而且 HTTP/1.1 本身没有连接级的流控机制,它完全依赖 TCP 滑动窗口来控制发送速率,代价是队头阻塞一个慢请求会把后面所有请求一起堵在后面。
HTTP/2 把请求拆成了"流(stream)",一条 TCP 连接上可以同时跑几十个流,每个流独立编号独立处理,这解决了队头阻塞。但它也带来了一个新问题:如果不做流控,一个流狂发数据就能把整条连接的内存吃光,把同连接上其他流全部拖垮。于是 HTTP/2 引入了两级流控窗口:
连接级窗口(Connection-level window):整条 TCP 连接允许在途(还没被对方确认消费)的数据总量上限。
流级窗口(Stream-level window):单个请求流允许在途的数据量上限。
接收方通过发送窗口更新帧(WINDOW_UPDATE 帧)来告诉发送方"我这边缓冲区腾出来了,你可以再发这么多"。发多少、什么时候发,全部由接收方的读取速度决定。翻译成人话就是:HTTP/2 的发送速率由最慢的那个接收方决定,而不是由带宽决定。这条规律是后面所有坑的根源。

二、默认值有多小:65535 字节听起来很多,其实很少
HTTP/2 协议规范规定,连接级窗口和流级窗口的初始值都是 65535 字节,也就是 64KB。这个数字在 2015 年写规范的时候是合理的那时候的网页主要是文本,一个 64KB 的窗口够塞好几个页面。但今天一个大点的 JS bundle、一个设计稿、一个小视频,64KB 早就见底了。
更关键的是,规范里说的是"初始值",而不是"上限"。理论上客户端和服务端都可以协商出一个更大的窗口,比如 Chrome 在 TLS 握手时通过扩展协商出的初始窗口可以到几 MB甚至几十 MB。但问题在于:协商出来的值再大,也挡不住某个客户端就是只肯给你 64KB。老版本浏览器、某些移动端 SDK、部分 Java HTTP 客户端,压根不支持窗口扩展协商,它给你的就是一个死板的 65535。
而且流级窗口的上限通常还受接收端 TCP 接收缓冲区大小约束,Linux 默认的 rmem 也就几百 KB 级别,你把连接窗口协商到 8MB,流窗口依然给你卡在几百 KB。这就形成了一个很典型的场景:一台带宽 1Gbps 的机器,链路上跑着一条 HTTP/2 连接,上面挂着几十个并发流,其中有一个客户端在 4G 网络上,接收速度只有 50KB/s。发送方就再也不敢放数据了因为一旦放超了,接收端的缓冲区满了,TCP 接收窗口归零,整条连接直接进入零窗口状态,前面那些跑得好好的流全部陪葬。

三、我踩到的那三个具体问题
下面这三件事,是我在这台机器上实际花时间才搞明白的,写出来给同样在排查的人省点时间。
问题一:反向代理把客户端的慢传染给了整条连接。 Nginx 反代到后端时,如果后端返回的是一个流式响应(比如大文件下载、分块传输),Nginx 会先缓冲后端数据,再按自己的节奏发给客户端。默认配置下 Nginx 用两个缓冲区做这件事,但当客户端慢的时候,缓冲区填满,Nginx 就停止从后端读取,后端的上传任务就一直挂着。这时候你用 top 看源站,CPU 是空的,但 nginx worker 的 sendmsg 调用全部卡在阻塞状态。更麻烦的是,同一条 HTTP/2 连接上的其他流也会被 Nginx 一起降速因为 HTTP/2 是复用的,代理端一个流堵住,整条连接的下行节奏都被拖慢。
问题二:Nginx 默认没开 http2 的服务端参数,很多人在 http{} 里写错了位置。 很多人以为开了 listen 443 ssl http2 就完事了,实际上 Nginx 的 HTTP/2 模块有一批参数必须放在 http{} 这个块里:http2_max_concurrent_streams 控制单连接并发流数,默认值是 128;http2_max_field_size、http2_max_header_size 限制头部大小;http2_recv_buffer_size 决定接收缓冲区。更重要的是反代侧的 proxy_buffering、proxy_buffer_size、proxy_buffers 这几个参数必须一起调,否则缓冲这块永远是小马拉大车。我这台机器上原来的配置 proxy_buffer_size 8k、proxy_buffers 8 8k,加起来才 72KB 的缓冲,一个慢客户端就能把缓冲填满。
问题三:客户端发了窗口更新帧,但代理这边没翻译过来。 这是最坑的一个。HTTP/2 客户端与服务端直连时,窗口更新帧是端到端有效的;但当中间插了一层 Nginx 反代时,客户端的窗口更新帧只对 Nginx 生效,Nginx 与后端之间如果是 HTTP/1.1,那就是另一套完全独立的流控(TCP 滑动窗口)。如果客户端收得慢,Nginx 内部的缓冲就成为瓶颈,Nginx 停止从后端拉数据,后端的发送方就一直等,看起来就像"后端接口也变慢了"。后来我们把后端也改成 h2c(HTTP/2 over cleartext,用 proxy_http_version 2 加 proxy_set_header Connection ""),让 Nginx 和后端之间也走 HTTP/2 的窗口协商,情况才真正好转中间少了一层缓冲,也就少了一层把慢传染给全连接的机会。

四、我是怎么定位的,你可以照着这个顺序查
第一步,看服务端到底发了多少窗口更新帧。用 tcpdump 抓一段线上流量,只看 HTTP/2 的帧类型:
tcpdump -i eth0 -s0 -A 'tcp port 443' | grep -i window_update
如果抓下来发现服务端几乎不发窗口更新帧,说明接收端一直没消费,数据全堵在服务端内核的接收缓冲区里这时候要看 ss -tinm 的 Send-Q 和 Recv-Q 是否堆积。第二步,用 nghttp2 自带的客户端工具做对照实验:nghttp -nv https://你的域名 能打印出每条流的窗口变化,跟 curl --http2 的表现一比,就能看出是本机服务端的窗口太小,还是特定客户端的窗口太小。第三步,对比一下 h2 和 h2c 的表现差异,如果 h2c 明显更快,说明问题出在 TLS 协商出来的窗口扩展或者中间层翻译上。
第四步,把参数调大:
http2_max_concurrent_streams 256;
proxy_buffer_size 32k;
proxy_buffers 16 32k;
proxy_busy_buffers_size 128k;
调完重启 Nginx,用 wrk2 或者 hey 压一轮看 P99。这里有个细节值得强调:别指望把连接窗口调大就万事大吉。规范允许协商大窗口,但最终生效的窗口永远受接收端 TCP 缓冲区和客户端实现的双重限制。真正稳妥的做法是"限流 + 隔离":对单连接并发流数设上限避免一个慢客户端占满整条连接的流配额;对大文件下载这种天然慢的接口单独拆到 HTTP/1.1 或独立的域名去,别让它和普通 API 挤在同一条 HTTP/2 连接上;对上传接口在应用层加上分片和断点续传,让客户端不必长期占着一条流。
五、回到开头那台机器,最后怎么解决的
最后我们的处理是三管齐下:一是在 Nginx 的 http{} 块里把 proxy_buffers 系列调大到 64k 一级并显式设置 http2_max_concurrent_streams;二是在后端应用层给上传接口按用户维度限并发,超过阈值直接返回 429 而不是让请求挂在那儿占着窗口;三是给文件下载接口换了个独立域名,走 HTTP/1.1,彻底不跟业务 API 混连接。改完第二天,那几个用手机网络的用户反馈上传时间从 30 秒回到了 4 秒左右,带宽利用率也从 10% 提到了 60%。
回过头看这事,我最大的收获不是记住了几个参数名,而是理解了 HTTP/2 流控的本质:它的公平性是靠"最慢者拖住所有人"来实现的,这是设计取舍,不是 bug。HTTP/1.1 慢的是一条 TCP 连接,HTTP/2 慢的是一条连接上的一个流,但窗口是共享额度,慢流一样能把整条连接拖住。所以做高并发服务时,除了盯带宽和 QPS,还得多盯一个指标:连接的零窗口持续时间。这玩意儿在普通监控里几乎看不到,得自己埋。
如果你也在折腾 HTTP/2、Nginx 反代或者 h2c 改造,欢迎把你的配置和现象贴出来一起看,这类问题基本上一贴配置就能定位到八成。另外如果你要给这套服务换一台扛得住高并发的机器,可以先看看 mikucloud(自有 AS 的电信级网络) 的线路说明,以及 moack(全球网络节点与快速交付) 的部署说明,挑一台链路质量匹配的,再按上面的思路调参数,效率会高很多。


喜欢
顶
难过
囧
围观
无聊





