Socket 这个词,几乎每个做后端的人都听过,但真正能把 Socket 网络编程讲清楚的人并不多。很多人第一次接触它是在大学课堂上,背了一堆socket、bind、listen、accept的函数原型,考试过了,回头写项目还是不知道从哪儿下手;也有人在工作中被 "连接超时"、"粘包"、"TIME_WAIT 太多" 这类问题按在地上摩擦了好几轮,才回过头补这块基础。这篇内容就是给这两类人准备的:不管你是刚入门想搞明白一次网络请求到底在系统里发生了什么,还是已经写了几年业务代码、想系统梳理 Socket 编程的核心机制和避坑经验,都能从下面这些内容里拿到能直接用的东西。
我会从协议栈和文件描述符这些底层概念切入,把服务端客户端的骨架代码拆开讲,再把阻塞、非阻塞、IO 多路复用这几种并发模型的取舍逻辑摊平,最后落到一个能跑起来、能扛压测的小服务实现,以及一堆真实排查过的坑。全程用从业者之间聊天的口吻,该给代码给代码,该给参数计算给计算过程,不绕弯子。
1. 先把 Socket 这个概念的底摸清楚
1.1 一次跨机通信在系统内核里到底走了哪些层
很多人对 Socket 的理解停留在"一个用于网络通信的 API",这个说法不算错,但太浅了,浅到你在遇到性能问题或者诡异报错时完全用不上。Socket 本质上是操作系统提供的一层抽象,它把 TCP/IP 协议栈的复杂度全部封装起来,向上暴露成一套类似文件读写的接口。你调用send()的时候,数据并不是"直接飞到对面"的,中间要经过应用缓冲区、内核发送缓冲区、网卡驱动队列、物理链路,到了对端再反向走一遍。
具体拆开看,你的应用进程调用send()写入数据,数据先从用户态内存拷贝到内核态的 socket 发送缓冲区(SO_SNDBUF控制大小),然后 TCP 协议栈负责分段、加序号、算校验和,交给 IP 层加地址,再交给链路层封装成帧,最后由网卡发出去。对端收到后逐层剥开,数据进入接收缓冲区(SO_RCVBUF),应用调用recv()再拷贝回用户态。这个"两次拷贝"的路径非常关键,因为它解释了大量性能现象:为什么零拷贝技术值钱、为什么缓冲区大小会直接影响吞吐、为什么大包和小包的延迟表现不同。
理解这条链路之后,你会发现 Socket 编程里绝大多数"玄学问题"都有物理来源。比如发送方send()返回成功,只代表数据进了内核缓冲区,不代表对端收到了,更不代表对端应用处理了。这一点在写业务代码时特别容易出事:有人以为send返回了就是"消息已送达",于是紧接着更新数据库状态,结果对端进程刚好崩了,数据丢了,本地状态却是"已发送"。
从调用层次上说,应用层看到的是socket()返回的那个整数句柄,往下是系统调用接口,再往下是传输层(TCP/UDP)、网络层(IP)、链路层。每一层都有自己的缓冲区、自己的重传策略、自己的错误语义。你在应用层看到的ECONNRESET、EPIPE、ETIMEDOUT,其实是不同层抛上来的信号。能把这条链路的每一段职责分清楚,排错效率至少提升一倍。
1.2 为什么 Socket 在系统里表现为一个文件描述符
这是 Unix 设计哲学里非常漂亮的一笔。在 Unix 体系下,"一切皆文件",Socket 也被抽象成文件描述符(file descriptor,简称 fd),一个非负整数。这意味着你可以对 Socket 用read()、write()、close(),也可以把它塞进select()、poll()、epoll()这些多路复用机制里统一管理。
这个设计带来的直接好处是:网络 IO 和磁盘 IO 在编程模型上能统一。你不需要为"读网络"和"读文件"写两套完全不同的调度逻辑,事件循环可以同时监听 Socket、管道、文件变更。反过来,这个设计也带来一个必须记住的约束:fd 是有限资源,每个进程能打开的 fd 数量受ulimit -n限制,默认常见是 1024 或者 65535。做高并发服务时,第一件事就是把这个值调大,否则连接数刚到一千就开始报 "Too many open files"。
还有一个容易忽略的点:fd 会被复用。你关闭了一个 fd,系统可能立刻把这个编号分配给新的连接。如果你的代码里有"延迟关闭"或者"异步回调里还在用旧 fd"的逻辑,就可能出现"操作到了错误的连接上"这种极难排查的问题。我在早期写过一个连接池,因为超时回收和业务线程释放之间存在竞争,出现过一次向错误连接写数据的线上事故,最后就是靠"关闭前先把 fd 置为 -1 并加引用计数"解决的。
提示:任何持有 fd 的结构体,在关闭路径上都要做幂等保护,关闭后立刻把句柄置为无效值,避免 fd 复用导致的误写。
另外,fd 在父子进程间会继承,fork()之后子进程拥有一份拷贝,close()只减少引用计数。写多进程服务时,如果父进程不关闭监听 fd,会出现"父进程退出后端口仍然被占用"的错觉;反过来,如果子进程继承了不该有的连接 fd,还会导致连接迟迟不释放。这类问题在进程模型的项目里非常常见。
1.3 TCP 与 UDP 的选型:别只看"可靠"两个字
新手选协议时最常见的判断是"TCP 可靠,UDP 不可靠,所以选 TCP"。这个判断在多数业务场景下是对的,但作为决策依据太粗糙了。真正该问的问题有几个:你的消息能不能容忍丢失?能不能容忍乱序?对延迟的敏感度有多高?是否需要一对多广播?连接的生命周期是长是短?
TCP 提供的是字节流,保证有序、可靠、有流量控制和拥塞控制。代价是建连需要三次握手、断连需要四次挥手、有重传机制、有队头阻塞问题。UDP 提供的是数据报,发出去就不管了,不保证顺序也不保证到达,但开销极小,没有建连成本,天然支持广播和多播。
下面这张表是我自己在做选型时常用的对照,比单纯记"可靠不可靠"实用得多:
| 维度 | TCP | UDP |
|---|---|---|
| 数据边界 | 无边界,字节流,需要应用层自己切包 | 有边界,一个包一个消息 |
| 连接状态 | 有连接,内核维护状态机 | 无连接,内核不维护会话 |
| 首部开销 | 20 字节起 | 8 字节 |
| 拥塞控制 | 有,会自动降速 | 无,需要应用层自己做 |
| 典型延迟 | 建连多一个 RTT | 无建连开销 |
| 适用场景 | 配置下发、文件传输、数据库访问、大部分业务 RPC | 实时音视频、游戏位置同步、DNS 查询、日志上报 |
举两个具体例子说明选型逻辑。做设备状态上报,如果一条状态丢了,下一条状态很快就会覆盖它,那用 UDP 完全合理,还能省掉大量建连开销;但如果做的是订单创建,一条消息丢了就是资损,那必须 TCP,而且还要在应用层做幂等和确认。做实时语音,晚到 200ms 的音频包已经没有播放价值了,TCP 的重传反而会造成更大的延迟抖动,这时候 UDP 加前向纠错才是正解。
还有一个中间地带值得提:很多团队会在 UDP 之上自己实现一层可靠性协议,做选择性重传、序列号、拥塞控制。这条路不是不能走,但工作量远超想象,除非你的场景对延迟极度敏感且团队有充足的协议栈经验,否则老老实实用 TCP 更划算。
2. 服务端与客户端的最小骨架:每个系统调用都在干什么
2.1 服务端五步曲的完整语义
服务端的骨架代码就那么几行,但每一行的语义和可能的失败原因都值得掰开说。以 Python 为例,这段代码可以直接跑:
import socket srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("0.0.0.0", 9527)) srv.listen(128) while True: conn, addr = srv.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) try: while True: data = conn.recv(4096) if not data: break conn.sendall(data) finally: conn.close()第一行socket()创建的是一个"协议族 + 类型"的组合。AF_INET表示 IPv4,SOCK_STREAM表示面向连接的字节流。这里有个细节:SOCK_STREAM并不强制等于 TCP,理论上你可以用它承载其他流式协议,但在 IPv4 语境下它就是 TCP。
第二行SO_REUSEADDR是我强烈建议所有服务端都加上的选项。它的作用是允许绑定处于TIME_WAIT状态的地址,避免服务重启时出现 "Address already in use"。注意它解决的是"残留状态导致的绑定失败",不能解决"另一个进程正在监听同一端口"的冲突,后者该报错还是会报错。
第三行bind()把 fd 和具体的 IP 端口绑定。绑0.0.0.0表示监听所有网卡,绑具体 IP 则只监听那一块网卡。多网卡机器上这个选择很关键,绑错了会出现"本机 curl 通、外部访问不通"的诡异现象。
第四行listen()是最容易被误解的一行。它并不是"开始监听",而是"把这个 fd 转成被动监听状态,并设置队列长度"。真正的连接接收发生在accept()。listen的参数是 backlog,后面会专门讲它和内核参数的关系。
第五行accept()从已完成连接的队列里取出一个连接,返回一个新的 fd。注意这里是新 fd,监听 fd 本身不参与数据收发。很多人写代码时误用监听 fd 去recv,结果当然拿不到数据。
recv()返回空字节串时表示对端已经关闭连接(收到了 FIN),这是判断连接结束的标准方式,不要靠异常来判断。sendall()会循环发送直到全部写完,比裸send()安全,因为send()在内核缓冲区满时可能只写进去一部分。
2.2 三次握手在代码里的落点:listen 队列与 accept 的真实关系
这是我认为 Socket 基础里最值得讲透的一点,因为它直接决定了你的服务在高并发下会不会莫名其妙丢连接。
三次握手发生在内核里,应用完全感知不到。内核为每个监听 fd 维护两个队列:半连接队列(SYN queue)和全连接队列(accept queue)。客户端发来 SYN,内核回 SYN+ACK 并把连接放入半连接队列,状态SYN_RECV;客户端回 ACK 后,内核把连接移到全连接队列,状态ESTABLISHED,等着应用调accept()取走。
关键问题是:全连接队列的长度由谁决定?答案是min(backlog, net.core.somaxconn)。也就是说,你在listen()里传 128,但系统somaxconn默认可能是 4096,那实际生效就是 128;反过来你传 65535,somaxconn是 128,实际生效还是 128。很多人调了半天 backlog 没效果,就是没看系统参数。
更麻烦的是队列满了之后的行为。默认情况下,Linux 会丢弃新来的 ACK,客户端认为握手完成了(因为它发出了 ACK),但服务端其实没建立连接,客户端后续发数据会触发重传,最后超时。这种问题在压测时表现为"连接成功率不是 100%,但没有明显报错",非常难查。
提示:压测前先确认
net.core.somaxconn和net.ipv4.tcp_max_syn_backlog的实际值,再把应用 backlog 设成它们的合理比例,不要盲目调大应用参数。
怎么判断队列是否溢出?看netstat -s或者ss -lnt里的Recv-Q和Send-Q列。对于监听状态的 socket,Recv-Q表示当前全连接队列里等待 accept 的连接数,Send-Q表示队列上限。如果Recv-Q长期接近Send-Q,说明 accept 处理不过来,要么提高 accept 的并发度,要么检查是不是有阻塞操作卡在了 accept 循环里。
我踩过一次很典型的坑:服务在 accept 之后立刻做了一次同步的配置读取(读远程配置中心),单次耗时 20ms,QPS 一上来 accept 循环直接被拖慢,全连接队列迅速打满,客户端开始出现随机连接超时。后来把配置读取改成异步预热,accept 循环里只做 fd 分发,问题立刻消失。这个经验的普适结论是:accept 循环里不要做任何可能阻塞的事情。
2.3 客户端 connect 成功了,不代表服务真的可用
客户端的流程是socket→connect→send/recv。connect()对 TCP 来说会触发三次握手,返回 0 表示握手完成。但这里有个巨大的认知陷阱:握手完成只证明对端内核协议栈活着,不证明对端应用能处理请求。
真实场景里,服务进程可能正在 GC 停顿、可能线程池满了、可能逻辑死锁,但内核依然会替它完成三次握手,因为握手是内核行为,不需要应用参与。所以你的connect会成功,然后send也成功(数据进了内核缓冲区),真正的失败要等到对端内核缓冲区满了之后回 RST,你才会在后续的send或recv上收到ECONNRESET。
这解释了一个常见现象:监控显示"TCP 连接建立成功率 100%",但业务成功率只有 95%。连接层的健康指标不能替代业务层指标,两边都得看。
另一个细节是 connect 超时。默认的 connect 超时由内核决定,通常要经历多次 SYN 重传,长达几十秒到两分钟。业务代码里几乎不应该依赖这个默认值,必须自己设超时。Python 里可以用settimeout(),Go 里用net.DialTimeout,Java 里通过Socket.connect(addr, timeout)。超时值怎么定?我一般用 P99 建连耗时乘以 2 到 3 倍,再结合业务能容忍的最大等待时间来取较小值。比如内网 P99 是 3ms,那就设 50ms 到 100ms,绝不设 5 秒。
还有一个容易被忽略的点:连接建立之后要设置读超时。没有读超时的 Socket 一旦对端卡住,你的线程就会永久挂在那里,线程池很快被耗尽。这个坑我在做网关时踩过,当时一个下游服务因为磁盘问题卡死,不返回也不断开,网关的连接池在几分钟内被全部占满,整条链路雪崩。
3. 阻塞、非阻塞与 IO 多路复用:并发模型怎么选
3.1 阻塞改非阻塞,改的到底是什么
阻塞 IO 的行为很直白:调recv()时如果没有数据,线程就睡在那里,直到有数据、连接关闭或者出错。这种模型写起来最简单,一个连接一个线程,逻辑线性,调试友好。但它的成本在于线程。每个线程要占栈空间(默认 1MB 到 8MB 不等),加上上下文切换开销,几千个连接就到顶了。
非阻塞 IO 是通过fcntl(fd, F_SETFL, O_NONBLOCK)或者创建时指定标志打开的。改成非阻塞之后,recv()没数据会立刻返回 -1 并设置errno为EAGAIN或EWOULDBLOCK(这两个值在 Linux 上相等)。这意味着你必须自己轮询,而纯轮询会烧 CPU,所以非阻塞几乎总是和多路复用配合使用,单独用没有意义。
这里有个必须记住的坑:非阻塞模式下,send()也可能只写一部分。返回值和errno有几种情况要分清。返回大于 0 表示写进去了这么多字节,剩下的要自己循环;返回 -1 且errno是EAGAIN表示内核缓冲区满了,要等可写事件;返回 -1 且errno是EINTR表示被信号中断,直接重试即可。还有人会遇到EPIPE,那是对端已经关闭连接你还在写,通常会伴随一个SIGPIPE信号,服务端一般要忽略这个信号,改为通过返回值处理。
recv()的返回值语义同样重要:返回 0 表示对端正常关闭(收到 FIN);返回 -1 且EAGAIN表示暂时没数据;返回 -1 且EINTR表示被中断要重试;返回 -1 且ECONNRESET表示对端异常关闭(发了 RST)。这四种情况处理错了,就会出现"连接明明断了但程序还在傻等"或者"把正常的暂时无数据当成断连"。
我自己写非阻塞代码时有个固定习惯:把 IO 操作封成一个返回三种状态(完成、需等待、已关闭)的函数,业务层只处理这三种状态,不直接碰errno。这样能大幅降低出错概率,也让代码在其他平台上更容易移植。
3.2 select、poll、epoll 的差异与选择依据
多路复用的核心思想是:用一个线程监听大量 fd,谁有事件就处理谁。三代机制的差异主要在数据结构和内核实现上。
select最老,用位图表示 fd 集合,有 1024 个 fd 的硬上限,每次调用都要把整个集合从用户态拷贝到内核态,返回后还要线性扫描找出就绪的 fd,复杂度是 O(n)。poll换成了链表结构,取消了数量限制,但拷贝和线性扫描的问题还在。epoll是 Linux 的方案,用红黑树管理 fd 集合,用就绪链表存放有事件的 fd,只在注册和注销时拷贝,epoll_wait直接返回就绪列表,复杂度 O(1)。
| 特性 | select | poll | epoll |
|---|---|---|---|
| fd 数量上限 | 1024(受 FD_SETSIZE 限制) | 无硬上限 | 无硬上限 |
| 每次调用是否拷贝全集 | 是 | 是 | 否,仅注册时拷贝 |
| 就绪查找方式 | 线性扫描 | 线性扫描 | 内核回调放入就绪链表 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 触发模式 | 水平触发 | 水平触发 | 水平触发 + 边缘触发 |
| 适用连接规模 | 几百 | 几千 | 万级以上 |
epoll的两种触发模式是必须搞懂的重点。水平触发(LT):只要 fd 的读缓冲区还有数据,epoll_wait就会一直返回它。这种模式写起来安全,一次没读完下次还会通知你。边缘触发(ET):只在状态发生变化时通知一次,从"无数据"变成"有数据"时通知,之后不管你还剩多少数据都不会再通知。ET 模式效率更高,因为减少了重复通知,但要求你必须一次把数据读干净。
ET 模式的正确写法是循环读直到recv返回EAGAIN,而且 fd 必须是非阻塞的。如果 fd 是阻塞的,你在最后一次读的时候就会永久阻塞,整个事件循环卡死。这是 ET 模式最经典的死法,几乎每个用 epoll 的人都踩过。
int ep = epoll_create1(0); struct epoll_event ev, evs[64]; ev.events = EPOLLIN | EPOLLET; ev.data.fd = listen_fd; epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { int n = epoll_wait(ep, evs, 64, -1); for (int i = 0; i < n; i++) { int fd = evs[i].data.fd; if (fd == listen_fd) { while (1) { int c = accept(listen_fd, NULL, NULL); if (c < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; } set_nonblocking(c); ev.events = EPOLLIN | EPOLLET; ev.data.fd = c; epoll_ctl(ep, EPOLL_CTL_ADD, c, &ev); } } else { while (1) { char buf[4096]; ssize_t r = read(fd, buf, sizeof(buf)); if (r > 0) { /* 处理数据,继续读 */ } else if (r == 0) { /* 对端关闭,close 并 EPOLL_CTL_DEL */ break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; /* 真错误,close */ break; } } } } }注意上面代码里accept也要循环,因为 ET 模式下可能一次来多个连接。这段代码是 epoll 服务端的心智模型,理解透它,再去看各种框架的底层实现就很容易了。
3.3 线程池模型和事件驱动模型,按场景选
这两种模型没有绝对优劣,全看场景。
线程池模型(一个连接绑定一个线程,或者连接和线程多路复用但业务处理用线程池)的优势是编程简单,业务逻辑可以写成线性的、阻塞的形式,出问题容易定位,栈上能看到完整调用链。劣势是连接数受线程数限制,上下文切换成本高。适合连接数在几千以内、业务逻辑复杂、阻塞调用多的场景,比如内部管理系统、数据库代理。
事件驱动模型(Reactor/Proactor)的优势是资源占用低,单机轻松撑几万到几十万连接,没有线程切换开销。劣势是编程难度陡增,任何阻塞操作都会拖垮整个事件循环,业务逻辑被拆成状态机,调试困难。适合连接多、单连接业务简单、高并发的场景,比如网关、推送服务、消息中转。
我做选型时的经验判断是:先看连接数规模,一万以下优先线程池,因为开发维护成本低得多;超过一万再考虑事件驱动。还有一条更实用的原则——如果团队里没人能独立写出正确的 ET 模式代码并做压测验证,就别上事件驱动。见过太多项目用异步框架写出的代码比同步版本还慢,因为里面藏着一个没被发现的阻塞调用。
中间方案是用成熟框架,比如 Java 的 Netty、Python 的 asyncio、Go 的 goroutine 模型。Go 的网络模型实际上是运行时帮你做了多路复用加协程调度,写起来像同步代码,跑起来是异步的,这是目前性价比很高的方案。但要记住,goroutine 里如果调了 CGO 的阻塞函数,会占用一个系统线程,依然可能拖垮调度。
4. 从零搭一个能扛压测的小服务
4.1 环境与依赖准备
这一节把前面的知识落成一个可运行的实现。语言选 Python,因为代码最短最直观,核心逻辑换成 C 或 Java 是一样的。
环境上要确认三件事:fd 上限、端口范围、内核队列参数。压测之前建议这样检查:
ulimit -n cat /proc/sys/net/core/somaxconn cat /proc/sys/net/ipv4/ip_local_port_range ss -lntulimit -n是当前 shell 的 fd 限制,服务进程继承这个值。要放开的话,改/etc/security/limits.conf加两行,或者在 systemd 服务里配LimitNOFILE。ip_local_port_range决定客户端可用端口范围,做高并发压测时如果这个范围太小,会大量出现端口耗尽。ss -lnt用来确认服务实际监听的地址和队列情况。
依赖上不需要任何第三方库,标准库的socket和selectors就够了。selectors模块会根据平台自动选择epoll、kqueue或select,写出来的代码跨平台。
4.2 backlog、缓冲区、连接数怎么算
参数不能拍脑袋定,得有依据。
backlog 的算法:队列需要容纳的是"已握手但还没被 accept 取走"的连接。设服务的 accept 速率为 A(每秒),每次从队列取走到处理完成(注意不是业务处理,只是 accept 之后分发出去)的平均耗时为 t 秒,那么队列平均长度约为 A×t。比如 A 是每秒 5000 次 accept,分发耗时 1ms,平均队列长度是 5。要防突发,取 10 倍余量就是 50,再考虑客户端同时发起连接的浪涌,设 512 或 1024 是比较稳妥的。注意这个值最终会被somaxconn截断,所以系统参数也得一起调。
缓冲区的算法:缓冲区大小影响吞吐上限,特别是跨地域长链路。理论上限由带宽时延积(BDP)决定,公式是带宽(字节/秒)乘以 RTT(秒)。举个具体的例子,假设链路是 100Mbps,RTT 是 40ms,那么 BDP = 100×10⁶÷8 × 0.04 = 500KB。这意味着如果接收缓冲区只有默认的 128KB,发送方会频繁被窗口限制停下,吞吐打不满。所以跨地域传输场景要把SO_RCVBUF和SO_SNDBUF调到接近 BDP 的量级。内网 RTT 通常小于 1ms,缓冲区设大了反而占内存,默认值或者 64KB 到 256KB 就够。
单连接内存开销估算:内核侧每个 TCP 连接的结构体加收发缓冲区,粗略算 4KB 到 16KB,取决于缓冲区设置;应用侧还要加自己的读缓冲和连接对象。按每连接 16KB 算,一万连接约 160MB,十万连接就是 1.6GB。这个账必须在容量规划时算清楚,不然一上量就是 OOM。
| 参数 | 作用 | 内网建议值 | 跨地域建议值 |
|---|---|---|---|
| listen backlog | 全连接队列长度 | 1024 | 1024 |
| SO_RCVBUF | 接收缓冲区 | 128KB | 接近 BDP |
| SO_SNDBUF | 发送缓冲区 | 128KB | 接近 BDP |
| TCP_NODELAY | 禁用 Nagle | 开启 | 视消息模式而定 |
| 读超时 | 防止线程挂死 | 3~10 秒 | 10~30 秒 |
TCP_NODELAY单独说一下。TCP 默认启用 Nagle 算法,会把小包攒起来一起发,减少网络上的小包数量。这在批量传输时是好事,但在请求-响应模式下是灾难:你发了一个 10 字节的请求,Nagle 要等对端 ACK 或者攒够一个 MSS 才发,可能凭空多出几十毫秒延迟。做 RPC、游戏、即时通讯这类交互式服务,一律关掉 Nagle。代价是网络上小包变多,但在现代网络条件下这个代价可以接受。
4.3 粘包与拆包:长度前缀协议怎么写
TCP 是字节流,没有消息边界。你连续发两次,对端可能一次收到全部,也可能分三次收到一半。这不是 bug,是 TCP 的设计如此。解决办法只有在应用层定义协议,常见的有三种:固定长度、分隔符、长度前缀。
固定长度简单但浪费,适合定长指令的场景。分隔符像 HTTP 的\r\n\r\n、Redis 协议的\r\n,实现简单但需要转义,且要防止恶意构造超长消息导致内存爆掉。长度前缀最通用,是绝大多数二进制协议的选择:先发 4 个字节表示消息体长度,再发消息体。
import struct MAX_BODY = 4 * 1024 * 1024 # 单条消息上限,防止恶意大包 def pack(body: bytes) -> bytes: if len(body) > MAX_BODY: raise ValueError("body too large") return struct.pack(">I", len(body)) + body class Framer: def __init__(self): self.buf = bytearray() def feed(self, data: bytes): self.buf.extend(data) out = [] while True: if len(self.buf) < 4: break (length,) = struct.unpack(">I", self.buf[:4]) if length > MAX_BODY: raise ValueError("frame too large") if len(self.buf) < 4 + length: break body = bytes(self.buf[4:4 + length]) del self.buf[:4 + length] out.append(body) return out这段代码有几个刻意的设计。struct.pack(">I", ...)用大端序,网络字节序就是大端,跨语言对接时不会因为端序问题出错。MAX_BODY上限是必须的,没有上限的解析器会被一个构造的 4GB 长度字段直接打爆内存。Framer用bytearray累积数据,每次喂进来就尝试解析,解析出完整帧就删除已消费部分。
性能上有个优化点:del self.buf[:n]在数据量大时会有内存搬移开销。高频场景可以改成维护读偏移量,累积到一定阈值再整体清理,或者用环形缓冲区。我做过一个对比测试,单连接每秒十万帧的量级下,用偏移量替代del能降低约 15% 的 CPU 占用,量级不大但白拿。
注意:跨语言对接时一定要在协议文档里写明长度字段是几字节、什么端序、是否包含头长度。我见过一个项目因为 Java 端按包含头部算、C 端按不包含算,排查了整整两天。
4.4 心跳、超时与优雅关闭
长连接必须有心跳,原因有两个:一是中间设备(负载均衡、防火墙)会清理长时间无数据的连接,通常空闲超时是几分钟到几十分钟;二是需要及时感知对端已经死掉。
心跳参数怎么定?我的经验是:心跳间隔取中间设备空闲超时的一半以下,连续 3 次无响应判定为死连接。比如负载均衡的空闲超时是 60 秒,心跳间隔设 20 到 25 秒。判定阈值设 3 次是因为单次丢包在网络中是正常现象,不能一次失败就断开。这样最坏情况下感知断连需要 75 秒左右,业务上通常可以接受;如果要求更快,就缩短间隔。
心跳包本身要尽量小,通常就是一个固定头部加一个类型字段。响应也要快,不能在心跳路径上做任何查库、加锁的操作。我见过把心跳处理写进业务队列的实现,业务一堵心跳就超时,然后被判定死连接踢掉,形成雪崩。
优雅关闭是另一个必做项。所谓优雅,就是服务停止时不是直接杀掉进程,而是:先从负载均衡摘除、停止 accept 新连接、处理完正在处理的请求、给对端发 FIN、等对端回 FIN、最后关闭。直接kill -9的后果是客户端收到 RST,正在传输的数据丢失,客户端还会因为没收到 FIN 而认为是网络异常,触发重试风暴。
具体实现上,收到停止信号后先关闭监听 fd(这样不再有新连接),然后遍历活跃连接,对每个连接调用shutdown(fd, SHUT_WR)发送 FIN,进入半关闭状态,等待对端关闭或者超时,最后close。设置一个总的优雅关闭超时,比如 30 秒,超时就强杀,避免无限等待。
5. 常见问题与排查实录
5.1 典型错误速查表
下面这张表是我这些年攒下来的高频问题清单,遇到现象直接对号入座能省不少时间:
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| Address already in use | 未设 SO_REUSEADDR,或另一进程占用 | ss -lntp看占用进程 |
| Too many open files | fd 上限不足 | ulimit -n,检查是否有 fd 泄漏 |
| 连接偶发超时,握手成功率不是 100% | 全连接队列溢出 | ss -lnt看 Recv-Q/Send-Q |
| 小包延迟忽高忽低,平均多几十毫秒 | Nagle 算法 | 开启 TCP_NODELAY |
| 大量 CLOSE_WAIT | 代码收到 FIN 后没 close | 检查关闭路径是否遗漏 |
| 大量 TIME_WAIT | 主动关闭方过多 | 连接复用,或调整内核参数 |
| 数据解析错乱、消息串位 | 粘包未处理 | 检查是否用长度前缀切帧 |
| 大文件传输吞吐上不去 | 缓冲区小于 BDP | 计算 BDP,调大 SO_SNDBUF/SO_RCVBUF |
| 线程池耗尽 | 无读超时,线程阻塞在读上 | 全链路设置读超时 |
我把其中两个最容易误判的单独摊开讲。
5.2 TIME_WAIT 和 CLOSE_WAIT 的真相
先说 TIME_WAIT。它出现在主动关闭方,持续时长是 2 倍 MSL,Linux 上通常 60 秒。存在的理由是:保证最后一个 ACK 能到达对端(如果 ACK 丢了,对端会重发 FIN,此时还能响应),以及让旧连接的残留报文在网络中消散,避免污染新连接。
看到 TIME_WAIT 数量大,很多人的第一反应是"出问题了",其实这经常是正常现象。高并发的短连接服务里,TIME_WAIT 上万个很正常,因为它就是一个正常的回收状态。真正需要关注的是它有没有导致客户端端口耗尽:如果客户端每秒新建几千连接,60 秒的 TIME_WAIT 就意味着要占用几十万个端口,而默认端口范围可能只有两万多个。这才是需要处理的问题。
处理思路按优先级排:第一,用长连接替代短连接,这是最有效的办法,直接消灭连接建立频率;第二,用连接池复用;第三,扩大本地端口范围;第四,调整 TIME_WAIT 相关的内核参数(要谨慎,改不好会有协议层面的副作用)。顺序很重要,很多人一上来就调参数,其实连接池一改问题就没了。
再说 CLOSE_WAIT。这个状态表示对端已经发了 FIN,本端内核回了 ACK,但应用还没有调用 close。所以 CLOSE_WAIT 数量大,几乎百分之百是应用代码的问题,不是内核参数问题。常见原因:收到了recv返回 0 但没有走关闭逻辑、异常路径上没 close、连接池归还逻辑有 bug、回调里抛异常导致后续代码没执行。
这个区别很关键:TIME_WAIT 多通常是设计问题(短连接太多),CLOSE_WAIT 多一定是代码 bug。看到 CLOSE_WAIT 增长,别去调内核,去看代码里所有recv返回 0 的分支。
5.3 抓包和定位的基本手法
排查网络问题,光看日志不够,得看实际报文。最基础的工具是tcpdump,抓下来再用分析工具看。
tcpdump -i eth0 -nn -s 0 -w /tmp/cap.pcap 'tcp port 9527'-nn不做域名和服务名解析,-s 0抓完整包,-w写文件方便后续分析。抓的时候注意控制范围,生产环境全量抓包会占大量磁盘和 CPU,最好加上 host 或 port 过滤,并设置包数量上限。
抓包之后看什么?几个关键点:三次握手是否完整(有没有 SYN 发了但没 SYN+ACK)、有没有 RST、重传次数、窗口大小是否被压到很小。重传多说明有丢包或链路问题,窗口小说明接收方处理慢或者缓冲区不足,出现 RST 要看是谁发的、在哪个阶段发的。
另一个必会工具是strace,卡在系统调用上时特别有用:
strace -f -p <pid> -e trace=network -T-T会打印每个系统调用的耗时,能直接看出是哪个recv卡了很久。这个手段在排查"服务没响应但不报错"类问题时几乎是必杀技。
排查的通用顺序我总结成:先看现象层(监控、日志、错误码),再看连接状态(ss看队列和状态分布),再看系统调用(strace看阻塞点),最后看报文(tcpdump)。顺序别反,直接抓包往往信息量太大,先缩小范围效率高得多。
5.4 几条没人写进文档的经验
最后说几条我在实际项目里总结出来、但很少在教程里看到的东西。
第一条,永远不要相信"连接是可靠的"这种判断。任何时候网络都可能断,中间设备可能重启,对端进程可能被 OOM 杀掉。所有基于长连接的状态都要能在断连后重建,连接池要有健康检查,写操作要有重试和幂等。我见过一个项目把用户会话状态全放在连接对象上,负载均衡一重启,所有用户全部掉线重登。
第二条,压测要压真实的连接模式。短连接服务和长连接服务的瓶颈完全不同,用ab测短连接的结果去推导长连接服务的容量,基本没用。压测时要关注的不只是 QPS,还有连接建立速率、平均连接时长、P99 延迟、错误率。只看 QPS 很容易得出错误结论,比如把 backlog 调大 QPS 涨了,但实际是因为队列吸收了更多请求,延迟其实恶化了。
第三条,日志里要打连接标识和耗时。每个连接分配一个唯一 ID,每次读写都记录耗时和字节数。出问题时能快速定位是某个连接的问题还是全局问题。这个成本很低,收益极高,但很多团队就是不打,出了问题只能靠猜。
第四条,非阻塞代码写完必须用工具过一遍。我现在的习惯是写完关键路径后用静态检查加代码走查,专门找有没有在事件循环里调用了可能阻塞的函数(文件读写、DNS 解析、加锁、甚至某些看似无害的日志同步刷盘)。这类问题在测试环境完全看不出来,因为它只在压力大的时候才暴露。
提示:DNS 解析是隐藏的阻塞大户,很多 HTTP 客户端在建连时才做解析,一次解析可能耗时几十毫秒。事件驱动的服务里要提前解析好,或者用异步解析。
第五条,协议设计要给自己留扩展空间。长度前缀之后要留至少一个字节的版本号和一个字节的消息类型,不要把所有信息都塞进固定结构里。我吃过一次亏,早期协议没留版本字段,后来要加压缩支持,只能同时维护两套解析逻辑,灰度了三个月才切完。
这套东西我大概花了三四年才形成比较完整的认知,中间踩的坑大多集中在"以为理解了其实没理解"的地方,比如把listen当成开始监听、把connect成功当服务可用、把send返回当消息送达。真正把这些语义边界搞明白之后,写网络代码的心态会完全不一样——你会本能地去想每一步失败之后会发生什么,而不是假设它一定成功。