每一节都是一个你会遇到的真实问题
不知道底层原理,你连问题出在哪都定位不了
现场:你的 HTTP 网关上线了,QPS 3000,短连接模式。上线第一天没事,第三天早高峰突然大量报错:Cannot assign requested address。新连接全建不了,服务瘫了。
将近 10 万个 TIME_WAIT 连接。端口号只有 65535 个,被占满了。
你需要知道的:TCP 四次挥手和 TIME_WAIT
FIN = 我不写了,ACK = 收到。为什么挥手要四次而握手只要三次?因为收到 FIN 只代表对方不写了,自己可能还有数据没发完(半关闭),ACK 和 FIN 不能合并。
MSL = Maximum Segment Lifetime,报文最大存活时间,Linux 通常 60 秒,2MSL = 120 秒。
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_WAIT | NAT 环境下丢包,生产禁用 |
决策原则: 短连接 QPS 超过 500 就该考虑长连接池。不是"最佳实践",是算出来的。
现场:你用 Netty 写了个内部 RPC 框架,payload 只有几十字节。压测时 P99 延迟莫名多了 40ms,但业务逻辑只花了 2ms。
排查:抓包发现小包没有立刻发出去,等了约 40ms 才和后面的数据一起发送。
你需要知道的:Nagle 算法和 TCP_NODELAY
TCP 默认开启 Nagle 算法:如果有已发送但未确认的数据,新的小包不会立刻发出,而是等前一个 ACK 回来或凑够 MSS 再发。目的是减少小包数量。
但 RPC 场景下你的每个请求就是一个小包,Nagle 算法让你白等了一个 RTT。
一行配置关掉 Nagle。你不知道 Nagle 算法,你就只会在业务层找延迟,永远找不到。
现场:你的 Netty IM 系统,客户端连续发了 "hello" 和 "world",服务端只触发了一次 channelRead,buffer 里是 "helloworld"(粘包)。或者 2KB 的 JSON 收到了两次,第一次只有 1.4KB(拆包)。
根因:TCP 是字节流协议——它不知道你的"消息"在哪里结束。往 TCP 里写数据就像往水管里倒水,对面收到的是一股连续水流。
你需要做的决策:协议怎么设计?
你得自己定义消息边界。三种方案:
| 方案 | 实现 | Netty 解码器 | 适用 |
|---|---|---|---|
| 固定长度 | 每条消息定长 | FixedLengthFrameDecoder | 长度固定 |
| 分隔符 | 用 \n 或自定义分隔符 | DelimiterBasedFrameDecoder | 文本协议 |
| 长度字段 + 消息体 | 头部固定字节存长度 | LengthFieldBasedFrameDecoder | 最常用 |
UDP 不需要处理粘包——UDP 是数据报,每个包独立,有天然的消息边界。TCP 是水管(字节流),UDP 是快递(数据报)。
现场:你的 Netty 服务有 1000 个并发连接,某个 Handler 里同步查了一次 MySQL(200ms)。结果不光这个请求慢了,其他 999 个连接的消息也延迟了 200ms。
根因:你需要理解 NIO 的线程模型。
BIO / NIO / AIO 的本质区别
| 模型 | 谁等数据就绪 | 谁拷贝数据 | 线程模型 |
|---|---|---|---|
| BIO | 线程自己阻塞等 | 线程自己等拷完 | 一个线程一个连接 |
| NIO | Selector 通知"ready" | 线程自己去读(同步) | 一个线程管多个连接 |
| AIO | 内核做完了回调通知 | 内核帮你读好(异步) | 回调驱动 |
NIO 核心变化:一个线程通过 Selector 管理多个 Channel(连接)。哪个 Channel 有事件就绪就处理哪个。
三大组件:Channel Buffer Selector
Netty 的 EventLoop = 一个线程 + 一个 Selector。一个 EventLoop 管着几百个 Channel。你在 Handler 里同步查 MySQL 200ms,这个 EventLoop 被阻塞,它管的所有 Channel 全部饿死。
解法:把阻塞操作扔到业务线程池:
面试问 BIO/NIO 区别——不是背概念,是要你理解"为什么 Netty 的 Handler 里不能做阻塞操作"。
问题:Redis 是单线程,为什么比你多线程的 Java 服务还快?不是因为"内存快"这么简单——你的 Java 服务数据也在内存里。
Redis 用的是 epoll(I/O 多路复用)。一个线程同时监听几万个客户端连接,谁有数据来就处理谁。
select vs epoll
| 维度 | select | epoll |
|---|---|---|
| fd 注册 | 每次全量拷贝 | epoll_ctl 注册一次,内核红黑树维护 |
| 就绪检测 | 遍历全部 fd,O(n) | 内核回调机制把就绪 fd 挂到 rdlist,O(1) |
| fd 上限 | 1024 | 无限制 |
LT(水平触发,默认):fd 就绪后没处理完,下次 epoll_wait 还会通知。安全但可能多通知。
ET(边缘触发):只通知一次,必须一次读完(循环读到 EAGAIN)。高效但容易漏读。Nginx 用 ET。
Redis = 单线程 + epoll + 事件循环
Netty = EventLoop 线程 + Selector(epoll 的 Java 封装)+ 事件循环
Nginx = worker 进程 + epoll(ET)+ 事件循环
本质都是:事件驱动 + I/O 多路复用。面试时说出这个串联,面试官知道你是真理解了。
现场:你的文件服务传 500MB 文件。机器带宽 1Gbps,但实际吞吐只有 50Mbps,CPU 和磁盘都没打满。
默认接收缓冲区最大 6MB。跨机房 RTT 50ms 的情况下:
带宽延迟积(BDP) = 带宽 × RTT = 1Gbps × 50ms = 6.25MB
接收方 rwnd 最大 6MB < BDP 6.25MB,发送方永远无法填满链路。
你需要知道的:滑动窗口和流量控制
实际发送窗口 = min(cwnd, rwnd)
cwnd(拥塞窗口):拥塞控制管的,防止把网络搞炸
rwnd(接收窗口):接收方在每个 ACK 里告诉你"我还能收多少",防止把接收方搞炸
rwnd = 0 时发送方停发,但定期发零窗口探测报文防止死锁。
你不知道 rwnd 和 BDP,你就只会怀疑带宽不够或者磁盘太慢,永远定位不到是 TCP 窗口卡了你。
现场:你有个定时任务,每小时批量调下游 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:
现场:微服务 A 调 B,走 HTTPS 短连接。业务逻辑只花 5ms,但端到端延迟 45ms。跨机房 RTT 10ms。
算笔账:TCP 三次握手 1.5 RTT = 15ms + TLS 握手 2 RTT = 20ms + 请求响应 1 RTT = 10ms = 45ms,其中 35ms 花在建连接上。
TCP 三次握手
三次握手的目的:双方确认彼此收发能力 + 防止历史失效连接被误建。两次握手不行——服务端收到旧 SYN 直接分配资源等着,但客户端早就不要这个连接了。
TLS 握手(RSA 版本)
任何一方随机数生成器有缺陷,另外两个随机数能补救。只用 Pre-Master Secret 一个值,一旦随机数质量不行密钥就可预测。
对称 + 非对称怎么配合?非对称(RSA)只在握手阶段用——安全传递密钥。慢,但只用一次。对称(AES)用于数据传输。快,适合大量数据。非对称解决密钥交换,对称解决数据传输。
怎么优化?
| 方案 | 效果 | 适用 |
|---|---|---|
| 长连接 + 连接池 | 省掉反复握手,只有首次花 35ms | 最常用 |
| HTTP/2 | 一个连接多路复用多个 Stream | 解决队头阻塞 |
| gRPC | 基于 HTTP/2 + Protobuf | 微服务间首选 |
| TLS Session Resumption | TLS 握手缩短到 1 RTT | 短连接优化 |
选 gRPC 还是 HTTP/1.1 的量化依据——不是"gRPC 更先进",是你算出来每次调用省了 35ms 建连开销。
现场:你的移动端 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 彻底解决
现场:你的系统用 JWT 做认证。运营封禁了一个恶意用户,但这个用户接下来 2 小时还在正常使用——因为他的 JWT token 还有 2 小时才过期。
Cookie + Session 的协作:
想踢人下线?Redis 里删掉这个 Session 就行,下次请求查不到直接 401。
JWT 的工作方式不同:
JWT 的 trade-off
| 维度 | Session + Redis | JWT |
|---|---|---|
| 状态 | 有状态,服务端存 Session | 无状态,服务端不存东西 |
| 扩展性 | 需要共享 Redis | 天然分布式,加机器就行 |
| 踢人能力 | 删 Redis 立刻生效 | 做不到,token 过期前一直有效 |
| 每次请求成本 | 查一次 Redis(~1ms) | 验签(CPU ~0.1ms) |
| payload 安全 | 数据在服务端 | Base64 编码不是加密 |
JWT 无法主动吊销。你说"搞个黑名单就行"——黑名单存哪?Redis。那你又变成有状态了,JWT"无状态"的优势直接打折。这就是 trade-off。
选型条件:
需要"随时踢人" → Session + Redis
对实时踢人不敏感、追求极致无状态 → JWT
要两者兼顾 → JWT + Redis 黑名单(混合方案,复杂度最高)
分布式 Session 方案对比
| 方案 | 做法 | 问题 |
|---|---|---|
| Session Sticky | Nginx ip_hash 打到同一台 | 机器挂了 Session 丢 |
| Session 复制 | Tomcat 集群广播同步 | 5 台以上广播风暴 |
| 集中存储 Redis | 所有机器无状态,共享 Redis | Redis 需高可用 |
现场:你的服务调下游,返回 502。你花了半小时翻自己的日志和代码,什么都没找到。
真相:502 是 Bad Gateway——你的 Nginx 收到了下游的无效响应,或者下游直接挂了。问题根本不在你这边。
状态码速查
| 状态码 | 含义 | 说人话 |
|---|---|---|
| 200 | OK | 标准成功 |
| 201 | Created | 资源创建成功(POST 创建用户) |
| 204 | No Content | 成功但无返回体(DELETE) |
| 301 | Moved Permanently | 永久重定向(http → https) |
| 302 | Found | 临时重定向(登录后跳首页) |
| 304 | Not Modified | 资源没变,用本地缓存 |
| 400 | Bad Request | 你的请求参数有问题 |
| 401 | Unauthorized | 没登录 |
| 403 | Forbidden | 登录了但没权限 |
| 404 | Not Found | 资源不存在 |
| 500 | Internal Server Error | 代码抛了未捕获异常 |
| 502 | Bad Gateway | 上游挂了/返回了非法内容 |
| 503 | Service Unavailable | 服务过载或维护 |
| 504 | Gateway Timeout | 网关等上游超时 |
502 是网关收到了垃圾回复(上游活着但回复不对),504 是网关压根没收到回复(上游超时或挂了)。
现场:安全团队扫你的 Nginx access log,发现用户手机号出现在 URL 里:
安全审计不通过。GET 的参数在 URL 的 query string 里,会被:Nginx access log 记录、浏览器历史记录保存、CDN/代理缓存、Referer 头泄露给第三方。
GET vs POST 的实际区别
| 维度 | GET | POST |
|---|---|---|
| 语义 | 读操作,safe + idempotent | 写操作,非幂等 |
| 参数位置 | URL query string | 请求体 |
| 缓存 | 可被浏览器缓存、收藏书签 | 不可以 |
| URL 长度限制 | 浏览器限制 2KB~8KB | 不受限 |
| 日志暴露 | 参数在 URL 中被记录 | 请求体不记日志(默认) |
| 编码 | 只支持 URL 编码 | 支持 multipart、JSON 等 |
从协议层面看,GET 可以带 body,POST 也能用 query string,区别更多是语义规范和浏览器实现的约定。但约定就是约定,你不遵守就会出安全事故。
现场:你把服务从机器 A 迁到机器 B,域名 DNS 指向改了,但有些客户端请求还在打到旧机器 A(已下线),报连接超时。
根因:DNS 有多级缓存,每一级的 TTL 不一样。你改了权威 DNS 的记录,但中间层的缓存还没过期。
DNS 解析完整链路
1. 先把 DNS TTL 调到很短(比如 60 秒)
2. 等旧 TTL 过期(比如原来是 1 小时,就等 1 小时)
3. 改 DNS 记录指向新机器
4. 确认流量全切过去后,再把 TTL 调回正常值
你不知道 DNS 多级缓存,你就不明白为什么"我明明改了 DNS 怎么还有流量打到旧机器"。
每个事故就是一个面试题的入口。面试官问"说说 TCP 四次挥手",你脑子里浮现的不应该是"FIN ACK FIN ACK",而是"TIME_WAIT 堆积把端口耗尽那次事故"。
从事故讲到原理,从原理讲到解法。这样你答出来的不是背的,是理解的。