运维知识
悠悠
2026年9月15日

一个HTTP请求发到服务器后,到底经历了什么?把解析过程扒得明明白白

其实这个问题挺有意思的。我们天天写接口、配Nginx、调Tomcat,但真正把一个HTTP请求从字节流一路拆解到你代码里拿到的那个request对象,中间到底发生了什么,很多人还真说不清。有时候你觉得"不就是发个请求嘛",可真让你说清楚服务端拿到那一坨字节之后干了啥,可能就卡壳了。

今天我就把这事儿从头到尾讲一遍,从最原始的字节开始,一层一层剥给你看。看完这篇,不管你是写业务的还是搞运维的,遇到那些诡异的解析bug、抓包看不懂的报文、莫名其妙的400错误,心里都能有个数。

先搞清楚一件事:HTTP本质上就是一段文本

很多人对HTTP有种神秘感,觉得它是啥高深协议。其实撕开外皮看,HTTP/1.1的请求就是一段纯文本,人眼能直接读的那种。你用telnet连上一个80端口,手敲几行字上去,服务器就能给你返回内容。

我经常拿这个给新人演示,效果特别好。你打开终端敲:

telnet fuzhoupyy.work 80

连上之后手动输入:

GET / HTTP/1.1
Host: fuzhoupyy.work

注意最后要敲两个回车。然后你就能看到服务器把整个网页返回过来了。这就说明,所谓的HTTP请求,服务端收到的其实就是一堆按约定格式排列的字符而已。所谓"解析",就是把这堆字符按规矩拆开、归类、理解。

理解了这一点,后面就好办了。

HTTP请求报文的三大块结构

一个完整的HTTP请求,物理上就是一段连续的字节流,但逻辑上分成三部分:请求行、请求头、请求体。中间用特定的分隔符隔开。

我们抓一个真实的POST请求看看,比如提交一个表单:

POST /api/login HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Length: 43
Connection: keep-alive
User-Agent: curl/7.68.0

{"username":"admin","password":"123456"}

这段东西传到服务端网卡上,实际是这样的字节:

POST /api/login HTTP/1.1\r\n
Host: api.example.com\r\n
Content-Type: application/json\r\n
Content-Length: 43\r\n
Connection: keep-alive\r\n
User-Agent: curl/7.68.0\r\n
\r\n
{"username":"admin","password":"123456"}

看到那个\r\n没有?这玩意儿是关键中的关键。它是回车换行符,ASCII码分别是13和10。HTTP协议规定,每一行都用\r\n结尾。而请求头和请求体之间,是用一个空行隔开的,也就是连续两个\r\n\r\n\r\n)。

服务端解析的整个逻辑,就是围绕着找\r\n这个事儿转的。你把这个抓准了,解析这事儿基本就通了大半。

服务端拿到的第一个东西:一个socket和一堆字节

先别急着讲怎么拆。得先明白服务端拿到的是啥。

当客户端发起请求,经过三次握手建立TCP连接后,服务端这边其实拿到的是一个socket文件描述符。数据是从这个socket里一点一点read出来的。

这里有个特别容易被忽略的点:TCP是流式协议,它根本不知道什么HTTP不HTTP。你以为客户端发了一个完整的请求,服务端就能一次性完整收到?想得美。

TCP层面,数据可能被拆成好几个包过来,也可能好几个请求粘在一起过来(就是常说的粘包)。服务端read一次,可能只读到半个请求行,也可能读到一个半请求。这就是为什么HTTP需要那些\r\nContent-Length——它得靠这些边界信息,自己从字节流里把一个个完整的HTTP消息切出来。

我之前排查过一个诡异的问题,一个自研的简易HTTP服务,偶尔会解析出乱七八糟的请求。查了半天发现就是没处理好这个流式读取,人家假设一次read就能读全请求,结果高并发下大包被拆分,直接解析崩了。这坑踩过一次就记一辈子。

第一步:解析请求行

拿到数据后,服务端要做的第一件事,是从字节流里找到第一个\r\n,把它前面这一整行切出来。这一行就是请求行。

请求行长这样:

POST /api/login HTTP/1.1

它由三部分组成,中间用空格分隔:

  • 请求方法:POST
  • 请求路径(URI):/api/login
  • 协议版本:HTTP/1.1

服务端会按空格把这三段拆开。这里也有坑。比如路径里如果带查询参数:

GET /search?q=hello world&page=1 HTTP/1.1

这个空格在hello world里,按理说URL里不该出现裸空格,规范做法应该URL编码成%20。但总有不老实的客户端直接把空格塞进来,服务端如果简单粗暴按空格split,就会解析错。所以健壮的解析器一般是找第一个空格和最后一个空格来定位。

请求方法这块,服务端会校验是不是合法的方法。常见的GET、POST、PUT、DELETE、HEAD、OPTIONS、PATCH这些。如果收到一个不认识的方法,很多服务器会直接返回400或者501。

路径这块,如果带问号,还得进一步拆成路径部分和查询字符串部分。查询字符串q=hello&page=1这种,会被按&拆成键值对,每个键值对再按=拆开,最后做URL解码。你在框架里request.getParameter("page")拿到的东西,就是这一步给你准备好的。

第二步:解析请求头

请求行切完之后,剩下的就是一行一行的请求头,直到遇到那个空行为止。

解析请求头的逻辑很直接:循环读取,每次找到一个\r\n就切出一行,直到切出来的这一行是空的(也就是碰到了\r\n\r\n那个空行),说明头部结束了。

每一行头部长这样:

Content-Type: application/json

按第一个冒号拆开,冒号前面是header名,后面是值。注意是第一个冒号,因为值里面完全可能再出现冒号,比如:

Host: api.example.com:8080

如果你傻乎乎按所有冒号split,端口号就被拆没了。

还有个细节,冒号后面通常有个空格,解析的时候要把这个前导空格去掉。header名一般是大小写不敏感的,规范做法是统一转成小写或者用不区分大小写的方式存储。所以你写Content-Type还是content-type,服务端都认。

头部会被存成一个键值对的结构,通常是map。但这里又有个坑:同一个header名可以出现多次。比如:

Set-Cookie: a=1
Set-Cookie: b=2

或者一些头允许用逗号合并多个值。所以严格的实现里,header的值其实是个列表,不是单个字符串。很多人写简易解析器就栽在这,后面的把前面的覆盖了。

关键中的关键:怎么知道请求体有多长?

头部解析完,碰到空行了,接下来就是请求体(body)。

但问题来了,服务端怎么知道body有多长?读到哪里算完?TCP流式的,它可不会自己停下来告诉你"body结束了"。

这就要看头部里的信息了。主要有两种方式:

第一种,靠Content-Length。

如果请求头里有Content-Length: 43,那服务端就知道,空行之后再读43个字节,就是完整的body。读够43个就停,多的不属于这个请求(可能是下一个请求粘过来的)。

这里就体现出Content-Length的重要性了。如果这个值写错了,比如实际body有50字节但header写了43,服务端只读43字节,剩下7字节就残留在缓冲区,可能污染下一个请求的解析。反过来如果header写100但实际只有43,服务端会一直傻等着读剩下的57字节,直到超时。我见过因为Content-Length算错导致接口一直卡住超时的案例,排查起来特别费劲,因为日志里啥错都没有,就是慢。

第二种,靠Transfer-Encoding: chunked。

有些场景下,客户端发数据的时候自己都不知道总共多长,比如流式上传、动态生成的内容。这时候就用分块传输编码。

头部里会带:

Transfer-Encoding: chunked

这时候就没有Content-Length了。body变成了一块一块的,每块前面先用十六进制标明这块有多长,然后跟着数据,最后用一个长度为0的块表示结束。长这样:

1a\r\n
这是第一块的数据内容一共26字节\r\n
10\r\n
这是第二块16字节\r\n
0\r\n
\r\n

服务端解析chunked的逻辑就是:读一行拿到十六进制长度,转成十进制,然后读那么多字节,读完再读下一块的长度,直到读到长度为0的块,body结束。

顺便提一句,Transfer-EncodingContent-Length如果同时出现,这就是个安全隐患了。历史上大名鼎鼎的HTTP请求走私(Request Smuggling)攻击,很多就是利用前端代理和后端服务器对这两个头处理不一致来搞事情的。规范规定同时出现时应该以Transfer-Encoding为准并忽略Content-Length,但不同实现处理不一样,攻击者就钻这个空子。搞安全的对这个应该很熟。

GET请求没有body,那参数从哪来?

有人可能会问,GET请求一般没body,那它的参数呢?

GET的参数全在URL的查询字符串里,也就是请求行那个路径里的问号后面。前面讲解析请求行的时候提过了,服务端在解析请求行时就把查询参数拆出来了,根本用不到body这一步。

所以你会发现,GET请求解析起来其实更简单,读到空行就结束了,没有body要处理。这也是为什么GET适合做那种幂等的、可缓存的查询操作。

而POST的表单参数,如果是application/x-www-form-urlencoded这种类型,那body里的格式跟查询字符串一模一样,也是key=value&key=value,只不过位置从URL挪到了body里。服务端解析完body拿到这段字符串,再用同样的方式拆键值对、URL解码。

如果是文件上传,Content-Type会是multipart/form-data,body结构就复杂多了,用一个boundary分隔符把不同的字段和文件分隔开,每一部分又有自己的小header。这块解析逻辑相当繁琐,好在框架都帮你封装好了。

把整个流程串起来

我把服务端解析一个HTTP请求的完整流程再捋一遍,你脑子里过一遍就通了:

TCP连接建立,服务端从socket循环读字节,往缓冲区里塞。一边塞一边找\r\n。找到第一个\r\n,前面这行是请求行,拆出方法、路径、协议版本,路径里有问号的把查询参数也拆了。

继续往下一行行找\r\n,每行按第一个冒号拆成header键值对,存进map(注意多值的情况)。一直读到一个空行,说明头部结束。

看头部里有没有Content-Length,有就按这个长度读body;没有的话看有没有Transfer-Encoding: chunked,有就按分块方式读;两个都没有(比如GET),那就没有body,请求到此结束。

body读完,根据Content-Type决定怎么解析body内容,是JSON就交给JSON解析器,是表单就拆键值对,是文件上传就按multipart拆。

到这,一个原始的字节流就变成了你代码里那个漂漂亮亮的request对象。你调request.getMethod()request.getHeader()request.getParameter()拿到的一切,全是上面这些步骤给你准备好的。

几个我踩过或者见别人踩过的坑

不要假设一次read能读完整个请求。前面说过了,TCP是流,必须循环读、按边界切。自己写解析器最容易死在这。

不要用所有冒号或者所有空格去split。请求行用第一个和最后一个空格定位,header用第一个冒号拆。值里带冒号带空格的情况太常见了。

不要忽略Content-Length和实际body长度不一致的问题。这会导致要么卡死超时,要么污染后续请求。

不要在没搞清chunked编码的情况下手写HTTP解析。分块传输的边界处理很容易出错。

不要忽视header可以重复出现的事实。用单值map存header,重复的会互相覆盖,Set-Cookie这种场景直接就废了。

还有一个,header名大小写不敏感这事儿一定记牢。有些客户端发content-type小写,你代码里get("Content-Type")就拿不到,白白排查半天。

写在最后

其实你把这套流程搞明白了会发现,HTTP解析没有想象中那么玄乎,核心就是:基于\r\n做分行,基于空行区分头和体,基于Content-Length或chunked确定body边界。这三条抓住了,剩下的都是细节。

而理解这套东西的真正价值,不在于面试能答上来,而在于你排查问题的时候心里有底。抓包看报文你能看懂每一个字节的含义,遇到400错误你知道是哪一步格式不对,遇到接口卡死你能想到会不会是Content-Length算错了,遇到安全告警你知道请求走私是咋回事。这些才是实打实能救命的东西。

技术这玩意儿,越是底层的、越是基础的,反而越经得起时间考验。框架天天换,语言年年新,但HTTP的这套规矩,几十年了还是这样。把根基打牢了,上面的花活学起来都快。

好了,今天就唠到这。如果这篇对你有帮助,帮忙点个赞、转发给身边同样在啃HTTP的兄弟。有啥想看的主题也可以留言告诉我,下期咱们可以聊聊HTTP/2和HTTP/3是怎么把这套文本协议给推翻重来的,那又是另一个有意思的故事了。

关注我,@运维躬行录,跟你一起把那些天天用但没搞透的东西,一个一个啃明白。少走弯路,少加班,少背锅。


公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码