凌晨两点多,值班的同学在群里甩了张监控截图:服务进程还在,端口也占着,可所有客户端都在报连接失败。登上去一看,进程确实没死,但accept那个循环早就卡在某个环节里不动了。这类问题我碰到过不止一次,根子基本都出在“服务器端怎么持续监听客户端请求”这件事上——很多人以为写个while True把accept()包起来就叫持续监听,实际上那只是最外面一层壳。真正让服务能长时间稳定跑下去的,是 backlog 的语义、阻塞与非阻塞的选择、连接处理的分工、退出时的资源回收,这几件事叠在一起形成的整体设计。
这篇东西聊的就是服务器端持续监听客户端请求这个看似基础、实际坑最深的前置环节。它要解决的问题很直接:让一个服务进程在启动之后,能够一直接收新连接、一直处理数据、一直可被外界访问,而不是接了一两个连接就退出,或者跑着跑着就假死。适合谁看:刚接触 socket 编程、想搞清楚listen和accept到底怎么配合的同学;已经能写出 demo、但线上服务总是出现连不上、卡死、句柄耗尽的同学;以及想把并发模型选型逻辑彻底理清楚的同路人。下面我按设计思路、机制细节、可落地代码、排障实录、稳定性加固五块展开,代码用 Python 演示,因为它的 socket 接口最贴近系统调用本身,把这里看明白了,换到别的语言只是语法差异。
1. 内容整体设计与思路拆解
1.1 “监听一次”和“持续监听”差的到底是什么
先把这个词拆开。listen()这个系统调用本身并不“监听”,它做的事情是在内核里为这个 socket 标记一个状态:从主动连接型变成被动接收型,同时挂上一条接收队列。真正把连接一条条捞出来交给你处理的,是accept()。所以“服务端持续监听”这句话的准确含义是:一个长期存活的进程,反复执行“从内核接收队列里取出已完成握手的连接,并交给处理逻辑”这个动作,中间不退出、不长时间卡死、不遗漏。
一次性的版本之所以不够用,问题不在于代码写错了,而在于它把生命周期和单次连接绑在了一起。假设你写的是“accept 一次、处理一次、关闭、退出”,那么第二个客户端来的时候,内核队列里可能还有它,但已经没有进程在等它了,于是它拿到的要么是连接被拒,要么是连上之后立刻被断开。这种错误在做课程作业时看不出来,因为测试脚本通常只发一次请求。但真实场景里,客户端是持续、并发、不可预测地到来的,服务端必须是那个“永远在门口站着的人”,而不是“开一次门就回屋睡觉的人”。
我一般会把整个服务端的生命周期切成四段来看:绑定(bind)、进入接收态(listen)、循环取连接(accept loop)、处理并回收(handle & close)。前三段是“门”的部分,第四段是“屋里的活”。绝大多数线上事故,出问题的都不是第四段业务逻辑,而是前三段的结构没搭稳——比如 accept 循环里做了耗时操作,导致取连接的速度跟不上新连接到来的速度;又比如没有对 accept 返回的错误做区分处理,一次短暂的资源不足就让整个循环抛异常退出了。
1.2 三种并发姿态,选错会一直难受
持续监听只是“能接”,接住之后怎么处理,才是决定这套服务能撑多大并发量的关键。常见的姿态有三种,我把它们的取舍逻辑摊开讲。
第一种:单线程阻塞循环。一个线程,accept 拿到连接后原地处理,处理完关掉,回到 accept。它的优点是简单到不需要任何额外概念,调试成本极低,出问题一眼能看出来卡在哪。缺点是处理期间完全无法接受新连接——注意,这里说的“无法接受”不是内核拒绝,内核依然在帮你完成握手并把连接放进队列,是你的程序没去取。队列满了之后,后来的客户端就会被内核直接丢掉或者超时。这种模式只适合处理极快、连接数极少的场景,比如本机的一个配置查询小工具。
第二种:每连接一线程(或一进程)。accept 拿到连接后,把这条连接丢给一个工作线程,主线程立刻回到 accept 继续取。优点是逻辑直观,每个连接的处理代码可以写成顺序式的,不用考虑状态机。缺点是资源开销随连接数线性增长,一万个空闲连接就是一万个线程栈,内存和调度开销都很可观。它适合连接数在几百以内、每个连接都有持续交互的场景,比如内部管理后台的长连接推送。
第三种:IO 多路复用(事件驱动)。用一个线程(或少量线程)同时盯着大量文件描述符,谁有数据可读、谁有新连接进来,就去处理谁,全程不阻塞。它的优点是单机可以扛住几万甚至十几万连接,资源占用极低。缺点是把“顺序式的业务代码”变成了“状态机 + 回调”,写起来心智负担大,一个连接的处理流程被切成了好几段,调试也麻烦。
我自己的判断标准很朴素:连接数 × 单连接平均处理时长,算出来的积如果很小(比如几十),第一种就够;积在几百到几千,且业务代码复杂,用第二种更省心;连接数上万、但每个连接大部分时间在等待,第三种才有意义。很多团队一上来就上事件驱动,结果业务逻辑复杂到到处是状态标记,维护成本远超省下来的那点资源。选型这事儿,先问业务形态,再看技术栈。
1.3 一个容易被忽略的设计决策:谁负责关闭
还有一个我见过很多次的设计疏漏——没有明确“谁在什么时机关闭连接”。持续监听的服务,连接是有生命周期的,如果不定义清楚关闭责任,最常见的结果就是连接越积越多,最后ulimit -n被吃满,新的 accept 直接报 “Too many open files”,整个服务看起来还在跑,实际上已经完全接不了新连接了。
我的做法是三处兜底,缺一不可:业务处理完主动关闭(正常路径)、读超时后强制关闭(防止对端异常消失)、进程退出时统一回收(防止残留)。这三条听着简单,但只要缺一条,长期运行的服务迟早会在这上面栽跟头。下面第二节我把 accept 循环里的机制细节讲透,第三节再给可以抄的代码。
2. 核心细节解析与实操要点
2.1 listen 的 backlog 不是“最大连接数”
这是被误解最多的一个参数。listen(128)里的 128,很多人以为代表“这个服务最多支持 128 个连接”,其实不是。它表示的是内核为该监听套接字维护的接收队列的参考长度。而且在 Linux 上,实际生效值取的是min(你传的值, net.core.somaxconn),后者是个系统级上限,很多发行版默认是 128 或者 4096。
更要紧的是,从内核 4.x 之后,这个队列实际上被拆成了两条:一条存“收到了 SYN、还没完成三次握手”的半连接,一条存“握手已完成、等着被 accept 取走”的全连接。accept 只从全连接队列取。所以如果你的 accept 循环被业务逻辑拖慢,全连接队列会先涨满,涨满之后内核的行为取决于tcp_abort_on_overflow这个开关:默认是 0,此时内核会静默丢弃新完成握手的连接(客户端感知为连上了但收不到响应,然后超时);设成 1 就会直接回 RST,客户端立刻收到连接被拒。
我一般把 backlog 设成 128 到 1024 之间,同时顺手确认一下系统上限。1000 以内并发的服务,128 通常够用;如果压测时发现netstat -s里listen queue of a socket overflowed这个计数在涨,那就说明队列确实在溢出,需要同时调大 backlog 和处理速度。这里有个实操提示:调大 backlog 治标,调快 accept 循环治本,因为队列再长也只是缓冲,取的速度跟不上,迟早还是会满。
2.2 accept 返回的是一条全新的“河”
新手最容易懵的一点是:accept()的返回值是一个新的 socket 对象,跟用来监听的srv完全是两码事。srv这条连接从头到尾只做一件事——接客,它永远不承载任何业务数据。新返回的conn才是这个客户端专属的通道,读写都在它身上做,处理完要close()的也是它。
这个区分非常重要,因为把两者搞混会导致两类典型错误。第一类是拿srv去recv,结果程序永远收不到数据(或者收到的是错误的数据)。第二类是在循环里对srv调了close(),那这个服务就彻底废了,后面的客户端全部连不上。我见过有同学在finally块里顺手把srv也关了,本地测一次没问题,因为测一次程序就退出了,等到做持续运行才发现第二个连接就挂。
还有个细节:conn继承了监听套接字的部分属性,但不继承 backlog 这类概念。如果需要对每条连接单独设置超时、设置TCP_NODELAY(关闭 Nagle 算法减少小包延迟),都应该在conn上做,而不是srv上。顺手补一句,accept()除了返回conn,还会返回一个(ip, port)的元组,那是对端的地址,也就是客户端的出口 IP 和端口,不是什么服务端地址,日志里打这个通常比打自己的端口有用得多。
2.3 阻塞点在哪,怎么被唤醒
理解了阻塞点,才能理解为什么服务会“假死”。accept()默认是阻塞的:全连接队列为空时,调用它的线程会进入睡眠状态,被内核挂起,不占 CPU。此时只要有一个新连接完成握手,内核就会把该线程唤醒。这个机制本身很高效,问题出在“唤醒之后你干了什么”。
假设你的写法是这样的:accept 拿到 conn 后,直接开始conn.recv()读数据,读完再处理,处理完再回去 accept。那么在“等这个客户端发数据”的这段时间里,主循环是完全停住的。如果这个客户端连上来之后一直不发数据(比如是网络扫描、或者客户端本身卡死了),你的服务就会被这一个连接彻底锁死。这就是我开头说的那个凌晨两点的现场——表现是进程在、端口在、CPU 接近零,但所有新连接都连不上。
所以判断一个 accept 循环写得好不好,有个很直接的标准:从 accept 返回到下一次 accept 之间的代码,耗时必须是常数级的、可预期的、极短的。任何可能阻塞的操作(读数据、查库、调用外部接口、sleep),都不应该出现在这段区间里。这条规则我写在代码注释里,每次 review 都会看一遍。
2.4 关闭连接的正确顺序和半关闭
close()这个动作,在 TCP 层面是“我这边不再发数据了”,会发出一个 FIN。但对方是否还有数据要发,你是控制不了的。所以严格的关闭需要分两步:先shutdown(SHUT_WR)表示“我这边的数据发完了”,然后继续读,直到读到对方也发了 FIN(recv返回空字节串),这时候再close()。这个流程就是所谓的半关闭处理。
为什么要这么讲究?因为如果你在发完响应后立刻close(),而此时对方还有数据在网络上传输,内核会回一个 RST,对方应用层可能收到“连接被重置”,丢掉本该收到的最后一段数据。对于交互式协议,这种“最后一条消息丢了”的问题排查起来极其痛苦。
不过实际工程里,很多协议设计成“一问一答、长度自描述”,这种情况下直接关也不会出问题。我的经验是:自定义二进制协议或者有严格顺序要求的场景,老老实实做半关闭;HTTP 这类自描述协议,直接关没问题。但无论哪种,记得在close()之前把连接从你的管理表里移除,不然会出现“连接已关但表里还在”的幽灵条目。
3. 实操过程与核心环节实现
3.1 最小可用的持续监听骨架
先给一个能跑起来、并且真的持续工作的版本。这段代码不长,但每一行都有它的理由,我在注释里写清了。
import socket import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(threadName)s %(message)s", ) HOST = "0.0.0.0" # 监听所有网卡,只监听本机则用 127.0.0.1 PORT = 9527 BACKLOG = 128 def handle(conn, addr): """单条连接的处理逻辑,必须把耗时操作限制在这里面。""" conn.settimeout(30) # 防止对端卡死导致本线程永久阻塞 try: while True: data = conn.recv(4096) if not data: # 对端发了 FIN break conn.sendall(data) # 这里做个最简单的回显 except socket.timeout: logging.warning("read timeout from %s", addr) except ConnectionResetError: logging.warning("peer reset %s", addr) finally: conn.close() def serve_forever(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 进程重启时端口立刻可复用,避免 TIME_WAIT 导致启动失败 srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(BACKLOG) logging.info("listening on %s:%d", HOST, PORT) try: while True: conn, addr = srv.accept() logging.info("accepted from %s", addr) handle(conn, addr) # 注意:这里是串行处理 finally: srv.close() if __name__ == "__main__": serve_forever()这段代码能持续接,但只能串行处理——handle()返回之前,accept 不会再被调用。它适合验证“路通不通”,不适合真实使用。要让它能同时服务多个客户端,需要把handle挪到别的执行单元里。
3.2 加线程池,把 accept 循环解放出来
最省事的改法是每来一个连接起一个线程。但线程数不可控,并发一高就会被系统调度拖垮。我一般直接用固定大小的线程池,配合一个队列做背压。
import socket import logging import signal import threading from concurrent.futures import ThreadPoolExecutor HOST, PORT, BACKLOG = "0.0.0.0", 9527, 128 WORKERS = 64 # 工作线程数,下面的计算过程会给出取值依据 MAX_INFLIGHT = 512 # 同时在处理中的连接上限,超过就拒绝 stop_event = threading.Event() pool = ThreadPoolExecutor(max_workers=WORKERS, thread_name_prefix="worker") inflight = threading.BoundedSemaphore(MAX_INFLIGHT) def handle(conn, addr): conn.settimeout(30) try: while not stop_event.is_set(): data = conn.recv(4096) if not data: break conn.sendall(data) except (socket.timeout, ConnectionResetError): pass finally: conn.close() inflight.release() # 释放配额,让后面的连接能进来 def serve_forever(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(BACKLOG) # 关键:设置超时,让 accept 周期性返回,才能响应退出信号 srv.settimeout(1.0) logging.info("listening on %s:%d", HOST, PORT) while not stop_event.is_set(): try: conn, addr = srv.accept() except socket.timeout: continue # 没新连接,回去检查退出标志 except OSError as e: logging.error("accept failed: %s", e) continue if not inflight.acquire(blocking=False): logging.warning("too many inflight, reject %s", addr) conn.close() continue pool.submit(handle, conn, addr) srv.close() pool.shutdown(wait=True) logging.info("server stopped") def _on_signal(signum, frame): logging.info("signal %s received, stopping", signum) stop_event.set() if __name__ == "__main__": signal.signal(signal.SIGINT, _on_signal) signal.signal(signal.SIGTERM, _on_signal) serve_forever()这里面有三个设计点值得单独说。
第一,srv.settimeout(1.0)。没有这个,accept()会一直阻塞在系统调用里,即使你设了退出标志,它也感知不到,最后只能靠kill -9收场。设了超时之后,每秒醒一次看看标志,退出就变得很干净。这种“用超时换取可中断性”的手法,在需要优雅退出的循环里几乎通用。
第二,inflight这个信号量。它是用来做背压的:处理能力有上限,超过了就快速拒绝,而不是让队列无限堆积。快速拒绝其实是对客户端更友好的做法,因为它能立刻知道失败然后重试,比连上之后石沉大海好得多。
第三,pool.submit里传的是conn和addr,不是srv。这个细节容易写错,写错了就是上面说的“第二条连接就挂”。
3.3 事件驱动版本:连接数上万时该用这个
如果连接数要上到几千甚至上万,上面的线程池就不划算了。这时候用selectors模块,一个线程盯住所有连接。
import selectors import socket import logging sel = selectors.DefaultSelector() def accept(sock): conn, addr = sock.accept() conn.setblocking(False) # 必须非阻塞,否则一个慢连接会拖住整轮 sel.register(conn, selectors.EVENT_READ, data=addr) logging.info("accepted %s", addr) def read(conn, addr): try: data = conn.recv(4096) except (ConnectionResetError, BlockingIOError): data = None if not data: sel.unregister(conn) conn.close() logging.info("closed %s", addr) return try: conn.sendall(data) except (BrokenPipeError, ConnectionResetError): sel.unregister(conn) conn.close() def run(host="0.0.0.0", port=9527): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(1024) srv.setblocking(False) sel.register(srv, selectors.EVENT_READ, data=None) logging.info("event loop listening on %s:%d", host, port) while True: for key, mask in sel.select(timeout=1.0): if key.data is None: accept(key.fileobj) else: read(key.fileobj, key.data)这里的坑和上面完全不同。事件驱动模式下,所有 socket 必须是非阻塞的,任何一次recv都不能霸占时间;sendall在非阻塞 socket 上也可能只发出去一半,严格来说应该自己管理发送缓冲区、注册EVENT_WRITE继续发。我上面为了可读性用了sendall,在数据量小的回显场景没问题,但如果你要传大文件,必须补上发送缓冲区的逻辑,否则会丢数据。
3.4 参数怎么算,别凭感觉填
WORKERS那个 64 不是拍脑袋来的,这里给一个实际的计算过程。假设压测或者线上观测给出这几个数:峰值并发连接 5000,每条连接平均每秒发起 1 次请求,单个请求的服务端处理耗时 8 毫秒(含业务计算和一次本地缓存读取)。
先算总吞吐需求:5000 × 1 = 5000 请求/秒。再算单线程能力:1 / 0.008 = 125 请求/秒。那么理论需要的线程数是 5000 / 125 = 40 个。考虑到流量有毛刺、GC 或者偶发的慢请求会拉长处理时间,一般留 50% 到 80% 的余量,取 64 到 72 比较合适。这个数字如果设成 200,多出来的线程大部分时间在等锁,反而增加上下文切换开销;设成 20,队列会堆起来,延迟飙升。
| 参数 | 取值建议 | 依据 |
|---|---|---|
backlog | 128 ~ 1024 | 不超过net.core.somaxconn,压测中溢出计数为 0 即可 |
| 工作线程数 | 理论值 × 1.5 ~ 1.8 | 理论值 = 峰值 QPS ÷ 单线程 QPS |
| 连接读超时 | 30 秒(交互类)/ 300 秒(推送类) | 短于业务心跳周期的 2 倍 |
| 同时在处理连接上限 | 线程数 × 8 | 超过则快速拒绝,防止队尾延迟雪崩 |
| accept 超时 | 0.5 ~ 1 秒 | 兼顾退出响应速度和空转开销 |
顺带把系统层面的两个数也确认一下。cat /proc/sys/net/core/somaxconn看队列上限,ulimit -n看单进程能开多少文件描述符。后者尤其关键,每条连接占一个 fd,5000 并发意味着至少 5000+ 个 fd,默认的 1024 肯定不够,需要调到 65535 这种量级。这个改动要在服务启动前生效,改运行中的进程没用,得重启。
4. 常见问题与排查技巧实录
4.1 启动就报 Address already in use
这是新手第一个会撞上的墙。原因通常是上一次的进程没退干净,或者端口还处在 TIME_WAIT 状态。TIME_WAIT 是 TCP 的正常机制,主动关闭方会在这个状态停留 2MSL(一般 60 秒左右),目的是防止旧连接的残余报文干扰新连接。
处理办法是给监听套接字加SO_REUSEADDR,就是代码里那行setsockopt。它允许绑定一个处于 TIME_WAIT 的地址,是服务端程序的标准配置,基本没有不加的理由。注意它和SO_REUSEPORT不是一回事,后者是多个进程绑同一个端口做负载分担,用错了会出现“请求被随机分到某个进程”的诡异现象。
排查命令我一般用这两条:
ss -lntp | grep 9527 # 看谁在占这个端口 ss -antp state time-wait | wc -l # 统计 TIME_WAIT 数量如果 TIME_WAIT 数量异常大(几万),说明服务端在频繁主动关闭短连接,这时候更该考虑的是改成连接复用,而不是去调内核参数。
4.2 客户端连不上,但服务端口明明在
这个现象的排查路径要分层走,从外到内依次确认,不要跳步。
第一层,进程是否真的在监听。ss -lntp看有没有LISTEN状态的条目,注意看监听的地址是0.0.0.0还是127.0.0.1——只监听回环地址的话,外部当然连不上,这是配置写错里最常见的一种。
第二层,连接请求有没有到内核。netstat -s | grep -i listen输出里有两行关键计数:SYNs to LISTEN sockets dropped和listen queue of a socket overflowed。前者在涨说明半连接队列满了,通常是握手阶段的 SYN 被丢,可能是 backlog 太小或者遭遇了大量异常连接;后者在涨说明全连接队列满了,也就是我前面反复强调的:accept 循环太慢。
第三层,accept 之后有没有卡住。这时候进程在、端口在、连接也建立了,但客户端发数据没响应。用py-spy dump --pid <pid>打一下线程栈,能直接看到卡在哪个函数的哪一行。这个工具对线上问题定位效率极高,因为它不需要重启进程、不需要提前埋点。
第四层,长时间运行后突然整体失联。先看 fd 数量:ls /proc/<pid>/fd | wc -l,如果接近ulimit -n,那就是连接泄漏,回到 2.4 节检查关闭逻辑。
4.3 CPU 打满但业务量很小
这种“空转”在事件驱动或者用了非阻塞 socket 的代码里特别常见。典型原因是select或者accept返回了“没有数据”,但代码里的处理逻辑没做区分,直接又回去调用,形成了一个死循环。因为每次调用都立刻返回,CPU 就被这一个循环吃满了。
识别方法很简单,top -H -p <pid>看是哪个线程在烧 CPU,然后抓栈。如果是事件循环线程,基本就是这个原因。修法有两种:一是确认select的timeout参数不是 0(0 表示立即返回,会空转,通常应该给个 0.5 到 1 秒);二是对可读事件的处理要严格判断返回值,读到空或者EAGAIN就走注销路径,不要原地重试。
还有一类隐蔽的空转:连接被对端关了,但你没从监听集合里移除,于是每次事件循环都会把这条“永远可读且永远返回空”的连接捞出来处理一遍。所以sel.unregister那行代码千万不能省。
4.4 连接数涨到某个值就再也不涨了
几乎百分百是文件描述符耗尽。现象是 accept 开始报OSError: [Errno 24] Too many open files,但你的错误处理写了continue,于是循环继续跑,一直报错,一直拒绝,服务看起来还活着。
这里有个我踩过的坑值得说:如果accept抛异常后你直接continue,那么在某些情况下(比如 fd 耗尽是瞬时的)确实能自愈,但如果泄漏是持续的,这个循环就会变成一个高频的错误日志发生器,把磁盘写满,然后引发更大的故障。我的做法是给 accept 的错误加个计数和退避,连续失败超过阈值就记录一条 ERROR 并短暂 sleep,同时暴露一个指标供监控告警。
根治当然是防泄漏。三条硬规矩:所有accept返回的conn都必须有对应的close,且尽量写在finally里;所有open的文件、connect出去的 socket 同理;进程退出时用pool.shutdown(wait=True)等所有在飞的连接处理完。这三条做到了,fd 数量会稳定在一个平台上,不会缓慢爬升。
4.5 常见问题速查表
| 现象 | 最可能的原因 | 快速确认方式 | 处理方向 |
|---|---|---|---|
| 启动即报端口占用 | 残留进程或 TIME_WAIT | ss -lntp | grep 端口 | 加SO_REUSEADDR,确认旧进程已退出 |
| 只有本机能连 | 绑定了127.0.0.1 | ss -lntp看监听地址 | 改成0.0.0.0或指定网卡 IP |
| 高并发下连接超时 | 全连接队列溢出 | netstat -s | grep overflowed | 加快 accept 循环,调大 backlog |
| 进程在但无响应 | accept 循环内阻塞 | py-spy dump --pid | 把阻塞操作移出 accept 区间 |
| CPU 100% 无业务 | 事件循环空转 | top -H定位线程 | 检查 select 超时与事件注销 |
| 运行数小时后失联 | fd 泄漏 | ls /proc/pid/fd | wc -l | 补全 close,调大ulimit -n |
| 偶发收到连接重置 | 未做半关闭直接 close | 抓包看是否回 RST | 改为shutdown后读空再关 |
| 退出时残留连接 | 没等线程池结束 | ps -L看线程状态 | shutdown(wait=True)+ 超时兜底 |
5. 稳定性加固与长期运行实践
5.1 心跳和空闲连接回收
持续监听的服务有个特点:连接会一直挂着,但大部分时间是空闲的。如果放任不管,这些空闲连接会占着 fd、占着线程池配额、占着内存,最终把资源吃干。所以必须有一套空闲回收机制,而回收的前提是能判断“这条连接是不是还活着”。
判断方式有两种。一种是应用层心跳:客户端每隔 N 秒发一个特定的心跳包,服务端收到就刷新这条连接的最后活跃时间,超过阈值(比如 3 个心跳周期)没收到就主动关掉。这种方式最可靠,因为它验证的是应用层的可达性,而不是 TCP 层的连通性。另一种是依赖 TCP 的 keepalive,设置SO_KEEPALIVE并调小探测间隔。它的问题是默认参数下要两小时才开始探测,改参数需要动系统配置,而且它只能发现“链路彻底断了”,发现不了“客户端进程卡死但 TCP 栈还在回应”这种情况。
我一般两个都上:应用层心跳做主要判断,TCP keepalive 做兜底。实现上,给每条连接维护一个last_active时间戳,然后起一个低频的巡检线程(比如每 30 秒跑一次),扫一遍连接表,把超时的挑出来shutdown+close。扫描频率不用太高,但要保证巡检线程本身不会因为遍历大表而卡住,所以连接表要用支持并发遍历的结构,或者干脆分片。
5.2 观测指标:先能看见,才能修好
一套持续监听的服务,如果没有指标,出问题时只能靠猜。我在部署时会强制要求这几个数暴露出来,成本很低,收益极大。
当前连接数和历史累计连接数,两者一起看能区分“连接泄漏”(当前数持续爬升不降)和“流量增长”(两者同比涨)。accept 速率,如果它明显低于预期的接入 QPS,说明 accept 环节成了瓶颈。处理队列长度,也就是等待被处理的连接数或者任务数,这个数持续大于零就说明工作线程不够。单请求处理耗时的 P99,平均数没意义,P99 才能暴露毛刺。accept 错误计数,按错误类型分开统计,EMFILE和其他错误要区分对待。
日志方面,我习惯在 accept 成功、连接关闭、异常断开这三个点各打一条,格式统一带上连接标识、对端地址、耗时。连接标识用一个自增 ID 或者 uuid 都行,关键是能在日志里把一条连接的全生命周期串起来。这一点在排查“某个客户端说它发了数据但服务端没收到”这类扯皮问题时特别有用。
5.3 压测怎么压才有意义
最后说压测。很多人压测就是起一堆客户端猛发请求,看到 QPS 数字挺高就收了。这种压法只能证明“在理想条件下能跑多快”,证明不了“能不能持续跑”。
真正有价值的压测要覆盖三种场景。稳态压测:用预期的峰值流量跑 30 分钟以上,观察内存、fd 数、连接数是否平稳,有没有缓慢爬升的趋势。这个最能暴露泄漏类问题,因为泄漏在短时间内看不出来。毛刺压测:在稳态基础上,每隔几秒突然把并发翻三倍,持续一两秒。这模拟的是真实的流量突增,看的是队列和背压机制是否生效,以及突增过后能不能自动恢复。异常断开压测:客户端连上后不发数据、发一半就断、发了不读响应,各种异常姿势都来一遍。这个最能暴露资源回收的漏洞,我在这上面抓到过好几次 fd 泄漏。
压测工具不用太复杂,Python 起几百个协程或者线程循环发请求就够了,重点是要能控制“发多少、发多快、怎么断”。另外提醒一句,压测机器和服务机器别是同一台,否则你压的是本机回环,测不出真实的网络行为和队列表现。
跑完一轮压测,我会重点看四个数:fd 数量有没有回到基线、内存有没有回到基线、有没有出现连接数单调递增、错误日志里有没有新出现的异常类型。这四项都干净,这套持续监听的骨架才算真的稳了。
最后分享个我自己的习惯:无论是哪种并发模型,我都会先在本地用一段脚本做“长跑”——起三个客户端,一个正常收发、一个连上就不说话、一个每 10 秒断一次,然后让服务跑一整夜,第二天早上看日志和资源曲线。这个土办法花的时间不多,但抓到的问题比任何单次功能测试都多,因为它模拟的正是“持续”这两个字真正的含义。