🔥

从生产事故学网络基础

每一节都是一个你会遇到的真实问题
不知道底层原理,你连问题出在哪都定位不了

事故一0%
事故 01

新连接全部超时 —— TIME_WAIT 堆积

现场:你的 HTTP 网关上线了,QPS 3000,短连接模式。上线第一天没事,第三天早高峰突然大量报错:Cannot assign requested address。新连接全建不了,服务瘫了。

ss -s # TCP: 131072 (estab 47, closed 98342, orphaned 0, timewait 98342)

将近 10 万个 TIME_WAIT 连接。端口号只有 65535 个,被占满了。

你需要知道的:TCP 四次挥手和 TIME_WAIT

主动关闭方 被动关闭方 |--- FIN ---------->| "我写完了" |<-- ACK -----------| "收到" |<-- FIN -----------| "我也写完了" |--- ACK ---------->| "收到,再见" | | |-- TIME_WAIT ------| 主动关闭方等 2MSL 才真正释放

FIN = 我不写了,ACK = 收到。为什么挥手要四次而握手只要三次?因为收到 FIN 只代表对方不写了,自己可能还有数据没发完(半关闭),ACK 和 FIN 不能合并。

MSL = Maximum Segment Lifetime,报文最大存活时间,Linux 通常 60 秒,2MSL = 120 秒。

🎯 为什么要 TIME_WAIT?

1. 最后一个 ACK 如果丢了,对方会重发 FIN。你还在 TIME_WAIT,能重发 ACK。你要是直接关了,对方的 FIN 没人理,连接永远关不掉。

2. 让这条连接的所有旧报文在网络中消亡,防止和新连接(同一个四元组)的数据混淆。

解法:QPS 3000 × 120 秒 TIME_WAIT = 36 万连接堆积。端口不够用。

方案做法代价
长连接池Keep-Alive 复用 TCP 连接最根本的解法,需管理连接池
tcp_tw_reuse=1允许复用 TIME_WAIT 连接内核参数,极端情况收到旧数据
tcp_tw_recycle=1快速回收 TIME_WAITNAT 环境下丢包,生产禁用

决策原则: 短连接 QPS 超过 500 就该考虑长连接池。不是"最佳实践",是算出来的。

事故 02

RPC 小包场景延迟多了 40ms

现场:你用 Netty 写了个内部 RPC 框架,payload 只有几十字节。压测时 P99 延迟莫名多了 40ms,但业务逻辑只花了 2ms。

排查:抓包发现小包没有立刻发出去,等了约 40ms 才和后面的数据一起发送。

你需要知道的:Nagle 算法和 TCP_NODELAY

TCP 默认开启 Nagle 算法:如果有已发送但未确认的数据,新的小包不会立刻发出,而是等前一个 ACK 回来或凑够 MSS 再发。目的是减少小包数量。

但 RPC 场景下你的每个请求就是一个小包,Nagle 算法让你白等了一个 RTT。

bootstrap.option(ChannelOption.TCP_NODELAY, true);

一行配置关掉 Nagle。你不知道 Nagle 算法,你就只会在业务层找延迟,永远找不到。

事故 03

消息被粘在一起 / 只收到半条

现场:你的 Netty IM 系统,客户端连续发了 "hello" 和 "world",服务端只触发了一次 channelRead,buffer 里是 "helloworld"(粘包)。或者 2KB 的 JSON 收到了两次,第一次只有 1.4KB(拆包)。

根因:TCP 是字节流协议——它不知道你的"消息"在哪里结束。往 TCP 里写数据就像往水管里倒水,对面收到的是一股连续水流。

你需要做的决策:协议怎么设计?

你得自己定义消息边界。三种方案:

方案实现Netty 解码器适用
固定长度每条消息定长FixedLengthFrameDecoder长度固定
分隔符用 \n 或自定义分隔符DelimiterBasedFrameDecoder文本协议
长度字段 + 消息体头部固定字节存长度LengthFieldBasedFrameDecoder最常用
+--------+----------+ | length | body | | 4 bytes| N bytes | +--------+----------+
🎯 TCP vs UDP 的直觉区别

UDP 不需要处理粘包——UDP 是数据报,每个包独立,有天然的消息边界。TCP 是水管(字节流),UDP 是快递(数据报)。

事故 04

一个 Handler 阻塞,全部连接饿死

现场:你的 Netty 服务有 1000 个并发连接,某个 Handler 里同步查了一次 MySQL(200ms)。结果不光这个请求慢了,其他 999 个连接的消息也延迟了 200ms。

根因:你需要理解 NIO 的线程模型。

BIO / NIO / AIO 的本质区别

模型谁等数据就绪谁拷贝数据线程模型
BIO线程自己阻塞等线程自己等拷完一个线程一个连接
NIOSelector 通知"ready"线程自己去读(同步)一个线程管多个连接
AIO内核做完了回调通知内核帮你读好(异步)回调驱动

NIO 核心变化:一个线程通过 Selector 管理多个 Channel(连接)。哪个 Channel 有事件就绪就处理哪个。

Selector selector = Selector.open(); channel.register(selector, SelectionKey.OP_READ); while (true) { selector.select(); // 等事件(底层就是 epoll_wait) for (SelectionKey key : selector.selectedKeys()) { if (key.isReadable()) { /* 处理 */ } } }

三大组件:Channel Buffer Selector

🎯 回到事故

Netty 的 EventLoop = 一个线程 + 一个 Selector。一个 EventLoop 管着几百个 Channel。你在 Handler 里同步查 MySQL 200ms,这个 EventLoop 被阻塞,它管的所有 Channel 全部饿死

解法:把阻塞操作扔到业务线程池:

EventExecutorGroup bizGroup = new DefaultEventExecutorGroup(16); pipeline.addLast(bizGroup, new YourBlockingHandler());

面试问 BIO/NIO 区别——不是背概念,是要你理解"为什么 Netty 的 Handler 里不能做阻塞操作"

事故 05

Redis 单线程凭什么扛 10 万 QPS

问题:Redis 是单线程,为什么比你多线程的 Java 服务还快?不是因为"内存快"这么简单——你的 Java 服务数据也在内存里。

Redis 用的是 epoll(I/O 多路复用)。一个线程同时监听几万个客户端连接,谁有数据来就处理谁。

select vs epoll

维度selectepoll
fd 注册每次全量拷贝epoll_ctl 注册一次,内核红黑树维护
就绪检测遍历全部 fd,O(n)内核回调机制把就绪 fd 挂到 rdlist,O(1)
fd 上限1024无限制
🎯 触发模式(面试追问率 50%)

LT(水平触发,默认):fd 就绪后没处理完,下次 epoll_wait 还会通知。安全但可能多通知。

ET(边缘触发):只通知一次,必须一次读完(循环读到 EAGAIN)。高效但容易漏读。Nginx 用 ET。

🎯 三个系统同一个模型

Redis = 单线程 + epoll + 事件循环

Netty = EventLoop 线程 + Selector(epoll 的 Java 封装)+ 事件循环

Nginx = worker 进程 + epoll(ET)+ 事件循环

本质都是:事件驱动 + I/O 多路复用。面试时说出这个串联,面试官知道你是真理解了。

事故 06

带宽 1Gbps 实际只跑了 50Mbps

现场:你的文件服务传 500MB 文件。机器带宽 1Gbps,但实际吞吐只有 50Mbps,CPU 和磁盘都没打满。

cat /proc/sys/net/ipv4/tcp_rmem # 4096 87380 6291456

默认接收缓冲区最大 6MB。跨机房 RTT 50ms 的情况下:

带宽延迟积(BDP) = 带宽 × RTT = 1Gbps × 50ms = 6.25MB

接收方 rwnd 最大 6MB < BDP 6.25MB,发送方永远无法填满链路。

你需要知道的:滑动窗口和流量控制

[已确认] [已发送未确认 | 可发送未发送] [不可发送] ^ ^ 窗口左边界 窗口右边界

实际发送窗口 = min(cwnd, rwnd)

cwnd(拥塞窗口):拥塞控制管的,防止把网络搞炸

rwnd(接收窗口):接收方在每个 ACK 里告诉你"我还能收多少",防止把接收方搞炸

rwnd = 0 时发送方停发,但定期发零窗口探测报文防止死锁。

🎯 解法
sysctl -w net.core.rmem_max=16777216 sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216"

你不知道 rwnd 和 BDP,你就只会怀疑带宽不够或者磁盘太慢,永远定位不到是 TCP 窗口卡了你

事故 07

慢启动让批量调用全部超时

现场:你有个定时任务,每小时批量调下游 API 5000 次。每次新建 TCP 连接,前几百个请求特别慢,后面才正常。

根因:TCP 拥塞控制的慢启动。新连接的 cwnd 从 10 个 MSS 开始,每个 RTT 翻倍。你突然灌 5000 个请求进来,cwnd 还没涨上去,数据堆在发送缓冲区排队。

TCP 拥塞控制四阶段

1. 慢启动(Slow Start):cwnd 从小值开始,每 RTT 翻倍(指数增长)。名字叫"慢"是因为起点小,不是增长慢。

2. 拥塞避免(Congestion Avoidance):cwnd 达到阈值 ssthresh 后,每 RTT 只加 1(线性增长)。试探网络极限。

3. 快重传(Fast Retransmit):收到 3 个重复 ACK → 判断丢包 → 立刻重传,不等超时。

4. 快恢复(Fast Recovery):快重传后:ssthresh = cwnd / 2,cwnd = 新 ssthresh,线性增长。不回到 1。

🎯 面试必追问:两种丢包的处理

超时丢包(严重):cwnd 回到 1,重新慢启动

3 次重复 ACK(轻微):快重传 + 快恢复,cwnd 减半不归零

解法:不要每次定时任务都新建连接。用长连接池,TCP 连接预热好,cwnd 已经涨上去了,一上来就能全速发。

或者调大初始 cwnd:

ip route change default via <gw> initcwnd 32
事故 08

微服务间调用延迟高 —— 握手成本

现场:微服务 A 调 B,走 HTTPS 短连接。业务逻辑只花 5ms,但端到端延迟 45ms。跨机房 RTT 10ms。

算笔账:TCP 三次握手 1.5 RTT = 15ms + TLS 握手 2 RTT = 20ms + 请求响应 1 RTT = 10ms = 45ms,其中 35ms 花在建连接上。

TCP 三次握手

客户端 服务端 |--- SYN, seq=x -------->| 客户端发起 |<-- SYN+ACK, seq=y, | 服务端同意 | ack=x+1 | |--- ACK, ack=y+1 ------>| 客户端确认

三次握手的目的:双方确认彼此收发能力 + 防止历史失效连接被误建。两次握手不行——服务端收到旧 SYN 直接分配资源等着,但客户端早就不要这个连接了。

TLS 握手(RSA 版本)

客户端 服务端 |--- Client Hello ----------------->| | Client Random + 支持的密码套件 | |<-- Server Hello ------------------| | Server Random + 证书(含公钥) | | [验证证书链 → 根 CA] | | [生成 Pre-Master Secret] | |--- 用公钥加密 Pre-Master -------->| | [双方用三个值生成会话密钥:] | | Client Random + Server Random | | + Pre-Master Secret → AES Key
🎯 为什么三个随机数缺一不可?

任何一方随机数生成器有缺陷,另外两个随机数能补救。只用 Pre-Master Secret 一个值,一旦随机数质量不行密钥就可预测。

对称 + 非对称怎么配合?非对称(RSA)只在握手阶段用——安全传递密钥。慢,但只用一次。对称(AES)用于数据传输。快,适合大量数据。非对称解决密钥交换,对称解决数据传输。

怎么优化?

方案效果适用
长连接 + 连接池省掉反复握手,只有首次花 35ms最常用
HTTP/2一个连接多路复用多个 Stream解决队头阻塞
gRPC基于 HTTP/2 + Protobuf微服务间首选
TLS Session ResumptionTLS 握手缩短到 1 RTT短连接优化

选 gRPC 还是 HTTP/1.1 的量化依据——不是"gRPC 更先进",是你算出来每次调用省了 35ms 建连开销

事故 09

HTTP/2 在弱网下反而比 HTTP/1.1 更慢

现场:你的移动端 App 用 HTTP/2 和服务端通信。WiFi 下体验很好,但在地铁(高丢包率)里反而比 HTTP/1.1 还卡。

三代 HTTP 的演进和各自的问题

HTTP/1.0:每个请求新建 TCP 连接,用完就断。30 个资源 = 30 次握手。

HTTP/1.1:长连接(Keep-Alive)+ 管线化(Pipelining)。但响应必须按顺序返回 → 队头阻塞。浏览器 workaround:开 6 个并行 TCP 连接。

HTTP/2:多路复用(一个 TCP 连接上多个 Stream 并行)+ 二进制分帧 + HPACK 头部压缩 + 服务端推送。

🎯 为什么弱网下更慢?

HTTP/2 所有 Stream 共用一个 TCP 连接。TCP 层丢了一个包,所有 Stream 都要等重传——这是 TCP 层的队头阻塞。

HTTP/1.1 开了 6 个 TCP 连接,一个连接丢包只影响它自己,其他 5 个不受影响。

演进逻辑:1.0 连接浪费 → 1.1 长连接但有应用层队头阻塞 → 2.0 多路复用解决应用层但暴露 TCP 层队头阻塞 → 3.0 (QUIC) 用 UDP 彻底解决

事故 10

用户被封号了但还能操作

现场:你的系统用 JWT 做认证。运营封禁了一个恶意用户,但这个用户接下来 2 小时还在正常使用——因为他的 JWT token 还有 2 小时才过期。

Cookie + Session 的协作:

用户登录 → 服务端创建 Session(存 Redis),生成 SessionID → Set-Cookie: JSESSIONID=abc123 返回给浏览器 → 后续请求自动带 Cookie: JSESSIONID=abc123 → 服务端从 Redis 查出 Session 数据

想踢人下线?Redis 里删掉这个 Session 就行,下次请求查不到直接 401。

JWT 的工作方式不同:

用户登录 → 服务端生成 JWT(Header.Payload.Signature) → Payload 里带着 {userId: 123, role: "admin", exp: ...} → 返回给客户端,客户端每次请求带上 → 服务端只做验签(检查 Signature),不查任何存储

JWT 的 trade-off

维度Session + RedisJWT
状态有状态,服务端存 Session无状态,服务端不存东西
扩展性需要共享 Redis天然分布式,加机器就行
踢人能力删 Redis 立刻生效做不到,token 过期前一直有效
每次请求成本查一次 Redis(~1ms)验签(CPU ~0.1ms)
payload 安全数据在服务端Base64 编码不是加密
🎯 核心缺陷

JWT 无法主动吊销。你说"搞个黑名单就行"——黑名单存哪?Redis。那你又变成有状态了,JWT"无状态"的优势直接打折。这就是 trade-off。

选型条件:

需要"随时踢人" → Session + Redis

对实时踢人不敏感、追求极致无状态 → JWT

要两者兼顾 → JWT + Redis 黑名单(混合方案,复杂度最高)

分布式 Session 方案对比

方案做法问题
Session StickyNginx ip_hash 打到同一台机器挂了 Session 丢
Session 复制Tomcat 集群广播同步5 台以上广播风暴
集中存储 Redis所有机器无状态,共享 RedisRedis 需高可用
事故 11

接口返回 502,你却去查自己的代码

现场:你的服务调下游,返回 502。你花了半小时翻自己的日志和代码,什么都没找到。

真相:502 是 Bad Gateway——你的 Nginx 收到了下游的无效响应,或者下游直接挂了。问题根本不在你这边

状态码速查

状态码含义说人话
200OK标准成功
201Created资源创建成功(POST 创建用户)
204No Content成功但无返回体(DELETE)
301Moved Permanently永久重定向(http → https)
302Found临时重定向(登录后跳首页)
304Not Modified资源没变,用本地缓存
400Bad Request你的请求参数有问题
401Unauthorized没登录
403Forbidden登录了但没权限
404Not Found资源不存在
500Internal Server Error代码抛了未捕获异常
502Bad Gateway上游挂了/返回了非法内容
503Service Unavailable服务过载或维护
504Gateway Timeout网关等上游超时
🎯 502 vs 504 区分法

502 是网关收到了垃圾回复(上游活着但回复不对),504 是网关压根没收到回复(上游超时或挂了)。

事故 12

GET 参数被安全审计扫出来了

现场:安全团队扫你的 Nginx access log,发现用户手机号出现在 URL 里:

GET /api/user?phone=13800138000 HTTP/1.1

安全审计不通过。GET 的参数在 URL 的 query string 里,会被:Nginx access log 记录、浏览器历史记录保存、CDN/代理缓存、Referer 头泄露给第三方。

GET vs POST 的实际区别

维度GETPOST
语义读操作,safe + idempotent写操作,非幂等
参数位置URL query string请求体
缓存可被浏览器缓存、收藏书签不可以
URL 长度限制浏览器限制 2KB~8KB不受限
日志暴露参数在 URL 中被记录请求体不记日志(默认)
编码只支持 URL 编码支持 multipart、JSON 等
🎯 面试考点

从协议层面看,GET 可以带 body,POST 也能用 query string,区别更多是语义规范和浏览器实现的约定。但约定就是约定,你不遵守就会出安全事故。

事故 13

DNS 缓存没刷新,流量全打到旧机器

现场:你把服务从机器 A 迁到机器 B,域名 DNS 指向改了,但有些客户端请求还在打到旧机器 A(已下线),报连接超时。

根因:DNS 有多级缓存,每一级的 TTL 不一样。你改了权威 DNS 的记录,但中间层的缓存还没过期。

DNS 解析完整链路

浏览器 DNS 缓存(几分钟) ↓ 没命中 操作系统 DNS 缓存 ↓ 没命中 hosts 文件(/etc/hosts) ↓ 没命中 本地 DNS 服务器(ISP 提供,可能缓存了 TTL 时间内的结果) ↓ 没命中 递归/迭代查询: 根 DNS(.) → 顶级 DNS(.com) → 权威 DNS(baidu.com) → 拿到 IP
🎯 迁移服务的正确做法

1. 先把 DNS TTL 调到很短(比如 60 秒)

2. 等旧 TTL 过期(比如原来是 1 小时,就等 1 小时)

3. 改 DNS 记录指向新机器

4. 确认流量全切过去后,再把 TTL 调回正常值

你不知道 DNS 多级缓存,你就不明白为什么"我明明改了 DNS 怎么还有流量打到旧机器"。

总结

所有事故串成一条链路

用户输入 URL ↓ DNS 解析(多级缓存) ← 事故十三 ↓ TCP 三次握手 ← 事故八 ↓ TLS 握手 ← 事故八 ↓ HTTP 请求(GET/POST) ← 事故十二 ↓ Nginx 返回状态码 ← 事故十一 ↓ 你的 Netty 服务收到请求 ← 事故四 ↓ LengthFieldBasedFrameDecoder 解码 ← 事故三 ↓ 业务 Handler 处理 ↓ 查 Redis(单线程 + epoll) ← 事故五 ↓ 查 Session / 验 JWT ← 事故十 ↓ TCP 发送响应 发送窗口 = min(cwnd, rwnd) ← 事故六、七 ↓ HTTP 响应 ↓ 浏览器渲染 ↓ TCP 四次挥手 / Keep-Alive 复用 ← 事故一、二、九
🎯 怎么用这份笔记

每个事故就是一个面试题的入口。面试官问"说说 TCP 四次挥手",你脑子里浮现的不应该是"FIN ACK FIN ACK",而是"TIME_WAIT 堆积把端口耗尽那次事故"

从事故讲到原理,从原理讲到解法。这样你答出来的不是背的,是理解的。