尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

网络协议实战(3):HTTP/1.1 到 HTTP/3 的演进

发布时间:2026/9/26 11:17:44

资讯中心
01
ARTICLE

网络协议实战(3):HTTP/1.1 到 HTTP/3 的演进

网络协议实战(3):HTTP/1.1 到 HTTP/3 的演进
问题背景上一篇算清了一条 TCP 连接能跑多快——窗口决定单连接吞吐上限。这一篇算另一笔账一个页面几百个资源、一次下单七八个接口调用应用层该怎么在连接这张棋盘上摆放它们。这就是 HTTP 协议演进的两千年战争史从一问一答关连接到一条连接排长队从排队太慢开六条到六条互相踩窗口、握手打到服务端半死最后到一条连接里再开一百条流。线上症状你都见过网关日志 200ms 服务时间、用户侧却要 3 秒慢请求把同连接的兄弟全堵了浏览器 Network 面板里一排 Stalled 灰色条HTTP/2 开了之后弱网反而比 HTTP/1.1 慢丢包下的传输层队头阻塞。这些问题的答案都在同一条演进线上每一代 HTTP 都在解决上一代的固定成本同时亲手埋下新的坑。本篇先用手写解析器看清 HTTP/1.1 的报文语法这是读懂一切上层协议的地基再用一个确定性调度模型把三代协议的队头阻塞量化对比。核心原理第一层HTTP/1.1 的报文语法是一台状态机分帧机。RFC 9112 定义的报文没有长度前缀也没有分隔符魔法纯文本三件套起始行 CRLF分隔的头部 空行结束正文边界只有两种合法声明方式——Content-Length: N定长读完 N 字节即是一个报文或Transfer-Encoding: chunked每块先写十六进制长度再写数据0\r\n\r\n收尾。解析器的全部复杂度就在这什么时候能确定这个报文结束了。Content-Length与实际字节不一致、或两者同时出现解释不一致就是请求走私request smuggling攻击与网关串包事故的同源代码——上一篇强调先读干净再 close根源也在这里TCP 是字节流HTTP 报文边界的唯一真相在头部里。第二层keep-alive 解决了重连却制造了排队。HTTP/1.0 默认每请求一条连接每个请求都要付一遍 TCP 握手上一篇还有一段慢启动前半程的税。1.1 把持久连接变成默认进一步提出流水线pipelining请求不必等响应可以连发。理论上很美现实中死于一条规定——响应必须按请求顺序返回。只要队头是个慢查询后面所有已就绪的响应都被扣着而那个请求→响应的 FIFO 约束一旦在链路中间被任何代理、任何实现瑕疵打破就会出现响应串到错误请求上的致命错乱。于是浏览器默认关掉流水线退而求其次同一域名开 6 条并行连接规范 RFC 7230 原话是不应该超过两条这个数字被所有浏览器无视成了教科书级的规范输给现场。六连接的代价六份握手与慢启动、六倍服务端 socket 压力、域名分片sharding进一步把连接数推到几十条HTTPS 时代每条都要付 TLS 握手——固定成本堆成了山。第三层HTTP/2 的二进制分帧层把排队改成交错。RFC 9113 把报文语法重构连接上跑的是帧9 字节帧头31 位流 ID 8 位类型 长度 标志一个请求-响应占一个流多个流的帧在同一个 TCP 字节流里任意交错响应不再排队——应用层队头阻塞被消灭。头部也二进制化HPACKRFC 7541用静态表 动态表 霍夫曼编码把重复的 Cookie/UA 压成索引这是多流并行的前提——不然每个流都背着几 KB 头。但 HTTP/2 保留了一个致命继承它下面还是 TCP。TCP 的按序交付语义意味着任何一个字节丢了后面所有流的已到达帧都得在接收缓冲区里等重传——应用层明明能乱序消费传输层却不答应。弱网丢包 1-2%下 HTTP/2 反而输给六连接就是这条。再加上单连接丢包触发拥塞控制全局退让上一篇的乘性减现在只砸在一棵树上HTTP/2 的天花板从设计第一天就写好了。第四层HTTP/3 把传输层搬进用户态。RFC 9114 不修补 TCP换底座QUIC 跑在 UDP 上每个流独立重传、独立排序丢了流 A 的包流 B/C 照常交付握手与 TLS 1.3 合并成一次往返后续连接可 0-RTT 提前发数据连接标识用连接 ID 而非四元组——换 Wi-Fi 不掉线。拥塞控制也搬进了用户态发多少个包、怎么退让全由实现说了算Chrome 默认 CUBIC 变体、可换。代价同样真实UDP 被网络中间件歧视限速、内核旁路吃 CPU、协议栈生态防火墙白名单、QoS、审计工具全要重新适配。三代演进的主线就一句话每一代都在消灭上一代的固定成本重连、排队、头膨胀队头阻塞从连接级 → 请求级 → 传输包级一路下沉HTTP/3 把它压到了单流以内。第一次代码实验及输出手写一个最小 HTTP/1.1 解析器跑在回环上客户端在同一条连接上一次连发两个请求模拟流水线服务端按序解出并分别用Content-Length定长响应和chunked流式响应客户端用同一套规则自动还原正文。所有逻辑就是上面第一层说的语法跑一遍胜过读十遍 RFC 附录。importsocketimportthreading SERVER_LOG[]CLIENT_LOG[]defparse_head(fp,who):读一行请求行/状态行 头部, 直到空行为止(HTTP 头部以 CRLFCRLF 结尾)firstfp.readline().decode(ascii).rstrip(\r\n)headers{}whileTrue:linefp.readline().decode(ascii)iflinein(\r\n,\n,):breakk,_,vline.rstrip(\r\n).partition(: )headers[k.lower()]vreturnfirst,headersdefread_body(fp,headers):报文正文由头指示: Content-Length 定长, 或 Transfer-Encoding: chunkedifheaders.get(transfer-encoding,).lower()chunked:parts[]whileTrue:sizeint(fp.readline().strip(),16)ifsize0:fp.readline()# 末尾 CRLF, 协议规定 chunked 以 0 长块结束breakparts.append(fp.read(size))fp.readline()# 吃掉每个 chunk 后面的 CRLFreturnb.join(parts)ifcontent-lengthinheaders:returnfp.read(int(headers[content-length]))returnbdefserver(srv):conn,_srv.accept()fpconn.makefile(rb)outconn.makefile(wb)foriinrange(2):# 同一连接上按序解两个请求 流水线line,headersparse_head(fp,S)SERVER_LOG.append(S: 请求行 %r 头部 Host%s Connection%s%(line,headers.get(host),headers.get(connection)))ifi0:# 响应 1: 定长 Content-Lengthbodyb{orders: 42}out.write((HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: keep-alive\r\n\r\n%len(body)).encode(ascii)body)else:# 响应 2: 边生成边发的 chunkedchunks[b{page:,b 3,b1, ok: true}]out.write(bHTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n)forcinchunks:out.write((%x\r\n%len(c)).encode(ascii)cb\r\n)out.write(b0\r\n\r\n)out.flush()conn.close()srv.close()srvsocket.socket()srv.bind((127.0.0.1,0))srv.listen(1)portsrv.getsockname()[1]tthreading.Thread(targetserver,args(srv,))t.start()clisocket.socket()cli.settimeout(5)cli.connect((127.0.0.1,port))# 一次写出两个请求 HTTP/1.1 流水线; 正文无 body, 靠空行分隔头部cli.sendall(bGET /api/orders HTTP/1.1\r\nHost: demo.local\r\nConnection: keep-alive\r\n\r\nbGET /api/list?page31 HTTP/1.1\r\nHost: demo.local\r\nConnection: keep-alive\r\n\r\n)fpcli.makefile(rb)foriinrange(2):line,headersparse_head(fp,C)bodyread_body(fp,headers)CLIENT_LOG.append(C: 响应 %r - 正文 %s%(line,body.decode(utf-8)))cli.close()t.join()print( 服务端日志(同一条 TCP 连接上按序解出两个请求) )forxinSERVER_LOG:print(x)print( 客户端日志(按序解出两个响应, 正文自动还原) )forxinCLIENT_LOG:print(x)运行输出 服务端日志(同一条 TCP 连接上按序解出两个请求) S: 请求行 GET /api/orders HTTP/1.1 头部 Hostdemo.local Connectionkeep-alive S: 请求行 GET /api/list?page31 HTTP/1.1 头部 Hostdemo.local Connectionkeep-alive 客户端日志(按序解出两个响应, 正文自动还原) C: 响应 HTTP/1.1 200 OK - 正文 {orders: 42} C: 响应 HTTP/1.1 200 OK - 正文 {page: 31, ok: true}两个值得盯住的细节。第一服务端能在一串字节里切出两个请求靠的全是空行边界readline读到\r\n\r\n时多出来的字节恰好属于下一个请求——这就是字节流分帧的实感也是为什么任何网关改了头部换行风格都可能串包。第二chunked 响应的正文是三段碎片拼出来的{page:、3、1, ok: true}读方不需要知道总长——所以服务端能边生成边发LLM 流式输出、SSE 推送全靠这个而定长响应必须先攒齐整个 body 才能写头。生产上网关把流式接口整段缓冲的故障十有八九是中间层看到Transfer-Encoding: chunked时自作聪明地重组了 Content-Length。工程化改进把演进史变成你手里的开关分四步。第一步测量协议版本对时延的真实影响。curl -s -o /dev/null -w %{http_version} 连接复用%{num_connects} DNS%{time_namelookup} 连接%{time_connect} TLS%{time_appconnect} 首字节%{time_starttransfer} 总%{time_total}\n --http1.1 url再跑一遍--http2/--http3curl 需 nghttp3/quiche 支持。对比两组数如果 HTTP/2 的首字节改善明显而总时间没变瓶颈在传输带宽而非排队如果num_connects在 1.1 下是 6、2.0 下是 1你已经看见了连接数收敛。第二步服务器端按固定成本清单逐项关账。keep-alive 空闲超时必须显著大于客户端连接池的空闲回收上一篇的四次挥手竞态在 HTTP 层的投影upstream_keepalive打开后网关到服务端的连接复用率要上看板——大量新建连接的代价是每次一个 TLS 握手加一段慢启动HTTP/2 的后端gRPC、内网服务间调用优先 h2c 明文复用省掉逐跳 TLS 又吃到多路复用。第三步弱网与内网区别对待传输层。内网 RTT1ms、丢包近零HTTP/2 的单连接复用是纯赚公网移动网络观察ss -ti的重传率丢包高的域名评估开 HTTP/3QUIC 每流独立恢复并在监控里把HTTP/2 重传等待单列——同一个响应慢在 1.1 是排队、在 2.0 是重传、在 3.0 才可能是真的应用慢。第四步分帧纪律写进代码评审。任何手写 HTTP 解析/代理的代码收到Content-Length与实际读取字节不一致时断开连接而非续读Content-Length与Transfer-Encoding同时出现时按最保守解释拒绝响应 1xx/204/304 这类无正文状态码时绝不能带正文——这三条是请求走私 CVE 的共同解药RFC 9112 的 6.3 节逐条写了判定算法。第二次代码实验及输出下面用确定性排队模型单位 ms网络 RTT 取 1ms服务时间即表里的 SERVE量化三代协议6 个请求共享带宽资源其中第 4 个是 200ms 慢查询看最后一个请求什么时候完成。模型模拟的是协议语义串行/流水线/复用不含拥塞控制正是为了把队头阻塞这一个变量单独拎出来。RTT1# 单位 ms, 单程网络时延取 1ms 便于观察协议层差异SERVE[30,30,30,200,30,30]# 6 个请求的服务耗时, 第 4 个是慢查询defhttp10_short():HTTP/1.0 短连接: 每个请求一条新连接, 固定成本 建连 请求往返return[2*RTTsforsinSERVE]defhttp11_serial():HTTP/1.1 单连接串行(不流水线): 发下一个请求前必须收完上一个响应done,clock[],0forsinSERVE:clock2*RTTs done.append(clock)returndonedefhttp11_pipelined():HTTP/1.1 流水线: 请求可以连发, 但响应必须按请求顺序回——慢响应卡全队done,clock[],0forsinSERVE:clockmax(clock,RTT)s# 上一条响应没发完, 这一条只能排队done.append(clockRTT)returndonedefhttp11_six_conn():HTTP/1.1 六连接实践: 每个请求独占一条连接, 互不排队return[2*RTTsforsinSERVE]defhttp2_mux():HTTP/2 单连接多路复用: 帧带流 ID 交错传输, 慢流不挡其他流的帧return[RTTsforsinSERVE]print(1) 应用层队头阻塞对比 (第 4 个请求是 200ms 慢查询):)forname,fin[(HTTP/1.0 短连接,http10_short),(HTTP/1.1 串行,http11_serial),(HTTP/1.1 流水线,http11_pipelined),(HTTP/1.1 六连接,http11_six_conn),(HTTP/2 复用,http2_mux)]:donef()print(%-18s 第6个请求完成于 %5.0f ms, 全部完成 %5.0f ms%(name,done[-1],max(done)))print()print(2) HTTP/2 逃不掉的传输层队头阻塞 (确定性模型, 每帧 1460B):)frame_stream[A,A,B,C,D,E]# 帧在 TCP 字节流中的顺序lost1# TCP 层丢失第 2 帧(流 A)wait2*RTT# 丢失检测 重传至少两个 RTTprint(丢失帧 %d(流%s) 后, TCP 必须按序交付, 之后的帧全部滞留:%(lost1,frame_stream[lost]))foriinrange(lost1,len(frame_stream)):print( 帧 %d(流%s) 已到达接收缓冲区, 仍被扣压 %d ms%(i1,frame_stream[i],wait))print(结论: 应用层多路复用救不了传输层乱序, 这正是 HTTP/3 把传输层搬到 UDP 上的理由)运行输出1) 应用层队头阻塞对比 (第 4 个请求是 200ms 慢查询): HTTP/1.0 短连接 第6个请求完成于 32 ms, 全部完成 202 ms HTTP/1.1 串行 第6个请求完成于 362 ms, 全部完成 362 ms HTTP/1.1 流水线 第6个请求完成于 352 ms, 全部完成 352 ms HTTP/1.1 六连接 第6个请求完成于 32 ms, 全部完成 202 ms HTTP/2 复用 第6个请求完成于 31 ms, 全部完成 201 ms 2) HTTP/2 逃不掉的传输层队头阻塞 (确定性模型, 每帧 1460B): 丢失帧 2(流A) 后, TCP 必须按序交付, 之后的帧全部滞留: 帧 3(流B) 已到达接收缓冲区, 仍被扣压 2 ms 帧 4(流C) 已到达接收缓冲区, 仍被扣压 2 ms 帧 5(流D) 已到达接收缓冲区, 仍被扣压 2 ms 帧 6(流E) 已到达接收缓冲区, 仍被扣压 2 ms 结论: 应用层多路复用救不了传输层乱序, 这正是 HTTP/3 把传输层搬到 UDP 上的理由第一张表是全系列最省钱的一张图。单连接串行时慢请求把 30ms 的小弟拖成 362ms——这就是 1.1 时代雪碧条的全部痛苦流水线只省下请求发送的 RTT362→352对头慢毫无办法所以它被历史淘汰得理所当然。六连接并行使小请求回到 32ms但代价是 6 倍握手、6 份慢启动回到上一篇每条连接的前几个 RTT 都在爬坡。HTTP/2 复用把固定成本压到 1 条连接、把排队压到 0第 6 个请求 31ms——注意它与六连接只差 1ms真正的差距在服务端连接数与 TLS 开销上。第二张表是给 HTTP/2 泼的冷水TCP 只认字节序号必须连续交付流 B/C/D/E 的数据明明都到了却因为流 A 丢的一个包全体滞留——把2 ms在移动网络里换成重传超时动辄 200ms就能理解为什么开了 h2 弱网更慢不是玄学是这张表的放大版。常见陷阱其一keep-alive 超时设置倒挂网关 60s 关连接、服务端连接池 300s 才回收复用到刚被对端 FIN 的连接上就报 reset/502——空闲回收永远是客户端更积极。其二把 Content-Length 交给库猜手写代理时只读固定头部、按缓冲大小而非 Content-Length 切分边界一错后面全错串包事故都从这个差几个字节开始。其三迷信升级 HTTP/2 页面就快慢查询在后端的话h2 的复用只是让小请求先走页面完成时间仍被最慢资源决定——服务端并发度才是本体协议只是搬运工。其四HTTP/2 下照旧域名分片h2 一个域名一条连接是设计意图分片多域名等于退回六连接时代还多付握手反过来 h1 时代的压缩字典被分片稀释的问题倒是自动解决了。其五0-RTT 当免费午餐HTTP/3 会话恢复可以提前发数据但重放攻击窗口存在带副作用的请求POST 下单不该进 0-RTT各实现默认策略不同要显式检查。其六只盯浏览器侧QUIC 的 UDP 在内网/容器/云 LB 上可能被 QoS 压制甚至黑洞发 HTTP/3 前先确认网络路径对 UDP 500ms 级别的Alt-Svc回退是否健康。落地清单用curl -w模板测 http_version/num_connects/time_starttransfer先量化再动协议开关keep-alive 空闲回收客户端池 网关 源站逐级更短避免复用被单方面挥手的连接手写解析/代理严守分帧纪律CL 与 TE 冲突即拒绝、读不足即断连、1xx/204 不带 body内网服务间用 h2/h2c 上游连接池复用公网高丢包域名评估 HTTP/3 并监控 UDP 路径弱网指标单列重传等待与排队等待两类区分 1.1 队头阻塞与 2.0 传输层队头阻塞三代 HTTP 的恩怨讲完你会发现一个反复出现的名字TLS。keep-alive 复用省的是 TCP 握手0-RTT 省的是 TLS 握手HPACK 字典的前提是加密后的头不能再逐字节明文比对——几乎每一次协议演进都在跟握手成本较劲而其中最贵、最容易配错的一段就在 TLS。下一篇《网络协议实战4HTTPS 与 TLS 握手证书机制》我们亲手用 ssl 模块解剖证书链、版本协商和那 1-2-3 个往返。参考来源RFC 9112: HTTP/1.1: https://datatracker.ietf.org/doc/html/rfc9112RFC 9113: HTTP/2: https://datatracker.ietf.org/doc/html/rfc9113RFC 9114: HTTP/3: https://datatracker.ietf.org/doc/html/rfc9114RFC 7541: HPACK: Header Compression for HTTP/2: https://datatracker.ietf.org/doc/html/rfc7541RFC 9110: HTTP Semantics: https://datatracker.ietf.org/doc/html/rfc9110MDNHTTP 协议概述https://developer.mozilla.org/en-US/docs/Web/HTTP 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《网络协议实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。