传输层这一章我讲过很多遍,每次讲到它,底下问的问题都高度重合:服务明明起来了却连不上、压测时延迟突然翻倍、机器上堆了几万个连接状态看着像卡死、抓包发现重传率莫名其妙很高。这些问题往上追溯,八成都会落到传输层这一层——它是整条链路上“背锅最多”的一层,因为应用层看不见它,网络层不负责它,出问题时它既像原因又像结果。传输层解决的核心问题其实就一句话:在不可靠的、尽力而为的底层网络之上,如何让两台机器上的两个进程,像面对面说话一样把数据完整、有序、可控地交换完。TCP 用序号、确认、重传、窗口、拥塞控制这一整套机制把可靠性硬扛了下来,UDP 则把这份责任原封不动地交还给你。这篇内容我打算按“定位—细节—动手验证—排错”的顺序写,既适合刚学完网络分层、对端口和三次握手还只有模糊印象的新手,也适合已经写了几年业务代码、但每次遇到连接异常还是靠重启解决的开发者。看完至少能拿到三样东西:一套能自己讲清楚的原理链条、一套能直接抄的命令和过滤表达式、一份遇到症状就能对上号的排查表。
1. 传输层的定位:它到底替你干了哪些活
1.1 从“主机到主机”到“进程到进程”的关键一跳
网络层交付的是“把数据送到某台主机”,注意是主机,不是程序。一台机器上同时跑着数据库、缓存、Web 服务、日志上报、定时任务,网络层把包丢到网卡上就算完事,它根本不知道这个包该交给谁。传输层补的就是这一跳:它用端口号把“主机到主机”的投递细化成“进程到进程”的投递。这个区分听起来像教科书里的一句话,但它解释了日常开发中大量困惑——为什么我改了一行配置重启服务,客户端报的还是连接被拒绝?因为对端主机是通的,只是那个端口上没有进程在 listen,传输层收到 SYN 之后直接回了 RST。同理,为什么同一台机器上两个服务不能抢同一个端口?因为端口是传输层的寻址空间,冲突发生在这一层,网络层管不着。理解了这个职责边界,你排查问题时的第一刀就会切得准:先判断是主机不可达(网络层及以下),还是端口不可达(传输层),还是请求进去了但处理出错(应用层)。这三类故障的排查路径完全不同,混在一起看只会越查越乱。
1.2 传输层给应用层提供的其实是一组抽象接口
很多人把传输层理解成“TCP 和 UDP 两个协议”,这个理解偏窄。更准确的视角是:传输层向上提供的是一组抽象,TCP 和 UDP 是这组抽象的两种实现方式。这组抽象包括:寻址(端口)、连接(是否维护状态)、可靠性(是否保证送达与顺序)、流量控制(是否考虑接收方处理能力)、拥塞控制(是否考虑网络承载能力)、消息边界(是否保留应用写入的边界)。你写代码时调用的每一个 socket 接口,本质上都是在选这组抽象里的某几项。比如你给一个日志上报通道设成 UDP,就是在说“我不要连接状态、不要可靠性、但我需要保留消息边界,并且我能接受丢包”;你把一个内部 RPC 通道设成 TCP,就是在说“我要顺序和可靠,消息边界我自己在应用层切”。这个视角的价值在于,当你遇到“为什么我的协议要自己实现重传”这类问题时,答案不在 TCP 的代码里,而在这组抽象的取舍里——你选了 UDP,就等于把这组抽象里被 TCP 拿走的那几项自己接回来了。
1.3 TCP 与 UDP 不是优劣关系而是成本转移关系
新手最容易有的一个误解是“TCP 比 UDP 高级”。真实情况是:TCP 把可靠传输、顺序保证、流量控制、拥塞控制全部实现在内核里,代价是连接状态要占内存、握手挥手要额外往返、丢包时要等重传、拥塞时要降速;UDP 把这些全部省掉,代价是这些事要么不做,要么你在应用层自己做,而且做出来大概率不如内核那套打磨了三十年的实现。所以选型的正确问法不是“哪个更好”,而是“这份成本由谁来付更划算”。一个高频交易场景的消息通道,宁可自己实现一套轻量的序号加选择性重传,也不愿意承受 TCP 队头阻塞带来的延迟抖动,这是把成本转移到应用层的合理案例。一个后台管理系统的接口调用,直接上 TCP 就行,因为它的流量特征平稳、对延迟不敏感,自己实现一套可靠性纯属浪费。我在实际项目里见过反过来的例子:有人为了“性能”把一个内部文件传输改成 UDP,然后在应用层实现了一套带确认和重传的协议,最后实测吞吐还不如直接 TCP——因为他在用户态做的重传没有拥塞控制,网络一拥塞就疯狂重传,把链路彻底压垮了。这个坑很典型,值得单独记一笔。
2. 端口与套接字:日常踩坑密度最高的一层
2.1 端口三段划分带来的权限差异
端口号是 16 位,范围 0 到 65535,业界把它分成三段:0 到 1023 是系统保留端口,1024 到 49151 是注册端口,49152 到 65535 是动态端口(也叫临时端口)。这三段的划分不是学术分类,它直接决定了你部署时会不会被权限拦住。系统保留端口在多数系统上只有特权用户才能绑定,这就解释了一个很常见的现象:本地用高位端口跑得好好的服务,换到生产环境改成 80 或 443 之后启动失败,报权限不足。有人图省事直接用管理员身份跑业务进程,这是很糟糕的做法——一个处理外部请求的进程不该拥有那么大的权限。正确做法是让服务监听高位端口,再在入口处做转发,或者只给这一个可执行文件授予绑定低位端口的能力。注册端口这一段是给你自定义服务用的,但如果你的服务要对内对外都开放,最好别乱占,尤其别占一些知名服务常用的端口,将来和别的组件部署在同一台机器上就会撞。动态端口是内核分配给主动发起连接的一方的,这一点非常关键,后面讲四元组和端口耗尽时会反复用到。
2.2 四元组才是决定“一条连接”的唯一标识
初学者常以为“连接”由端口唯一确定,其实不是。一条 TCP 连接由四元组唯一确定:源 IP、源端口、目的 IP、目的端口。这四个值任意一个不同,就是两条不同的连接。这个结论能解释一连串看似奇怪的现象。第一,为什么服务端只监听一个端口,却能同时处理几万个客户端?因为服务端的 IP 和端口固定,但客户端的 IP 和端口各不相同,四元组不冲突。第二,为什么同一台客户端机器反复连同一个服务,会占用大量本地端口?因为每次新建连接内核都要分配一个新的源端口,端口用完就分配不出来,报“无法分配请求的地址”。第三,为什么 sometimes 会看到同一个客户端 IP 和端口同时存在两条连接?那基本是客户端代码里出现了端口复用配置不当,或者服务端处于特殊的重启状态,属于要重点排查的异常。第四,为什么做服务端压测时,压测机自己先崩了?因为压测机作为客户端,本地端口的可用范围只有那么几千个,加上进入 TIME_WAIT 状态要占着不放一段时间,端口很快就被耗光,瓶颈根本不在被测服务。
2.3 端口占用排查的三条路径
线上遇到“连不上”,第一步永远是确认端口到底有没有在监听、监听在哪个地址上。这里给出三条我常用的路径,覆盖不同场景。
| 排查目标 | 常用命令 | 需要关注的点 |
|---|---|---|
| 看端口是否监听 | ss -lntp | 关注 Local Address 是 0.0.0.0 还是 127.0.0.1,前者对外,后者只本机 |
| 看某端口谁在用 | ss -lntp 'sport = :8080' | 输出里的进程名和 PID 能直接定位到具体程序 |
| 看连接状态分布 | ss -s与ss -tan state established | 各状态的连接数量,异常堆积往往指向具体问题 |
| 看协议栈统计 | netstat -s | 重传、丢包、监听队列溢出等计数器的增量 |
这里有个高频坑必须点出来:监听地址写成 127.0.0.1,外部一定连不上。这个问题在容器化部署之后变得特别常见,因为容器内的 127.0.0.1 和宿主机的 127.0.0.1 完全是两个东西。排查时不要只看“进程在不在”,一定要看监听地址那一列。另一个坑是同一端口在 IPv4 和 IPv6 上各监听一份,ss输出里会看到0.0.0.0:8080和[::]:8080两行,这时候如果客户端解析到了 IPv6 地址而服务端只正确处理了 IPv4,就会出现“有些机器能连有些不能”的诡异现象。
3. TCP 的可靠性是怎么堆出来的
3.1 序号与确认号:可靠传输的地基
TCP 把应用层交下来的字节流切成段发送,每个字节都有一个序号。注意是每个字节都有序号,不是每个段。段头里的“序号”字段表示的是这个段第一个字节的序号,“确认号”表示的是“我期望收到的下一个字节的序号”,也就是“这个序号之前的所有字节我都收到了”。这个“累计确认”的设计非常精妙:它不需要对每个段单独确认,一个确认号就能覆盖之前所有已收到的数据,极大减少了确认报文的数量。但它也有代价——如果中间丢了一个段,接收方只能一直重复确认最后一个连续字节的序号,发送方看到的是一串重复确认,这就是后面“快速重传”的触发信号。理解“字节流编号”这件事还能顺带解决一个经典困惑:为什么 TCP 会“粘包”?因为 TCP 根本没有“包”的概念,它给你的是一个连续的字节流,应用层写两次的数据可能被合并成一个段发出去,也可能一次写入被切成两个段。所谓粘包,本质是应用层没有自己定义消息边界,把字节流的责任推给了传输层,而传输层压根不负责这件事。
3.2 超时重传:RTO 到底是怎么算出来的
重传时机的选择是 TCP 最见功力的地方。定得太短,网络只是稍微抖一下你就重传,白白浪费带宽还可能加剧拥塞;定得太长,丢一个包就要卡很久,体验直接崩。所以 RTO(重传超时)不是一个固定值,而是动态估算的。基础算法是维护两个量:平滑往返时间 SRTT 和往返时间偏差 RTTVAR。每测到一个新的往返样本 RTT,就按加权方式更新这两个量,公式大致是 SRTT 向新样本靠拢八分之一,RTTVAR 取偏差的加权平均,最终 RTO 等于 SRTT 加上四倍的 RTTVAR。乘四这个系数是有讲究的,它在统计意义上给了足够的余量,让 RTO 不会因为个别抖动频繁误触发。实际实现里还有几条补充规则:初始 RTO 通常给一个比较大的值,避免连接刚开始就乱重传;RTO 有一个下限(常见是两百毫秒量级),防止在高频网络上过度敏感;每重传一次 RTO 就翻倍退避,上限通常到几十秒。这里有一处必须注意的细节:当一个段被重传过之后,它的往返样本就不可信了——你无法判断收到的确认是针对第一次发送还是第二次发送。所以标准做法是重传过的段不采样 RTT,这就是 Karn 算法的核心思想。如果你在做协议分析时发现某个连接的 RTO 一直居高不下,往往就是在反复重传,样本一直被跳过,估算值始终收敛不下来。
3.3 滑动窗口与流量控制:别把接收方撑爆
确认机制只保证“丢了能补”,但它不限制发送方发多快。如果发送方是一条千兆链路,接收方是个处理能力很弱的小服务,那发送方会把接收方的缓冲区灌满,然后大量数据被丢弃,引发连锁重传。滑动窗口就是来解决这个问题的:接收方在每一个确认报文里带上自己的“窗口”字段,告诉发送方“我现在还能接收多少字节”,发送方据此限制未确认数据的总量。这个机制叫流量控制,注意它和拥塞控制的区别——流量控制关心的是接收方扛不扛得住,拥塞控制关心的是网络扛不扛得住。窗口字段在头部只有 16 位,最大表示 65535 字节,在早期网络够用,但到了高带宽高延迟链路上就明显不够了:假设往返时延是 100 毫秒,窗口只有 64KB,那理论吞吐上限就是 64KB 除以 0.1 秒,大约 5Mbps 出头,再多带宽也用不上。所以后来引入了窗口缩放选项,在握手阶段协商一个缩放因子,把窗口的实际含义放大若干倍。这也解释了一个排查经验:跨地域的长距离传输如果吞吐上不去,先看两边有没有开启窗口缩放,再看接收窗口是不是被应用层读取太慢卡住了——接收缓冲区满了,窗口就会缩到很小甚至零,发送方只能停等。
3.4 拥塞控制:慢启动、拥塞避免、快重传、快恢复
拥塞控制是 TCP 里最复杂也最容易被忽视的部分,因为它对应用层完全透明,但它的行为直接决定了你的服务在网络抖动时的表现。它维护一个拥塞窗口,和接收方通告的窗口取较小值作为实际可发送量。整个过程分几个阶段。慢启动:连接刚建立时不知道网络能承受多少,所以从一个很小的窗口开始,每收到一个确认就把窗口加一,效果是指数增长,一个往返翻一倍。拥塞避免:窗口涨到一个阈值之后改为线性增长,每个往返只加一个最大段大小,避免撞墙。快重传:连续收到三个重复确认,说明某个段大概率丢了但后续段还在到达,此时不等超时立刻重传那一个段。快恢复:快重传之后不把窗口打回起点,而是降到原来的一半左右继续线性增长,因为收到重复确认说明网络还在传数据,情况没那么糟。真正触发超时重传的时候才是重罚,窗口直接回到最小值重新慢启动。这套机制在课本上是标准的,实际内核里还会叠加更复杂的判决逻辑,但这四个阶段足够你解释绝大多数现象。比如你压测时看到吞吐先快速爬升、然后突然掉一半再慢慢爬,那就是撞上了拥塞避免的阈值或者发生了一次丢包;如果吞吐直接掉到很低再慢慢爬,多半是超时重传触发了慢启动。
3.5 三次握手与四次挥手的状态细节
握手三次的理由,面试里被问烂了,但真正理解的人不多。核心有两条。第一,要同步双方的初始序号——双方各自需要一个起始序号,而序号的同步需要“我发你确认、你发我确认”这两个方向都完成,合计至少三次交互。第二,要防止历史连接请求造成混乱:网络中可能存在一个很久以前滞留的握手请求,如果只有两次握手,服务端收到这个过期请求就会直接建立连接并分配资源,而客户端根本不认这条连接,资源就白白泄漏了。三次握手里,服务端发出确认后还要等客户端再确认一次,过期请求就过不了这一关。挥手四次的原因更简单:TCP 是全双工的,两个方向的数据流相互独立,一方说“我没数据要发了”不代表另一方也发完了,所以需要各自发一次结束请求、各自确认一次。这也解释了一个常见现象:主动关闭一方会进入一个等待状态,等够两倍的最大报文生存时间才真正释放,目的是确保最后一个确认能到达对方,同时让本次连接的残留报文在网络中消失,不至于污染之后复用同样四元组的新连接。被动关闭一方如果迟迟不关闭,就会卡在关闭等待状态,这个状态下的连接不会被自动回收,只能等应用层调用关闭。所以当你看到某台机器上某个状态堆积成千上万时,基本可以断定是应用层漏了关闭逻辑,而不是内核出了问题。
4. 动手抓一次包:完整看懂一条连接的一生
4.1 环境准备与过滤表达式
光看文字描述永远记不住状态流转,抓一次包全清楚了。准备两台能互通的机器,一台跑服务端,一台跑客户端,中间不要经过任何会改写报文的东西。服务端用一个最简单的监听方式即可,客户端用命令行工具发起请求。抓包命令我一般这么写:
# 在客户端侧抓,只抓目标端口,不做域名解析,避免抓包本身产生流量 sudo tcpdump -i any -nn -s 0 'tcp port 8080' -w /tmp/tcp-8080.pcap # 抓完之后统计一下重传和重复确认,快速判断链路质量 sudo tcpdump -r /tmp/tcp-8080.pcap -nn 'tcp[tcpflags] & tcp-syn != 0'几个参数值得说明。-i any表示抓所有网卡,适合不确定走哪张网卡的场景;-nn表示 IP 和端口都不做反向解析,一方面是快,另一方面是避免抓包过程本身触发额外的域名查询流量,把你要观察的东西污染了;-s 0表示抓完整报文不截断,排查应用层数据问题时必须加;-w直接落盘,因为终端打印会丢包,尤其是高流量场景。过滤表达式写tcp port 8080比写主机地址更精准,因为同一台机器上可能还有其他连接在跑。
4.2 关键字段怎么看
打开抓包结果,一条 TCP 报文里真正需要盯的字段其实就几个。序号和确认号看数据流是否连续;标志位看这条报文在干什么,SYN 是发起,FIN 是结束,RST 是强制重置,PSH 通常表示发送方希望尽快交付;窗口字段看接收方还有多少余量;选项字段里藏着最大段大小、窗口缩放因子、选择性确认这些协商结果,跨地域传输性能问题时这几个选项是必看的。排查时有个非常实用的小技巧:如果看到 RST 报文,先看它的序号是不是零。序号为零的 RST 通常表示“这个端口上没有进程在监听”,是内核直接回的;序号非零的 RST 通常是某一方主动中止连接,往往意味着应用层逻辑出了问题或者连接被中间设备清理了。这两种情况的排查方向完全不同,前者去查服务有没有起来,后者去查应用日志和超时配置。
4.3 一次典型的建连—传输—断开时间线
一次干净的交互大致是这样的:客户端发 SYN,携带自己的初始序号和选项;服务端回 SYN 加 ACK,携带自己的初始序号、对客户端的确认号,以及自己协商出的选项;客户端回 ACK,握手完成,此时客户端进入已建立状态,服务端从半连接队列里把这条连接挪到已完成队列,等待应用层取走。数据传输阶段会看到一连串的请求与确认,注意确认号是累计的,所以经常出现多个段共用一个确认。断开阶段:主动关闭方发 FIN,进入终止等待一;被动方回 ACK,进入关闭等待;被动方应用层处理完剩余数据后发自己的 FIN;主动方回 ACK,进入时间等待状态,等够两倍最大报文生存时间后连接彻底消失。抓包时最值得观察的是确认号的变化节奏:如果确认号长时间不前进,而发送方在重复发同一段数据,那就是丢包加重传;如果确认号前进得很快但窗口字段一直在缩小,那是接收方处理不过来,属于应用层读取速度的问题,改内核参数没用,得优化消费逻辑。
4.4 时间等待与关闭等待:两个被误解最深的状态
这两个状态经常被当成“故障”,其实一个是正常机制,一个是应用缺陷。时间等待状态是主动关闭方必然经历的,持续时间通常是固定的几十秒。它的存在有两个目的:保证最后一个确认能送到对方(如果这个确认丢了,对方会重发结束请求,此时本方还能再回一个确认);让本次连接的残留报文在网络中自然消亡,避免它们被误认为新连接的数据。所以这个状态堆积本身不可怕,可怕的是堆积速度超过消散速度,说明短连接用得太猛。真正需要警惕的是关闭等待状态,它表示对方已经发了结束请求、本方也确认了,但本方的应用层始终没有调用关闭,连接就一直挂着。这个状态是不会自动超时回收的,堆到把文件描述符耗光,服务就彻底起不来了。我的经验是:看到关闭等待数量持续上涨,直接去查代码里有没有在异常分支忘记关闭连接的地方,尤其是那些用了连接池但取用之后在异常路径上直接抛出的写法。
5. UDP:把可靠性交回给应用层
5.1 什么时候 UDP 才是正解
判断标准其实不复杂。第一种情况是对延迟的敏感度远高于对完整性的要求:实时音视频、实时位置上报、监控指标采集,丢一两个包对整体体验几乎无影响,但如果为了补一个包而等待重传,反而会造成明显的卡顿。第二种情况是应用层已经有自己的可靠性机制:比如某些自研的传输协议,它在应用层维护序号和重传,如果底下再用 TCP 就会变成双重保障,反而因为 TCP 的队头阻塞拖慢整体。第三种情况是天然的一问一答、无状态:比如简单的查询请求,一问一答,丢了就重发一次,用 TCP 建连挥手的开销比请求本身还大。第四种是需要广播或组播:TCP 是点对点的,做不了这个。反过来,什么情况绝对不要用 UDP?涉及资金、订单、配置下发这类“必须完整送达且不能乱序”的场景,老老实实上 TCP,别自己造。我见过有团队为了省事在 UDP 上自己实现了一套确认重传,结果漏了一个边界条件——重传的包和新包乱序到达时没有去重,导致同一条指令被执行了两次,这种问题排查起来极其痛苦。
5.2 在 UDP 上补可靠性要补齐哪几块
如果你确定要用 UDP,需要自己想清楚四件事。第一是消息边界:UDP 保留边界,一个报文就是一个消息,这一点比 TCP 省心,但要注意报文有大小上限,超过上限会在 IP 层被分片,任一分片丢失整个报文就废了,所以应用层要自己控制单包大小。第二是去重和排序:给每个消息编号,接收端维护一个已收到的编号集合,乱序到达的先缓存,重复的直接丢弃。第三是重传策略:不能简单地超时就重传,否则网络一拥塞就会疯狂重传把链路压死,需要参考往返时延动态调整,并且加一个总的重传次数上限,超了就放弃并通知上层。第四是拥塞控制:这是最容易被忽略的一块。UDP 没有内建的拥塞控制,如果你的应用无节制地往外发,遇到瓶颈链路时会把同一链路上的其他流量全部挤死,这在共享网络环境里是非常糟糕的行为。做实时传输的成熟方案一般都会实现某种基于延迟或丢包反馈的发送速率调节,这块的复杂度不低,评估工作量时别低估。
5.3 UDP 问题的排查思路
UDP 没有连接状态,排查方式和 TCP 完全不同。TCP 可以看状态机,UDP 只能看收发计数。第一件事是确认包到底发出去了没有、有没有到达对端,这必须在两端同时抓包对比:发送端抓到、接收端没抓到,问题在中间链路或对端的接收缓冲区;两端都抓到了但应用层没处理,问题在应用层的读取逻辑。第二件事是看接收缓冲区是否溢出,系统的协议栈统计里会有“接收缓冲区错误”这类计数器,如果它一直在涨,说明应用层读取速度跟不上报文到达速度,需要加大缓冲区或者提高消费能力。第三件事是看报文大小,如果单包超过链路的 MTU,会触发分片,而分片只要丢一片整个报文就废了,丢包率会被放大好几倍。我的习惯做法是把单包控制在比较保守的范围内,宁可多发几个包,也不要依赖分片——分片在跨广域网的场景下可靠性很差。
6. 疑难速查与参数调优经验
6.1 症状、原因、处置的速查表
这张表是我这几年攒下来的,遇到问题先在里面找一行,能省掉大量瞎猜的时间。
| 症状 | 优先怀疑 | 处置方向 |
|---|---|---|
| 连接被立即拒绝 | 端口没监听,或监听在本地回环地址 | 查监听地址与进程状态 |
| 连接一直卡在建立阶段 | 中间设备丢弃了握手报文,或半连接队列满 | 查队列溢出计数与链路 |
| 传输一段时间后卡住 | 接收窗口缩到零,应用层读得太慢 | 优化消费逻辑,必要时扩缓冲区 |
| 吞吐呈锯齿状波动 | 触发拥塞避免阈值或丢包降速 | 查重传率,评估链路质量 |
| 短连接场景端口耗尽 | 主动关闭方产生大量时间等待 | 改用长连接或连接复用 |
| 连接数持续上涨不释放 | 应用层漏了关闭,堆积在关闭等待 | 查异常分支的资源释放 |
| 单次请求偶发几十毫秒额外延迟 | 小包合并与延迟确认叠加 | 对延迟敏感的场景关闭合并 |
最后一行这个现象特别值得展开。有一类经典问题:客户端连续发两个小请求,第二个请求莫名其妙多等了几十毫秒。原因是发送端开启了小包合并(把小数据攒一攒再发),而接收端开启了延迟确认(收到数据后不立刻确认,等一小会儿看看有没有数据要捎带回去),两边一配合就形成了“你等我我等你”的局面。解决办法很简单,对延迟敏感的长连接在应用层把这个合并关掉,代价是网络上的小包会变多,所以只在确实需要低延迟的通道上做这个设置。
6.2 几个值得动但必须先想清楚的内核参数
调参这件事我的态度一直很保守:先定位问题,再动参数,动完必须有用例能量化对比。下面这几个是相对安全、收益明确的。
| 参数方向 | 作用 | 调整前提 |
|---|---|---|
| 已完成连接队列上限 | 提高瞬时并发建连的容纳能力 | 应用层 accept 速度跟得上,否则只是把溢出点后移 |
| 半连接队列上限 | 应对建连突发 | 有明确的突发建连场景,且不是被异常流量冲击 |
| 本地端口可用范围 | 缓解客户端端口耗尽 | 压测机或高并发客户端场景,且要评估对端五元组压力 |
| 接收/发送缓冲区上限 | 提升高带宽高延迟链路的吞吐 | 带宽延迟积确实很大,盲目调大只会增加内存占用 |
| 时间等待状态复用 | 缓解主动关闭方的状态堆积 | 只在客户端侧考虑,服务端侧要谨慎 |
关于缓冲区,给个粗算方法:链路带宽乘以往返时延,就是理论上需要的缓冲区大小,业内叫带宽延迟积。比如带宽一百兆、往返时延一百毫秒,算下来大约是 1.25 兆字节,那缓冲区至少要给到这个量级才能跑满。注意这是理论上限,实际还要看应用层的读取速度,如果应用层每秒只能处理几十兆,那缓冲区开再大也没用,只是让数据在内存里多待一会儿。
6.3 我在实操里踩过的几个坑
第一个坑:早期排查一个“偶尔连不上”的问题,查了两天服务端日志一无所获,最后在客户端抓包才看到是握手阶段就收到了重置报文。原因是对端机器上有一个旧的进程还占着端口,而我们的服务启动时没检查绑定结果,报错被吞掉了,进程活着但没在监听。从那以后我养成一个习惯,服务启动脚本里必须加一步监听状态自检,启动完立刻查一次端口,查不到就直接失败退出,绝不带着假活状态跑。
第二个坑:一次压测中吞吐怎么都上不去,监控显示带宽只用了三成。查了半天以为是应用层瓶颈,最后发现压测机和被测服务之间隔着一段高延迟链路,而两端的接收窗口都没开缩放,窗口上限只有六十几 KB,按延迟一算理论吞吐就卡在那个位置。开了窗口缩放之后吞吐直接翻了几倍。这个案例让我记住一点:跨地域的链路,先算带宽延迟积,再看窗口够不够,别一上来就怀疑代码。
第三个坑:有个服务在容器里跑,健康检查一直失败,但手动进容器访问本地端口是通的。原因是健康检查从宿主机发起,而服务监听的是容器内的回环地址。这个问题的根源不在传输层,但表象完全在传输层,排查时如果不看监听地址那一列,就会一直往网络策略、防火墙的方向查,越查越偏。
第四个坑:一批短连接服务在上量之后开始报“无法分配请求的地址”,第一反应是服务端端口不够,实际是客户端本地端口被时间等待状态占满了。改成连接复用之后问题消失。这里有个判断依据很好用:如果报错发生在发起连接的一方,且该方连接状态里时间等待的数量在持续上涨,基本就是本地端口耗尽,跟服务端没关系。
第五个坑:自研 UDP 通道在高丢包环境下出现指令重复执行,前面提过。复盘时发现重传逻辑没有和去重逻辑对齐,重传包被当成新包处理了。后来我们定了一条规矩:凡是自己实现可靠性的通道,先去构造一个“重复、乱序、延迟、丢失”四种情况同时出现的测试用例,能过这个用例再上线。这个用例构造起来不难,写个小工具按指定模式发报文就行,但它能挡住绝大部分低级错误。
传输层这一层的特点就是,原理看起来都很朴素,但一旦落到真实链路上,各种机制互相咬合,表现出来的现象往往和书本上的单点描述完全对不上。我个人的体会是,把它当成一套“有代价的保障方案”来看,比当成一堆要背的知识点要有效得多——每一条机制都在解决一个具体问题,同时都引入了新的开销,你理解了这笔交易,排查问题时自然就知道该看哪里、该怀疑谁。