面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

计算机基础阅读 05月29日 00:12

TCP 可靠传输靠哪些机制保证?RTO 和 SACK 是怎么工作的?

TCP 可靠传输靠六个机制保证:序列号+确认应答(保证有序、检测丢包)、超时重传+快重传(丢包恢复)、校验和(检测数据损坏)、流量控制(防接收方溢出)、拥塞控制(防网络过载)。其中序列号和重传是核心,其他都是辅助。追问超时重传的 RTO 怎么算?为什么不能固定值?网络延迟波动大,固定值不靠谱——设小了正常延迟就触发多余重传,设大了真丢包要等很久才恢复。RTO 基于实测 RTT 动态计算:RTO = SRTT + 4 × RTTVAR,SRTT 是平滑 RTT(历史加权平均),RTTVAR 是 RTT 波动幅度。首次测量 SRTT = RTT,后续每次 SRTT = 7/8 × 旧SRTT + 1/8 × 新RTT。Linux 还有个下限(tcprtomin,默认 200ms),防止 RTO 过小导致过度重传。累积确认有什么问题?SACK 怎么解决的?累积确认只告诉你"到这个序号之前的都收到了",中间丢了哪个说不清。比如发了 1-10 号包,5 号丢了,ACK 会一直回 5(表示期望收到 5),6-10 号收到了但不确认。发送方不知道 6-10 号到底到了没有,可能重传 5-10 全部。SACK(Selective ACK)在 TCP 选项里额外报告已收到的非连续块,发送方就知道只重传 5 号就行。Linux 默认开启 SACK(tcp_sack=1),对高延迟高丢包链路很重要。序列号为什么是按字节编号而不是按包编号?因为 TCP 是字节流协议,应用层不关心包的边界——你写 1000 字节,TCP 可能拆成两个 500 字节的段,也可能合成一个。按字节编号让接收方能精确知道每个字节的位置,不管怎么拆分合成都能正确重组和确认。也方便滑动窗口按字节数控制流量。面试追问:ISN(初始序列号)为什么不用 0 或 1?防止旧连接的报文段被新连接误接收——ISN 基于时钟递增,每隔 4 微秒加 1,不同连接的序列号空间错开。校验和能检测所有错误吗?不能。校验和是 16 位反码求和,只能检测单比特错误和大部分多比特错误,但两个错误恰好互相抵消时校验和不变。以太网的 CRC 能检测更多错误,但端到端路径上中间设备可能修改数据(如 NAT 改 IP 地址),TCP 校验和是端到端验证的最后防线。如果需要更强的完整性保证,要用 TLS 的 MAC 或应用层哈希。写段代码# 模拟 TCP 序列号和累积确认def simulate_seq(): sent = 0 # 下一个要发的字节号(ISN=0 简化) acked = 0 # 已确认的字节号 segments = [(0, 100), (100, 200), (200, 300)] # (起始, 结束) for start, end in segments: print(f"发送: seq={start}, 数据长度={end-start}") # 模拟中间段丢失 print(f"收到: ACK={100} (第1段确认)") print(f"收到: ACK={100} (第2段丢失,重复ACK)") print(f"收到: ACK={100} (第3个重复ACK→快重传)") print(f"重传: seq={100}, 数据长度=100") print(f"收到: ACK={300} (累积确认到第3段)")simulate_seq()
计算机基础阅读 05月29日 00:11

TCP 四次挥手的过程是什么?为什么不能三次挥手?

TCP 四次挥手是断开连接的过程:主动方发 FIN,被动方回 ACK,被动方再发 FIN,主动方回 ACK。之所以要四次而不是三次,是因为 TCP 全双工——每个方向必须单独关闭。第二次挥手时被动方可能还有数据没发完,不能把 FIN 和 ACK 合并,只能先确认,等数据发完再关自己的方向。追问TIME_WAIT 为什么要等 2MSL?两个原因。第一,确保最后的 ACK 能到——如果 ACK 丢了,被动方会重传 FIN,TIME_WAIT 状态的主动方可以重新回 ACK。如果主动方直接 CLOSED,收到重传的 FIN 只能回 RST,被动方会报连接异常。第二,让网络中属于这个连接的旧报文段全部消亡——MSL 是报文最大生存时间(RFC 793 建议 2 分钟,Linux 实际是 60 秒),等 2MSL 确保来回两个方向的旧包都过期,不会干扰后续新连接。TIME_WAIT 过多怎么办?短连接场景下(比如 HTTP/1.0 每个请求一个连接),主动关闭方会积累大量 TIMEWAIT,每个占一个五元组(源IP、源端口、目的IP、目的端口、协议),端口耗尽后新连接无法建立。解决方法:启用 tcp_tw_reuse(允许 TIMEWAIT 端口给新连接用,依赖时间戳判断旧包)、改用长连接(HTTP/1.1 Keep-Alive)、让客户端主动关闭(服务端被动关闭不产生 TIME_WAIT)。注意 tcp_tw_recycle 在 NAT 环境下会导致连接失败,Linux 4.12 后已移除此选项。为什么不能三次挥手?假设第二次和第三次合并——被动方收到 FIN 后立刻发 FIN+ACK。问题是被动方可能还有数据没发完:它收到主动方的 FIN 只是说"我不发了",不代表"你也不能发了"。被动方需要继续发送剩余数据,发完才能关自己的方向。如果提前发 FIN,这些数据就丢了。只有在被动方恰好也没有数据要发时,才能合并成三次挥手——这就是延迟 ACK 场景下偶尔观察到三次挥手的原因。CLOSE_WAIT 很多是什么问题?CLOSEWAIT 是被动方收到 FIN 后、还没发 FIN 之前的状态。大量 CLOSEWAIT 说明应用层没有调用 close()——通常是因为代码 bug:忘了关 socket,或者线程池满了来不及处理。一个典型的坑是:HTTP 服务端在请求处理异常时没关连接,客户端超时断开后,服务端停留在 CLOSEWAIT。排查方法:netstat 统计 CLOSEWAIT 数量,配合 lsof 找到对应进程,检查代码里的 socket 关闭逻辑。写段代码# 查看系统 TCP 连接状态分布import subprocessresult = subprocess.run(['netstat', '-tan'], capture_output=True, text=True)states = {}for line in result.stdout.splitlines()[2:]: parts = line.split() if len(parts) >= 6: state = parts[5] states[state] = states.get(state, 0) + 1for state, count in sorted(states.items(), key=lambda x: -x[1]): print(f"{state:20s} {count}")
计算机基础阅读 05月29日 00:10

TCP 拥塞控制的四个算法是什么?超时重传和快重传怎么区分?

TCP 拥塞控制有四个算法:慢启动、拥塞避免、快重传、快恢复。核心思想是"先试探再加码"——不清楚网络能承受多少时就少发,确认没问题再加速;一旦发现拥塞就立刻降速。控制对象是 cwnd(拥塞窗口),和 rwnd 取较小值作为实际发送窗口。追问慢启动为什么叫"慢"?明明指数增长很快"慢"是相对没有拥塞控制的情况——TCP 早期一上来就发满窗口,容易打爆网络。慢启动从 cwnd=1 MSS 开始,每收到一个 ACK 就 +1 MSS,一个 RTT 内翻倍(1→2→4→8→16)。看起来快,但初始只有 1 MSS,比直接发满窗口保守得多。cwnd 到达 ssthresh 后切换到拥塞避免的线性增长(每 RTT +1 MSS),不再翻倍。ssthresh 的初始值通常很大,第一次丢包后才设为 cwnd/2。3 个重复 ACK 为什么就重传?不等超时吗?等超时太慢了。RTO 通常是几百毫秒到几秒,而 3 个重复 ACK 说明后续包已经到了,只是中间那个丢了——网络还在工作,只是丢了一个包。快重传收到第 3 个重复 ACK 就立即重传,不用等 RTO,恢复速度差了一个数量级。为什么是 3 个不是 1 个?因为偶尔乱序也会产生重复 ACK,1 个就重传太激进,3 个基本确认是丢包。超时重传和快重传的处理有什么不同?超时重传意味着网络可能严重拥塞——连 ACK 都回不来了,所以处理更激进:ssthresh = cwnd/2,cwnd 直接降到 1 MSS,重新慢启动。快重传说明网络还通着(后续包到了),只是丢了一个包,所以处理温和:ssthresh = cwnd/2,cwnd 降到 ssthresh 而不是 1,进入快恢复。这就是为什么快重传后不回慢启动——丢一个包不代表网络崩溃。BBR 和传统拥塞控制有什么不同?传统算法(Reno/Cubic)基于丢包判断拥塞——丢包就降速,不丢就加速。问题是在高延迟高丢包的网络(无线、跨国)里,丢包不一定是拥塞,可能是链路本身的特性,传统算法会白白降速。BBR(Google 2016 年提出)不看病包,直接测量带宽和 RTT,把发送速率调整到带宽-延迟积(BDP),目标是把管道填满但不溢出。实测:YouTube 启用 BBR 后跨洲链路吞吐量提升 10-100 倍。写段代码# 模拟慢启动和拥塞避免的 cwnd 变化def simulate_cwnd(ssthresh=16, max_rtt=20): cwnd = 1 # 初始 1 MSS for rtt in range(1, max_rtt + 1): if cwnd < ssthresh: cwnd *= 2 # 慢启动:指数增长 phase = "慢启动" else: cwnd += 1 # 拥塞避免:线性增长 phase = "拥塞避免" print(f"RTT {rtt:2d}: cwnd={cwnd:3d} MSS [{phase}]")simulate_cwnd()
计算机基础阅读 05月29日 00:09

TCP 滑动窗口机制是怎么工作的?零窗口和糊涂窗口是什么?

TCP 流量控制靠滑动窗口:接收方在 ACK 里通告自己还能接收多少数据(rwnd),发送方据此控制发送量,不让接收方缓冲区溢出。发送窗口 = min(rwnd, cwnd),rwnd 是接收方给的流量控制信号,cwnd 是发送方自己根据拥塞状况算的。这里只说 rwnd 的部分。追问零窗口是怎么回事?怎么恢复?接收方缓冲区满了,在 ACK 里通告 rwnd=0,发送方就必须停。但接收方应用层读完数据释放缓冲区后,会发一个窗口更新报文告诉发送方可以继续了。问题在于:这个窗口更新报文丢了怎么办?发送方会一直等,死锁。解决方法是发送方启动持续计时器,定期发零窗口探测报文(只含 1 字节数据),迫使接收方回 ACK 并带上最新窗口值。这就是为什么零窗口不会永远卡住。糊涂窗口综合征是什么?接收方应用层每次只从缓冲区读 1 字节,就通告 rwnd=1;发送方就发 1 字节数据——一个 41 字节的包只装了 1 字节有效载荷,带宽利用率 2.4%。这就是糊涂窗口综合征(Silly Window Syndrome)。接收端的解决方法叫 Clark 方案:缓冲区空闲不到 MSS 或总缓冲区的 35% 时不通告新窗口;发送端配合 Nagle 算法,攒够数据再发。两端一起防才能根治。rwnd 和 cwnd 有什么区别?rwnd 是接收方告诉发送方"我还能收多少",防止接收方溢出;cwnd 是发送方自己算的"网络还能承受多少",防止网络拥塞。流量控制(rwnd)是端到端的保护,拥塞控制(cwnd)是全局性的保护。实际发送窗口取两者最小值——任何一个窗口满了都不能多发。面试中经常把这两个混在一起考,要分清楚哪个是对方给的,哪个是自己算的。滑动窗口的"滑动"体现在哪?窗口把发送缓冲区的数据分成四段:已确认 ← 窗口左边界 | 已发送未确认 | 可发送未发送 | 窗口右边界 → 不可发送。收到 ACK 后左边界右移(滑过去),接收方通告新窗口后右边界右移(窗口打开)——这就是"滑动"。如果右边界不变就是窗口关闭,左边界追上右边界就是零窗口。写段代码# 模拟滑动窗口发送def sliding_window_send(data, window_size): sent = 0 # 窗口左边界 acked = 0 # 已确认 while acked < len(data): # 发送窗口内的数据 while sent < min(acked + window_size, len(data)): print(f"发送 data[{sent}]") sent += 1 # 模拟收到 ACK print(f"收到 ACK={acked+1}") acked += 1 # 左边界滑动sliding_window_send(list(range(8)), window_size=3)
服务端阅读 05月29日 00:09

Python 多线程和多进程有什么区别?GIL 对多线程有什么影响?

核心区别:进程是资源分配单位,有独立内存空间;线程是调度单位,共享进程内存。在 Python 里这道题的特殊之处是 GIL——全局解释器锁让同一时刻只有一个线程执行 Python 字节码,所以 CPU 密集型任务用多线程不会加速,必须用多进程。I/O 密集型任务多线程够用,因为等 I/O 时 GIL 会释放。追问GIL 到底锁的是什么?为什么不能去掉?GIL 保护的是 CPython 的内存管理——引用计数。CPython 的对象引用计数不是线程安全的,如果多个线程同时修改引用计数,对象可能被提前释放或泄漏。加锁是最简单的方案,代价是多线程无法真正并行。Python 3.13 开始实验性支持 free-threaded 模式(PEP 703),试图移除 GIL,但目前生态兼容性还是问题。短期内 GIL 不会消失。多线程既然不能并行,还有用吗?有用。线程等待 I/O(网络请求、文件读写、数据库查询)时会释放 GIL,其他线程可以执行。所以 I/O 密集型场景(爬虫、API 调用、数据库操作)用多线程完全没问题。实测:100 个 HTTP 请求,单线程串行 10 秒,10 线程并发约 1.2 秒,接近 10 倍加速。但如果是纯计算(比如大矩阵运算),10 线程和 1 线程耗时几乎一样,甚至更慢——线程切换本身有开销。什么时候用多进程?有什么坑?CPU 密集型任务:数据处理、图像处理、机器学习训练。multiprocessing.Pool 是最常用的接口。最大的坑是进程间通信开销——进程不共享内存,数据要序列化传输。传一个大 numpy 数组给子进程,pickle 序列化的时间可能比计算本身还长。解决方案:用 multiprocessing.shared_memory(Python 3.8+)或 multiprocessing.Array 共享内存,避免序列化。另一个坑是子进程的异常不会自动传播到主进程,需要手动处理。协程和多线程有什么区别?协程是用户态的协作式调度,线程是内核态的抢占式调度。协程的切换由代码控制(await),线程的切换由操作系统控制。协程没有锁的问题——同一个时刻只有一个协程在执行,不存在竞态条件。代价是协程里不能有阻塞调用,一阻塞整个事件循环就卡住了。asyncio 适合高并发 I/O(上万连接的聊天服务器),threading 适合少量 I/O 并发或需要兼容阻塞库的场景。写段代码from multiprocessing import Poolimport timedef heavy_compute(n): return sum(i * i for i in range(n))if __name__ == '__main__': # 多进程:4核并行,接近4倍加速 with Pool(4) as p: results = p.map(heavy_compute, [10**7] * 4) print(results) # 对比:多线程对CPU密集型无效 # 4个线程和1个线程耗时几乎一样(GIL)
服务端阅读 05月29日 00:07

Python 描述符是什么?数据描述符和非数据描述符优先级怎么排?

描述符是实现了 __get__、__set__、__delete__ 中任意一个的类,被赋值给另一个类的类属性后,会拦截那个属性的访问。Python 的属性查找有一套隐藏规则:当解释器在类(及其 MRO)的 __dict__ 里找到的值是描述符时,不会直接返回它,而是调用描述符的 __get__ 方法。这就是 property、classmethod、staticmethod 的底层原理——它们都是描述符。追问数据描述符和非数据描述符有什么区别?优先级怎么排?关键区别是有没有 __set__。实现了 __get__ + __set__ 的叫数据描述符,只有 __get__ 的叫非数据描述符。优先级:数据描述符 > 实例 __dict__ > 非数据描述符。换句话说,数据描述符能拦截赋值操作,实例 __dict__ 里写不进去;非数据描述符拦截不了,一旦实例 __dict__ 有了同名 key 就被覆盖了。这就是为什么 property(数据描述符)设了 setter 后 obj.x = 1 一定走 setter,而普通方法(非数据描述符)可以被实例属性遮蔽。Python 属性查找的完整顺序是什么?按这个顺序:1. 类及其 MRO 的 __dict__ 里找,如果是数据描述符就调 __get__ 返回;2. 实例 __dict__ 里找;3. 回到类的 __dict__,如果是非数据描述符就调 __get__ 返回。这解释了一个经典面试题:为什么实例能覆盖普通方法但覆盖不了 property?因为方法是非数据描述符,步骤 2 的实例 __dict__ 优先级更高;property 是数据描述符,步骤 1 就截走了。set_name 是什么?为什么需要它?Python 3.6 新增的钩子。描述符被赋值到类属性时,解释器自动调用 desc.__set_name__(owner, name),把属性名传进去。之前描述符不知道自己叫什么名字,要么手动传(age = Typed('age', int)),要么用元类扫描类 __dict__ 来推断。有了 __set_name__,Django ORM 的 name = CharField() 就不用重复写字段名了——CharField.__set_name__ 会自动收到 'name'。描述符里怎么存值?为什么不能直接用 self.xxx?描述符实例是类级别的,所有实例共享同一个描述符对象。如果你在 __set__ 里写 self.value = val,所有实例共享同一个 value,后面的赋值会覆盖前面的。正确做法是存到 obj.__dict__[self.name] 里,或者用 weakref.WeakKeyDictionary 做 self → value 的映射。前一种更常见(property 就这么做的),后一种适合描述符本身需要维护额外状态的场景。写段代码# 用 __set_name__ 实现类型检查描述符class Typed: def __init__(self, expected_type): self.expected_type = expected_type def __set_name__(self, owner, name): self.name = name # 自动获取属性名 self.storage = f'_{name}' def __get__(self, obj, objtype=None): if obj is None: return self return getattr(obj, self.storage, None) def __set__(self, obj, value): if not isinstance(value, self.expected_type): raise TypeError(f'{self.name} 需要 {self.expected_type.__name__}') setattr(obj, self.storage, value)class User: name = Typed(str) age = Typed(int)u = User()u.name = "Alice" # OKu.age = 25 # OK# u.age = "25" # TypeError: age 需要 int
服务端阅读 05月29日 00:06

Python 列表推导式和生成器表达式有什么区别?什么时候该用哪个?

区别就一个字:方括号 [] 立即算出所有结果放进列表,圆括号 () 返回一个生成器对象,用到哪个才算哪个。[x**2 for x in range(10)] 执行完内存里就有 10 个数;(x**2 for x in range(10)) 执行完只多了一个 200 字节的生成器对象,值还没算。追问生成器只能遍历一次,踩过坑吗?这是最常见的陷阱。你写 gen = (x for x in range(5)),第一次 list(gen) 得到 [0,1,2,3,4],再 list(gen) 就是 []——生成器耗尽了。如果后面的代码还要用,要么转成列表存起来,要么重新创建生成器。调试时这个坑尤其烦人:你在调试器里 print(list(gen)) 看了一眼,后面代码就拿不到数据了。sum(x2 for x in range(N)) 和 sum([x2 for x in range(N)]) 哪个快?大多数人觉得生成器快,实际不一定。生成器省内存是肯定的,但每次 yield 有函数调用开销。列表推导式的循环在 C 层执行(CPython 实现中 listcomp 是专用字节码),而生成器每次 yield 要切换栈帧。数据量小时列表版反而更快——省下的内存分配开销比 yield 开销小。数据量大时生成器版才赢,因为列表版要先把所有结果存内存。经验值:N 10 万生成器才有明显优势。字典推导式和集合推导式呢?语法一样,换括号就行:{k: v for k, v in pairs} 是字典推导式,{x for x in items} 是集合推导式。注意集合推导式没有生成器版本——{x for x in items} 是立即求值的,没有惰性集合。如果需要惰性去重,得用生成器 + set() 分两步。嵌套推导式怎么读?从左到右读,和嵌套 for 循环的顺序一致。[f(x,y) for x in A for y in B] 等价于 for x in A: for y in B: f(x,y)。超过两层就该换普通循环了,没人能在脑内解析三层推导式。Python 之禅说得明白:可读性很重要。写段代码# 生成器只能遍历一次的坑gen = (x**2 for x in range(5))print(list(gen)) # [0, 1, 4, 9, 16]print(list(gen)) # [] ← 耗尽了!# 需要多次使用就转列表squares = [x**2 for x in range(5)]print(squares[:3]) # [0, 1, 4]print(squares[3:]) # [9, 16]# 管道式处理用生成器省内存lines = (line.strip() for line in open('big.log')) # 不读全文errors = (line for line in lines if 'ERROR' in line)count = sum(1 for _ in errors) # 只计数,不存结果
服务端阅读 05月29日 00:05

Python 面向对象的核心概念有哪些?MRO 和描述符怎么理解?

Python 面向对象的核心是四件事:用类组织数据和行为的封装机制、通过继承复用代码、用多态让不同对象响应同一接口、以及 Python 自己的特殊之处——MRO、描述符、slots 这些面试高频考点。基础概念(类/实例/属性/方法)不展开,下面只说容易踩坑和被追问的部分。追问Python 的 MRO 是怎么排的?为什么不用深度优先?Python 3 用 C3 线性化算法计算 MRO。核心规则:子类排在父类前面,同一层按定义顺序排,不能违反前两条规定。为什么不用深度优先?因为菱形继承下深度优先会重复访问基类。经典例子:D 继承 B 和 C,B 和 C 都继承 A,深度优先的顺序是 D→B→A→C→A,A 被访问两次。C3 的结果是 D→B→C→A,每个类只出现一次,且 B 在 C 前面(定义顺序)。通过 ClassName.__mro__ 可以查看任意类的解析顺序。slots 能省多少内存?有什么代价?普通 Python 对象用 __dict__ 存属性,一个空对象就要占 56 字节(64 位 CPython)。__slots__ 用固定数组替代字典,属性直接按偏移量访问,省掉哈希表开销。实际测量:100 万个只有 name 和 age 属性的对象,用 __dict__ 约 160MB,用 __slots__ 约 48MB,省 70%。代价是不能再动态添加属性,而且继承时如果父类没有声明 __slots__,子类照样会有 __dict__,优化白做。实际项目中,Django 的 QuerySet 用了 __slots__ 优化大量小对象。描述符是什么?property 和 classmethod 跟它什么关系?描述符是实现了 __get__、__set__、__delete__ 中任意一个的类。Python 的属性查找有个隐藏步骤:如果找到的对象是描述符,就调用它的 __get__ 返回结果,而不是直接返回对象本身。property 就是描述符——你的 getter/setter 被 __get__/__set__ 包装了;classmethod 也是描述符——它的 __get__ 把类传给函数而不是实例。区分数据描述符(有 __set__)和非数据描述符(只有 __get__):数据描述符优先级高于实例 __dict__,非数据描述符优先级低于实例 __dict__。这就是为什么 property 能拦截赋值而普通方法不行。new 和 init 有什么区别?__new__ 创建对象并返回,__init__ 初始化已创建的对象。__new__ 是类方法(第一个参数是 cls),__init__ 是实例方法(第一个参数是 self)。单例模式用 __new__ 控制:如果 _instance 已存在就直接返回,不再创建新对象。__init__ 做不到这点——它执行时对象已经创建了。另一个场景:不可变类型(str、int、tuple)的子类化必须重写 __new__,因为这些类型的对象在 __new__ 阶段就已经确定了值,__init__ 改不了。写段代码# 描述符实现懒加载属性class LazyProperty: def __init__(self, func): self.func = func def __get__(self, obj, cls): if obj is None: return self value = self.func(obj) obj.__dict__[self.func.__name__] = value # 缓存到实例字典 return valueclass Data: @LazyProperty def expensive(self): print("计算中...") return sum(range(1000000))d = Data()print(d.expensive) # 计算中... 499999500000print(d.expensive) # 499999500000(不再计算,从 __dict__ 直接取)
计算机基础阅读 05月29日 00:02

TCP Keep-Alive 机制是什么?为什么还需要应用层心跳?

TCP Keep-Alive 是操作系统提供的连接存活检测机制:连接空闲一段时间后,内核自动发探测包,根据对端响应判断连接是否还活着。三个核心参数控制行为——空闲多久开始探测(tcpkeepalivetime,默认 7200 秒)、探测间隔(tcpkeepaliveintvl,默认 75 秒)、探测几次放弃(tcpkeepaliveprobes,默认 9 次)。最差情况下,从连接断开到被检测出来要 7200 + 75×9 = 7875 秒,超过 2 小时。追问为什么默认 2 小时这么长?RFC 1122 建议至少 2 小时,是出于对网络风暴的担忧——如果全网所有连接都以短间隔发探测包,本身就是一场 DDoS。2 小时在服务器间稳定网络里够用了,问题出在移动端:运营商 NAT 设备的连接跟踪表有限,空闲 5 分钟(移动 2/3G)到 28 分钟(电信 3G)就淘汰条目,连接就被静默丢弃了,2 小时探测根本来不及救。既然有 Keep-Alive,为什么还要应用层心跳?三个原因。第一,Keep-Alive 只能检测连接是否可达,不能检测对端进程是否卡死——进程死锁时 TCP 连接还活着,Keep-Alive 照样通过。第二,Keep-Alive 的探测包不带业务数据,服务端对探测无感知,无法在应用层做状态同步。第三,参数是系统级的,改了影响所有连接,不如应用层心跳可以按业务精细控制间隔。微信的心跳从 30 秒到 300 秒动态调整,Keep-Alive 做不到。Keep-Alive 探测包长什么样?一个不包含数据的 ACK 包,序列号设为对端期望的序列号减 1,这样对端会发现序列号不匹配,回一个 ACK 带上正确的期望序列号——探测就成功了。如果对端回复 RST,说明进程已崩溃重启;如果连续 9 次无响应,内核判定连接死亡,关闭 socket 并返回 ETIMEDOUT。什么场景下 Keep-Alive 就够了?服务器之间的稳定内网连接。比如微服务间 gRPC 长连接、数据库连接池,网络环境可控,不存在 NAT 超时问题,Keep-Alive 配合较短的探测间隔(比如 60 秒)就能及时清理僵死连接。这些场景不需要应用层心跳的灵活性。写段代码import socketsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) # 60秒后开始探测sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # 每10秒探测一次sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # 探测3次放弃
计算机基础阅读 05月29日 00:00

什么是 TCP Nagle 算法?为什么会造成 40ms 延迟?

Nagle 算法的核心规则只有一条:连接上有未被确认的小包时,不再发新的小包,等 ACK 回来再把缓冲区里的数据攒一起发。目的是减少小包数量——Telnet 按一个键就产生一个 41 字节的包,其中 40 字节是 TCP+IP 头部,有效载荷只有 1 字节,带宽利用率不到 3%。1984 年 John Nagle 在 RFC 896 里提出这个方案,解决的就是交互式应用疯狂发小包导致的广域网拥塞。算法默认开启(RFC 1122 推荐),通过 TCP_NODELAY 选项关闭。追问Nagle 和延迟 ACK 怎么会互相卡死?Nagle 在发送端等 ACK,延迟 ACK 在接收端等更多数据再确认,两者同时启用就形成僵持:发送方写了一个小包,等 ACK;接收方收到后不马上回 ACK,等 40ms(Linux 默认)或 200ms(Windows 默认)看还有没有后续数据。典型场景是 write-write-read 模式:第一次写直接发出,第二次写被 Nagle 挡住,接收端延迟 ACK 等 40ms,发送端就卡在这 40ms 上。腾讯云有实际案例,营销平台 10% 的请求耗时稳定卡在 38-42ms,根因就是这对组合。什么时候必须关掉 Nagle?实时交互场景:游戏同步、远程桌面、WebSocket 推送。这些场景宁可多发几个小包也不能容忍额外延迟。Redis 3.x 的主从同步曾因 Nagle 导致从库延迟飙升,后来在源码里给同步 socket 加了 TCP_NODELAY 才解决。Nagle 和 TCP_CORK 有什么区别?Nagle 是"有小包没确认就不发",侧重减少小包数量;CORK 是"攒够一个 MSS 再发",侧重提高吞吐量。CORK 更激进——把数据一直攒到 MSS 或超时(通常 200ms)才放行,适合 HTTP 流水线这类一次要发大量数据的场景。Nginx 在发送响应头时用 CORK,等响应体也凑齐了一起发,减少系统调用次数。怎么确认线上问题是 Nagle 引起的?抓包看时序:发送端有小包发出后,超过 40ms 才收到 ACK 或才发下一个包,大概率是 Nagle + 延迟 ACK。也可以临时在客户端设 TCP_NODELAY 做对比——如果 40ms 档消失了就确认了。注意要区分是 Nagle 的问题还是单纯的网络延迟,对比开关前后的延迟分布比看绝对值更可靠。写段代码// 禁用 Nagle 算法int flag = 1;setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));// Linux 上启用快速 ACK(接收端)setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &flag, sizeof(flag));