凌晨两点被告警叫醒,日志里躺着一条ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接,这个TCP连接重置错误10054几乎是每个写网络程序的人都会撞上的坎。它不像超时那样"讲道理"——没有漫长的等待,没有分阶段的握手,连接就是在一瞬间被判了死刑。很多同学习惯性地把它归类成"网络不稳定",然后挂上三层重试草草了事,结果在压测或者生产环境高峰期,这类错误成片爆发,重试反而把下游打得更惨。
这篇东西想干的事很具体:把 10054 这个错误码从"内核吐给应用层的一个数字"倒推回 TCP 协议栈上的那个 RST 包,讲清楚它是谁发的、为什么发、在什么条件下发,然后用三组最小可运行的实验在本地把它稳定复现出来,最后落回到工程侧——怎么定位、怎么重连、参数怎么算。这篇内容适合正在写长连接服务、HTTP 客户端、消息推送、数据库连接池的同学,也适合刚接触 socket 编程、被这个错误码反复折磨的新手。我不打算把它写成一份 API 手册,而是按我这些年排查这类问题的实际路径来讲,中间会夹杂几个踩过的坑和几处反直觉的结论。
1. 先把概念钉死:10054 到底是谁发出的错误
1.1 从"连接莫名其妙断开"说起
很多人第一次见 10054 是在一个很平淡的场景里:客户端和服务端跑得好好的,突然某一刻recv或者send抛出异常,错误码 10054,消息是"远程主机强迫关闭了一个现有的连接"。第一反应往往是去查服务端日志,结果服务端那边风平浪静,甚至压根没记录到这条连接出过问题。于是就有了"网络抖动"这个万能背锅侠。
真相要从 TCP 的复位机制讲。TCP 是面向连接的协议,正常情况下结束连接要么走四次挥手(FIN/ACK 来回两轮),要么因为超时重传耗尽而中止。但协议还留了第三条路——RST 包。RST 是 TCP 报文头里六个标志位中的一个,语义非常粗暴:"这条连接我不认了,立刻作废,别再来数据"。收到 RST 的一方,内核会直接把对应的 socket 置为无效状态,唤醒所有阻塞在这个 socket 上的调用,把错误码递给应用层。
在 Windows 的 Winsock 错误表里,这个错误码就是 10054,常量名WSAECONNRESET。关键在于,10054 是本地内核报给本地应用的,不是对端"发"过来的一个错误码。对端发的是 RST 包,本地内核收到后翻译成 10054 交给应用。这个区别很重要:如果你在服务端看到大量 10054,说明是别人给服务端发了 RST;如果你在客户端看到,说明有人给客户端发了 RST。搞清楚 RST 的方向,排查就成功了一半。
1.2 RST 包与 Winsock 错误码的映射关系
把链路拆开看会更清楚。应用层调用recv(),进入 Winsock 层,Winsock 把请求交给 TCP/IP 驱动,驱动检查这个 socket 对应的连接控制块(TCB)状态。如果 TCB 已经被标记为"已复位",驱动立刻返回WSAECONNRESET,Winsock 层原样透传给应用,应用侧表现为异常或错误返回值。
所以整个链路是:远端或中间设备发 RST → 本机 TCP 收到并校验合法 → TCB 置为复位态 → 挂在 socket 上的阻塞调用被唤醒 → 返回 10054。
这里有个容易被忽略的点:RST 包不是随便什么人都能发的有效包。TCP 收到 RST 时要做一次合法性校验,核心是序列号是否落在当前接收窗口内。如果序列号明显对不上,内核不会直接关连接,而是回一个 ACK(俗称 challenge ACK),要求对端重新同步。所以一个"打偏了"的 RST 是打不死连接的。这个特性在排查时很有用:如果抓包里看到 RST 的序列号明显不合理,那基本可以排除是被动复位,反而要怀疑是不是链路上有设备在做注入或者扫描探测。
1.3 四个易混错误码的对照表
10054 经常和它的几个"邻居"混为一谈,实际处置手段完全不同。我整理了一张对照表,这几个码我建议直接背下来,比现场翻文档快得多。
| 错误码 | 常量名 | 语义 | 触发点 | 对端应用是否还活着 |
|---|---|---|---|---|
| 10054 | WSAECONNRESET | 连接被复位 | 收到合法 RST | 通常已退出或已放弃这条连接 |
| 10053 | WSAECONNABORTED | 连接被本地协议栈中止 | 重传耗尽、保活失败 | 大概率已失联 |
| 10060 | WSAETIMEDOUT | 连接尝试超时 | 三次握手无响应 | 不可达或被丢弃 |
| 10061 | WSAECONNREFUSED | 目标积极拒绝 | SYN 收到 RST 响应 | 端口没监听,主机在线 |
| 10058 | WSAESHUTDOWN | 本地已关闭还继续操作 | 自己调过 shutdown | 与本错误无关 |
这张表最值得琢磨的是 10054 和 10053 的区别。10053 是"我自己等不下去了",是本地协议栈在重传若干次都没收到任何回应之后,主动放弃;10054 是"有人明确告诉我别等了",是收到了复位信号。前者说明链路上数据全丢,后者说明链路上有设备或主机在主动表态。一个丢包率高的弱网环境,出来的多半是 10053 和 10060;一个对端进程频繁重启、或者中间设备会话表频繁老化的环境,出来的才是 10054。方向搞错了,排查会绕很远。
2. RST 是怎么发出来的:六个高频触发场景逐条拆
2.1 场景一:对端进程消失,半开连接上继续发包
这是最常见的一种。对端进程因为崩溃、被强制终止、或者容器被调度走,socket 被操作系统回收。但这里有个细节很多人搞错:进程退出并不必然发 RST。如果发送缓冲区是空的,内核正常关闭 socket 会走 FIN,对端收到的是一个礼貌的挥手请求,正常情况下应该回 FIN 完成四次挥手。
真正出事的是接下来的这段时间差。对端进程已经没了,但它那一侧的 socket 已经关闭,而本端应用毫不知情,仍然认为连接有效,继续调用send()发数据。这些数据到达对端后,对端内核发现"这个四元组没有对应的 TCB 了",直接回一个 RST。本端收到 RST,下一个recv()或者send()就报 10054。
这个路径解释了一个很典型的现象:10054 往往不是在断开的那一刻报的,而是在断开之后第一次真正收发数据时才报。如果你的应用有 30 秒心跳,那对端消失后的第一声心跳就会撞上 RST,于是你在日志里看到的是"每 30 秒出现一批 10054",而不是零散出现。这个时间规律本身就是一条重要线索,后面排查部分会用它来反推心跳配置。
2.2 场景二:close 时接收缓冲区还有未读数据
这一条是最容易被忽视、也最值得单独拎出来讲的。根据 TCP 的约定,一个 socket 被关闭时,如果它的接收缓冲区里还堆着没被应用读走的数据,内核会认为"这些数据已经没人要了",此时不发送 FIN,而是直接发送 RST。原因是协议层面没法表达"我关了但我还有数据没读",用 FIN 会造成对端误以为数据已全部交付。
这个行为在 Windows 上同样成立。所以下面这种代码在真实项目里到处都是:
conn, addr = server.accept() header = conn.recv(4) # 只读了 4 字节头部 # 中间发生异常,或者逻辑分支提前 return conn.close() # 缓冲区里还有 body 没读,内核发 RST客户端那边表现就是 10054。而且这种 bug 特别隐蔽,因为服务端日志上看起来"正常关闭了连接",一点报错都没有。我在一个协议解析模块里就踩过:解析失败走了异常分支直接 close,客户端侧成片 10054,查了整整半天,最后是在服务端 close 之前加了一行drain才定位到。
处置思路很简单:关闭之前先把接收缓冲区的数据读干净,或者明确调用shutdown(SHUT_RDWR)再 close,让内核知道"我确实不要了"。有些语言封装提供了close_gracefully之类的接口,原理就是先读完再关。
2.3 场景三:SO_LINGER 强制零超时
SO_LINGER这个选项控制的是关闭 socket 时对未发送数据的处理方式。它的结构体通常是两个字段:l_onoff和l_linger。三个组合对应三种行为,我列一下,这个表在实际调优时很关键:
| l_onoff | l_linger | 关闭行为 | 对端表现 |
|---|---|---|---|
| 0 | 任意 | 后台继续尝试发送剩余数据,然后 FIN | 正常挥手 |
| 1 | 大于 0 | 最多等待 linger 秒,超时后丢弃并 RST | 可能收到 RST |
| 1 | 0 | 立即丢弃发送缓冲并发 RST | 必然 10054 |
第三种是把l_onoff置 1、l_linger置 0,业内俗称"硬关闭"或者"强杀连接"。它唯一的正当用途是在明确知道这条连接已经不可救、也不想等四次挥手的时候快速释放资源,比如服务端要应对大量连接洪峰、或者检测到恶意连接需要立刻切断。
但很多团队把它当成"关闭更快"的优化来用,这就完全跑偏了。零超时关闭意味着发送缓冲区里排队的响应数据直接被扔掉,对端可能正在等一个业务响应,结果等来一个 RST。HTTP 服务端如果对每个请求都这么关,客户端侧的连接池会持续收到 10054,而且因为看起来"服务端已经响应完了",排查难度极高。我见过一个中间件默认开了零超时,上线后客户端错误率涨了两个数量级,最后靠抓包才定位到 RST 是服务端主动发的。
2.4 场景四:中间设备把会话表项清了
跨网段、跨机房、经过负载均衡或防火墙的连接,几乎都绕不开会话表老化。中间的设备为了省内存,会维护一张五元组(或四元组)会话表,超过一定空闲时间没流量就清掉表项。表项清掉之后,如果两端还有数据包过来,设备的处理方式通常是回 RST——有的设备两个方向都回,有的只回一个方向。
这里有个非常反直觉的点:设备的老化时间往往远短于应用的认知。应用觉得自己有 60 秒心跳已经很勤快了,但某些设备的 TCP 空闲老化时间可能只有 300 秒甚至更短,而一个业务低峰期的连接可能十几分钟没有业务数据,只有心跳。心跳间隔如果配成 120 秒,勉强能撑住;如果配成 300 秒,正好卡在老化边界上,就会出现"偶发、无规律、集中在低峰期"的 10054,这种模式最难查,因为压测时压不出来。
更麻烦的是,不同厂商、不同型号、甚至同一台设备的不同版本,老化时间都可能不一样。指望查文档拿到准确值不现实,正确做法是实测:用一条空闲连接每 10 秒发一个字节,一直发到出现 RST,那个时间点就是实际的老化边界。这个实测数据是所有心跳参数配置的基准。
2.5 场景五:四元组复用与序列号错乱
这种场景相对少见但一旦出现极其难查。TCP 连接靠源 IP、源端口、目的 IP、目的端口这个四元组唯一标识。如果一条老连接还没完全消失(比如处于 TIME_WAIT 或某个中间状态),而一个新连接恰好复用了相同的四元组,就会出现两边的序列号对不上。
对端收到一个序列号完全不在自己接收窗口内的数据包,按协议要回一个 ACK 纠正;但如果收到的这个包的序列号恰好在窗口内但内容错乱,或者触发了某些边界条件,就可能被判定为异常,直接回 RST。客户端侧看到的就是刚建好连接、还没发几个请求就 10054。
典型的触发条件是客户端端口耗尽后快速复用。Windows 的动态端口范围默认在 49152 到 65535 之间,一共 16384 个端口。如果一台机器上有大量短连接高频请求,端口会被快速消耗。虽然 TIME_WAIT 会保留一段时间,但配合SO_REUSEADDR和某些激进配置,确实可能提前复用,进而撞上序列号错乱。
顺带说一句,Windows 上还有一个相关现象:如果本地端口复用得过于激进,可能遇到连接刚建立就被 RST 的情况,这类问题在客户端侧表现为"请求失败率随 QPS 上升而上升",而不是稳定复现。看到这种相关性,就该去查端口使用情况了。
2.6 场景六:安全软件与协议栈过滤驱动
终端安全软件、协议栈过滤驱动、流量审计组件,这些东西工作在比应用更底层的位置,它们有能力构造并注入 RST。常见触发原因包括:连接数超过策略阈值、目标端口被策略拦截、流量特征命中规则、证书或加密握手不符合策略。
这类 10054 有几个特征可以用来识别:第一,出现得很规律,比如每凑够 N 条连接就来一批;第二,抓包里能看到 RST 的序列号经常和当前连接的期望序列号对不上,或者 RST 来得非常"突兀",前面没有任何异常数据包;第三,把同一份程序换到另一台没有装同类软件的机器上跑,问题消失。第三条是最省事的验证手段,遇到怀疑注入的情况,我一般直接换机器对比,比在抓包里猜快得多。
3. 动手复现:三个最小可运行实验
3.1 实验环境与前置准备
复现是理解这类问题最快的路径。我准备了三组实验,全部在 Windows 上用 Python 做,因为 Python 的socket模块足够薄,能直接触达系统调用,不像很多高层框架那样把细节抹平。需要准备的东西很少:Python 3.8 以上,一个能用的抓包工具,两个终端窗口。
服务端和客户端的骨架先搭好。为了减少干扰,全部走回环地址127.0.0.1,这样排除了中间设备和网络抖动的影响,出来的 10054 一定是协议栈行为导致的。
# server_base.py import socket def make_server(port=9000): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", port)) srv.listen(8) return srv这段骨架后面三个实验都会复用。注意SO_REUSEADDR是必须的,否则反复实验时会因为 TIME_WAIT 卡住端口。
3.2 实验 A:未读数据加 close,最经典的 10054
这是场景二的直接复现。服务端只读一部分数据就关闭连接,客户端随后必然收到 RST。
# exp_a_server.py import socket srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", 9001)) srv.listen(8) conn, addr = srv.accept() first = conn.recv(4) # 只读 4 字节 print("server read:", first) conn.close() # 缓冲区里还有 996 字节没读 print("server closed with unread data")# exp_a_client.py import socket, time c = socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect(("127.0.0.1", 9001)) c.sendall(b"A" * 1000) # 一次性写入 1000 字节 time.sleep(0.3) # 等对端关闭 try: c.recv(1024) except OSError as e: print("client got error:", e.errno, e)先跑服务端,再跑客户端,客户端输出会是client got error: 10054 [WinError 10054] 远程主机强迫关闭了一个现有的连接。这里有个细节要注意:客户端sendall那一步通常不会报错,因为数据只是进了本地发送缓冲区;真正报错的是recv,因为 RST 到达时才会把 socket 置为无效。这个"延迟报错"的特性是排查时的重要线索,很多人看到日志上 send 成功了就以为连接是好的,其实只是错误还没浮出水面。
把服务端那行conn.recv(4)改成把数据读干净,再跑一遍,客户端的recv会返回空字节串(长度为 0),这才是一个优雅关闭该有的样子。两组结果对比一下,比看一百遍文档都直观。
3.3 实验 B:SO_LINGER 零超时制造主动 RST
第二个实验把关闭方式换成硬关闭,验证"即使没有未读数据,也能强制发 RST"。
# exp_b_server.py import socket, struct srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", 9002)) srv.listen(8) conn, addr = srv.accept() conn.recv(1024) # 数据读干净了 linger = struct.pack("hh", 1, 0) # l_onoff=1, l_linger=0 conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, linger) conn.close() # 立即丢弃发送缓冲并发 RST print("server hard closed")这里struct.pack("hh", 1, 0)打包的是 4 个字节,对应 linger 结构两个短整型字段。用"ii"在某些平台上也能跑通,但字节数不匹配,严格说是错的,我建议按结构体真实长度来写。
客户端代码和实验 A 一样。跑起来后你会发现,这次连recv都不用等,关闭之后立刻就能感知到 RST。如果服务端在关闭前还发过一段数据,客户端很可能连那段数据都读不到,直接被 10054 盖掉——这正是零超时最坑的地方,它会把已经排队待发的响应数据一起丢掉。
3.4 实验 C:半开连接上继续发包
第三个实验复现场景一。服务端正常关闭(发 FIN),但客户端应用层不理会,继续发送数据。
# exp_c_server.py import socket, time srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", 9003)) srv.listen(8) conn, addr = srv.accept() conn.close() # 正常关闭,发 FIN print("server sent FIN") time.sleep(10) # 进程还活着,但 socket 已经关了# exp_c_client.py import socket, time c = socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect(("127.0.0.1", 9003)) time.sleep(1.0) # 等对端发 FIN c.send(b"still here") # 这一句往往不报错 try: c.recv(1024) # 这里才拿到 10054 except OSError as e: print("client got error:", e.errno, e)这个实验的价值在于它揭示了一个时序:客户端send返回成功,不代表数据被对端接收了。RST 往往是"后知后觉"地到达,第一次send只是把数据推进了本地缓冲区,真正撞上 RST 是在下一次收发时。所以判断连接是否健康,靠 send 的返回值是不靠谱的,必须靠实际的读操作或者心跳响应。
另一个对照实验值得做:在客户端收到 FIN 之后,主动调用shutdown(SHUT_WR)关闭发送方向,然后继续recv,这时会收到长度为 0 的返回,而不是 10054。这个差别说明:对端关闭后你也停止发送,就是优雅半关闭;对端关闭后你还继续发,就会招来 RST。
3.5 用抓包确认 RST 的合法性
三个实验跑完,建议都用抓包工具看一遍。过滤表达式写tcp.flags.reset == 1,只看复位包。观察四个点:
第一,RST 的方向。是从服务端到客户端,还是反过来。方向决定了责任方。
第二,RST 的序列号。把它和这条连接上最后一个数据包的seq + len对比,如果两者能对上,说明这是一个"合理的"复位,是协议栈依据当前连接状态发出的;如果完全对不上,那 RST 的来源就可疑了,要考虑中间设备注入。
第三,RST 前面有没有 FIN。如果有 FIN 再有 RST,说明关闭流程走到一半被强行中断;如果直接 RST,那就是硬关闭或者半开连接上收包。
第四,时间间隔。RST 距离上一个数据包多久。如果稳定在某个数值附近(比如 300 秒、600 秒),基本可以确定是某台设备的会话老化,这个数值就是你要找的老化时间。
4. 现场排查:把 10054 定位到具体设备或代码行
4.1 三层排除法
线上遇到 10054,我一般按三层往下切,从快到慢,避免一上来就抓包。
第一层看分布。是所有客户端都报,还是只有特定机房、特定网段、特定版本报。全都报,问题大概率在服务端;只有一部分报,重点查那部分到服务端之间的链路和客户端本身。这一步用现成的监控数据就能完成,成本最低。
第二层看时间规律。错误是均匀分布,还是集中在某几个时间点,还是和空闲时长强相关。跟空闲时长相关,指向会话老化;跟 QPS 相关,指向端口复用或连接数超限;跟版本发布强相关,那就是代码问题,直接回滚验证。
第三层才是抓包 + 服务端日志对齐。在客户端和服务端同时抓,把 RST 出现的时刻和两边的应用日志时间戳摆在一起,看是谁先动的手。如果服务端日志显示"处理完成,正常关闭",而抓包里 RST 紧跟在这个时刻之后,那基本就是关闭方式的问题;如果服务端日志完全没记录,那 RST 很可能来自中间设备。
这个顺序很关键。我见过太多人一上来就在生产环境抓包,抓了几百兆的 pcap 分析了两天,最后还是靠"回滚一个版本问题就消失了"解决。先把范围缩小,再动工具。
4.2 抓包里的四个关键观察点
抓包不是把包存下来就完事,得知道看什么。除了前面说的方向、序列号、FIN 顺序、时间间隔,还有两个容易被忽略的观察点。
一个是RST 之前有没有重传。如果 RST 前面紧跟着几次重传(同一个序列号重复出现),说明链路当时在丢包,对端可能因为等不到数据而放弃。这种情况虽然报的是 10054,但根因是丢包,处置方向完全不同。
另一个是RST 之后有没有立刻重连。如果 RST 之后几毫秒内就出现了新的 SYN,说明应用层的重连逻辑生效了。这时候要关注的是重连的成功率和重连后的稳定性,而不是 RST 本身——有些场景下偶发 RST 加快速重连是完全可接受的,过度优化反而增加复杂度。
还有一个细节:Windows 上可以用netstat -ano看连接状态分布。如果发现大量连接卡在CLOSE_WAIT,那问题在应用层没正确关闭 socket,跟 10054 是两回事,但经常同时出现,容易混淆。
netstat -ano | Select-String "9001" Get-NetTCPConnection -State Established | Measure-Object4.3 心跳与保活参数怎么算
心跳间隔不是拍脑袋定的,有一套推导逻辑。设实测出来的最短设备空闲老化时间为T_idle(比如实测某型号设备是 300 秒),心跳间隔T_hb需要满足两个约束:
第一,要在老化之前至少刷新一次,所以T_hb < T_idle。
第二,要能容忍一次心跳丢失。最坏情况下,一次心跳丢了,下一次心跳要再等一个T_hb才发出去,所以实际刷新间隔最坏是2 * T_hb。这就要求2 * T_hb < T_idle,取安全系数后通常写成T_hb <= T_idle / 3。
按 300 秒算,T_hb <= 100秒,工程上取 60 秒,留足余量。这个 60 秒不是玄学,是从 300 除以 3 再向下取整到习惯值得到的。
TCP 自带的保活(SO_KEEPALIVE)参数另算。Windows 上默认的空闲时间是 2 小时,这个值对绝大多数业务来说等于没开。需要用SIO_KEEPALIVE_VALS或者较新版本支持的TCP_KEEPIDLE系列选项调整。一组常见的配置是空闲 60 秒、探测间隔 10 秒、探测 3 次,最坏判定断链时间为60 + 10 * 3 = 90秒。也就是说,对端静默 90 秒后本地才会认定连接失效,这个时间要和应用层的心跳超时配合好,否则会出现两边互相认为对方还活着的情况。
注意:保活探测失败返回的是 10053 而不是 10054。如果你在日志里同时看到这两个码,说明既有对端主动复位,也有链路长时间不可达,两种问题并存,要分开处理。
4.4 日志与抓包对齐的时间线方法
这一步是真正把问题钉死的关键。方法很土但极其有效:把客户端日志、服务端日志、抓包三个来源按时间戳排成一条时间线,精确到毫秒。
具体做法是,客户端在每次send和recv前后各打一行日志,带上连接标识和字节数;服务端在 accept、每次读写、close 前后各打一行;抓包里找到同一个连接的所有包,标出每个包的时间。然后对齐。
对齐之后,问题往往一目了然。比如时间线上出现:客户端 10:00:05.120 发出请求 → 服务端 10:00:05.123 收到请求 → 服务端 10:00:05.124 处理完成并 close → 抓包 10:00:05.124 出现 FIN → 客户端 10:00:05.300 再次 send → 抓包 10:00:05.301 出现从服务端到客户端的 RST。这时候结论就非常明确:服务端关闭太快,客户端还在复用这条连接。
我个人的习惯是,只要涉及网络问题,先把这条时间线画出来再下结论。跳过这一步直接猜原因,八成会猜错。
5. 工程化处置:重连、幂等与连接池治理
5.1 重连策略的参数与代码
10054 在很多场景下不可避免,所以必须有重连。但重连写不好就是灾难——下游已经压力很大了,所有客户端还在同一时刻疯狂重试,直接把服务打挂。
指数退避加抖动是标准答案。基础序列取 1、2、4、8、16、30、30……秒,上限 30 秒,再叠加 0 到 20% 的随机抖动,避免大量客户端在同一时刻同步重连。累积等待时间按前六次算:1+2+4+8+16+30 = 61秒,也就是说六次重试大约覆盖一分钟的故障窗口,够用了。
import random, time def backoff_delay(attempt, base=1, cap=30, jitter=0.2): d = min(cap, base * (2 ** attempt)) return d * (1 + random.uniform(-jitter, jitter)) def connect_with_retry(connect_fn, max_attempts=6): for i in range(max_attempts): try: return connect_fn() except OSError as e: if e.errno != 10054 or i == max_attempts - 1: raise time.sleep(backoff_delay(i))这段代码里e.errno != 10054那行判断很关键。不是所有错误都值得重试,纯连接复位重试性价比最高;如果是认证失败、协议错误,重试只会浪费资源。退避函数里cap的作用是防止无限增长,jitter的作用是打散重连风暴,两个参数都别省。
5.2 幂等边界:哪些请求可以无脑重试
重试的前提是幂等。这一点如果搞错,轻则数据重复,重则业务出错。
只读查询、按唯一键查询、幂等的更新(比如"设置为某个值",而不是"加一"),这些可以放心重试。创建类操作要慎重,必须有去重手段,比如客户端生成一个请求 ID,服务端按 ID 去重。扣减类操作绝对不能盲目重试,除非有明确的幂等键支持。
判断一个请求是否幂等,有个简单的问法:同一份请求发两遍,结果和发一遍完全一样吗?如果答案是否定的,那就要么加幂等键,要么不重试,把失败直接抛给上层处理。这个判断必须在发请求之前做,而不是在捕获到 10054 之后临时决定。
5.3 长连接治理的参数清单
把前面的结论整理成一份可以直接抄的配置清单,按场景取值:
| 参数 | 建议值 | 依据 | 说明 |
|---|---|---|---|
| 应用层心跳间隔 | 60 秒 | 实测老化时间 300 秒除以 3 | 留两次丢包容错 |
| 心跳超时判定 | 3 个周期 | 60 × 3 = 180 秒 | 避免误判断链 |
| 保活空闲时间 | 60 秒 | 与心跳错开 | 双保险 |
| 保活探测间隔 | 10 秒 | 探测 3 次共 30 秒 | 总判定 90 秒 |
| 关闭前 drain 超时 | 500 毫秒 | 覆盖典型响应大小 | 防止未读数据触发 RST |
| 客户端连接池空闲回收 | 45 秒 | 小于服务端保活超时 | 这是最容易出事的一项 |
| 最大重试次数 | 6 次 | 覆盖约 61 秒窗口 | 配合抖动 |
| 退避上限 | 30 秒 | 避免长时间空转 | 结合业务容忍度调整 |
表格里最容易被忽略的是"连接池空闲回收"那一行。经典事故模式是:服务端保活超时 60 秒,客户端连接池空闲回收 120 秒,于是客户端从池里取出一条已经被服务端标记为超时的连接发请求,必然 10054。解决办法很简单,客户端回收时间必须小于服务端超时时间,取 45 秒留出余量。这个参数错配是 HttpClient 类组件报 10054 的头号原因,我在不同项目里见过不下五次,每次都是同一个原因。
6. 常见问题速查表与踩坑记录
6.1 现象到处置的速查表
| 现象 | 初步判断 | 验证手段 | 处置方向 |
|---|---|---|---|
| 服务端日志正常,客户端成片 10054 | 关闭前有未读数据 | 抓包看 FIN 前是否有 RST | 关闭前 drain 接收缓冲 |
| 错误集中在低峰期、间隔规律 | 中间设备会话老化 | 实测空闲多久出现 RST | 缩短心跳到老化时间的 1/3 |
| 压测时错误率随 QPS 上升 | 端口耗尽或连接数超限 | 看动态端口占用 | 扩端口范围、改用连接池 |
| 每次都是请求发出后立刻失败 | 连接池取到了死连接 | 对比池空闲时间与服务端超时 | 池回收时间调小 |
| 换机器就复现不了 | 本地安全软件注入 RST | 抓包看 RST 序列号是否合理 | 加白名单或调整策略 |
| 重连后仍然频繁失败 | 下游真的挂了 | 看下游健康状态 | 停止重试、快速失败 |
| 只有部分请求失败、重试即成功 | 典型的池化竞态 | 统计失败位置分布 | 重试一次即可 |
这张表我建议贴在工位上,遇到问题先对号入座,能省掉大量翻日志的时间。特别提醒第一行和第四行,这两个是最高频的,加起来能覆盖七成以上的 10054 场景。
6.2 几个反直觉的坑
坑一:send返回成功不代表数据送达。前面实验 C 已经验证过。很多人写代码时依赖send的返回值判断连接健康,这是靠不住的。真正可靠的健康信号是收到对端的响应,或者心跳 ACK。
坑二:RST 之后立刻重连,可能踩到端口问题。服务端主动 RST 时,服务端不进入 TIME_WAIT,但客户端会。如果客户端在同一个端口上立刻重连,可能出现绑定失败。Windows 上可以观察端口占用情况,必要时让系统重新分配端口,不要硬绑。
坑三:设置SO_LINGER为 0 之后,close返回成功,但数据丢了。这个最阴险,因为应用层完全感知不到。所以除非有明确理由,别碰这个选项。
坑四:抓包里 RST 序列号不对,不代表没问题。有些设备的实现不规范,发出的 RST 序列号就是随机的,靠序列号校验反而会误判。这时候要结合时间、方向、是否可稳定复现综合判断。
坑五:把 10054 当成本地 bug 查了三天。有一类情况,服务端应用根本没崩,也没关闭连接,但 RST 就是来了。这时候要往链路上想,尤其是跨机房、经过多层代理的架构。我个人的经验是,查满两小时还没头绪,就立刻切换思路去抓包,别硬啃代码。
7. 一套可以落地的自检流程
把上面所有内容收成一套动作,遇到 10054 时按顺序执行,基本不会走偏。
第一步,确认错误方向。客户端报还是服务端报,还是两边都报。这一步决定了后面查哪一端。两边都报的情况极少,通常是中间设备同时给两端发了 RST。
第二步,抓一次完整的连接生命周期。从 SYN 开始,到 RST 结束,一个连接就够。看 FIN 和 RST 的先后顺序,看 RST 的序列号,看时间间隔。这一份包往往就能定性。
第三步,对照代码检查关闭路径。重点看三处:有没有在接收缓冲区未清空时 close、有没有用SO_LINGER零超时、异常分支里是不是直接 close 了连接。这三处是代码层面最常见的 RST 来源。
第四步,核对池化参数。客户端连接池的空闲回收时间、最大连接数、保活配置,和服务端的保活超时、最大连接数对齐一遍。参数错配是最容易修也最容易复发的问题。
第五步,实测中间设备的老化时间。用空闲连接一直挂着,每 10 秒发一个字节,记录出现 RST 的时间点。拿到这个数值之后再回调心跳参数,而不是拍脑袋。
第六步,确认重连逻辑的幂等边界。哪些请求能重试,哪些不能,写清楚,并且确认抖动确实生效了。
这套流程我自己跑过很多遍,大部分情况在前三步就能定位。真正难的是第四步和第五步,因为它们需要跨团队协作,客户端和服务端的参数往往由不同的人维护,出问题时互相觉得对方配得不对。这时候最好的办法是把两边的配置摆在一起,直接比数值,别争论。
最后分享一个小技巧:在所有涉及连接的日志里,把本地端口号打出来。同一个客户端 IP 加不同的本地端口,就能唯一定位一条连接。线上排查时,靠这个端口号就能把客户端日志、服务端日志、抓包三者串起来,效率比时间戳对齐还高。这个习惯我从很早以前就养成了,后来每次处理这类问题,第一件事就是找端口号。