第一次在日志里看到10054,很多人的第一反应是去查服务端。查了半天服务端日志干干净净,没有任何异常堆栈,也没有重启记录,于是开始怀疑网络,怀疑机房,怀疑人生。我最早接触这个错误码是在一个长连接推送服务上,客户端每隔十几分钟就批量报远程主机强迫关闭了一个现有的连接,服务端侧却安静得像什么都没发生。后来抓包才看明白,问题根本不在服务端的业务代码里,而在连接生命周期管理上——服务端的一次空闲回收,被客户端当成了“异常断开”。
TCP连接重置错误10054,全称WSAECONNRESET,是 Winsock 层给出的“连接被对端重置”的反馈。它不属于超时,不属于拒绝,也不完全等同于“断网”,它是收到或者发出了一个 RST 标志位报文之后的必然结果。这篇文章想做的事情很具体:把这个错误码从“现象”还原到“报文”,再从“报文”还原到“代码”,最后给出一套可以手工复现的实验环境和一套线上定位的排查链路。适合正在写网络客户端、维护连接池、调长连接保活、或者单纯被这个错误码折磨过的开发者看。无论你是刚学 socket 编程的新手,还是已经在生产环境里跟它缠斗过几轮的老手,下面这些内容都应该能对上你的某段记忆。
1. 10054究竟在描述哪一件事
1.1 错误码谱系:10054和它的三个近亲
先把家族关系理清楚,很多误判都源于把这一组错误码混着用。在 Windows 的 socket 错误体系里,10053、10054、10060、10061 经常在同一个日志文件里交替出现,但它们指向的责任方完全不同。
| 错误码 | 符号名 | 触发时机 | 报文层面的特征 | 责任方倾向 |
|---|---|---|---|---|
| 10053 | WSAECONNABORTED | 连接已被建立后中止 | 可能是本地协议栈、驱动或安全软件主动中止,也可能本地超时 | 本地侧居多 |
| 10054 | WSAECONNRESET | 连接已被建立后收到 RST | 明确的 RST 报文 | 对端或链路中间设备 |
| 10060 | WSAETIMEDOUT | connect 阶段或读写阶段超时 | 无响应,或重传耗尽 | 链路或对端不响应 |
| 10061 | WSAECONNREFUSED | connect 阶段被拒绝 | 对端回了 RST | 目标端口无监听 |
表里最容易被忽略的一行是 10061。它和 10054 在报文层面其实是同一件事——都是收到了 RST——区别只在于时机。连接还没建立起来的时候收到 RST,connect()会告诉你“目标积极拒绝”,也就是 10061;连接已经建立、进入 ESTABLISHED 之后收到 RST,recv()或send()才会告诉你 10054。所以下次看到 10061,别急着说“服务没起来”,它本质上是“有人替你回了 RST”,至于这个“有人”是目标主机内核还是中间一台设备,得看 RST 的源地址。
再补一个常被搞混的点:10054 和“连接被关闭”不是一回事。正常的关闭是四次挥手,双方各发一个 FIN,应用层感知到的是recv()返回 0,也就是读到 EOF。这是干净的、可预期的事情。而 RST 是单向的、不讲道理的:收到 RST 的那一方,内核会立刻把这个 socket 标记为不可用,发送缓冲区里没发完的数据直接丢弃,接收缓冲区里没读走的数据也一并作废,后续任何读写都返回 10054。换句话说,RST 是一种“我不跟你谈了,之前的对话也全部作废”的姿态。
1.2 内核在什么条件下替你发RST
理解 10054,核心是理解“谁在什么条件下会发 RST”。因为 RST 不是应用层能随便造出来的(虽然可以通过特定 socket 选项间接造),绝大多数 RST 都是内核依据 TCP 状态机自动发出的。
按 RFC 5961 的思路,内核发 RST 大致有这么几类场景。第一类是目标端口没有对应的监听套接字,收到 SYN 或者收到已建立连接的数据时,内核会回一个 RST,告诉对方“这里没人”。第二类是序列号不在接收窗口内,收到一个明显不属于当前连接的报文,内核会回 RST 并带上当前的 ACK 号,让对方赶紧死心。第三类是连接已经进入 CLOSED 状态,却仍然收到数据,这时候内核的回应就是 RST。第四类是应用层调用了close(),但接收缓冲区里还有未读数据,内核会认为这个连接状态不干净,直接发 RST 而不是走正常的 FIN 流程。第五类是应用层通过SO_LINGER显式要求立即中止,l_onoff=1, l_linger=0的组合会让close()直接变成发 RST。
这五类里,第一类和第三类是“对端消失”的典型,第二类多见于链路中间设备或序列号错乱,第四类和第五类是本地代码主动制造 RST的路径——这也是后面做复现实验的抓手。很多线上 10054 的锅,其实是自己这一侧悄悄发了 RST,然后被对端反过来或者被自己的下一次读写报出来。
还有一个容易忽略的细节:RST 报文的序列号规则。当 RST 是对某个特定报文段的响应时,序列号取该报文的 ACK 号;如果 RST 是独立发出的(比如针对已关闭端口的探测),则ACK位置 0、序列号取 0 或者当前的snd_nxt。抓包时看 RST 包的seq和ack是否为 0,能大致判断它是“响应式 RST”还是“主动式 RST”,这对定位有帮助。
2. 五条真实触发链路,按线上出现频率排序
2.1 对端进程退出与端口空窗期
这是最直白的一条:服务端进程因为 OOM 被杀、因为发布重启、因为 panic 退出,它持有的所有 socket 在内核层面被回收。内核在处理进程退出时,对已经建立的连接会走正常的 FIN 流程,理论上客户端应该收到 FIN 而不是 RST。但现实里有两个变数。
第一个变数是进程退出时接收缓冲区还有对端发来的数据没被读走。这时候内核不会走 FIN,而是直接发 RST。所以“服务端重启导致客户端 10054”这件事,往往发生在客户端恰好在进程死亡的那一瞬间往里发了一笔请求。第二个变数是端口空窗期:进程退出后端口释放,此时如果有报文迟到抵达,内核找不到对应的 socket,只能回 RST。这也是为什么客户端在服务端发布窗口内看到的错误码会在 10054、10061、10060 之间跳变。
提示:判断是不是这一类,看服务端的进程启动时间戳和客户端的首次报错时间是否落在同一个几十秒的窗口内。如果对得上,基本可以定性为发布抖动,处理方向是客户端加重试加发布侧的优雅停服,而不是去查网络。
还有一个变种值得单独提:容器环境下端口被“半释放”。某些场景下旧进程的 socket 还挂在 TIME_WAIT,新进程绑定同一端口失败或者用了SO_REUSEPORT,导致同一端口上有多个监听实例短暂共存,连接被分发到不该去的地方,表现也是连接被重置。这类问题用netstat -ano反复采样就能看出来。
2.2 未读数据加close的组合拳
这条链路在 HTTP 客户端里出现的频率极高,高到我愿意把它排在第一。完整过程是这样:服务端因为空闲超时(比如 60 秒没有任何请求)决定关闭连接,走正常的 FIN 流程;客户端内核收到 FIN,把连接置为 CLOSE_WAIT,并且把这个状态告诉应用层——但很多连接池实现并不会主动去检查这个状态,它只是把连接放回池子里“待复用”。
过一会儿客户端从池子里取出这条连接发请求。此时这条 TCP 连接在服务端眼里已经彻底关闭了。数据包发过去,服务端内核在 CLOSED 状态下收到数据,回一个 RST。客户端内核收到 RST,把 socket 置为错误状态,紧接着recv()或者下一次send()抛 10054。整个链路对应用层来说是“我发了个请求,然后连接被重置了”,看起来像是服务端突然发疯,实际上是自己的连接池用了一条对方早已关闭的连接。
这里有个非常关键的判别特征:报错几乎总是发生在“复用连接后的第一个请求”上,而不是长连接的中间。如果你的监控显示 10054 集中在请求 QPS 的低谷之后(比如夜间、午休、流量毛刺之间),那基本就是这条链路。
注意:判断服务端是否已经发了 FIN,用抓包看客户端有没有收到过 FIN 报文就行。如果看到过 FIN,但应用层没感知到就继续复用,那是客户端连接池的健康检查缺失;如果压根没看到 FIN 就直接 RST,那是 2.1 或者 2.4 的场景。
2.3 SO_LINGER被设成0
SO_LINGER这个选项的名字很有欺骗性,很多人以为它只跟“关闭时等多久”有关,实际上它还有一个副作用:l_onoff=1且l_linger=0时,close()不等待发送缓冲区排空,也不走四次挥手,直接发 RST 并丢弃缓冲区。这就形成了一个很诡异的局面——你的代码写的是“正常关闭”,对端看到的却是“连接被重置”。
我在一个内部 RPC 框架里见过这个坑。当时的动机是“关闭连接不要阻塞业务线程”,于是把 linger 设成 0。上线之后对端服务的错误日志里开始零星出现 10054,比例不高,因为只有在关闭瞬间缓冲区里有数据时才会触发,但排查成本极高,因为两边日志的时间戳对得上,但两边代码看起来都没问题。
各语言设置方式差别不大,但结构体内存布局差别很大,这是复现实验里必踩的坑:
| 语言/平台 | 设置方式 | 注意事项 |
|---|---|---|
| C / C++ | setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg)) | struct linger的字段宽度随平台变化 |
| Python (Linux) | struct.pack('ii', 1, 0) | 两个 int,8 字节 |
| Python (Windows) | struct.pack('hh', 1, 0) | 两个 u_short,4 字节,用ii会报错 |
| C# | socket.LingerState = new LingerOption(true, 0) | 单位是秒,0 表示立即 RST |
| Go | conn.(*net.TCPConn).SetLinger(0) | 0 即 RST,负数表示保持默认 |
| Java | socket.setSoLinger(true, 0) | 同上 |
Python 在 Windows 下用'ii'会抛OSError: [WinError 10042](协议选项不正确),这个报错和 10054 听起来不像一路的,但它恰恰把很多人挡在了复现实验之外。
2.4 空闲连接的会话表老化
穿过了链路中间设备的连接,会在这类设备上留下会话表项。会话表有老化时间,超时后设备会静默删除条目,并且通常不会通知两端。之后如果客户端再发数据,设备找不到对应表项,有两种处理:一种直接丢弃,客户端表现为重传和超时;另一种更“积极”,直接回一个 RST,客户端表现为 10054。
这类问题的标志性特征是时间规律性。把报错的时间戳按分钟聚合,如果峰值集中在某个固定间隔附近,比如每 5 分钟一批、每 15 分钟一批,那基本可以锁定为某台设备的空闲超时。常见的会话老化时间集中在 60 秒、120 秒、300 秒、350 秒、900 秒这几个档位,如果你看到的间隔正好卡在 350 秒左右,那不用怀疑,是典型的会话表回收。
应对思路不是去改设备参数(通常也改不了),而是让客户端自己维持“连接活性”:要么缩短连接池的空闲回收时间,让本地先关;要么开启 TCP 保活并把间隔设得比老化时间短。这两条后面第 5 章会展开。
2.5 半开连接与保活探测失败
半开连接是指一方认为连接还在,另一方已经消失。典型成因是对端主机断电、虚拟机被强制回收、网线被拔。这种情况下没有任何 FIN 或 RST 发过来,本地 socket 会一直停在 ESTABLISHED。
如果开了SO_KEEPALIVE,内核会在空闲一段时间后发探测包,探测次数耗尽还收不到响应,就会把连接置为错误状态,应用层收到的一般是 10054 或者 10053(视实现而定)。如果没有开保活,那么这条连接会一直挂着,直到应用层往上写数据触发重传,重传次数耗尽后才报超时(10060 或者 10053),而不是 10054。所以“保活探测失败报 10054”这个现象,本身就是“我开了保活”的证据。
这个区分的实际价值在于:当你在日志里看到 10054 并且时间戳落在长时间空闲之后,如果代码里确实没开保活,那就说明不是这条链路,得往 2.4 的设备老化上想。
3. 三个可复现的最小实验
3.1 实验一:用SO_LINGER构造确定性RST
这是最可控的复现方式,因为 RST 是你自己显式制造的,时间点完全确定。
服务端代码(Python,注意平台差异):
import socket, struct, sys IS_WINDOWS = sys.platform == 'win32' LINGER_FMT = 'hh' if IS_WINDOWS else 'ii' 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(1) print('listening on 9001') conn, addr = srv.accept() payload = conn.recv(4096) print('recv:', payload) conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') # 关键:linger on + 0 秒 => close 时直接发 RST conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack(LINGER_FMT, 1, 0)) conn.close() print('closed with RST') srv.close()客户端(C#,Windows 下能拿到原生错误码):
using System; using System.Net.Sockets; try { using var client = new TcpClient(); client.Connect("127.0.0.1", 9001); var stream = client.GetStream(); var req = System.Text.Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); stream.Write(req, 0, req.Length); var buf = new byte[4096]; int n = stream.Read(buf, 0, buf.Length); // 这里抛 10054 Console.WriteLine($"read {n}"); } catch (SocketException ex) { Console.WriteLine($"SocketErrorCode={ex.SocketErrorCode}, NativeErrorCode={ex.NativeErrorCode}"); // 期望输出:ConnectionReset, 10054 }跑一遍就能稳定复现。这个实验的价值不只是“看到了 10054”,更重要的是让你确认:RST 完全可以在没有任何网络故障的情况下,由一段看起来人畜无害的close()产生。
3.2 实验二:服务端进程硬杀
这个实验模拟真实的进程崩溃。启动一个简单服务,客户端连上后保持不动,然后用任务管理器或者taskkill /F结束服务端进程。
# Windows taskkill /F /PID <服务端进程号> # Linux 等价操作 kill -9 <服务端进程号>结果取决于细节:如果客户端此刻没有未读数据、服务端接收缓冲区也是空的,进程退出会走 FIN,客户端读到 EOF,看不到 10054。如果服务端接收缓冲区里还有客户端刚发来但没处理的数据,内核会发 RST,客户端下一轮读写就报 10054。
提示:想稳定复现,让客户端在杀进程之前发一大笔数据过去,并且服务端故意
sleep不读,把数据堆在接收缓冲区里。这个细节是我调了几次才摸出来的,第一次做实验的时候怎么都复现不出来,就是因为数据被服务端读干净了。
这个实验能非常直观地回答一个常见疑问:“服务端重启,客户端到底会看到什么?”答案是:看时机,可能是 EOF,可能是 10054,也可能是 10061。三种都可能,别指望一个确定的答案。
3.3 实验三:未读数据导致的重置
这是 2.2 那条链路的最小化版本,也是我认为最值得动手做的一个,因为它直接对应生产环境里最高频的场景。
流程是:客户端连上,服务端发一段数据,客户端故意不读,然后服务端close()。此时客户端的接收缓冲区里有未读数据,Linux 和 Windows 的内核行为略有差异,但都会导致连接被重置而不是优雅关闭。
# 服务端:发数据后立即关闭 conn.sendall(b'x' * 8192) conn.close() # 客户端:连接后先 sleep 几秒不读,再尝试读 time.sleep(3) data = sock.recv(65536) # 期望抛 ConnectionResetError / 10054反过来也成立:客户端发数据,服务端不读就关闭,客户端下一次写也会撞上 RST。把这个模式和 2.2 里“连接池复用陈旧连接”的场景对照着看,你会发现问题本质完全一致——一方在不干净的连接状态上强行关闭,另一方浑然不觉地继续使用。
3.4 用抓包确认RST到底是谁发的
做完上面三个实验,建议每个都配上抓包,因为“能复现”和“能定位”是两件事。
Wireshark 的过滤表达式:
tcp.flags.reset == 1如果包很大,用命令行更快:
tshark -r capture.pcap -Y "tcp.flags.reset==1" \ -T fields -e frame.number -e frame.time_relative \ -e ip.src -e ip.dst -e tcp.srcport -e tcp.dstport -e tcp.seq -e tcp.ack看三件事。第一,RST 的源 IP 是谁。如果是服务端 IP,说明对端内核发的;如果不是通信双方的任何一方,那说明有中间设备介入,这是极有价值的线索。第二,RST 出现在时序的什么位置。紧跟在某个数据包后面几微秒,说明是响应式的;孤立出现在一段空闲之后,说明是主动式的。第三,RST 的 seq/ack 是否为 0。全 0 通常是针对已关闭端口的探测响应。
注意:在本机回环地址上抓包时,RST 的方向判断容易出错,因为收和发走的是同一个接口。建议至少有一端在另一台机器上,或者干脆用两台虚拟机对着抓,对比两边的包序,结论会清晰得多。
4. 线上定位:从错误码倒推责任方
4.1 先看RST落在时序的哪个位置
线上没有条件随时抓包,所以第一手材料通常是应用日志里的错误时间戳。我的习惯是把时间戳按“距离上一次成功通信的间隔”重新排一遍,也就是算每次报错之前,这条连接空闲了多久。这一个动作能砍掉一大半的排查范围。
间隔在毫秒级、紧跟着请求发出就报错的,指向 2.2 的陈旧连接复用,或者 2.1 的端口空窗。间隔在一分钟到十几分钟、且呈现出明显的聚集性,指向 2.4 的会话表老化。间隔在几十分钟以上、且伴随重传,指向 2.5 的半开连接。间隔毫无规律、随机散布,那更可能是 2.3 的 linger 设置或者对端进程的随机崩溃。
这个分析方法不需要任何额外工具,只需要把日志导出成表格,加一列gap = 本次报错时间 - 上次该连接成功时间,然后画个直方图。我做过好几次,图形往往一眼就能看出答案。
4.2 netstat与连接池状态交叉验证
日志之外,第二手材料是连接的实际状态。Windows 上用:
netstat -ano | findstr :443Linux 上用:
ss -tanp state established ss -tanp state close-wait重点看两个东西。第一,有没有大量CLOSE_WAIT堆积。CLOSE_WAIT 意味着对端发了 FIN、本地内核已经处理,但应用层没有调用close()。如果连接池里有大量 CLOSE_WAIT,说明连接池根本没有做失效检测,2.2 那条链路几乎是必然发生的。第二,TIME_WAIT 的数量和分布。TIME_WAIT 多本身不是问题,但如果集中在某一端,说明短连接用得太多,可以考虑连接复用。
还有一个很实用的技巧:在报错发生前后各采一次netstat,对比这条四元组的状态变化。如果报错前是 ESTABLISHED、报错后直接消失(没有经历 TIME_WAIT),那说明连接是被 RST 打掉的,而不是正常关闭的,这和错误码是自洽的;如果报错前就已经是 CLOSE_WAIT,那答案就更明确了。
4.3 四个高频误判
排查过程中有几个特别容易走偏的方向,我把它们单独列出来。
误判一:以为是防火墙拦了。防火墙拦截的典型表现是丢包导致超时,而不是 RST。如果防火墙配置了 reject 而非 drop,那确实会回 RST,但这属于配置问题,看规则就能确认,不需要靠猜。用“有没有 RST 包”和“RST 源地址”两步就能排除。
误判二:以为是服务端业务代码主动断的。业务代码调用close()产生的是 FIN,不是 RST。除非服务端自己设了 linger,或者关闭时有未读数据,否则业务代码断不出 RST。所以看到 RST 就去翻业务日志,方向本身就偏了,应该去看服务端进程的生命周期和 socket 选项。
误判三:以为重试就万事大吉。重试能掩盖症状,但如果触发原因是 2.2,重试会让每一次复用都失败一次,QPS 低谷之后的那批请求会全部走一遍“失败-重试-成功”,延迟毛刺非常明显。更糟的是对非幂等接口做无脑重试,会直接产生重复写入。
误判四:把 10054 和 10053 混为一谈。前面表格里说过,10053 的责任方更偏本地。如果日志里两类错误混着出现,不要统一当“网络抖动”处理,要分开统计比例。比例接近说明可能有两套独立成因,得分别查。
5. 代码层面的处理姿势
5.1 幂等性是重试的前提
10054 天然适合重试,因为它是“连接级”失败,请求大概率没有到达业务处理逻辑(注意是大概率,不是一定——如果 RST 发生在服务端处理完但响应回不去的时候,请求实际已经生效了)。所以在设计重试策略之前,必须先把接口按幂等性分类。
安全的方法(GET、HEAD、带幂等键的 PUT)可以重试;明显不安全的(无幂等键的 POST)要么不重试,要么在最外层加去重。我的做法是给每个请求打一个客户端生成的Request-Id,服务端侧对同一个 ID 做短期去重,这样即使重试也不会产生重复业务。这套机制在 10054 场景下比在超时场景下更重要,因为超时至少还给了你“不确定”的预期,而 10054 往往让人误以为“肯定没成功”。
重试次数和退避也要克制。见过把重试次数设成 10 次的配置,一旦真出问题,一次用户请求会放大成 10 倍流量,直接把下游打挂。我的经验值是 2 到 3 次,退避用 100ms 起步的指数加随机抖动,够用了。
5.2 连接池的陈旧连接淘汰
这是治本的一招。核心思路只有一句话:让本地先于对端关闭空闲连接。因为本地主动关闭是干净的 FIN 流程,对端不会报错,也不会产生 RST。
具体怎么做,取决于你用的客户端。以 .NET 的SocketsHttpHandler为例:
var handler = new SocketsHttpHandler { // 连接空闲超过这个时间就主动关闭,默认是无限制 PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30), // 连接总寿命上限,防止长期复用同一批连接 PooledConnectionLifetime = TimeSpan.FromMinutes(5), // 每次取用前做一次可达性检查 ConnectTimeout = TimeSpan.FromSeconds(5), };关键参数是PooledConnectionIdleTimeout,它必须小于服务端的空闲超时。服务端常见默认值大致在 20 秒、60 秒、75 秒、120 秒这几个档位(不同服务器和不同版本差异较大,务必实测确认),所以客户端设 30 秒以下是比较稳的。如果服务端配的是 20 秒,客户端就得压到 10 秒以内。
Java 的HttpClient对应的是ConnectionPool的keepAlive或者底层KeepAliveStrategy;Go 的http.Transport对应IdleConnTimeout;Python 的requests配urllib3连接池时对应pool_maxsize和Retry里的backoff,但urllib3对陈旧连接的检测能力较弱,实际项目中更推荐httpx或者自己封一层健康检查。
提示:还有一个更暴力的做法——每次取用连接前发一个零长度或者极小的探测包。代价是增加一次往返,收益是能提前发现死连接。在高价值低 QPS 的场景里这个交换是划算的,在高 QPS 场景里就别这么干。
5.3 保活参数的实际调法
TCP 保活是在 2.4 那种“设备老化”场景下的唯一防线,因为客户端没法感知设备的行为,只能靠周期性发包维持表项活性。
Windows 的保活参数默认是全局的,改起来要动注册表,这在生产环境里通常不现实。所以更实际的做法是在应用层做心跳,或者使用 socket 层面的SO_KEEPALIVE配合每连接设置(Windows 10 以后支持SIO_KEEPALIVE_VALS做单连接设置,.NET 里有SocketOptionName.TcpKeepAliveTime等选项)。
Linux 侧就灵活得多,net.ipv4.tcp_keepalive_time和tcp_keepalive_intvl是全局的,单连接可以用TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT。更精准的是TCP_USER_TIMEOUT,它控制“发出数据后多久没被确认就判定连接死亡”,对半开连接的检测比保活更直接。
参数怎么定,取决于中间设备的空闲超时。假设你测出来是 300 秒,那保活间隔设 60 秒就够,留足三次探测的机会。设得太频繁会增加无谓流量,设得比老化时间还长等于没设。
// Go 侧同时设保活和用户超时 conn, err := net.Dial("tcp", addr) if tcpConn, ok := conn.(*net.TCPConn); ok { tcpConn.SetKeepAlive(true) tcpConn.SetKeepAlivePeriod(60 * time.Second) }5.4 一个可直接复用的重试封装
最后给一个能直接抄的封装思路,不依赖具体框架。核心是把“可重试的错误类型”、“退避策略”、“幂等判定”三件事解耦。
import socket, time, random RETRYABLE = (ConnectionResetError,) # Linux 下对应 errno.ECONNRESET # Windows 下 socket 层会抛 OSError,需要判 winerror == 10054 def is_retryable(exc: Exception) -> bool: if isinstance(exc, ConnectionResetError): return True if isinstance(exc, OSError) and getattr(exc, 'winerror', None) == 10054: return True return False def call_with_retry(fn, *, idempotent: bool, max_attempts: int = 3): if not idempotent: return fn() # 非幂等直接放行,不做重试 last = None for i in range(max_attempts): try: return fn() except Exception as exc: if not is_retryable(exc): raise last = exc sleep = 0.1 * (2 ** i) + random.uniform(0, 0.05) time.sleep(sleep) raise last这段代码的重点不在重试逻辑本身,而在idempotent这个必填参数。它逼着调用方在写代码的那一刻就想清楚“这个接口能不能重试”,而不是等到出事之后再回头改。我见过太多项目里重试逻辑是全局统一加在中间件里的,结果把非幂等的写接口也一起重试了,最后靠事后对账才发现数据重复。
还有一个细节:判断 10054 在 Windows 下要看winerror而不是errno,Python 的OSError这两个属性都会填,但值不一样,errno可能是 10054 也可能是别的。写成getattr(exc, 'winerror', None) == 10054 or exc.errno == 104这样的形式能同时覆盖两个平台,更省心。
6. 几句实操之后的体会
我做过的几次 10054 排查里,真正由“网络设备故障”导致的不到两成,剩下八成最终都落在两边代码的连接管理逻辑上——要么是客户端连接池没做失效检测,要么是服务端空闲超时比客户端短,要么是某个业务方在finally里随手close()时缓冲区还有数据。这个分布反过来提示了一件事:与其一上来就怀疑网络,不如先把自己的连接生命周期画一遍,看清楚“谁在什么时候关了哪条连接”。
还有一个我踩过的坑值得分享:有一次排查了整整两天,最后发现是自己本地的开发机开了某个抓包工具导致回环流量异常,生产环境根本没这个现象。这件事之后我养成了一个习惯,报错复现之前先确认环境是否干净,尤其是涉及回环地址的时候。环境不干净,后面所有的推理都建在沙子上。
最后补一个小技巧。如果怀疑是中间设备老化但又不确定具体的超时档位,可以写一个极简的长连接探针,每隔 10 秒记录一次时间戳,什么都不发,只等对端发东西过来或者连接断开。跑上一整晚,第二天把断开的时间戳减上一笔通信的时间,那个差值基本就是设备的真实老化时间。拿到这个数字,保活间隔和连接池空闲时间的配置就有依据了,不用再靠猜。