“Connection reset by peer”,这大概是后台开发最常碰到的报错之一。客户端发请求,服务端直接甩出来一个TCP RST,日志里留一句“由对端重置连接”,剩下就是一屋子人查防火墙、查服务状态、查网络,折腾半天发现可能只是某个参数没配对。
RST,全称TCP Reset,中文叫“复位报文”。它是TCP协议里最雷厉风行的角色:不需要协商、不需要确认,一个包过去,连接当场死亡。很多人习惯把RST和网络故障划等号,一看到抓包里出现RST就“网络坏了”,但RST在正常业务里其实非常常见,有时候甚至是负载均衡健康检查故意发出来的。
这篇文章不聊虚的,就针对“RST从哪来、怎么定位、怎么处理”这套问题,梳理我在实际排查中遇到的几种典型场景。无论你是后端开发、运维还是做网络监控,看完应该都能建立一套自己的排查思路,下次看到RST不再慌。
1. RST是什么?它和FIN到底哪里不一样
1.1 FIN是好好告别,RST是直接摔门
正常关闭走四次挥手:主动方发FIN,对端回ACK,然后反向也来一轮FIN/ACK,双方优雅地把连接关掉。这个过程的潜台词是“我数据发完了,你那边也确认完了,咱俩好聚好散”。
RST完全不是这个套路。它相当于一个人连“再见”都不说,直接把门摔上。收到RST的一端,内核会立刻丢弃这个连接的一切状态:接收缓冲区里没来得及读的数据丢掉,发送缓冲区里还没确认的数据也丢掉,连接直接从连接表里消失。整个过程不走TIME_WAIT,也不需要对方确认。
我把FIN和RST的区别整理成一张表,排查的时候对照着看会很直观:
| 维度 | FIN(四次挥手) | RST(复位) |
|---|---|---|
| 含义 | 有序关闭,双方数据已传完 | 异常终止,连接立即作废 |
| 标志位 | FIN | RST |
| 是否需要确认 | 需要ACK逐次确认 | 不需要,收到即生效 |
| 缓冲区处理 | 等对端收完再关 | 未读数据直接丢弃 |
| TIME_WAIT | 主动关闭方进入TIME_WAIT | 不走TIME_WAIT,立即清理 |
| 常见触发 | 应用正常close(),数据已发完 | 端口未监听、协议异常、主动放弃 |
很多场景下,业务日志里出现“Connection reset by peer”,本质就是你收到了对端发来的RST。但RST究竟是对端故意发的,还是网络中间设备发的,还是内核“顺手”发的,就是我们后面要拆解的重点。
1.2 有效RST和无效RST:先别急着下结论
这里有个特别容易被忽略的细节:RST不一定“有效”。
早年协议栈对RST的校验很宽松,攻击者只要伪造一个源IP和端口,再猜一个序列号,就可能把一条正常连接打断,这叫“盲RST攻击”。后来业界改了规范,核心思路就是:收到RST时,内核要检查它的序列号是否落在当前连接的接收窗口里。序列号对得上才认,对不上直接丢弃,连接继续跑。
所以你现在抓包可能会看到一种现象:抓包里确实有不少RST包,但服务一点没受影响,业务完全正常。这种大概率就是无效RST——某些扫描工具或者安全设备在猜序列号,猜不中就瞎发。排查时别一看到RST就跟业务故障挂钩,先确认它有没有“被内核接受”。
判断方法很简单但不一定直观:如果这条连接随后还正常收发数据,或者根本没断,那这个RST就是无效的。如果紧接着出现连接断开、发送失败,那才是有效RST,才需要我们继续追。
2. 工作里最常见的几种RST场景
2.1 端口没监听:内核替服务说“不”
这是最基础、也最常见的一种。客户端connect()到一个端口,但目标机器上根本没有进程监听这个端口,内核协议栈看一眼监听表,直接回一个RST,意思是“这门没开,别敲了”。
抓包表现非常明确:客户端发SYN,服务器回RST(通常带ACK标志,即RST,ACK),而且这个RST几乎瞬间就回来了,客户端connect()立刻失败。应用层看到的错误是ECONNREFUSED(errno 111),不是超时。
这里有个和其他场景的重要区别:RST是“明确拒绝”,“速度快”;而防火墙丢弃则是“石沉大海”,SYN一直重传、客户端一直等,最后超时报ETIMEDOUT(errno 110)。判断到底是“没监听”还是“被防火墙丢了”,看抓包里有没有RST就行。
实际排查中,我踩过一个坑:服务明明在跑,但监听的是127.0.0.1,客户端从外网连,内核发现目标IP是本机但没有对应监听,一样回RST。所以看到RST别默认“服务挂了”,先看一眼ss -tlnp确认监听地址对不对、监听没监听。很多时候,是配置里bind的地址写错了。
2.2 accept队列满:门开着,但柜台没人办理
如果说“端口未监听”是这扇门根本不存在,那accept队列满就是“门开着,但前台柜台没人处理,队伍排到门外,新客户直接被劝退”。
TCP三次握手完成后,连接不会直接进入应用,而是先放在内核的全连接队列(accept queue)里,等应用调用accept()取走。如果应用accept得太慢,队列满了,新来的连接就放不进去。Linux默认行为是:把三次握手的最终ACK悄悄丢掉,客户端那边就会觉得握手没完成,继续重传SYN/ACK。但如果你启用了某个内核参数(net.ipv4.tcp_abort_on_overflow=1),那队列满时内核会直接回RST,客户端看到的症状就从“连接建立超时”变成了“连接被重置”。
抓包特征:能看到正常的SYN、SYN-ACK,但握手收尾阶段出现RST。日志上客户端报“Connection reset by peer”,服务端自己可能没什么明显异常——因为accept循环还在跑,只是忙不过来。
排查方法很直接:
- ss -lntp看监听队列:注意Send-Q(backlog上限)和Recv-Q(当前堆积的连接数)。
- 如果Recv-Q长期接近Send-Q,说明应用accept速度跟不上,请求在排队。
- 另一个辅助指标是netstat -s里的SYN重传计数在涨。
这个场景的处理思路不是无脑调大backlog。backlog调大只是把“排队区”扩宽,如果应用本身处理不过来,排队区再宽也迟早会满。真正要做的是优化accept循环、增加worker进程、或者排查业务里有没有慢查询导致连接占用过久。
2.3 close()时机不对:数据还没读完就关门
这种情况非常阴,因为它完全符合代码里“正常关闭”的逻辑,但内核不这么认为。
举个例子:服务端处理完请求,调用close()准备关闭socket。但如果这时候接收缓冲区里还有客户端发来但没被读取的数据,Linux内核不会发FIN,而是直接发RST。原因也好理解:TCP协议要求,如果连接关闭时还有未处理的数据,就不能发FIN——因为FIN代表“我已读完了你发的所有数据并处理完毕”,明显不是事实。这时候发FIN会误导对端,RST才是诚实的:这些数据我不要了,连接强制终止。
业务表现上,客户端会收到“Connection reset by peer”,而不是正常的EOF。如果你的日志里经常出现对端报RST,而你的代码看起来是正常close的,那就要检查一下:close之前有没有把接收缓冲区的数据读完?比如有没有可能客户端发来的请求body只读了一半就开始处理并close?
抓包特征:这个场景最迷惑的地方在于,连接可能已经稳定运行了很久,数据交换也正常,但就在close这一刻,预想中的FIN没出现,取而代之的是RST。它的触发点非常局部,所以很多团队排查了好几天才发现是某个业务分支写的close()逻辑有问题。
另外一个相关的坑是:某些框架在收到客户端异常中断时会主动close,但socket里还残留着没读完的数据,内核顺手就回一个RST,两个方向都被打断,错误日志就更多了。遇到这种情况,与其纠结内核行为,不如先改代码,确保close前把该读的都读完。
2.4 SO_LINGER=0:主动给自己挖RST坑
还有一种RST是应用自己“作”出来的。
SO_LINGER这个socket选项用来控制close()之后的行为。如果设置SO_LINGER且linger时间为0,那么close()被调用时,内核会立即发送RST,彻底跳过FIN握手和TIME_WAIT状态。很多人为了应对大量短连接导致的TIME_WAIT堆积,就想用这个办法“秒关连接”。
表面上看,效果确实立竿见影:端口马上释放,没有TIME_WAIT了,新连接不会被旧连接卡住。但代价极其容易被低估:RST会直接打断对端还没发完的数据和还没确认的数据,业务层表现就是莫名其妙地“Connection reset by peer”。如果只是简单场景也许能忍,但一旦连接上还有进行中的请求,这个RST的破坏力就完全暴露了。
我的建议是别为了消TIME_WAIT去无脑SO_LINGER=0。TIME_WAIT虽然看起来占着端口,但它在TCP设计里有重要的兜底作用,防止旧连接的迟到数据包串到新连接。想减少TIME_WAIT的负面影响,优先考虑连接池、HTTP keep-alive、调大本地端口范围,这些方式不会带来RST这种破坏性副作用。
2.5 对端重启或进程崩溃:连接表没了,旧连接全变“非法”
服务运行得好好的,突然服务器重启、容器漂移,或者进程core dump后新进程起来——内核里的TCP连接表被清空了。问题是,客户端那边完全不知情,它还维持着一条“自以为健康”的连接。
等到客户端在这条旧连接上继续发包(哪怕只是一个窗口探测包),服务器这边查连接表,查无此连接,于是按照TCP规范,直接回一个RST。客户端看到这个RST,一脸懵:刚才还聊得好好的,怎么说翻脸就翻脸?
这种场景有两个明显的排查特征。
一是时间点特征:RST出现的时间,总是和某次重启、重新部署、网络配置变更高度重合。拉出服务器启动时间、容器重启时间和抓包时间一对比,基本就能对上。
二是方向特征:在抓包里看,RST往往是从“重启过的那一端”发出来的,而不是从“没变过的那一端”。
我处理过几次类似问题,最后都指向同一个结论:不要相信长连接能永远活着。对长时间运行的TCP连接,最好靠业务层的心跳或者TCP的keepalive机制尽早感知对端死亡,而不是让连接一直悬着,等到真正发包时才发现已经断了。RST在这里其实是在帮你快速发现故障,只是信息的“送达方式”有点粗暴。
2.6 重传超时:内核给连接判了死刑,但RST可能迟到
这个场景最容易被调包侠看漏。
假设客户端发了一包数据,服务器一直没回ACK。发送方会按指数退避算法不断重传,比如1秒、2秒、4秒、8秒……一直重传到超过上限(Linux里是net.ipv4.tcp_retries2,默认大概坚持15分钟),内核最终放弃,把连接标记为死亡,并通知应用层返回ETIMEDOUT。注意,这一步内核通常不会主动发RST,它只是默默把连接清掉。
但故事到这里往往还没结束。如果客户端这边已经放弃了,服务器那边却因为网络恢复后又开始发送旧数据,客户端内核一查连接表:没有这条连接。于是它回一个RST。也就是说,RST可能出现在“重传超时”很久之后,而且方向是从“先放弃的一方”发出来的。
所以你在抓包里会看到一串重传,然后安静很长一段时间,突然冒出一个RST。别只看最后一个包,往前面翻一翻,真正的凶手是一连串重传。日志层面两个错误码则会同时出现:一边是ETIMEDOUT(errno 110),另一边是ECONNRESET(errno 104)。
排查这种场景的要点是:RST只是最终症状,重传计时器才是根因。重点要查这段时间内为什么ACK一直回不来——是网络丢包、对方CPU打满、还是某个中间设备把包吃了?把重传时间段拉出来对照系统负载,往往比死盯RST本身更有效。
3. 实际动手:怎么用抓包让RST“开口说话”
3.1 tcpdump的正确姿势
排查RST的第一步永远是抓包。抓包别乱抓,命令写对了能省一半力气。
最基础的抓RST命令:
sudo tcpdump -i any 'tcp[tcpflags] & tcp-rst != 0' -nn -S-i any:抓所有网卡,省得看流量到底从哪个口进来。-nn:不做DNS和端口名解析,避免额外噪音。-S:显示绝对序列号。这个特别重要,否则看相对序列号时容易误判RST的序号是否有效。tcp[tcpflags] & tcp-rst != 0:这是tcpdump的经典过滤写法,只抓RST包。
如果只想抓某条连接的上下文(比如看RST前后到底发生了什么),可以把过滤条件带上端口:
sudo tcpdump -i any -nn -S 'tcp port 8080 and (tcp[tcpflags] & tcp-rst != 0 or tcp[tcpflags] & tcp-syn != 0)'这样能同时抓到这个端口上的SYN和RST,方便判断是“握手被拒”还是“传输被打断”。
抓包时我习惯顺手-w rst.pcap把原始包存下来,因为很多问题不是当场能看明白的,存成pcap文件丢给Wireshark慢慢看,比在终端里翻滚动输出舒服得多。
3.2 三步判断RST是谁发的、为什么发
拿到抓包结果,我会按三个层面去看。
第一步看方向。RST是从客户端发到服务器,还是反过来的?结合业务日志,基本能确定“谁先翻脸”。如果两边都有RST,就更可能是中间设备干的。
第二步看RST出现的位置。是握手阶段、传输阶段,还是本该挥手的地方?三个阶段对应完全不同的原因:握手阶段出现RST,优先怀疑端口没监听、防火墙拒绝;传输阶段出现RST,优先怀疑对端进程崩了、连接表被清空、或者重传超时;挥手阶段出现RST,往往和close()时机不对、SO_LINGER设置有关。
第三步看序列号。这一步能帮你区分“有效RST”和“无效RST”。如果RST的序列号落在当前连接接收窗口内,它是被内核接受的;如果是个离谱的值,那多半是扫描器或者安全设备瞎猜的。看到后一种,基本可以安心:这个RST不是业务故障的根因。
3.3 不抓包也能初步定位的系统命令
有些时候你没法在业务机器上抓包,或者问题已经过去了,只能靠系统积累的数据反推。这些命令值得收藏:
# 看新连接队列积压情况 ss -lntp | grep 8080 # 看每条连接的存活状态和重传情况 ss -tin state established '( dport = :8080 )' # 看系统累积的RST统计 netstat -s | grep -i resetnetstat -s的输出里通常能看到几类计数:“connections reset due to unexpected data”“resends”“resets received”等。如果RST计数在增长,但具体到某条连接又看不出规律,说明可能是全局性策略在网上做手脚。
配套的日志排查也别拉下。Nginx节点上如果连接被对端重置,error.log里多半会有“upstream prematurely closed connection”之类记录;应用层如果是Java/Python生态,常见异常就是“Connection reset”和“Broken pipe”。这些日志信息虽然不能直接告诉你RST怎么来的,但能帮你圈定时间和涉及节点。
3.4 Wireshark里进一步确认细节
把pcap拖进Wireshark,我最常用这几个操作:
- 显示过滤打
tcp.flags.reset == 1,立刻把所有RST包挑出来。 - 点开一个RST包,看TCP头里Sequence number和Acknowledgment number,和我们上面的三步判断法对一下。
- 对可疑连接右键Follow -> TCP Stream,看完整对话,确认RST之前双方到底聊到哪一步。
- 用Statistics -> TCP Stream Graph -> Time-Sequence Graph画时序图,看RST是不是发生在某个时间拐点。
Wireshark还有一个方便的地方:它会在Expert Info里自动标注“Connection reset (RST)”,点开就能跳到对应位置,尤其适合抓包文件比较大的时候。
4. 几个容易被误判成“故障”的RST藏得很深
4.1 健康检查:RST是它的“正常工作”
这是所有RST排查里最冤的一个。
负载均衡器做TCP健康检查时,常见的做法是发一个SYN,收到SYN-ACK后认为服务存活,然后立刻断开这个探测连接。有些LB实现为了不留下TIME_WAIT,直接发RST来断。于是在你的抓包里,就会看到规律的RST不断出现,而且源IP是LB的地址。
判断方法也很明确:RST出现的时间和频率高度固定,比如每5秒一次、每10秒一次,而且往往伴随成对的SYN和SYN-ACK。这种就不用处理,反正在抓包里看到规律RST,先想想是不是探活流量,别一上来就报故障。
4.2 中间设备“越权”发RST
跨网络的链路里往往藏着不少“热心肠”设备:防火墙、入侵防御系统、上网行为管理、DDoS清洗设备。它们都可能在认为连接“有问题”时,主动朝两个方向各发一个RST,把连接掐断。
这种RST和内核发的有明显的不同:有时候序列号完全不在双方协商的窗口范围内,属于“无效RST”,但因为切断的是路径上的数据流,所以还是会打断连接。特征是两个方向几乎同时出现RST,且时间点可能跟某个策略(比如连接空闲超时)挂钩。
排查手段只有一个靠谱:在链路两端(比如客户端机器和服务端机器)同时抓包,对比时间戳。RST如果两边同秒出现,且不在正常协议序列里,那就是中间设备干的,靠单边抓包很难实锤。
4.3 TIME_WAIT和RST的取舍
每个做网络的人都会和TIME_WAIT打交道。TIME_WAIT是主动关闭方在发送最后那个ACK后进入的状态,持续2MSL(Linux上通常默认60秒)。它的存在是为了确保旧连接的迟到数据包不会污染到新连接——这是TCP协议安全性的基石。
可代价也肉眼可见:高并发短连接场景下,大量socket卡在TIME_WAIT,端口不够用。于是有人动歪脑筋,用SO_LINGER=0强行绕过TIME_WAIT,代价就是RST满天飞。这类RST危害主要体现在:对端如果有数据还没发完,直接被砍断,业务表现为莫名其妙的重置。
实际上,绕开TIME_WAIT的办法很多,完全不需要用RST这种“杀敌一千自损八百”的方式。客户端这边把本地端口范围调大,服务端那边用连接池复用长连接,都能把TIME_WAIT压力降下来。不到万不得已,我不会在生成环境开SO_LINGER=0。
4.4 别把RST和“超时”搞成一锅粥
排查问题的时候,我经常看到同事把超时报错和RST混在一起讨论,其实这俩性质完全不同。
超时(ETIMEDOUT)的本质是:我等了很久,什么回包都没等到。可能是对端消失了、网络黑洞了、丢包没人管。真实世界里它就像你一直敲门但屋里毫无反应。
RST的本质是:对方存在,并且明确告诉我“不行/结束”。哪怕它是中间设备伪装的“对方”,它也代表一个实体回话了。
判断依据很简单:看抓包里有没有一个“回应包”。有回应但内容是RST,那是明确拒绝;完全没有回应,那是超时。两个错误码分开记,排查思路完全不同。
5. 排查速查表和几条实在话
5.1 场景-特征-处理方向速查表
| 场景 | 抓包典型特征 | 常见日志/错误码 | 处理方向 |
|---|---|---|---|
| 端口未监听 | SYN后立即回RST,ACK | ECONNREFUSED (111) | 查ss -lntp,确认端口和监听地址 |
| accept队列溢出 | 握手收尾出现RST,Recv-Q堆积 | 客户端connect重置/超时 | 加worker、优化accept,别只调backlog |
| close时未读完数据 | 正常连接中突然RST替换FIN | Connection reset by peer | 改代码,close前读完接收缓冲区 |
| SO_LINGER=0 | close()立刻RST,无FIN | 对端报reset | 尽量别用,改用连接池/调大端口范围 |
| 对端重启/崩溃 | RST发生在重启、部署后 | stuck连接+reset | 业务心跳及时感知,清理死连接 |
| 重传超时后遗症 | 先一串重传,再出RST | ETIMEDOUT + ECONNRESET | 查重传时间段的丢包和负载 |
| 健康检查 | 规律RST,源IP是LB | 无业务报错 | 确认是LB探活,不用处理 |
| 中间设备注入 | 双向同时RST,seq异常 | 偶发reset且时间诡异 | 双端抓包对比,找网络策略 |
5.2 我现在遇到RST会怎么处理
经历了这么多轮RST排查,我现在的习惯已经固化成一套流程,分享出来供参考。
第一步,不查防火墙,先看方向。RST是谁发给谁的,这决定了排查往哪个方向走。第二步,看序列号有效性。无效RST直接略过,别浪费时间。第三步,对照时间点。是不是刚部署、刚重启、刚调过网络参数?时间点对上了,问题基本锁定一半。第四步,再查系统统计和日志。netstat -s的计数、应用error log、LB健康检查记录,把这些拼起来。最后才轮到中间设备,因为排查中间设备成本最高,要拉多方抓包。
我还特别建议团队里有条件的话,把RST统计做成一个每日巡检指标。Linux的procfs提供了现成的计数器,脚本定期拉一次netstat -s里的reset相关字段做趋势图,哪天突然涨了,就说明有网络异常或配置变更,尽早发现比事后抓包轻松太多。
RST说到底不是玄学,它是TCP协议里一个再普通不过的控制信号。搞明白它背后对应的是哪一层的行为,问题就解决了一大半。下次日志里再跳出“Connection reset by peer”,按这套思路走一遍,你会发现自己淡定了很多。