03 · 计算机网络:TCP 与 UDP——游戏视角
定位:游戏岗的网络题有一半在问 TCP/UDP 的原理,另一半在问 “你们游戏为什么选 UDP”——后者是游戏客户端/服务器岗的分水岭题,答好了直接把话题引到 KCP/帧同步这些你熟悉的领域。本章把 TCP 的连接、可靠、拥塞三块 + UDP + 粘包一次讲透。 学完标准:三次握手/四次挥手每一步的状态和“为什么”都能说;流量控制 vs 拥塞控制分得清;能从队头阻塞角度论证“实时战斗为什么不用 TCP”;能接着讲 NAT 怎么打洞、哪些包值得在 UDP 上重传,以及帧同步和状态同步怎么选。
1. 四层模型
Section titled “1. 四层模型”| TCP/IP 四层 | 干什么 | 代表协议/设备 | 游戏里的对应 |
|---|---|---|---|
| 应用层 | 业务语义 | HTTP/DNS/自定义游戏协议 | 登录协议、帧同步指令、聊天 |
| 传输层 | 端到端(进程到进程) | TCP/UDP(端口号在这里) | 战斗连接 vs 登录/支付 |
| 网络层 | 主机到主机(全球寻址) | IP/ICMP(路由器) | 玩家到机房 |
| 链路层 | 相邻节点传帧 | 以太网/WiFi(交换机、MAC) | 最后一公里 |
面试金句:“TCP/UDP 只管把你送到对方’端口’,IP 只管送到对方’主机’——层次各管一段,出错各层各补。” 为什么分层:解耦(换 WiFi 不影响应用)、复用(HTTP 和游戏协议共用同一条 IP 路)、定位问题(丢包在哪层查)。
2. 三次握手与四次挥手
Section titled “2. 三次握手与四次挥手”三次握手(每步状态要背):
- 为什么不是两次? 两个理由:①防止历史重复 SYN 建立无效连接——旧 SYN 迟到,两次握手下服务端直接建立、单方面浪费资源;三次让客户端可以用第三次 ACK 拒掉(收到乱序 ack 就 RST)。②确认双方收发能力都正常:两次无法证明“服务端发的包客户端能收到”。
- 第三次 ACK 丢了怎么办? 服务端重传 SYN+ACK;此时客户端已 ESTABLISHED,若它发数据,数据包自带 ack 也能让服务端完成建立。
- 能带数据吗? 前两次不行,第三次 ACK 可以捎带数据(TCP Fast Open 就是利用它)。
四次挥手:
- 为什么四次而握手只要三次? 挥手时被动方的 ACK 和 FIN 不能合并——收到 FIN 只说明对方不发了,自己可能还有数据没发完,得先 ACK、等应用层把数据发完再单独 FIN。握手时服务端没这负担,SYN+ACK 一口气发。
- TIME_WAIT 为什么要等 2MSL(约 1~4 分钟)? ①保证最后一个 ACK 丢了还能收到对方重传的 FIN 并再回 ACK(让对方正常关闭);②让本连接的旧报文在网络里自然死亡,避免污染下一个相同四元组的新连接。
- TIME_WAIT 过多怎么办?(服务器高频短连接时堆积)
SO_REUSEADDR允许复用、开tcp_tw_reuse(时间戳保证下安全)、用长连接池从根上减少挥手次数。
面试金句:“握手三次是’确认双方都能收发’,挥手四次是’关发送和关接收要分开谈’;TIME_WAIT 等的是’网络里的遗孤报文死透’。”
3. TCP 的可靠性:序列号 + 确认 + 重传
Section titled “3. TCP 的可靠性:序列号 + 确认 + 重传”- 序列号 seq / 确认号 ack:每个字节编号;接收方回“我收到了前 ack-1 个字节”。解决丢、重复、乱序三件事(重复靠 seq 去重,乱序靠 seq 重排或丢弃重传)。
- 超时重传 RTO:发出去没 ack 就重发;RTO 自适应(RTT 加权平均 + 波动余量)。
- 快速重传:收到 3 个重复 ack 立刻重传对应段,不等超时——比超时触发快得多。
- SACK:选择性确认,告知收到的乱序区间,发送方只补洞。
流量控制 vs 拥塞控制(最易混,必考):
| 流量控制 | 拥塞控制 | |
|---|---|---|
| 保护谁 | 接收方(别把对方缓冲撑爆) | 网络(别把链路压死) |
| 依据 | 接收方通告的滑动窗口 rwnd | 发送方自己探测的 cwnd |
| 机制 | 窗口随接收方余量伸缩(零窗口→坚持定时器探活) | 慢启动→拥塞避免→快重传→快恢复 |
实际发送窗口 = min(rwnd, cwnd)。
拥塞控制四阶段(背曲线):
- 慢启动:cwnd 从 1 MSS 起,每 RTT 翻倍(指数涨,“慢”只是相对直冲);
- 拥塞避免:到阈值 ssthresh 后改为每 RTT +1(线性爬);
- 快重传:3 个重复 ack 立即重传丢失段;
- 快恢复:配合快重传——ssthresh = cwnd/2,cwnd 从新 ssthresh 起线性增长(不回到 1 重新慢启动);若是超时重传(说明网络真崩了)则 cwnd=1 重新慢启动。
4. 为什么游戏用 UDP
Section titled “4. 为什么游戏用 UDP”UDP 本体:无连接、不保证到达/顺序、尽力而为;头部 8 字节(TCP 20 起步);一对多广播/组播可用的就是它;没有拥塞控制=发多快你说了算(也是一把双刃剑)。
为什么实时战斗不用 TCP(按这条逻辑链答,满分):
- TCP 的可靠是有序的可靠——序号 5 丢了,6、7 到了也得在缓冲区等 5 重传,上层一毫秒都读不到:这叫 队头阻塞(HOL blocking);
- 实时游戏里“过时的包”没有价值——上一帧的位置丢了,正确做法是直接用最新帧,不是补发旧帧;
- TCP 拥塞控制会在丢包时主动降速(无线网络误丢也降)——延迟和抖动直接失控;还有 Nagle 算法攒小包、延迟 ack 攒确认,都在加延迟;
- UDP 头 8 字节 + 无连接 + 可控节奏——把“要不要重传、要不要顺序”还给应用层按业务定制。
所以游戏怎么组网(体现工程视野):
- 实时同步(位置/技能/帧同步指令):UDP + 应用层可靠性——收最新的、丢就丢了;关键事件(击杀/结算)在应用层做重传确认。
- 可靠有序(登录、匹配、聊天、支付):单独走 TCP——这些不在乎 100ms,在乎绝对可靠。
- KCP 思路(加分项):在 UDP 上做“可配置的 TCP”——ARQ(选择重传)+ 快速重传(1 个重复 ack 就触发,不等 3 个)+ 非退让流控(牺牲公平换延迟)+ 无序收包可选。一句话:TCP 求公平,KCP 求快,快在很多参数你可以自己调大(更频繁的重传=更低的延迟=更多的带宽浪费)。
5. NAT、打洞与可靠 UDP
Section titled “5. NAT、打洞与可靠 UDP”讲完为什么用 UDP,下一句通常是:玩家都在路由器后面,怎么连上?包丢了,关键技能怎么办?
NAT 为什么挡住 UDP:家用路由把内网 192.168.x.x:端口 换成公网 IP:端口。外面的人不知道内网地址;没有人先发包,映射就不存在,对方的包会被丢掉。TCP 有连接,NAT 好跟踪;UDP 无连接,映射还带超时,几十秒没流量就拆掉。
打洞(UDP hole punching),P2P 或帧同步主机常用这三步:
- 双方都先连一台有公网 IP 的协调服(STUN / 大厅),各自的 NAT 因此留下“内网 ↔ 公网”映射;
- 协调服把双方的公网 IP 和端口互相告诉对方;
- 双方同时向对方的公网地址发 UDP。各自的 NAT 看到这是自己先发出去的会话的回包,于是放行。这条映射就是洞。
要主动说出限制:对称 NAT 对每个目标换一个端口,打洞经常失败,退路是 TURN 中继(数据绕协调服转发,能通,但多一跳延迟)。现代做法会提 ICE:先试直连,失败再中继。答到“协调服交换地址 + 同时发包 + 失败走中继”就是满分骨架。映射有超时,所以要心跳保活。
可靠 UDP 的最小帧(应用层自己做,不要把 TCP 搬上来):
| 字段 | 作用 |
|---|---|
| sequence | 本包序号。收方永远保留最新序号,旧包直接丢(位置同步) |
| ack + ack_bits | “我收到到哪了”,外加最近 32 个包的位图。一次确认盖住一小段,比逐包 ACK 省 |
| 时间戳 | 算 RTT,做插值 |
| payload | 业务。单包留在 MTU 内(≤1472),自己拆,不靠 IP 分片 |
两条策略分开讲,不要混在一起:
- 状态流(位置、朝向):不可靠,只取最新。丢了就丢,下一帧盖掉。
- 事件流(开火、技能、结算):可靠。没出现在 ack_bits 里就重传,但重传次数按玩法调。这就是 KCP 相对 TCP 的差别:TCP 为了公平会退让,游戏为了延迟可以更勤地重传,用带宽换卡顿。
抖动缓冲(jitter buffer):包的到达间隔忽大忽小。渲染和逻辑不要来一包用一包,而是先积几十毫秒,按时间戳匀速取。缓冲越大越顺,延迟也越高。竞技游戏把这个数压到还能接受的最低。
帧同步和状态同步(UDP 题的下一问):网易、腾讯的客户端面几乎都会接这一句。前面的可靠 UDP 是传输策略,这一问是玩法层怎么用它。
确定性三件套,少一件就会各端结果不同:固定逻辑帧(不要用渲染的 deltaTime 推逻辑)、同一套随机数、不要依赖遍历顺序不稳定的容器。浮点在不同 CPU 上也可能分叉,严肃的帧同步会锁指令或改用定点数。一句话:状态同步传的是结果,帧同步传的是输入。
SYN Flood(握手题的安全追问):攻击者只发 SYN,不回第三次 ACK,服务端的半连接队列被占满,正常人排不进去。防御是 SYN Cookie:不存半连接状态,把状态编码进自己发出的序号,第三次 ACK 回来再校验重建。
面试金句:“UDP 解决的是不要队头阻塞;NAT 打洞解决的是双方都没有公网地址;可靠 UDP 解决的是哪些包值得重传。三件事是三层,不要用 TCP 一把梭。”
6. 粘包 / 拆包(TCP 特有,游戏协议设计题)
Section titled “6. 粘包 / 拆包(TCP 特有,游戏协议设计题)”为什么“包”会粘:TCP 是字节流,没有消息边界——它只保证字节按序到达,根本不知道你的“一条消息”是什么。Nagle/延迟确认还会把小段合并,recv 一次可能读到 半条 + 一条 + 半条。
四种解法(游戏主流是第 3 种):
| 方案 | 做法 | 用在哪 |
|---|---|---|
| 定长消息 | 每条固定 N 字节,不够补齐 | 简单但浪费,少见 |
| 分隔符 | 特殊字符结尾(\r\n) |
文本协议(Redis、HTTP 头) |
| 长度前缀(TLV) | 头部 len + type,先读头再读 len 字节体 |
游戏标准做法 |
| 应用层约定 | 靠上层状态机切 | 复杂协议 |
// 游戏网络层经典套路:环形缓冲 + 状态机while (有数据) { if (缓冲不足 头部长度) break; // 等半个头 读头 { uint16 len; uint16 msg_id; }; if (缓冲不足 len) break; // 等半个体 dispatch(msg_id, 取出 len 字节);}追问“UDP 有粘包吗?”——没有。UDP 是数据报,
recvfrom一次就是对方一次sendto的完整内容;但代价是单包有大小上限(建议 ≤ MTU 1472 字节避免 IP 分片——分片丢一片整包作废,实时游戏宁可拆小包)。
7. 高频面试题 Q&A(合上书能讲)
Section titled “7. 高频面试题 Q&A(合上书能讲)”Q1:TCP 和 UDP 的区别? 连接(有/无)、可靠性(确认重传有序/尽力而为)、头部(20+/8 字节)、流量+拥塞控制(有/无)、通信形态(一对一/支持广播组播)、速度与开销(慢重/快轻)。选型一句话:要可靠有序走 TCP,要低延迟可控走 UDP,游戏里通常两路并存。
Q2:三次握手为什么不能是两次? ①防历史 SYN:旧 SYN 迟到,两次握手下服务端直接建成无效连接,三次握手的客户端可以拒绝;②两次无法确认“服务端的包客户端能收到”,收发能力没验证完。
Q3:四次挥手为什么四次?TIME_WAIT 为什么等 2MSL? 被动方收到 FIN 只代表对方不发了,自己可能还有数据——ACK 先回,数据发完再 FIN,所以 ACK 和 FIN 分开。TIME_WAIT 两个目的:①最后一个 ACK 丢了还能应答对方重传的 FIN;②让旧报文自然死亡,不污染同四元组的新连接。
Q4:流量控制和拥塞控制的区别? 流量控制保护接收方(按对方通告的 rwnd 伸缩发送窗口);拥塞控制保护网络(自己探测 cwnd:慢启动指数、拥塞避免线性、3 重复 ack 快重传、快恢复减半不归一)。实际窗口取两者最小值。
Q5:TCP 怎么保证可靠? 序列号+确认号(丢/重/乱的治理)、自适应超时重传、快速重传(3 重复 ack)、校验和、滑动窗口流控、拥塞控制、连接管理。本质是“编号+确认+重传”三板斧加上一堆优化。
Q6:为什么游戏实时同步用 UDP? 核心是 TCP 的有序可靠造成队头阻塞:丢一个包,后面到的也得等重传,上层延迟直接抖动;而实时游戏里旧数据(上上帧位置)没有补发的价值,要的是“永远读最新”。加上 TCP 拥塞控制遇丢包降速、Nagle/延迟 ack 攒包,延迟不可控。UDP 让应用层自己定策略:常规状态走“只收最新”,关键事件应用层确认重传,可靠有序的(登录聊天)另开 TCP。
Q7:什么是粘包?怎么解决? TCP 是无边界字节流,recv 可能一次读到多条/半条消息。解法:定长、分隔符、长度前缀(游戏主流:头=长度+类型)、应用层状态机。UDP 无此问题(数据报保边界),但有单包尺寸建议(≤MTU 防分片)。
Q8:什么是队头阻塞? 前一环没就绪,后面排队的全被堵:TCP 层=序号低的丢包堵住后面已到的数据;HTTP/1.1 层=前一个响应没回完堵住同连接的下一个请求(HTTP/2 在 TCP 上仍有 TCP 层 HOL,QUIC 彻底解)。
Q9:RTO 和快重传的区别? RTO 是超时兜底(等一个自适应的超时时间,慢);快重传靠 3 个重复 ack 提前触发(收方连收 3 个后继段说明中间那个丢了),快得多——代价是只能定位“第一个洞”。
Q10:TCP keep-alive 和应用层心跳的区别? TCP keepalive 默认 2 小时才探测且只探测连接不探测业务,只适合清死链;游戏要的是秒级“玩家还活着吗+RTT 测量”,必须应用层心跳(顺带校时、带宽探测)。
Q11:一个 UDP 包最大能发多大? 理论上 64KB,但超过 MTU(以太网约 1500 字节,减头剩 ~1472)会 IP 分片——任何一片丢失整包重发,实时游戏应自己拆小包,不靠 IP 分片。
Q12:玩家都在 NAT 后面,UDP 怎么连上? 双方先连公网协调服,NAT 上留下映射;协调服交换双方的公网 IP 和端口;双方同时向对方发包,NAT 把回包当成自己发出会话的响应而放行,这就是打洞。对称 NAT 经常失败,就退到 TURN 中继,多一跳延迟。完整方案是 ICE:先尝试直连,不行再中继。映射有超时,要用心跳保活。
Q13:可靠 UDP 和 TCP 有什么区别?帧里放什么? TCP 是有序可靠,再加拥塞退让,丢一个包后面全部等待。可靠 UDP 把策略拆开:位置类只保留最新序号,旧包丢弃;技能和结算用序号加 ack 位图做选择重传,重传可以比 TCP 更勤,用带宽换延迟。帧里至少有 sequence、ack、ack_bits、时间戳和 payload,并且自己把包控制在 MTU 内。抖动用一小段缓冲,按时间戳匀速消费。
Q14:帧同步和状态同步怎么选? 状态同步是服务器算结果、广播实体状态,客户端插值,适合 MMO 和射击,重连拿快照,带宽随实体涨。帧同步是各端跑同一份确定性逻辑、网上只传操作,适合 MOBA 和格斗,带宽小,但浮点、随机数、遍历顺序都不能分叉,断线往往要追帧。一句话:状态同步传结果,帧同步传输入。
8. 本章自测(10 题,限时 20 分钟,先自己答再看答案)
Section titled “8. 本章自测(10 题,限时 20 分钟,先自己答再看答案)”1. 三次握手中每一步双方的 TCP 状态?
2. 第三次握手的 ACK 丢了会怎样?
3. 挥手为什么比握手多一次?
4. TIME_WAIT 停留在谁那边?
我的原答:等多久?两个目的?
5. 拥塞控制四阶段,各自 cwnd 怎么变?
6. 快重传的触发条件?为什么不等到超时?
7. 从队头阻塞出发,说清"实时战斗为什么不用 TCP"。
8. KCP 在 UDP 上加了什么?为什么比 TCP 快?
9. 粘包的四字解法?
我的原答:游戏主流是哪种、头里放什么?
10. 为什么游戏要应用层心跳而不是依赖 TCP keepalive?
📖 答案(先自己答完再展开)
- 客户端 SYN_SENT→ESTABLISHED;服务端 LISTEN→SYN_RCVD→ESTABLISHED(收到第三次 ACK)。
- 服务端重传 SYN+ACK;客户端若已发数据,数据包自带 ack 也能让服务端进入 ESTABLISHED。
- 被动方收到 FIN 只知对方不发,自己可能还有数据——先 ACK,发完再 FIN;两次动作不能合并,握手时 SYN+ACK 能合并所以只需三次。
- 主动关闭方;2MSL;①应答对方重传的 FIN 保证其正常关闭 ②旧报文死透不污染新连接。
- 慢启动:每 RTT 翻倍;到 ssthresh 后拥塞避免:每 RTT +1;3 重复 ack→快重传立即补发;快恢复:ssthresh 和 cwnd 减半后线性涨(超时则 cwnd=1 重新慢启动)。
- 收到 3 个重复 ack;说明后继段已到、中间那个大概率丢了——比等超时(一个 RTT 量级起步)快得多。
- TCP 有序可靠→丢一包后面全堵(队头阻塞);实时数据过时就无用,要“读最新”而非“补旧”;TCP 拥塞控制遇丢降速 + Nagle/延迟 ack 加延迟;UDP 无连接、头小、节奏可控,可靠性按业务在应用层定制(常规状态不重传、关键事件确认)。
- ARQ 选择重传 + 1 个重复 ack 即快重传 + 非退让流控 + 可选无序接收;快在重传更激进、不等保守超时、牺牲带宽换延迟——参数可按业务调。
- 定长/分隔符/长度前缀/应用层状态机;主流=长度前缀:头放 len+msg_id,先读头再按 len 收体。
- TCP keepalive 默认 2 小时、只探连接不探业务;游戏要秒级活性判断,心跳顺带测 RTT、校时、估算带宽。
9. 进阶追问(答不上来就回来复习)
Section titled “9. 进阶追问(答不上来就回来复习)”- SYN Flood 是什么?怎么防?见本章 §5(半连接队列被打满;SYN Cookie 不存半连接状态)
- QUIC/HTTP3 怎么解决 TCP 队头阻塞?(应用层 UDP 上做多流,流间独立无序交付;版本对比见 04)
- 窗口缩放、时间戳选项各干嘛?(大带宽长延迟网络的窗口扩容;精确 RTT + 防旧序号混入)
- 游戏网络线程收到 UDP 包后的完整路径?见本章 §5 和 §6,加上本项目 02:epoll 唤醒 → recvfrom → 环形缓冲 → 长度前缀切包 → 按序号取最新或按 ack 位图重传 → 消息分发到逻辑队列(与 09 章的任务队列接上)