☰
TCP/IP、HTTP与FTP协议联合排查:抓包定位上传超时与网络抖动
2026/10/26 2:57:54 网站建设 项目流程

线上传文件卡在 99% 然后超时、接口偶发几十毫秒到几秒的抖动、本地跑得好好的脚本换台机器就报连接被拒——这三类问题我一年里总要撞上好几回,追到最后基本都落在 TCP/IP、HTTP 和 FTP 这三层协议的行为差异上。很多人对它们的印象还停留在“背概念”:TCP 可靠、UDP 不可靠,HTTP 是无状态协议,FTP 用 21 端口。真到排查现场,光靠这几句话根本推不动,因为你缺的是“这个包为什么在这里被丢掉”“这个窗口为什么涨不上去”“这个 227 响应里的数字到底代表哪个端口”这类颗粒度的知识。

这篇东西不打算写成教材。我把 TCP/IP 协议、HTTP 协议和 FTP 协议这三块揉在一起讲,是因为它们在真实链路里本来就是叠着用的:FTP 的控制连接跑在 TCP 上,HTTP 的报文塞在 TCP 载荷里,而 TCP 又压着 IP 往下走。把它们拆开背,你会觉得知识点零散;摆到一条链路上看,很多东西自然就通了。内容适合已经写过代码但没系统梳理过网络的开发者,也适合需要定位线上网络问题、要跟抓包结果打交道的运维和测试;完全零基础也能看,我会把每个关键参数的计算过程摊开写。


1. 先把三个协议摆到同一张图上

1.1 一次下载动作里,它们各自在哪一层出场

假设你在命令行里敲了一条下载命令,把远端一个 8MB 的文件拉到本地。整个过程里三层协议的分工是这样的:

  • FTP 负责“我要这个文件、用什么模式传、传完了没”,它自己定义了一套命令和响应码;
  • HTTP 如果承载下载,负责“我要哪个资源、带什么请求头、服务端返回多少字节、能不能断点续传”;
  • TCP 负责把这两者产生的字节流切成段、编上号、确认、重传、按顺序交付;
  • IP 负责给每个段加上源和目的地址,让它在网络里一跳一跳地走。

关键在于:FTP 和 HTTP 都是“应用层协议”,它们对丢包、乱序、重复一无所知,因为它们默认下面那层已经把字节流整理干净了。所以当一次传输出问题,你要先判断故障发生在“字节流没整理干净”还是“字节流干净但语义不对”。前者的表现是连接重置、超时、卡住不动;后者的表现是返回了 403、550、404 这类明确的业务错误。

我做排查时的第一个动作永远是分清这两类。看到明确的错误码,说明链路是通的,去查权限、路径、请求头;看到连接建立失败或者中途卡死,才往下钻 TCP 和 IP。

1.2 职责边界表:什么时候该盯哪一层

现象最可能的层首查项
域名解析报错、连接超时无响应IP / 路由解析结果、路由可达性、目标端口是否监听
连接被拒绝(Connection refused)TCP目标端口未监听,或中间设备主动 RST
连接建立后立刻断开TCP / 应用应用层主动关闭、心跳超时、中间设备会话老化
传输中途卡住、速率掉到接近 0TCP丢包重传、窗口归零、MSS 被中间链路丢弃
返回 4xx / 5xxHTTP请求头、鉴权、服务端日志
返回 550 / 425FTP路径权限、数据连接建立失败

这张表是我自己排障时贴在显示器边上的东西。它的价值不在于精确,而在于给你一个先看哪里的顺序,避免一上来就抓包。抓包成本很高,几万个包里找一个异常,没有明确假设就是大海捞针。

提示:这张表只能用来决定“先看哪里”,不能用来下结论。同一个现象往往有两三个成因,比如“连接建立后立刻断开”既可能是应用层拒绝,也可能是中间设备认为会话空闲太久。判断依据是断开的时机:握手完成后 0 毫秒就断,多半是应用层;空闲了 300 秒才断,多半是中间设备的会话老化。


2. TCP/IP:每一分可靠性都要付账

2.1 四层结构与常用端口速查

TCP/IP 常被讲成四层:应用层、传输层、网络层、链路层。这个分法之所以经久不衰,是因为它对应了数据的封装顺序。应用层产生字节流,传输层切段加端口,网络层加 IP 头选路,链路层加帧头帧尾发出去。接收端反向拆一遍。

封装顺序带来一个很实用的推论:每一层的头部都会挤占这条链路能承载的有效载荷。以太网默认 MTU 是 1500 字节,这个 1500 是“IP 包的总长度上限”,不是有效数据量。扣掉 IP 头 20 字节、TCP 头 20 字节,剩下 1460 字节,这就是 MSS(最大报文段长度),也就是单个 TCP 段里最多能塞多少应用层数据。

如果 TCP 头部带了时间戳选项(12 字节),MSS 就要降到 1448。走 PPPoE 拨号的链路 MTU 是 1492,MSS 相应变成 1452。这就是为什么有些环境里“浏览器能开网页、但一传大文件就卡死”——小请求一个包就发完了,大文件要发满 MSS 的包,一旦链路的 MTU 比 MSS 假设的小,这些包会被静默丢弃,而 ICMP 的“需要分片”消息又常被中间设备过滤掉,发送端一直以为包发出去了。

常用端口我按“谁在监听”来记,比按协议背更牢:

端口协议说明
20FTP主动模式下的数据连接源端口(仅在主动模式出现)
21FTP控制连接,全程保持
22SSH / SFTP注意 SFTP 是 SSH 的子协议,跟 FTP 没有继承关系
53DNSUDP 为主,大响应或区域传送走 TCP
80 / 443HTTP / HTTPS443 是 TLS 的默认端口,不是协议本身
8080 / 3128常见代理端口配置里经常见到,但不是标准

注意:SFTP 和 FTP 只是名字像。SFTP 跑在 SSH 之上,只有一个加密连接;FTP 是明文双连接。很多“FTP 连不上,试试 SFTP”的建议本质上是换了协议,不是换了端口。

2.2 握手与挥手:状态机怎么读,包怎么在现场看

三次握手的本质是双方各自确认“我的发送能力”和“你的接收能力”。四个信息里,前两个由第一次和第二次交换完成,后两个由第二次和第三次完成,所以三次是下限,省不掉。

客户端 服务端 CLOSED LISTEN SYN seq=x ----------> SYN_SENT SYN_RECV <---------- SYN seq=y, ACK=x+1 ESTABLISHED ACK ack=y+1 ----------> ESTABLISHED

序号 x 和 y 是各自随机生成的初始序号(ISN)。随机化不是为了安全噱头,而是为了避免旧连接的残留报文被新连接误收——如果 ISN 每次都从 0 开始,上一个连接延迟到达的包会被当成新连接的数据。

挥手是四次,原因是 TCP 是全双工的,两个方向要各自关闭。被动关闭方收到 FIN 后只能先回 ACK,因为它的应用层可能还有数据没发完,等发完了再补一个 FIN。

主动关闭方 被动关闭方 FIN seq=u ----------> FIN_WAIT_1 CLOSE_WAIT(回 ACK) <---------- ACK=u+1 FIN_WAIT_2 <---------- FIN seq=w LAST_ACK ACK ack=w+1 ----------> TIME_WAIT CLOSED (等 2MSL)

现场看状态,最顺手的是这两条命令:

ss -antp | head -30 ss -ant state time-wait | wc -l

第一条看当前所有 TCP 连接的状态和归属进程,第二条专门统计 TIME_WAIT 数量。老系统上可能是netstat -ant,结果大同小异,但ss在内核里直接读,连接数上万时快得多。

TIME_WAIT 经常被当成“故障”,其实它是主动关闭方的正常状态,作用是两件事:一是保证最后一个 ACK 能重传(如果它丢了,对方会重发 FIN,此时本端还在 TIME_WAIT 才能正确回应);二是让本连接的残留报文在网络里自然消亡,不会污染复用同一四元组的新连接。Linux 上这个时长是固定的 60 秒,不能通过参数改成任意值,能调的只有“允许复用到什么程度”。

我踩过的坑:看到 TIME_WAIT 几千个就去调net.ipv4.tcp_tw_reuse,调完发现没效果。原因是这个参数只影响主动发起连接的一方在调用 connect 时能否复用 TIME_WAIT 的端口,而且只对出向连接有效。如果你的服务是被动接受连接的那一端,TIME_WAIT 根本不产生在你这里——它产生在客户端或者中间的代理上。搞错方向,调参自然没用。

2.3 窗口、MSS 与拥塞控制:把参数算给你看

这一节是我认为最值得花时间的地方,因为绝大多数“带宽跑不满”的问题答案都在这里。

TCP 的发送窗口受两个因素约束:接收方通告的窗口(rwnd,告诉你能发多少,我这边缓存够不够)和拥塞窗口(cwnd,发送方自己估的,网络现在能承受多少)。实际能发的数据量是两者的最小值。

先说为什么需要窗口缩放。TCP 头里的窗口字段是 16 位,最大 65535 字节,也就是 64KB。这个数字放在早期网络够用,放到现在是硬瓶颈。算一下就很清楚:

链路带宽 100 Mbps,往返时延 RTT = 40 ms 带宽时延积 BDP = 100,000,000 bit/s ÷ 8 × 0.04 s = 500,000 字节

也就是说,要让这条链路跑满,发送方在收到第一个 ACK 之前得能连续发出 500KB 的数据。而 16 位窗口最多允许 64KB 在途,理论上限只有65535 × 8 ÷ 0.04 ≈ 13 Mbps。差了将近 8 倍。

解决办法是窗口缩放选项(window scaling),在握手时协商一个移位因子,最大 14,把窗口上限拉到约 1GB。这就是为什么抓包时你会看到 SYN 包里带着WS=7这样的选项——它表示窗口值要左移 7 位,也就是乘 128。

再看拥塞控制,它决定的是“刚开始发的时候发多快”。慢启动阶段的 cwnd 从初始窗口(通常是 10 个 MSS 左右)开始,每收到一个 ACK 就加一个 MSS,效果是指数增长,一个 RTT 翻一倍。到慢启动阈值(ssthresh)后转入拥塞避免,每个 RTT 只加一个 MSS,线性增长。丢包时视算法不同而不同:传统算法把 cwnd 砍半,CUBIC 用三次函数恢复,BBR 则不看丢包,改看带宽和 RTT 的估计值。

这个差异在长肥管道(高带宽、高时延)上非常明显。同样 2% 的丢包率,传统丢包驱动的算法会把窗口砍到很低然后缓慢爬升,BBR 基本不受影响。所以“跨地域传输慢”很多时候不是带宽不够,是拥塞控制算法和丢包率不匹配。

# 看当前可用的拥塞控制算法 sysctl net.ipv4.tcp_available_congestion_control # 看默认算法 sysctl net.ipv4.tcp_congestion_control

实操心得:改拥塞控制算法前先确认瓶颈在哪。如果抓包显示重传率低于 0.1% 而吞吐还是上不去,多半是接收窗口太小或者应用层读得太慢,换算法没用。反过来,重传率超过 1% 且 RTT 波动大,换算法值得一试。判断依据在抓包工具的tcp.analysis.retransmission过滤器里,一眼能看出重传密度。

还有一个隐藏得很深的问题:Nagle 算法和延迟确认撞在一起会产生固定的 40 毫秒延迟。Nagle 的逻辑是“小包先攒着,等前面那个包的 ACK 回来再发”,延迟确认的逻辑是“收到数据先不急着回 ACK,等 40 毫秒看看有没有更多数据要一起确认”。两个凑一块就是:发送方等 ACK,接收方等更多数据,谁都等谁。这就是那些“接口明明是内网调用却稳定多出 40ms”的来源。

解法是给这类交互频繁、每次数据量很小的连接设TCP_NODELAY,关掉 Nagle。代价是小包变多,头部开销占比上升。

2.4 内核参数与线上高频现象

参数调整这块我不想给一张“万能调优表”,因为脱离场景的参数就是玄学。我只列几个真的会碰到、且判断依据明确的:

现象关键参数判断依据
大量 TIME_WAIT 堆积tcp_max_tw_buckets只在主动关闭方堆积,超出上限会被内核直接清掉(有日志)
短连接高频创建,端口耗尽ip_local_port_range本地端口范围默认约 2.8 万个,短连接 QPS 高时会打满
突发 SYN 丢包tcp_max_syn_backlog / somaxconn半连接队列或全连接队列溢出,ss -lnt可看 Send-Q
空闲连接被中间设备掐断tcp_keepalive_time需要小于中间设备的会话老化时间

关于 keepalive,有个认知偏差值得说清楚:TCP 自带的 keepalive 默认要 7200 秒才发第一个探测包,这个值远大于绝大多数中间设备(负载均衡、NAT 网关)的空闲会话老化时间(常见 300 秒到 900 秒)。所以“连接莫名其妙断了”很多时候是中间设备先把会话表项清了,而两端都以为连接还在。对策要么把 keepalive 时间调小,要么在应用层自己做心跳——后者更可控,因为你能明确知道心跳失败后该做什么。

全连接队列溢出是另一个典型。ss -lnt输出里的 Send-Q 对监听套接字来说就是全连接队列的最大长度,Recv-Q 是当前排队的已完成连接数。如果 Recv-Q 长期接近 Send-Q,说明应用层 accept 的速度跟不上,新连接会被丢弃或收到 RST。此时调大队列只是缓解,根子在应用的 accept 循环不够快或者被阻塞了。


3. HTTP:最容易上手也最容易被误解的一层

3.1 报文结构拆到字节级

HTTP 报文就是一段纯文本(HTTP/1.x 时代),格式极其简单:

GET /api/report?id=42 HTTP/1.1\r\n Host: demo.local\r\n User-Agent: demo-client/1.0\r\n Accept: application/json\r\n Connection: close\r\n \r\n (可选的消息体)

拆解一下:第一行是请求行,三段用空格分隔;接下来是若干头部,每行一个名称: 值;最后是一个空行,也就是\r\n\r\n里的第二个\r\n。这个空行是整个格式的分界线,它之前全是元数据,之后是消息体。

理解这一点,很多“报文解析失败”的问题就有答案了:如果你的请求体和头部之间少了那个空行,服务端会一直等,因为它不知道头部什么时候结束。反过来说,如果消息体长度没有通过Content-Length或Transfer-Encoding: chunked说明,服务端也不知道该读多少字节,只能等到超时。

响应报文对称:

HTTP/1.1 200 OK\r\n Content-Type: application/json\r\n Content-Length: 27\r\n ETag: "abc123"\r\n \r\n {"status":"ok","code":0}

状态码的分类逻辑我用一句话记:1 开头是“还在处理”,2 开头是“成了”,3 开头是“去别处”,4 开头是“你错了”,5 开头是“我错了”。真正需要背的是几个容易混的:

状态码含义容易搞错的地方
301 / 308永久重定向308 保证不改变请求方法,301 在某些客户端会把 POST 变成 GET
302 / 307临时重定向同上,307 保证方法不变
304未修改不是错误,是缓存命中,响应里没有消息体
401 / 403未认证 / 被拒绝401 是“你没证明你是谁”,403 是“知道你是谁但不给看”
502 / 504上游错误 / 上游超时502 是上游返回了无效响应,504 是等上游超时,根因都在上游

注意:304 响应必须不带消息体。有些自研网关在 304 里塞了一段 JSON 提示,浏览器能容忍,但严格的客户端会把这段内容当成下一个响应的开头,导致后续报文整体错位。这类问题只在长连接上出现,短连接测不出来,非常隐蔽。

3.2 版本演进到底在解决什么问题

HTTP 的版本史,本质是一部“怎么更高效地利用一条 TCP 连接”的历史。

HTTP/1.0 的默认行为是一个请求一条连接,请求完就关。每次请求都要重新握手,在 RTT 40ms 的链路上,握手加慢启动的开销可能比传输本身还大。HTTP/1.1 把持久连接变成默认(Connection: keep-alive成为默认语义),连接可以复用。

但复用带来新问题:队头阻塞。HTTP/1.1 的请求和响应必须严格按顺序对应,同一个连接上前一个响应没回来,后面的只能等。浏览器为了绕开这个限制,对同一域名开 6 到 8 条并发连接——这是客户端侧的妥协,不是协议解决的。

HTTP/2 用二进制分帧把这个问题从应用层解决掉:一条连接上可以有多个并行的流,每个流有自己的编号,帧可以交错发送再按流重组。同时做了头部压缩(HPACK),把重复的头部字段用索引代替,一个典型的请求头从几百字节压到几十字节。

但 HTTP/2 仍然跑在 TCP 上,所以 TCP 层的队头阻塞还在:一个包丢了,后面所有流的数据都得等它重传。HTTP/3 干脆换到 QUIC(基于 UDP),把可靠性、流控、多路复用都放到用户态实现,每条流独立处理丢包,一个流丢包不影响其他流。

版本传输层多路复用头部压缩主要痛点
HTTP/1.0TCP无无每请求一次握手
HTTP/1.1TCP无(靠多连接模拟)无应用层队头阻塞
HTTP/2TCP有(流)HPACKTCP 层队头阻塞
HTTP/3QUIC(UDP)有(流)QPACK中间设备对 UDP 的处理策略不一

实操心得:升级 HTTP/2 或 HTTP/3 之前,先确认收益在哪里。如果你的服务是内网短请求、RTT 只有零点几毫秒,升级带来的提升可能被协议复杂度抵消。真正受益的是高时延、多资源并发加载的场景。判断方法很简单:看抓包里的 RTT 和单页请求数,RTT 超过 30ms 且并发请求超过 20 个,升级才有明显意义。

3.3 手工发包与 curl 实测流程

想真正理解 HTTP,最好的办法是用最原始的工具发一次请求,看着字节一个个回来。

第一步,用 curl 看完整交互过程:

curl -v --http1.1 -H "Accept: application/json" \ -o /dev/null -s -w "dns=%{time_namelookup} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \ http://192.0.2.10/api/ping

这条命令的-w参数会输出四个关键时间点,它们的差值能直接定位慢在哪:

  • time_namelookup:DNS 解析耗时,如果这个数大,问题在解析不在服务;
  • time_connect减time_namelookup:TCP 握手耗时,大则说明网络可达性有问题;
  • time_starttransfer减time_connect:从连接建立到收到第一个字节,包含请求发送、服务端处理和首字节返回,这是最常出问题的一段;
  • time_total:总耗时,减去 ttfb 就是内容传输耗时。

第二步,用 nc 手工拼一个请求,观察响应:

printf 'GET /api/ping HTTP/1.1\r\nHost: demo.local\r\nConnection: close\r\n\r\n' \ | nc -N 192.0.2.10 80

-N的作用是发送完就关闭写端,让服务端知道请求发完了。如果你不加这个参数,服务端可能会一直等消息体,直到超时。

第三步,验证分块传输。有些接口不返回Content-Length,而是用Transfer-Encoding: chunked:

curl -v --raw http://192.0.2.10/api/stream 2>&1 | head -40

--raw会关掉 curl 的自动解码,你能看到原始的块格式:长度(十六进制)\r\n数据\r\n,最后以0\r\n\r\n结束。这个格式看起来奇怪,但它的价值在于发送方不需要预先知道总长度,边算边发。

3.4 缓存、连接复用、代理:三个高频误区

误区一:以为设了 max-age 就一定走缓存。缓存判定是多个条件的组合:Cache-Control的no-store优先级最高,直接禁;no-cache不是不缓存,而是每次用之前必须回源验证;max-age只在没有更强指令时生效。再加上 ETag 和 Last-Modified 触发的条件请求,实际行为经常和直觉相反。验证方法是看响应头里到底带了什么,而不是看请求里写了什么。

误区二:以为 keep-alive 越长越好。连接复用的收益是省掉握手,但代价是服务端要维持连接状态。如果客户端拿完连接就不用了,空闲连接会占着资源直到超时。更麻烦的是,如果服务端和中间设备对空闲超时的判断不一致,会出现“服务端认为连接还在、中间设备已经清了”的情况,此时客户端发请求过去会直接收到 RST。常见对策是服务端的空闲超时设得比中间设备短,让服务端主动关。

误区三:以为代理只是转发。正向代理和反向代理的行为差别很大。正向代理处理 HTTPS 时用的是CONNECT方法建立隧道,之后代理就看不到内容了;反向代理则可能改写头部、终止 TLS、加X-Forwarded-For。排查时如果发现“直连正常、走代理异常”,第一件事是确认代理有没有改写Host头——很多虚拟主机配置依赖 Host 头路由,改了就 404。

我踩过的坑:一个上传接口在直连环境正常,走反向代理后大文件必然截断。查了半天应用日志没线索,最后抓包发现代理默认限制了请求体大小,超出部分直接被丢弃并返回 413。这类问题在应用层日志里往往看不出来,因为请求根本没到达应用。


4. FTP:双连接模型为什么在今天依然难用

4.1 控制连接与数据连接的关系

FTP 最特殊的地方是它用两条独立的 TCP 连接:一条控制连接(默认 21 端口)全程保持,用来传命令和响应;一条数据连接每次传输时临时建立,用完就关。目录列表、文件下载、文件上传全走数据连接。

这个设计在 1971 年很合理——控制通道保持轻量,数据通道可以按需建立不同参数。但它带来的麻烦一直延续到今天:

  • 两条连接的方向可能相反。主动模式下,数据连接是服务端主动连客户端,这要求客户端可被外部访问;
  • 数据连接的目标端口是动态协商的。要么通过PORT命令告诉服务端“你连我这个端口”,要么通过PASV命令问服务端“你在哪个端口等我”;
  • 防火墙和 NAT 对第二条连接极不友好,因为它不在固定端口上,且方向和发起方都可能出乎意料。

控制连接的响应是带状态码的文本,格式是三位数字加说明。这套码和 HTTP 有相似之处但也有关键区别:FTP 的响应可以是多行的(第三位是-表示后续还有),而且同一时刻可能有多个响应交错,解析时要按行读、按状态码分组。

4.2 主动与被动模式的完整会话回放

先看主动模式(PORT)。客户端告诉服务端自己的 IP 和端口,服务端从 20 端口主动连过来:

客户端 服务端 220 FTP server ready <---------- USER demo ----------> 331 需要密码 PASS ****** ----------> 230 登录成功 TYPE I ----------> 200 切换二进制 PORT 192,0,2,10,195,149 ----------> 200 PORT 命令成功 LIST ----------> 150 开始传输 <---------- (服务端从 20 端口连客户端的 50069) 226 传输完成 <----------

PORT后面的六个数是h1,h2,h3,h4,p1,p2,IP 是192.0.2.10,端口是p1 × 256 + p2 = 195 × 256 + 149 = 50069。这个编码方式很原始,但必须记,因为日志里看到一串逗号分隔的数字,你得能立刻还原成地址。

再看被动模式(PASV),这是今天的默认选择:

客户端 服务端 PASV ----------> 227 Entering Passive Mode (192,0,2,10,195,150) <---------- (客户端主动连 192.0.2.10:50070) STOR report.bin ----------> 150 Ok to send data ----------> 226 Transfer complete QUIT ----------> 221 Goodbye

227响应里括号中的六个数同样是h1..h4,p1,p2的编码,端口是195 × 256 + 150 = 50070。这里是最容易出错的地方:如果服务端配置在 NAT 后面,它返回的 IP 可能是内网地址,客户端连这个地址当然连不上。解决办法分两种:服务端配置里指定对外通告的地址,或者在客户端强制忽略返回的 IP、只用端口连控制连接的对端地址。

用命令行实测一遍完整流程:

# 交互式会话,打开调试模式能看到完整的命令和响应 ftp -d 192.0.2.10

-d会打印每一条交互,包括 PASV 的原始响应。非交互场景可以用:

printf 'user demo pass\nbinary\npassive\nput local.bin remote.bin\nquit\n' | ftp -n -d 192.0.2.10

-n表示不自动登录,避免脚本被本地配置干扰。

模式数据连接发起方服务端要求客户端要求典型问题
主动 PORT服务端能访问客户端任意高端口需被外部访问客户端在 NAT 后必失败
被动 PASV客户端需开放一段端口范围无特殊要求返回内网 IP、端口范围未开放

4.3 穿防火墙的参数配置与替代考量

服务端在防火墙后面的标准做法是:限定被动模式的端口范围,然后把这个范围在防火墙上放开。

如果用的是常见的 FTP 服务实现,配置项大致是这样:

pasv_enable=YES pasv_min_port=50000 pasv_max_port=50100 # 明确通告对外的地址,避免返回内网 IP pasv_address=<对外地址>

端口范围开多大?按并发传输数算。每条数据连接占一个端口,如果峰值时有 50 个并发传输,范围至少要 50 个可用端口,再留点余量。范围开太大会扩宽防火墙开口,开太小会在高并发时出现“端口耗尽导致数据连接建立失败”,表现是425 Can't open data connection。

注意:被动模式下,数据连接的端口是从服务端发起监听、客户端来连,所以服务端的入向规则要放开这整段端口范围。这一点和主动模式正好相反,配置防火墙时写错方向的案例我见过不止一次,表现就是“登录成功但列目录卡住”。

如果环境允许,我个人的建议是认真评估要不要继续用 FTP。它的明文传输、双连接模型、端口动态协商,每一条都是运维负担。SFTP 用单连接、全程加密、只走一个端口,配置成本低得多;如果是程序间传文件,直接用 HTTP 上传接口往往更好——复用现有的鉴权、日志、限流体系。继续用 FTP 的理由通常是存量系统改造代价高,那就至少把被动模式端口范围固定下来,并把这个约定写进运维文档。

4.4 传输模式与编码:最典型的文件损坏来源

FTP 有两种传输模式:ASCII(TYPE A)和二进制(TYPE I)。ASCII 模式会做换行符转换——发送端把本地的\n转成\r\n,接收端再转回去。这个转换在纯文本换行上有用,但对任何二进制内容都是破坏。

我见过最典型的故障是:某次打包的压缩文件传到远端后大小不一致,解压报“文件损坏”。原因是客户端默认用了 ASCII 模式,压缩包里恰好有一些字节序列匹配了换行符模式,被改写了。

规则很简单:除了纯文本,一律用二进制模式。命令行 ftp 里敲binary,或者TYPE I。

ftp> binary 200 Switching to Binary mode. ftp> put archive.tar.gz

另一类隐蔽问题是文件名编码。FTP 协议本身不带编码声明,服务端和客户端默认编码不一致时,非 ASCII 文件名会显示成乱码,下载时找不到对应文件。处理办法要么在客户端指定字符集,要么在服务端统一配置。这类问题在日志里表现为“文件明明存在却报 550 找不到”,很有迷惑性。


5. 三协议联合排查:从现象推到根因的路径

5.1 分层排查清单

我习惯按“从下往上、先通后快”的顺序推,每一步都有明确的判定信号:

第一步,确认地址可达。用ping或者直接nc -zv <目标IP> <端口>测端口。nc的好处是直接测 TCP 握手,不受 ICMP 策略干扰。返回succeeded说明三次握手完成,链路和监听都没问题。

第二步,确认连接状态。连接能建立但行为异常时,看ss -ant里的状态。大量CLOSE_WAIT说明应用层没有正确关闭连接(收到 FIN 但没调 close),这是应用层 bug 的典型信号;大量SYN_RECV说明半连接堆积,可能是 SYN 洪水或者队列太小。

第三步,确认应用层语义。抓包或者开调试日志,看具体的请求和响应。HTTP 看状态码和头部,FTP 看三位响应码。这一步能区分“链路问题”和“业务问题”。

第四步,确认传输质量。只有在前面都正常、仅仅是速度不对时,才去看窗口、重传、MSS。

# 一步到位看连接建立耗时和重传情况 ss -tin dst 192.0.2.10

ss -i会输出每个连接的内核指标:rtt、cwnd、retrans、bytes_sent 等。retrans持续增长就是重传在发生,cwnd卡在一个很小的值说明拥塞控制在压制发送。

5.2 常见问题速查表

现象可能原因验证方法处理方向
请求卡住无响应,最终超时目标端口被防火墙丢弃(DROP 而非 REJECT)nc -zv卡住不返回检查中间策略,确认是丢包而非拒绝
连接被立即重置端口未监听 / 中间设备发 RSTnc -zv返回 refused确认服务进程和监听地址
大文件传一半卡死MTU 不匹配,大包被静默丢弃抓包看是否有满 MSS 的包无响应钳制 MSS 或调小 MTU
吞吐只有预期的几分之一接收窗口太小 / 未启用窗口缩放ss -ti看 cwnd 和 rwnd检查窗口缩放协商结果
接口稳定多出 40msNagle 与延迟确认互相等待抓包看 ACK 的时间间隔设置 TCP_NODELAY
上传到 99% 失败代理或网关限制请求体大小看是否返回 413 或无响应断开调整限制或改分片上传
FTP 登录成功但列目录卡住被动模式端口未放行抓包看数据连接是否建立放开端口范围
FTP 文件下载后损坏ASCII 模式误用于二进制文件比对传输前后文件大小和哈希强制二进制模式
非 ASCII 文件名找不到客户端与服务端编码不一致看目录列表里的文件名显示统一字符集配置
空闲连接莫名断开中间设备会话老化看断开前空闲了多久调小 keepalive 或加应用层心跳

5.3 抓包过滤表达式与读包技巧

抓包是最后手段,但用对了效率极高。关键是先想清楚要验证什么假设,再写过滤条件,不要一上来就全量抓。

# 只看目标主机的 80 端口,且只看 SYN 包 tcpdump -i any -nn 'host 192.0.2.10 and tcp port 80 and tcp[tcpflags] & tcp-syn != 0' # 抓 FTP 控制连接,输出 payload 便于看命令 tcpdump -i any -nn -A 'tcp port 21' # 抓 FTP 数据连接,只看前 200 字节避免刷屏 tcpdump -i any -nn -s 200 'tcp portrange 50000-50100'

在图形化抓包工具里,我常用的几个过滤器:

过滤器用途
tcp.flags.syn==1 && tcp.flags.ack==0只看握手的第一包,快速定位重传的 SYN
tcp.analysis.retransmission只看重传,密度反映链路质量
tcp.analysis.zero_window窗口归零,说明接收方处理不过来
http.request/http.response只看 HTTP 的请求和响应行
ftp.request.command/ftp.response.code只看 FTP 命令和响应码
tcp.stream eq N只看某一条连接,配合“跟踪流”看完整会话

读包有三个技巧值得单独说。

第一,看时间差而不是看内容。两个包之间的间隔超过 200ms,基本就能判定这里有等待,再顺着这个间隙去找是谁在等谁。

第二,看序号连续性。TCP 序号是连续的,如果两个包之间序号跳了,中间要么丢了要么乱序了。抓包工具会自动标注Previous segment not captured,这个标注出现的位置往往就是故障点。

第三,先看首包和末包。一次传输的建立阶段和关闭阶段信息量最大:建立阶段能看出窗口缩放、MSS、选项协商结果;关闭阶段能看出谁先发起关闭、有没有 RST。中间的数据部分反而不必逐包看。

实操心得:抓包前先确认抓的是不是你以为的那条路径。在有代理或者容器的环境里,-i any抓到的可能是宿主机视角的包,跟应用实际发出入的包不是一回事。我吃过一次亏,抓了半天发现看到的全是代理和后端之间的流量,客户端到代理那一段根本没抓到,白折腾一小时。


6. 我在实际项目里踩过的几处坑

6.1 几个容易被忽略的细节

第一个,握手成功不等于服务可用。三次握手是内核完成的,只要端口在监听、队列没满,握手一定成功。哪怕应用层已经卡死,握手照样能完成。所以“端口能连上”只能证明进程还活着,不能证明它能处理请求。判断服务是否真可用,必须发一次真实的业务请求。

第二个,TIME_WAIT 的数量要看在哪一端。前面提过,它只产生在主动关闭方。判断是谁主动关闭的,看抓包里谁先发 FIN。如果服务端是主动关闭方,TIME_WAIT 就堆在服务端,这时候调客户端的参数毫无意义。

第三个,HTTP 的Connection: close会掩盖很多问题。短连接每次都是全新的 TCP,问题不容易复现;长连接下才会暴露的报文错位、连接状态不一致、头部大小超限等问题,在短连接测试里全都看不到。做压测和排查时,一定要用长连接跑一遍。

第四个,FTP 的LIST和NLST走的是数据连接。很多人以为列目录是控制连接上的操作,其实目录列表也是通过数据连接传回来的。所以“能登录但列不出目录”几乎必然是数据连接建不起来,直接去查被动模式端口。

第五个,MTU 问题在虚拟化环境里更常见。容器网络、隧道封装(VXLAN 之类)都会额外消耗字节,实际可用 MTU 比 1500 小。表现是“小请求正常、大响应卡死”,因为大响应必然产生满 MSS 的包。排查方法是构造一个刚好等于 MSS 的请求,看能不能完整返回。

6.2 学习与验证的建议路径

如果你想把这三块真正吃透,我建议的路径是“先跑一遍,再抓一遍,最后改一遍”。

跑一遍的意思是:不借助任何库,用命令行工具把三种协议各走一遍完整流程。用nc发一次原始 HTTP 请求,用ftp -d看一次完整的命令交互,用tcpdump抓一次三次握手。这一步的目的是建立“协议就是一段有格式的文本/字节”的直觉,而不是抽象概念。

抓一遍的意思是:找一个真实的慢请求,完整抓包并逐层分析。从 TCP 握手耗时、窗口协商结果、重传密度,到 HTTP 的首字节时间、响应头、消息体长度,全部对上。这一步最能培养排障直觉,因为你会发现自己之前的猜测有一半是错的。

改一遍的意思是:在测试环境里主动制造故障。改 MTU 制造分片问题,用防火墙 DROP 而不是 REJECT 制造超时,把 FTP 强制设成主动模式看它怎么失败。亲手制造过一次,下次遇到同样的现象就能秒认。

至于资料,我个人的偏好是直接读 RFC 的目录和关键章节,不一定要通读全文。TCP 的行为细节看 RFC 9293,HTTP/1.1 看 RFC 9112,HTTP/2 看 RFC 9113,HTTP/3 看 RFC 9114,FTP 看 RFC 959。RFC 的好处是所有实现都得向它对齐,你在里面看到的每一条约束,背后都是一个真实踩过的坑。

最后分享一个我常用的验证小技巧:怀疑某个问题是 MTU 导致的,不用改配置,直接用ping带不同大小的载荷测:

# 1472 字节载荷 + 28 字节头部 = 1500,正好是标准 MTU ping -M do -s 1472 192.0.2.10 # 如果失败,往下试 1400、1300,找到能通的最大值 ping -M do -s 1400 192.0.2.10

-M do表示禁止分片,包发不出去会直接报错而不是静默丢弃。能通的最大载荷加 28 就是这条路径的实际 MTU。这个办法三十秒就能定位,比抓包快得多,是我遇到“大包不通”时的第一反应。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询