熟悉的场景:短请求像燕子一样飞快,但一旦通过移动代理启动一个大上传或下载一个大文件,连接就会在中途断开。文件刚到 40%,然后就没有动静了。重试请求有时有用,有时没用。最气人的是:日志一言不发,服务器看起来还活着,数据就是到不了。如果你遇到过这种情况,那你就找对地方了。

本文不讨论 HTTP 层的响应码和重试逻辑(关于 429 和指数退避有单独的文章,我们会引用)。这里我们要深入探讨传输层:TCP 为什么会断开,到底是谁掐断了连接,数据包路径上的三个不同超时是如何运作的,什么是 MTU 以及蜂窝网络中的分片,为什么基站切换会杀死长会话。最重要的是,我们会构建一个能从容应对这一切的客户端。

引言:下载到一半就没反应了,短请求却正常通过

为什么这个主题在 2026 年如此重要?因为移动代理已经成为爬虫、自动化、测试和电商平台操作的常用工具。蜂窝网络天生就比有线网络不稳定。它是为拿手机的人设计的:打开一个页面,读完,再关闭。它不是为机器设计的:保持一个 TCP 连接十分钟,然后通过它下载 1GB 数据。

这就是本节标题中所说的悖论。短请求之所以能成功,是因为它们在任意超时触发或基站切换(handover)发生之前就已经完成了。长操作存活时间更长,因此遇到各种问题的概率也更高:超时闲置、切换小区时断开、因 MTU 配置错误而导致大包丢失。

读完这篇文章,你将了解:

  • 如何五分钟内判断到底是谁断开了连接:你的客户端、代理、运营商还是目标服务器。
  • TCP keepaliveHTTP keep-alive 有什么本质区别,为什么两者都需要配置。
  • 如何测量路径上的三个超时,并找出决定连接命运的“最短超时”。
  • 为什么蜂窝网络中的 MTU 低于标准的 1500 字节,PMTUD 故障又是什么样子(症状:大响应卡死)。
  • 基于 Python 和 Node 的完整客户端代码,支持 Range 断点续传、上传分片和合理超时设置。

我们会使用技术语言,但每个术语都会用通俗易懂的方式解释。开始吧。

基础知识:移动代理连接的结构

在分析故障之前,我们先统一一下认知。当你通过移动代理发送请求时,数据包会经过一条很长的链路。理解这条链路,诊断就成功了一半。

数据包从客户端到服务器的路径

想象一下寄快递的路线。它要经过多个环节,每个环节都可能被延误或丢失:

  1. 你的客户端——发起请求的程序。它有自己的超时和 socket 设置。
  2. 代理服务器——接收你的连接,并打开到目标服务器的连接。这通常是两个不同的 TCP 连接“粘合”在一起。
  3. 移动调制解调器和无线电接口——这是一个又窄又“任性”的环节。数据包在这里通过无线电波飞到基站。
  4. 运营商核心网——NAT、网关、流量优先级系统。
  5. 公共互联网——到目标数据中心的骨干链路。
  6. 目标服务器——最终节点,它同样有自己的连接时长限制。

核心洞察:经过代理的连接不是一根管道,而是至少两根。客户端到代理是一条连接,代理到目标服务器是另一条。任何一段都可能断开,而且症状各不相同。

什么是会话,为什么它很脆弱

TCP 连接是一个虚拟通道。物理上,你和服务器之间并没有一根实际的电缆,而是双方约定好交换带序号的数据字节。只要双方都还记得序号和状态,连接就是“活着”的。一旦某一方丢了状态(NAT 重启、超时到期、小区切换),连接实际上就已经死了,但另一方可能很长时间都不知道。

正是这种情况造就了那些阴险的半开连接(half-open):一方认为通道还活着,一直在等数据,而另一方早就把它忘了。客户端就那样挂着,什么错误都没有,时间一分一秒过去。是不是很熟悉?

可能出现问题的三个层级

  • 无线层——丢包、延迟、小区切换。这是蜂窝网络的本质。
  • 网络层——运营商 NAT 超时、MTU、分片。
  • 应用层——代理和目标服务器的 HTTP 超时、响应体大小限制。

接下来我们逐一分析,并学会如何区分它们。

深入剖析:谁可能断开连接,以及如何判断到底是谁

这是最重要的诊断部分。在不知道“元凶”之前,你就是在盲打。好消息是:每个“元凶”都有自己的行为特征。

四个“嫌疑人”

1. 你的客户端。 这是最常见也是最容易被低估的“元凶”。HTTP 库往往有默认超时,你可能根本没注意到。比如:请求总超时、socket 读取超时、空闲超时。如果你的客户端自己断开了连接,日志里通常会出现类似 read timeout 或 socket timeout 的错误,并且堆栈中会显示你的代码位置。

2. 代理服务器。 代理有它自己的限制:连接最大存活时间、空闲超时、最大响应体积。当代理强制断开连接时,你通常会在响应体传输中突然收到 connection reset,或者连接在没有任何响应的情况下被直接关闭。而这时候,到代理的 ping 是通的,短请求也正常。

3. 网络运营商。 这是最隐蔽的。运营商的 NAT 表条目有存活时间。如果连接长时间没有流量,运营商就会删除 NAT 条目,后续数据包根本无处可送。症状:连接恰恰是在停顿期间失联,而不是在活跃传输期间。另外,基站切换时,运营商也常常是“元凶”。

4. 目标服务器。 它有自己的 keep-alive 设置和限制。很多服务器会在处理 N 个请求后或经过 T 秒后关闭连接。症状:服务器会发送 Connection: close 头,或者通过 FIN 正常关闭连接,而不是用 RST 强杀。

按症状诊断:行为特征对照表

我们来看一些典型特征,让你能在几分钟内锁定“元凶”。

  • 只在停顿期间断开,活跃传输时一切正常——基本可以断定是运营商 NAT 超时或代理的空闲超时。可以通过 keepalive 流量来“治疗”。
  • 总是在相同的数据量附近断开(比如大约 8MB 或 10MB)——代理或服务器对响应大小有限制。可以通过 Range 分片来解决。
  • 总是在大致相同的时间后断开(比如刚好 60 秒或 300 秒)——这是对连接时长的硬性限制。去找出其中最短的那个超时。
  • 大响应卡死,小响应正常——典型的 PMTUD 故障和 MTU 问题。下面有专门一节来讲。
  • 断开时间点毫无规律,不受体积和时间影响,而且移动中更频繁——基站切换、handover、无线信号质量下降。
  • 尝试发送数据时瞬间收到 RST——连接在某一侧已经死了(半开连接),或者代理主动重置。

初步诊断工具

为了区分 FIN(正常关闭)和 RST(强制重置),并搞明白是在哪一段断开的,可以这样操作:

  • Socket 级日志——记录断开的精确时间、已传输的字节数、异常类型。
  • 流量分析——抓包工具会告诉你是谁发送了 RST 或 FIN。如果 RST 来自代理的地址,那就是代理的问题。如果连接只是默默没有回应,没有任何数据包,那多半是中间节点(运营商)的问题。
  • 对照测试——通过有线网络直接重试同样的操作。如果有线稳定,而移动代理会断,那问题就出在移动这一段。

作者的经验:在我们处理过的问题中,70% 的“元凶”是运营商 NAT 超时或默认空闲超时,而不是代理或服务器。人们总是怪代理,但实际上只需要几行 socket 配置就能解决。

Keep-alive 与空闲超时:路径上有三个超时,最短的说了算

这是一个值得刻在脑子里的基本原理:数据包路径上存在多个相互独立的空闲超时,最终决定连接命运的,是最短的那个。就像一条链条:它总是从最薄弱的一环断开。

超时都藏在哪里

  1. 客户端的空闲超时。 你的程序在没有收到任何数据时,愿意等待多久。不同库的默认值从 30 秒到无限长都有。
  2. 代理的空闲超时。 代理在没有任何活动时,会将连接保持多久。典型值是 60 到 300 秒。
  3. 运营商的 NAT 超时。 核心网在没有流量时,会保留地址转换记录多长时间。对 TCP 来说通常是 300-600 秒,但在 UDP 或高峰时段,可能只有 30-60 秒。
  4. 目标服务器的 keep-alive 超时。 服务器在两个请求之间会将连接保持多久。常见值是 5-75 秒。

假设客户端愿意等 120 秒,代理在 90 秒后断开,而运营商在 60 秒时清理 NAT。谁会赢?运营商。连接会在空闲的第 60 秒死亡,而客户端和代理都不会立刻察觉。

如何测量最短超时

方法简单又可靠。建立连接,发出一个请求,然后保持沉默,等待并记录断开的时间。多重复几次,排除随机因素。

  1. 通过代理连接到一个支持 keep-alive 的测试服务器。
  2. 发送一个简短的请求,获得响应。
  3. 不要关闭连接。启动计时器。
  4. 每隔一秒钟,用非阻塞方式尝试读取 socket,检查连接是否还活着。
  5. 记录收到 RST、FIN 或 socket 变得不可读的时间点。

重复测量三次。如果断开总在大约 60 秒时发生,那就是你的空闲上限。因此,keepalive 流量的发送频率必须高于每 60 秒一次,并留出足够余量——比如每 20-25 秒一次。

战胜空闲超时的策略

既然最短超时决定一切,我们的任务就是不要让连接的空闲时间超过这个限制。有两种思路:

  • 用有效流量填满连接——在数据活跃传输时,空闲超时不会触发,因为根本没有空闲。所以连续下载很少因为空闲超时而中断,反而更容易受大小限制和连接时长限制的影响。
  • 在间隙中发送 keepalive 探测——当没有实际数据可发时(比如你在等服务器生成报告),就需要用人为流量来维持连接。这里就要用到两种 keep-alive 机制,我们下面单独讲。

TCP keepalive vs HTTP keep-alive:机制不同,两者都需要

业界之所以产生巨大混淆,是因为这两个名字太像了。TCP keepalive 和 HTTP keep-alive 是完全不同的东西,工作在不同的层级。要构建一个稳定的移动客户端,两者都必不可少。

HTTP keep-alive:连接复用

HTTP keep-alive(也叫持久连接)的意义在于不必为每个请求都新建一条 TCP 连接,而是让一条连接连续处理多个请求。它属于应用层。

为什么这对移动网络很重要?在蜂窝网络中新建一条 TCP 连接成本很高。三次握手,如果还加密的话,还要加上 TLS 握手。在延迟很高的无线链路上,这可能需要几百毫秒。通过复用连接,你可以为后续每个请求省下这笔时间。

但是!HTTP keep-alive 对运营商 NAT 超时毫无帮助。它只是让连接可以为下一个请求保持打开,但不会在间隔期间产生任何流量。

TCP keepalive:传输层的“心跳”

TCP keepalive 是 TCP 协议自带的机制。操作系统会定期发送一个空白的探测包(keepalive probe),用来确认对端是否存活,同时刷新中间 NAT 表的记录。它属于操作系统内核层。

TCP keepalive 正是你对抗运营商 NAT 超时和中间节点空闲超时的终极武器。每一个探测包都是流量,会重置整条路径上的空闲计数器。

TCP keepalive 有三个关键参数:

  • keepalive idle(即 keepalive time)——连接空闲多少秒后开始发送探测包。大多数操作系统默认是 7200 秒,也就是两小时。对移动网络来说这简直是一场灾难。
  • keepalive interval——在得不到响应时,每次探测之间的间隔。
  • keepalive count(probes)——连续多少次探测无响应后,判定连接已死。

移动网络推荐配置值

默认的两小时完全不适用。运营商早就在第一次探测之前就把 NAT 清掉了。下面是经过实践检验的可用配置:

  • keepalive idle:15-25 秒。 在短暂空闲后就开始发送探测,确保能抢在最短的 NAT 超时之前。
  • keepalive interval:10-15 秒。 如果探测没有回应,用较短的间隔重试。
  • keepalive count:3-4。 连续三到四次无应答后,判定连接已死并重新建立,而不是一直傻等。

这样组合会带来双重好处:连接不会在空闲时被运营商掐断;即使连接真的死了(比如因基站切换),客户端也能在 45-70 秒内感知到,而不是几个小时后才发现。快速发现死连接,等于实现了一半的稳定性。

为什么两种配置都需要

打个比方。HTTP keep-alive 就像把会议室一整天都预订下来,这样就不用每次去重新订。而 TCP keepalive 就像时不时去会议室看一眼,免得保洁员以为没人用而把它锁了。你只预订但不去看,会被锁上;你每次去看但每次都重新预订,又会浪费时间。所以两者都要。

MTU 与分片:为什么蜂窝网络中的数据包更小

接下来是导致卡死的最容易被忽视的原因。如果你遇到小响应正常,大响应直接卡死的情况——90% 的概率问题出在 MTU 和 PMTUD 故障上。

通俗解释 MTU 是什么

MTU(Maximum Transmission Unit,最大传输单元)指的是在不分片的情况下,能够发送到网络中的单个数据包的最大尺寸。标准以太网是 1500 字节。你可以把它想象成门的宽度:比门宽的家具必须拆散了才能搬进去。

问题在于,蜂窝网络中的有效 MTU 往往低于 1500。原因就是封装。移动流量在运营商网络内部会被包上一层又一层的协议外壳。每层外壳都会增加一些头部字节,留给有效数据的空间就更小了。移动网络中的实际 MTU 通常在 1400-1480 字节之间,有时甚至更低。

什么是 PMTUD,它为什么会失效

PMTUD(Path MTU Discovery,路径 MTU 发现)是一种自动探测整条路径上最大数据包尺寸的机制。工作方式如下:客户端发送一个设置了 Don't Fragment(不分片)标志的大包。如果途中遇到一个 MTU 更小的节点,该节点会丢掉这个包,并回送一个名为 fragmentation needed 的 ICMP 消息,告诉发送方允许的最大尺寸。客户端收到提示后,就会减小数据包大小。

这套机制看起来很完美,但它有一个脆弱的条件:ICMP 消息必须能一路回传到客户端。然而在现实中,很多网络、防火墙和安全策略会直接屏蔽 ICMP。于是,《PMTUD black hole》(黑洞)这场灾难就这样发生了。

PMTUD 故障的表现:卡死的解剖学

这是一个非常典型且特征明显的场景:

  1. 客户端建立连接。握手过程都是小包,一切正常。
  2. 客户端发送一个简短的请求,小包顺利通过,响应也回来了。小响应没问题。
  3. 服务器开始发送大响应,使用 1500 字节的完整大小数据包,并且设置了 Don't Fragment 标志。
  4. 路径上某个 MTU 为 1400 的节点丢掉了这些包,因为它们太大了而且不允许分片。
  5. 节点回送 ICMP fragmentation needed,但这个 ICMP 被防火墙拦截,到不了发送方。
  6. 服务器没有收到任何提示,继续发送同样的大包,继续被丢弃。如此无限循环。
  7. 结果:小包(头部、ACK)都能通过,而响应数据却过不来。连接就这样在一个大响应的中间彻底卡死。

这就是为什么短请求正常,大响应卡死。这并不神秘,这是 PMTUD 黑洞。而且,这恐怕是处理移动流量的工程师们“头秃”的最常见原因。

如何解决 MTU 问题

解决方案有几个层级,从应用层到网络层:

  • MSS clamping(MSS 钳制)。 这是最可靠的网络层解决方案。MSS(Maximum Segment Size,最大分段大小)是 TCP 双方在握手时协商好的最大数据段大小。如果显式限制 MSS,让最终的数据包能装进实际的 MTU(比如 MSS 1360-1400),服务器从一开始就会发送合适大小的数据包,完全不需要 ICMP。这样能从根源上治好黑洞问题。
  • 降低接口 MTU。 如果你能控制流量经过的调制解调器或设备,可以把接口 MTU 设为 1400 或更低。这样所有出站数据包都不会超过安全尺寸。
  • 放行 ICMP。 如果你能控制路径上的防火墙,请放行 fragmentation needed 这类 ICMP 消息。这样标准的 PMTUD 就能正常工作了。
  • 应用层手段。 如果你要下载一个大文件,可以把它切分成多个 Range 请求。每个请求都是一次独立的短传输,不太容易遇到大流量导致的那些问题。后面在实战部分会详细讲。

一个洞察:即使你控制不了网络,你通常也能通过连接设置或代理服务参数来影响 MSS。优秀的移动代理服务商会在自己这边做好 MSS clamping,替你消除黑洞问题。这也是衡量服务质量的标准之一。

基站切换与跨网漫游:TCP 为什么扛不住

现在来说说最根本的不兼容。蜂窝网络是为移动而生的,但 TCP 不是。这是架构层面的冲突。

为什么 TCP 绑定的是 IP 地址

一个 TCP 连接由这样四个元素唯一定义:源地址、源端口、目标地址、目标端口。这就像是连接的门牌号。只要其中一个元素发生变化,这就变成了另一个连接,原来的连接就失效了。

那么在移动网络中会发生什么?在某些基站切换或技术切换场景下(比如在不同代际的网络之间,或者在不同网络之间),移动节点的对外 IP 地址可能会改变。这时候 TCP 就无能为力了:它无法让连接在新地址上继续。旧连接直接死掉,所有在途数据都丢失了。

切换(Handover):不总是致命,但总伴随风险

平心而论,现代网络会尽量让切换无缝衔接:在切换小区时保持同一个 IP。很多时候确实能做到,你根本感觉不到。但有时候会发生 IP 变更、持续一两秒的无线链路中断,或者丢包突发。对短请求来说,这些都可以忽略。但对一个十分钟的会话来说,这就是一场彩票,每一次切换都是一次抽奖。

切换导致断开的典型症状

  • 断开是随机的,与数据量或时间没有关联。
  • 如果设备在物理移动(真实 SIM 卡的移动代理很常见),断开频率会增加。
  • 断开后,新建连接一切正常——网络是活的,死掉的只是那一条旧的连接。

怎么应对:接受现实,构建韧性

在 TCP 层面,你无法战胜 IP 变更。但你可以把客户端设计成这样:连接断开不是事故,而是正常流程。道理很简单:经过移动网络的连接天生就是“一次性”的,客户端必须能够无缝重建连接,并从断点继续工作

下面这些具体技巧,我们会在代码里实现:

  • 操作幂等性。 把请求设计成可以安全重试的。Range 下载是幂等的:重复请求同一个字节范围,得到的结果是一样的。
  • 断点续传,而不是重新下载。 断开后不要从头开始,而是从最后收到的字节继续。这样能节省流量和时间。
  • 快速感知连接死亡。 通过前面讲到的激进 TCP keepalive,你可以在几秒内发现死连接,而不是等几分钟。
  • 短事务。 单次传输越短,越不容易被切换打断。把大上传拆成小片段,正是这一原则的直接体现。

Python 实战:支持断点续传和超时控制的稳定客户端

理论讲够了,我们开始动手吧。先从 Python 开始。我们将构建一个客户端,实现:配置 TCP keepalive、设置合理的超时、通过 Range 分片下载文件,并在断开后支持断点续传。

第 1 步:在 socket 上配置 TCP keepalive

标准的 HTTP 库默认不会启用激进的 keepalive。你需要直接操作 socket。在 requests 生态中,可以通过自定义传输适配器来实现:它会设置 socket 选项,比如启用 keepalive,将 idle 设为 20 秒,interval 设为 10 秒,count 设为 3。概括来说,逻辑就是:我们注册一个适配器,在建立连接时,在底层设置这些参数。

核心配置用文字描述就是这样:启用 SO_KEEPALIVE 选项,然后设置 TCP_KEEPIDLE 为 20,TCP_KEEPINTVL 为 10,TCP_KEEPCNT 为 3。不同操作系统中这些选项的名称略有区别,生产环境里需要根据平台进行判断。

第 2 步:合理的超时设置

关键原则是:始终显式设置超时,并把连接超时和读取超时分开。没有超时的默认设置是一个陷阱,客户端会在半开连接上永远挂起。

  • 连接超时:10 秒。 移动网络建立连接会更慢一些,但 10 秒已经足够宽裕。
  • 读取超时:30 秒。 这是指两段数据之间允许的最大空闲时间,而不是整个传输的时限。如果 30 秒内没有收到任何字节,就认为连接出了问题。
  • 整体操作预算 建议在断点续传的逻辑层单独控制,而不是用一个大而全的请求超时来兜底。

第 3 步:使用 Range 下载并支持断点续传

算法思路如下。以写入模式打开目标文件。检查已下载的大小(如果文件已部分存在)。通过 Range 请求头请求从当前位置到文件末尾的剩余字节范围。边接收边写入文件。如果遇到中断(读取异常、连接断开),不要慌张:重新检查磁盘上已有多少字节,然后从新的起始位置再次请求。如此循环,直到文件完整下载完成。

具体步骤用文字描述如下:

  1. 通过请求头(HEAD 或 GET 加 Range: bytes=0-0)来确定文件的完整大小,读取 Content-Range 头。
  2. 检查本地已下载部分的大小。
  3. 如果本地大小等于完整大小,说明文件已就绪,直接退出。
  4. 否则,构造一个从本地大小开始的 Range 请求。
  5. 以 64-256 KB 的块读取响应数据,并追加写入文件。
  6. 成功完成后,校验完整性(大小,条件允许时还要校验校验和)。
  7. 如果断开,增加重试计数,稍作停顿,然后回到第 2 步。
  8. 在传输层限制最大重试次数(比如 5-8 次),防止遇到系统性问题时无限循环。

一个重要细节:请确认服务器支持 Range。标志是响应头里有 Accept-Ranges: bytes,并且 Range 请求返回 206 Partial Content。如果服务器直接返回 200 并忽略 Range,就无法断点续传,只能整文件下载,这时 keepalive 和正确的 MSS 设置就显得格外重要。

第 4 步:拆解大上传任务

当你不是下载,而是上传大量数据时(比如发送数据或拉取大型报告),同样可以使用“拆碎”的原则。把任务拆成一个个固定大小的分页或分块。每个分块都是一次独立的短事务,拥有自己的错误处理逻辑。维护进度日志:记录哪些分块已经确认完成。断开时,只需要重试未确认的分块。这样,一个脆弱的十分钟大操作就变成了一串稳定的小操作。

关于基于服务器响应状态码的重试策略,本文不作讨论,因为那是单独一篇关于 429 和指数退避的文章,建议你读完本文后再去看那篇。我们的焦点是传输层:断开、超时、大小。

Node.js 实战:稳定的 HTTP Agent 与流式处理

接下来,我们把同样的原则应用到 Node.js 生态。这里的核心角色是管理连接池的 Agent 对象。

第 1 步:配置带 keep-alive 的 Agent

在 Node 中,创建一个启用了 keepAlive 的 HTTP 或 HTTPS Agent。将 keepAlive 设置为 true,可以让 Agent 复用连接(这是 HTTP 层)。此外,还需要设置 keepAliveMsecs,这是 socket 层发送 TCP keepalive 探测包的时间间隔。对移动网络,建议把 keepAliveMsecs 设置在 15000-20000 毫秒,以领先于运营商的 NAT 超时。同时,限制 maxSockets 和 maxFreeSockets,避免创建过多冗余连接。

第 2 步:不同层级的超时

在 Node 中,超时需要在多个位置设置,缺一不可:

  • 连接建立超时——通过请求的选项设置,或者通过 socket 的 connection 事件处理函数设置。
  • Socket 空闲超时——使用设置 socket timeout 的方法。超时触发后,必须显式销毁 socket,并将其作为一次断开来处理。Node 不会在超时时自动关闭 socket,只会触发事件——这一点经常被忽略,结果连接一直挂在那里。
  • error 和 close 事件处理——请求和 socket 上的 error、close 事件,都应该引入受控的重试逻辑。

推荐值与 Python 类似:连接超时约 10 秒,socket 空闲超时约 30 秒,keepalive 探测每 15-20 秒一次。

第 3 步:流式下载与断点续传

在 Node 中,使用流是自然而然的事。断点续传的逻辑与 Python 相同:检查本地文件大小,以追加模式打开写入流,从当前大小开始构造带 Range 请求头的请求,为 data、end、error 事件绑定处理函数。收到 data 事件时,把数据块写入文件。收到 end 事件时,检查文件是否完整。收到 error 或过早的 close 事件(并且实际接收量少于预期)时,从新位置重新发起请求。

在 Node 中实现稳定性的关键点,是正确处理流的提前结束。即使连接断开了,end 事件也可能照常触发,哪怕数据没有接收完整。因此,一定要把实际收到的数据量与 Content-Length 或 Content-Range 头所指示的期望值进行核对。不要只依赖 end 事件。

第 4 步:在传输层封装重试机制

把整个下载过程封装在一个带有限次重试的循环里。每次重试之间做短暂的停顿(对于传输层断开,1-3 秒就够了,因为原因不是服务器过载,而是网络事件)。记录重试次数。如果达到上限,就把错误向上抛出,并附上诊断信息:已接收多少字节、错误类型是什么、重试了多少次。这些诊断信息在排查问题时非常宝贵。

常见错误:这些“坑”不要踩

我们来看一些在别人代码中经常见到的反面模式。避开它们,一半的问题就消失了。

错误 1:没有显式设置超时

这是最常见也最令人头疼的问题。没有超时的客户端会卡在半开连接上永远无法自拔。线程被阻塞,资源无法释放,而你还以为操作仍在进行。务必显式设置超时。没有超时并不是无限耐心,而是一颗定时炸弹。

错误 2:依赖默认的 TCP keepalive 设置

默认的两个小时让 TCP keepalive 在移动网络中形同虚设。很多人只要启用了 SO_KEEPALIVE 就觉得万事大吉,却不知道第一个探测包要等 7200 秒才会发出,那时运营商早就把一切都清理干净了。请显式配置 idle、interval 和 count。

错误 3:每次断开后都重新下载整个文件

下载到 95% 时断开,然后从头再来,这不仅浪费时间,还会浪费额外的移动流量(代理流量通常都是按量计费的)。对于任何大型下载,使用 Range 断点续传都是必须的。

错误 4:忽视 MTU 和 MSS

很多人花了几个月时间跟“大响应卡死”作斗争,反复调整超时和代理设置,而真正的元凶其实是 PMTUD 黑洞。如果你发现大响应会卡死、小响应却没问题,那么第一件事就是检查 MTU,并配置 MSS clamping。这会为你省下几个星期。

错误 5:把传输层断开和服务器响应混为一谈

传输层断开(RST、读取超时、socket 已死)和服务器返回的错误码响应是两种不同的情况,应对策略也完全不同。对于传输层断开,适合用短停顿和断点续传来快速重试。对于服务器返回 429 这类状态码,则需要指数退避——这属于另一篇文章的范畴。不要在同一个处理逻辑里把这两层混在一起。

错误 6:重试过于激进

如果遇到系统性问题(比如代理完全不可用),仍然无限循环重试,就会变成一种寄生式负载。一定要设置重试次数上限,并在达到上限后带着诊断信息有始有终地结束操作。

错误 7:不校验结果的完整性

“下载完成”并不等于“数据正确”。一次断开可能会留下一个被截断的文件,而这个文件从各种外在特征上看都像是完整的。请始终核对文件大小,对于重要数据还要比对校验和。

错误 8:让一条连接存活时间过长

移动网络中的连接活得越久,遇到切换、IP 变更或 NAT 超时的累积概率就越高。定期重建连接不是软弱,而是明智的卫生习惯。不要害怕重开连接。

诊断与构建稳定性的工具和资源

用对工具能节省大量时间。下面这个“武器库”值得常备。

网络与数据包诊断

  • 抓包与分析。 数据包捕获工具就是你的显微镜。它能告诉你谁发了 RST、ICMP fragmentation needed 有没有送回来、链路中实际的数据包大小是多少、流量在哪里中断。没有它,MTU 诊断和查找断开元凶就只能是瞎猜。
  • 路径探测工具。 路由追踪和 MTU 检测工具可以帮助你找出路径上哪里限制了数据包大小。有些工具还专门用来探测能够通过的最大包大小——这正是配置 MSS 所需要的。
  • 测试用的回显服务器。 一个简单的、支持 keep-alive 并能按指定大小返回数据的服务器,在测量空闲超时以及在可控环境下复现大响应问题时,是无可替代的。

客户端相关的库与方法

  • 支持灵活 socket 配置的 HTTP 客户端。 优先选择那些能让你访问连接参数、并且能在底层设置超时和 keepalive 的库。
  • 流式处理机制。 在处理大型下载时,必须对流式处理响应体,不能把整个响应都放进内存。
  • 进度日志。 一个简单的状态存储(哪些分块已收到、磁盘上已有多少字节)可以让断点续传变成一件小事。

代理基础设施的质量

这里要单独强调一下:相当一部分传输层问题,可以通过选择靠谱的代理服务商来规避。正确的 MSS clamping 配置、合理的空闲超时、切换时尽量保持 IP 稳定、对 keep-alive 的良好支持——这些都是成熟服务的标志。像 MobileProxy.space 这样的服务商在架构设计上就会考虑这些传输层细节,从而帮客户端分担不少麻烦。但即使是完美的代理,客户端也必须有足够的健壮性——因为无线信道本质上就是无法预测的。

案例与结果:实践中的效果

我们来看几个有代表性的典型案例,以及它们的解决方案。数据都是平均值,但反映了真实的量级。

案例 1:上传目录到一半卡死

场景。 一个团队通过移动代理,用单个请求上传一份很大的商品目录。响应大约 15MB。总是在 6-8MB 左右卡死,没有任何报错,就是一片寂静,直到几分钟后总超时触发。

诊断。 小请求完全正常。抓包显示,大包都带有 Don't Fragment 标志,而 ICMP fragmentation needed 没有返回。典型的 PMTUD 黑洞。

解决方案。 配置了 MSS clamping,确保数据包不超过移动网络实际 MTU 的容量,同时把上传拆分为每 2MB 一个的分页请求。

结果。 卡死完全消失。上传时间变得可以预测,即使偶尔断开,也只需要重试一个分页,而不是整个目录。上传成功率从大约 40% 提升到了几乎 100%。

案例 2:连接在等待期间死亡

场景。 客户端请求生成一份大型报告,服务器需要 90-120 秒来处理,然后返回结果。但等到报告准备好时,连接已经死了。

诊断。 测量空闲超时后发现,在空闲约 60 秒时连接必断。元凶是运营商 NAT 超时:等待期间完全没有流量。

解决方案。 启用了 TCP keepalive,将 idle 设为 20 秒,interval 设为 10 秒。这样在等待期间,连接每 20 秒就会被探测包“刷新”一次。

结果。 连接能够撑到报告生成完毕。等待期间的断开不再发生。额外的好处是,客户端不再干等好几分钟,而是在 45-60 秒内就能发现连接确实已经死亡。

案例 3:活跃爬取过程中的随机断开

场景。 长时间的爬虫会话毫无规律地断开,与数据量或时间都没有关联。在特定时段尤其频繁。

诊断。 各种迹象都指向切换期间的 IP 变更。断开后,新连接能瞬间建立——证明网络是活的。

解决方案。 我们按照“连接是临时的”这一哲学重构了客户端:短小且幂等的事务、进度日志、断开后快速重建连接、带短停顿的有限次重试。

结果。 从物理层面来说,断开依然存在(IP 变更无法被“战胜”),但它们不再构成问题。每次断开只需要重试一个短事务,这只是一瞬间的事。整个流程的可靠性大幅提升,工程师们再也不用在夜里盯着日志了。

案例总结

请注意一个规律:每个案例的解决方案都不是更换代理,而是理解传输层并正确配置客户端。通过症状诊断锁定具体“元凶”,然后对症下药,处理起来又快又准。

FAQ:移动网络连接断开的常见问题

为什么通过移动代理时,短请求正常,长请求却会断开?

因为长操作存活时间更长,更容易遇到各种传输层问题:空闲时的超时、响应大小限制、连接存活时间限制、切换导致的 IP 变更,以及大数据包上的 PMTUD 黑洞。短请求在这些机制触发之前就已经结束了。解决办法是配置 keepalive、MSS,并实现 Range 断点续传。

如何判断到底是谁断开了连接——代理、运营商还是服务器?

看“行为特征”。停顿期间断开——运营商 NAT 超时或代理空闲超时;固定数据量附近断开——代理或服务器的大小限制;固定时间点断开——连接存活时间限制;大响应卡死——MTU 和 PMTUD 问题;移动中随机断开——切换(handover)。要想得到准确答案,请使用抓包工具:它能告诉你 RST 来自哪个地址,ICMP 是否返回。

TCP keepalive 和 HTTP keep-alive 有什么区别?

HTTP keep-alive 工作在应用层,允许复用同一条连接来处理多个请求,从而节省建立新连接的开销。TCP keepalive 工作在操作系统内核层,会定期发送探测包,让连接在空闲时保持“存活”,并刷新 NAT。前者节省新请求所需的时间,后者防止连接因空闲而断开。两者都需要。

移动网络应该把 TCP keepalive 设置为多少?

一个起步值:idle 15-25 秒,interval 10-15 秒,count 3-4。这种组合既能领先于运营商激进的 NAT 超时,也能快速发现死连接。具体数值,请针对你的运营商和代理,实测一下你的空闲超时再调整。

什么是 PMTUD 黑洞?如何识别?

这是指路径上的某个节点丢弃了过大的数据包,但它发出的“请减小包大小”的 ICMP 通知被拦截,无法到达发送方。结果就是大包不断地被丢弃,而小包却能正常通过。识别特征很明显:握手和小响应都没问题,但大响应会彻底卡死。解决办法是使用 MSS clamping 或降低 MTU。

基站切换时,TCP 连接还能保住吗?

如果网络在切换时保持同一个对外 IP,那么连接可以存活下来,但可能会有短暂延迟。如果 IP 变了,那就没办法,旧 TCP 连接已经不可逆转地死亡,因为 TCP 与地址和端口的组合强绑定。正确的策略不是不惜一切代价保住连接,而是把客户端设计成能无缝重建连接,并从断开的地方继续。

断开后如何才能正确断点续传?

先确认服务器是否支持 Range(响应头中有 Accept-Ranges: bytes,并且 Range 请求返回 206)。断开后,获取已下载部分的大小,然后用 Range 请求头从这个字节开始请求剩余内容,并追加写入同一个文件。重复此过程直到下载完成,同时限制重试次数。最后一定要核对最终大小是否符合预期。

移动代理的 HTTP 客户端应该设置怎样的超时?

要把连接超时(约 10 秒)和读取空闲超时(约 30 秒,指两段数据之间的间隔,而不是整个传输)分开设置。整体操作时长应该在断点续传逻辑中进行控制,而不是用一个大超时来包办。永远不要将超时设置为无限。

如果我已经配置了基于响应状态码的重试策略,还需要做别的吗?

需要。基于响应状态码的重试(比如处理 429 时使用指数退避)属于应用层,是另一个话题。传输层断开——RST、读取超时、socket 死亡——需要单独的逻辑:短停顿的快速重试、断点续传、keepalive,以及正确的 MSS 配置。这两层逻辑不能互相替代,必须在客户端中同时存在。

代理服务商的选择会影响传输层稳定性吗?

影响很大。成熟的代理服务商通常会配置 MSS clamping、合理的空闲超时,尽量在切换时保持 IP 不变,并正确处理 keep-alive。这能在你的代码介入之前就解决一部分问题。但即使是完美的代理,也不代表你不需要构建健壮的客户端,因为无线信道本质上就是难以预测的。

结语:连接是临时的,但数据必须送达

我们一起走完了从症状到稳定客户端的完整路径。让我们把重点沉淀下来,让这篇文章成为你的收藏夹常客。

第一。 移动代理长连接断开并非玄学,而是非常具体的机制导致的结果:三个层级上的空闲超时、大小和时间限制、MTU 问题以及切换。每个机制都有各自的特征,通过症状来诊断,几乎总能找到“元凶”。

第二。 路径上最短的超时决定了连接的命运。先测量它,然后把 TCP keepalive 设置得更激进一些——对移动网络,idle 设为 15-25 秒。请记住,TCP keepalive 和 HTTP keep-alive 是两种不同的机制,两者都需要。

第三。 如果大响应卡死、小响应正常,那么几乎可以确定是 PMTUD 黑洞。解决办法是 MSS clamping 和正确的 MTU。不要花几周时间去反复调整超时,请先检查数据包大小。

第四。 切换导致的 IP 变更无法被“打败”,但你可以让它变得不可怕。围绕“连接是临时的”这一特性来构建客户端:短且幂等的事务、Range 断点续传、进度日志、快速重建连接、有限次重试,以及必须的完整性校验。

你的下一步很简单。测量你的代理和运营商上的空闲超时。启用并配置 TCP keepalive。测试大响应时的行为,必要时调整 MSS。实现 Range 断点续传。另外,一定要学习关于响应状态码重试策略的资料,理解 429 和指数退避——这样你才能把应用层也一并搞定,而不仅仅是传输层。

移动网络在本质上就是不可预测的。但一个尊重这种“天性”的客户端,可以把这种不可预测性从“熬夜值班”的源头,变成一种日常的背景噪音。连接是临时的,这很正常。重要的是,数据最终一定要到达。现在,你知道该怎么做了。