• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-07-17 19:06 Aet 隐藏边栏 |   抢沙发  2 
文章评分 1 次,平均分 5.0

DHCP

要解决的问题

  1. 如果每台新接入网络的设备,都要人工手动配置IP地址、子网掩码、默认网关、DNS服务器地址,在一个有几百上千台设备的网络里(比如公司、学校),这个工作量会非常大
  2. 而且容易出现IP地址冲突(两台设备手动配置了相同的IP)

DHCP(Dynamic Host Configuration Protocol,动态主机配置协议)

  1. 就是用来自动化解决这个问题的
    1. 一台设备刚接入网络时,自己没有任何IP地址,通过DHCP协议,向网络里的DHCP服务器"要"一个可用的IP配置,服务器自动分配,不需要人工干预

经典的DORA四步交互过程

  1. Discover(发现)
    1. 客户端广播一个"有没有DHCP服务器"的请求包,发给255.255.255.255(广播地址),局域网内所有设备都会收到,但只有DHCP服务器会响应
  2. Offer(提供)
    1. DHCP服务器收到请求后,从自己维护的地址池里挑一个尚未分配的IP,连同子网掩码、租约时间、网关地址、DNS服务器地址这些信息,打包成一个Offer广播回去
    2. 此时客户端还没有正式的IP,服务端理论上可以尝试单播到客户端提供的临时标识上,但很多实现里仍然选择广播,以保证兼容性和简单性
    3. 如果局域网里有多个DHCP服务器,客户端可能会收到多份Offer,客户端通常会选择最先收到的那一份
  3. Request(请求)
    1. 客户端从收到的一个或多个Offer里,选定其中一个,再次用广播的方式,明确告诉网络"我确认要接受这个IP地址"
    2. 这次广播还有一个重要作用:让局域网内其他没被选中的DHCP服务器知道'这次不用你了',它们可以把之前为这次请求预留的IP地址释放回地址池
  4. Ack(确认)
    1. 被选中的那台DHCP服务器,正式确认这次分配,客户端收到Ack后,才会真正把这个IP地址、子网掩码、网关等信息应用到自己的网卡配置上
    2. 这一步完成之后,客户端才第一次真正拥有了一个可用的IP地址,可以开始正常的网络通信

DHCP用的是UDP,不是TCP

  1. DHCP基于UDP,服务器监听67端口,客户端监听68端口
  2. 为什么不用TCP
    1. TCP需要先建立连接(三次握手),而建立TCP连接的前提是双方都得有IP地址

DHCP具体分配了哪些配置信息

  1. Offer/Ack里携带的信息,通常包括:
  2. IP地址:
    1. 分配给这台设备的具体IP
  3. 子网掩码:
    1. 决定这个IP所在网段的范围
  4. 默认网关地址:
    1. 默认路由的下一跳
    2. DHCP会把这个信息下发给客户端,客户端收到后,自动在本机路由表里加上一条默认路由(0.0.0.0/0指向这个网关)
  5. DNS服务器地址:
    1. 告诉客户端,以后要把域名解析成IP,该去问哪个DNS服务器
  6. 租约时间(Lease Time):
    1. 这个IP地址的"使用期限",不是永久分配的

租约机制——为什么IP地址有"到期时间"

  1. DHCP分配的IP地址不是永久的,而是有一个租约期(常见配置是几小时到几天不等)
  2. 这个设计的意义在于
    1. IP地址池是有限的资源,如果一台设备离开了网络(比如关机、拔网线),它原来占用的IP应该能被收回,重新分配给别的设备,而不是永久占着

续租机制

  1. 客户端在租约到期之前(通常是租约时间过半时),会主动向DHCP服务器发一个续租请求,如果服务器同意,租约时间重新计时
  2. 如果客户端在租约到期前一直没有成功续租(比如网络断开了一段时间),这个IP地址会被服务器收回,重新放回地址池
    1. 客户端如果之后重新连上网络,可能会被分配到一个完全不同的IP地址

DHCP中继(DHCP Relay)

  1. DHCP高度依赖广播,但广播包默认情况下不会被路由器转发到其他网段(这是广播的固有特性,避免广播风暴扩散到整个大网络)
  2. 这就带来一个问题
    1. 如果DHCP服务器和客户端不在同一个局域网/网段,客户端的广播请求根本传不到服务器那里
  3. 解决方案是DHCP中继代理(DHCP Relay Agent)
    1. 通常由路由器或者交换机充当,它工作在客户端所在的网段,监听到DHCP广播请求后,把这个请求转换成单播,直接转发给远端的DHCP服务器,再把服务器的响应转发回客户端所在网段并重新广播出去

为什么每一步都要"广播"

  1. 这是理解DHCP最反直觉的一点
    1. 整个过程几乎全程使用广播,而不是像正常通信那样"发给某个具体的IP"
  2. 原因很简单
    1. 客户端此时根本没有IP地址(源IP是0.0.0.0),自然也不知道DHCP服务器的IP地址是什么,没法"点对点"通信,只能靠广播,让同一个局域网内所有设备都收到这个包,由DHCP服务器自己识别并响应

子网掩码

概述

  1. 子网掩码本身不是直接"决定"网段范围的独立信息,而是用来把一个IP地址,拆分成"网络部分"和"主机部分"两段,而"网络部分相同"就是"处于同一网段"的判定标准

本质

  1. 一个用来做"按位与"运算的过滤器
  2. 子网掩码(比如255.255.255.0)和IP地址一样,是一个32位的二进制数,写成二进制是:

它的作用是

  1. IP地址按位分成两部分——掩码里"1"对应的位,是IP地址的"网络部分"
  2. 掩码里"0"对应的位,是IP地址的"主机部分"

具体怎么算出"网段"

  1. 拿一个具体的IP举例:192.168.1.10,子网掩码255.255.255.0

算出网段之后,具体怎么用

  1. 本机要判断"我要访问的目标IP,和我是不是同一网段",具体算法是:

  2. 用本机IP AND 子网掩码,算出本机所在的网络号

  3. 用目标IP AND 同一个子网掩码,算出目标所在的网络号

  4. 两个网络号一样,就是同网段(可以直接ARP解析目标MAC)
    不一样,就要走网关(ARP解析网关MAC,让网关帮忙转发)

  5. 掩码越长,网段越小(可用IP越少)

    1. 255.255.255.0这种写法,更常见的是用CIDR记法表示为/24——意思是"前24位是网络部分"

默认网关

概述

  1. 默认网关准确来说是"一个和你在同一网段内、负责帮你转发跨网段流量的设备的IP地址"

家庭场景下

  1. 这个设备确实通常就是你的路由器
  2. 你电脑上配置的"默认网关地址",填的正是路由器LAN口(局域网这一侧)的IP地址
  3. 常见的是192.168.1.1192.168.0.1这类地址

更严谨的定义

  1. 网关是"角色",不是特指某种设备类型

  2. 一个和你的设备处于同一网段、且开启了IP转发功能的设备,它的IP地址就是你的默认网关

  3. 这个"角色"通常由路由器承担,但本质上,任何一台配置正确、开启了转发功能的机器,理论上都能充当网关,不一定非得是一台"路由器"这个专用硬件产品

  4. 比如

    1. 这里你电脑上配置的默认网关,可能是一台三层交换机上配置的网关地址,而不是一台传统意义上的"路由器"设备

网关有自己的公网IP

  1. 经典情况:路由器WAN口拥有真正的公网IP
    1. 在这种(曾经是主流的)情况下:
    2. 路由器的WAN口,通过PPPoE拨号或者DHCP从你的宽带运营商那里,获得一个真正的、全球唯一、可被公网直接路由的IP地址

  1. 越来越常见的例外:CGNAT(运营商级NAT)
    1. 由于IPv4地址枯竭日益严重,现在很多宽带运营商(尤其是移动宽带、部分地区的家庭宽带),不再给每个家庭用户分配一个真正的公网IP,而是:
    2. 这意味着,你的路由器的WAN口,拿到的其实是一个"运营商内部使用的私有地址"

  1. 怎么判断自己是不是处于CGNAT之下
    1. 登录路由器的管理后台,看它WAN口显示的IP地址,访问ip.sb这类网站查到的公网IP对比
    2. 如果两者不一致,就是处于CGNAT之下

LAN

  1. LAN(Local Area Network,局域网)口
  2. 连接家里内部的设备
  3. 私有地址

WAN

  1. WAN(Wide Area Network,广域网)口
  2. 连接家以外的网络——通常是一根从光猫插过来的网线,连接的是宽带运营商网络
  3. 可能是真正的公网IP,也可能是CGNAT给你分配的运营商内部私有地址

IP转发功能

IP转发功能的本质

  1. 一个内核层面的开关
  2. IP转发(IP Forwarding)是操作系统内核的一个配置项,决定这台机器,收到一个"目的IP不是自己"的数据包时,应该怎么处理:
    1. 关闭(默认状态,普通主机):直接丢弃这个包,不做任何转发
    2. 开启(路由器/网关角色的机器):根据本机的路由表,把这个包重新转发出去,发给正确的下一跳

为什么普通电脑默认关闭这个功能

  1. 如果所有设备(手机、电脑)默认都开启了IP转发功能会怎样
    1. 任何一台设备,只要收到了一个不是发给自己的包,都会尝试帮忙转发
    2. 这不仅没有实际意义(普通设备通常只有一个网络接口,能转发到哪里去?
    3. 还存在明显的安全隐患:一台被入侵的普通电脑,如果开启了IP转发,可能被利用成一个跳板,在你完全不知情的情况下帮攻击者转发恶意流量、绕过网络边界的安全策略

具体怎么查看和开启

  1. linux

  1. windows

NAT(网络地址转换)的关系

  1. NAT是"转发+改写"的一种特殊形式
    1. NAT功能的正常工作,必须建立在"IP转发功能已经开启"这个基础之上
  2. NAT的完整流程
    1. 路由器收到一个不是发给自己的包
    2. 判定要转发
    3. 在转发之前顺便做地址改写(NAT)
    4. 重新封装、发出去
  3. "判定要转发"这一步,正是IP转发功能在起作用
    1. 如果这个开关是关闭的,路由器收到这种包会直接丢弃,连"要不要做NAT"这个问题都不会被问到,NAT规则根本没有机会被执行

NAT(网络地址转换)

NAT要解决的根本问题

  1. 私有IP地址不能在公网上被直接路由,全世界无数个局域网都在复用192.168.x.x这类地址段,私有IP地址不能在公网上被直接路由,全世界无数个局域网都在复用192.168.x.x这类地址段
    1. NAT的作用,就是在私有网络和公网的边界(通常是你家的路由器),把内网设备的私有IP,转换成一个外部可路由的公网IP
  2. 还有一个同样重要的原因:IPv4地址枯竭
    1. IPv4总共只有大约43亿个地址,远远不够给全球每一台联网设备都分配一个独立的公网IP
    2. NAT让一个公网IP,可以被一整个内网(可能几十上百台设备)共享使用,这是缓解IPv4地址不够用这个问题的一个关键技术手段

NAT的三种类型

  1. 静态NAT(Static NAT)
    1. 一对一、固定的映射关系:一个内网私有IP,永久对应一个公网IP
    2. 这种方式不节省公网IP地址(有多少台内网设备要联网,就要占用多少个公网IP),所以很少用于普通场景
    3. 常见于企业需要把内部某台服务器,永久暴露给公网访问的场景
  2. 动态NAT(Dynamic NAT)
    1. 路由器维护一个公网IP地址池(比如有10个公网IP),内网设备访问外网时,从池子里动态挑一个空闲的公网IP临时分配给它用,用完(连接结束)释放回池子给别的设备用
    2. 这种方式比静态NAT稍微节省一点,但如果同时上网的设备数超过了地址池大小,还是会不够用
    3. 这也不是现在最常见的方案
  3. NAPT / PAT(端口地址转换)
    1. 这是目前绝对主流的NAT方案,因为它能做到"一个公网IP,同时支撑成千上万个内网设备的并发连接"
    2. 原理是利用TCP/UDP的端口号这个维度,做进一步的区分

NAPT内部维护的映射表——具体怎么区分成大量连接

映射表条目的完整生命周期

  1. 创建:
    1. 内网设备第一次发起一个连接(比如TCPSYN包,或者UDP的第一个数据包)经过路由器时,路由器会创建一条新的映射记录,内容大致是:

  1. 使用:
    1. 后续这条连接的所有后续包(不管是发出去的,还是对方回复回来的),路由器都根据这条记录做相应的地址/端口改写和转发
    2. 发出去的包,把源地址改成公网IP:公网端口
    3. 回来的包,路由器看到目标是"公网IP:公网端口"这个组合,反查这张表,找到对应的内网IP:内网端口,把目标地址改回内网地址,再转发进内网
  2. 销毁:
    1. TCP连接正常四次挥手关闭后,这条映射记录通常会保留一小段时间(应对可能的重传包),之后被清理释放
    2. UDP因为没有连接的概念(无状态),路由器通常靠"一段时间没有新流量经过就超时清理"这种方式,来判断这条NAT映射是否还需要保留
    3. 这个超时机制,也是很多依赖UDP的实时应用(包括游戏)需要"心跳保活"的另一层原因:
      如果长时间不发包,NAT映射条目可能被路由器清理掉,即使应用层连接逻辑上还"活着",底层的NAT穿透路径也已经失效了

NAT带来的一个核心问题

  1. 外部主动发起的连接无法直接进来
    1. 这是NAT机制的一个副作用:
    2. NAT映射表的创建,依赖"内网设备率先发起连接"这个动作
    3. 如果没有这个"先发出去"的动作,路由器根本不知道该往哪个内网设备转发,所以外部主动发起的连接,默认情况下无法穿透NAT,直接找到内网某台设备

NAT穿透(NAT Traversal)技术

  1. :两个客户端都处于各自的NAT之后,双方都没有公网IP,都不能"被动"接受对方发起的连接
    1. 这时候需要用到专门的NAT穿透技术
  2. UDP打洞(UDP Hole Punching):
    1. 利用一个第三方公网服务器(通常叫STUN服务器)辅助,让两个客户端几乎同时向对方发送UDP包,"骗"过双方的NAT,让各自的NAT认为"是我这边先发起的",从而各自在NAT上打开一个"洞",使得对方的包能够穿透进来
  3. TURN服务器:
    1. 如果打洞失败(某些类型的NAT,比如对称型NAT,打洞成功率很低),退而求其次,通过一台公网服务器中转双方的流量,牺牲一些延迟,换取连接的可靠性

UDP打洞(UDP Hole Punching)

UDP打洞的核心巧思在于

  1. 利用NAT"看到内网设备主动发出去过东西,就允许对应方向的回复包进来"这条通用规则
  2. 让两台设备互相"骗"对方的NAT,以为对方发来的包是"我方主动请求后的正常回复",而不是一次全新的、外部主动发起的连接

前提:NAT的一条通用行为规则

  1. 当内网设备A,主动向外网某个地址发送过一个包之后,NAT会在映射表里记住这条"通道",并且在一段时间内,允许从那个外部地址返回的包,顺着这条通道进入内网,转发给A
    1. 这是NAT为了让"正常的请求-响应"通信能工作,必须具备的基本行为
  2. UDP打洞的关键漏洞利用点在于
    1. NAT在判断"要不要放行这个入站包"时,通常只看这个包的来源IP和端口,和之前记录的映射是否匹配,并不会去验证这个包在内容上是不是真的是'对之前那次请求的回复'
    2. 它只是机械地检查"地址对不对得上",对不上就照放行

完整的打洞步骤

信令服务器怎么保证AB拿到对方的公网地址

  1. 前提:AB各自与信令服务器建立的是两条独立的TCP连接
    1. 每一条TCP连接,在服务端这边,都对应一个独立的socket对象(accept()返回全新socket、五元组唯一标识这条连接)
    2. 服务端完全能够准确区分"这条消息是从A的连接发来的,那条消息是从B的连接发来的"
  2. 信令服务器内部,靠什么把"消息"和"具体是谁"对应起来
    1. 在于应用层的业务逻辑设计:
    2. 服务器需要维护一张"用户身份"到"这个用户对应的那条TCP连接(socket)"的映射表
  3. 具体流程通常是这样:
    1. A连接到信令服务器后,第一件事通常是发一条"我是谁"的消息(比如带上一个用户ID、或者游戏里的玩家ID,这个ID通常是A之前登录游戏账号系统时就已经确定好的)
      服务器收到后,在自己维护的连接表里,记录一条用户ID → 这条TCP连接对应的socket/ConnectionContext的映射
      B连接上来后,做同样的事情
    2. 第二步:A明确指定要和"用户B"建立连接
    3. 第三步:服务器根据这个身份标识,精确转发

为什么这个过程能"骗"过NAT

  1. 第一步:借助STUN服务器,发现自己在NAT外的"公网映射地址"
    1. 客户端A并不知道自己经过NAT转换后,在公网上呈现出来的地址是什么
    2. STUN服务器的作用很简单:ASTUN服务器发一个UDP包,STUN服务器看这个包到达自己这里时,源地址显示的是什么,把这个信息回复告诉A</li> <li>B也用同样的方式,得知自己是公网IP:端口Y
  2. 双方交换彼此的公网映射地址
    1. 这一步需要一个信令服务器(可以和STUN是同一台,也可以是专门的业务服务器)
    2. AB双方,把各自从STUN那里得知的公网映射地址,通过这台服务器互相告知对方
  3. 第三步(核心):双方几乎同时,主动向对方的公网映射地址发送UDP
    1. 这是打洞能成功的关键动作
    2. A主动向B的公网IP:端口Y发一个UDP包,B也主动向A的公网IP:端口X发一个UDP
  4. 为什么这个动作能"骗"过NAT
    1. A发包给B时,ANAT会创建一条映射:"允许来自B的公网IP:端口Y的包,进入到A的内网设备"
      这本来是为了让B将来的回复包能正常返回给A而设置的规则
    2. B发包给A时,BNAT同样会创建一条对称的映射:"允许来自A的公网IP:端口X的包,进入到B的内网设备"
    3. 关键在于:
    4. B主动发给A的这个包,虽然B自己的NAT认为"这是我方发起的新请求",但ANAT收到之后,发现来源地址正好匹配自己刚刚因为'A发给B'这个动作而创建的映射记录
    5. ANAT会误以为这是"BA之前那次请求的正常回复",从而放行
    6. 同样地,A发给B的包,也会被BNAT用同样的逻辑误判为"回复",而放行

为什么必须"几乎同时"

  1. 如果A先发包给B,但B这边还没来得及发包给A(比如B的动作慢了几百毫秒),这个时候A发的包到达BNAT时,BNAT里还没有为A创建任何映射记录
  2. NAT会认为这是一个"外部主动发起的、之前没见过的连接",大概率会直接丢弃
  3. 所以实际实现里
    1. 双方通常会收到对方的公网地址后,立刻开始发送,而且往往会连续发送多个包、并配合一定的重试机制,而不是只发一次

怎么做到几乎同时

  1. 其实不需要做到毫秒级别精确的"同时",这是一个常见的误解
  2. NAT映射有一个"有效期窗口",不是发出瞬间就失效
    1. NAT创建的映射记录,通常会保留几十秒到几分钟
    2. 这意味着"同时"这个词,不需要理解成"精确到毫秒级的物理同步",只需要做到"双方各自发起打洞动作的时间点,落在同一个大致的时间窗口内(比如几秒钟之内)",就完全够用了
  3. 具体的实现技巧1:借助信令服务器,统一发出"开始"信号
    1. 因为AB都通过TCP连接在线连着信令服务器

  1. 具体的实现技巧2(更关键、更实用):不是只发一次,而是连续重试发送
    1. 这是实际生产系统里真正用来解决"时序不确定性"的核心手段,比单纯依赖"服务器同步发令"更可靠
    2. 不指望"一次性精确同步",而是收到对方地址后,立即开始,连续、重复地发送UDP包(比如每隔100-200毫秒发一个,持续几秒钟),而不是只发一次就等待

并不是所有NAT类型都能打洞成功

  1. 完全锥形NAT(Full Cone NAT)
    1. 最容易打洞成功
    2. 只要内网设备发过一次包出去,这个映射对任何外部地址都开放,不限制"必须是之前发过去的那个目标"
  2. 受限锥形NAT(Restricted Cone NAT)
    1. 限制稍微严格一点,只允许"之前发送目标的IP"发回来的包,但不限制端口
  3. 端口受限锥形NAT(Port Restricted Cone NAT)
    1. 更严格,必须IP和端口都匹配之前发送的目标,才允许放行
  4. 对称型NAT(Symmetric NAT)
    1. 最难打洞
    2. 这种NAT的特点是,同一个内网设备,访问不同的外部目标时,NAT会分配不同的公网映射端口
    3. 这意味着,A为了给STUN服务器发包而暴露出来的那个"公网端口",和A实际发给B时用的端口可能完全不一样
    4. BSTUN那里拿到的A的地址信息,根本不是A实际会用来跟B通信的那个端口,打洞会直接失败
  5. 如果双方任意一方是对称型NAT(或者更糟,双方都是),UDP打洞的成功率会大幅下降,这时候通常只能退而求其次,使用TURN服务器做纯中转
    1. 这也是为什么实际生产的实时通信系统(包括WebRTC),都会同时准备"STUN(尝试直连)+ TURN(中转兜底)"两套方案,根据实际网络环境自动选择,不能只依赖打洞

TURN服务器

STUN的根本区别

  1. STUN
    1. 只负责告诉你,你自己在NAT外面看起来是什么地址
  2. TURN
    1. AB之间怎么打洞都打不通时
    2. TURN服务器会站出来,直接充当一个"数据转发中转站",A发的所有数据,先发给TURN服务器,再由TURN服务器转发给B,反之亦然

为什么TURN一定能成功,而打洞不一定

  1. TURN服务器本身部署在公网上,拥有真正的公网IP,而且没有NAT这一层障碍
  2. AB连接TURN服务器,都是"内网设备主动发起连接到一台公网服务器"这种最普通、最不会失败的连接模式

TURN协议内部的基本工作方式

  1. Allocate(分配)请求
    1. A首先向TURN服务器发一个"Allocate"请求,意思是"请在你这台服务器上,给我分配一个专属的中转地址(IP+端口)"
    2. TURN服务器同意后,会在自己身上开辟一个专属于A的中转端口,并把这个地址告诉A
  2. 交换中转地址(通过信令服务器)
    1. AB各自从TURN服务器拿到自己的"中转地址"后,依然需要通过信令服务器,把这个地址告诉对方(和交换STUN发现的地址是同一个流程,只是这次交换的是TURN分配的中转地址)
  3. Permission(权限)机制
    1. TURN服务器出于安全考虑,不会无条件地把任何发到这个中转端口的数据都转发出去——A需要显式告诉TURN服务器"允许B的地址往这里转发数据"
    2. 这是一个额外的权限声明步骤,防止TURN服务器被滥用成一个任意转发的开放代理
  4. Send/Data传输
    1. 之后A要发数据给B,实际操作是:
    2. A把数据包装在一个TURN协议的"Send"消息里,发给TURN服务器,注明"这是要转发给B的"
    3. TURN服务器收到后,剥离TURN协议的外层包装,把原始数据通过一个"Data"消息,转发给B
    4. 反向也是同样的流程

ICE框架

  1. STUN、打洞、TURN这几种手段,统一整合起来的标准做法

DNS服务器地址

DNS要解决的问题

  1. DNS(Domain Name System,域名系统)的作用,就是把这种人类好记的域名,翻译成机器真正需要的IP地址

DNS服务器地址是什么

  1. 常见的DNS服务器地址,比如:

DNS用的是UDP(通常),端口53

  1. DNS查询默认用UDP协议,原因和我们之前讲DHCPUDP类似
  2. 但如果返回结果的数据量超过UDP报文的合理大小限制(比如DNSSEC签名信息、或者一个域名对应很多条记录),会退化使用TCP,这也是DNS服务器同时要监听UDPTCP这两种协议、都用53端口的原因

TCP包大小

  1. 1500是标准以太网的MTU(最大传输单元)

UDP包大小

  1. UDP头只有8字节(源端口2字节+目的端口2字节+长度2字节+校验和2字节,是一个非常精简的头部,不像TCP头有序号、确认号、窗口这些字段)

应用层协议设计

消息类型区分

请求-响应的关联

  1. 在真实场景里,客户端可能会连续发出好几个不同类型的请求,这些请求的响应,不一定按发送顺序原样返回
  2. 解决方案是加一个"请求ID"字段
    1. 客户端每发一次请求,自己生成一个唯一的ID(比如自增计数器),服务端处理完,把这个ID原样带在响应里返回
    2. 客户端收到响应后,根据这个ID,找到自己之前对应哪一次请求,而不是依赖"先发的先回"这种脆弱的顺序假设

  1. 消息类型(Type)从1字节扩展成2字节
    1. 现在的MsgType只有3种(DATA/HEARTBEAT/HEARTBEAT_ACK),1字节(最多256种类型)看起来够用
    2. 但真实协议,消息类型可能有几百上千种

版本兼容性

  1. 如果没有版本号,新旧客户端和服务端之间,一旦协议格式变了,就会互相解析出错(比如老客户端不认识新加的字段,直接解析崩溃)

序列化格式的选择

  1. 目前的DATA消息,payload就是原始字符串
  2. 方案1:手写二进制格式
    1. 优点:体积最小、解析速度最快,没有任何冗余开销
    2. 缺点:每加/改一个字段,发送方和接收方的代码都要同步手动修改,维护成本高,容易出错
  3. 方案2:文本格式,比如JSON
    1. 优点:可读性极好,调试方便
    2. 缺点:体积比二进制大很多(字段名本身也要占用字节),解析速度比二进制慢(需要做字符串解析),对带宽和延迟敏感的游戏场景来说,通常不是首选

  1. 方案3:Protobuf(Google Protocol Buffers)这类二进制序列化框架
    1. 先用一种描述语言(.proto文件)定义好消息结构
    2. 然后用Protobuf的代码生成工具,自动生成对应语言(C++/C#/Java等)的序列化/反序列化代码
    3. 兼顾了体积小(接近手写二进制的效率)和维护性好(不用手动管理字段偏移量,加字段不容易破坏兼容性,Protobuf设计上天然支持"新增字段、老代码忽略未知字段"这种向后兼容

错误处理机制

  1. 目前的协议里,如果服务端处理某个请求失败了,没有任何机制能告诉客户端"为什么失败"
  2. 加一个错误码字段(比如0表示成功,非0表示各种具体错误原因)

消息类型的路由——大型项目里常见的"分发表"模式

  1. 现在ProcessIncomingData里用的是switch语句处理不同MsgType,消息类型一多(几百种),这个switch会变得又臭又长
  2. 实际生产项目常见的做法是用一张"消息类型→处理函数"的映射表(分发表)
    1. 这种设计的好处是每种消息类型的处理逻辑,可以独立写在单独的函数/文件里,新增消息类型不需要改动核心分发代码
    2. 更符合"开闭原则"(对扩展开放,对修改关闭)

安全性校验

  1. 当前代码没有对payloadLen做上限校验,这是一个需要在真实协议设计里明确补上的点

字节序

字节序要解决的问题(同一个数字,不同机器存储方式不同)

  1. 一个多字节的数字(比如uint32_t这种4字节整数),在计算机内存里,到底是"高位字节存在前面"还是"低位字节存在前面",不同CPU架构的约定是不一样的——这就是字节序(Endianness)

大端 vs 小端——两种存储方式

为什么会有这两种不同的约定

  1. 大端(Big-Endian)
    1. 数字的"高位"(大头)存在内存地址较低的位置
    2. 这种存储方式,和我们人类日常读写数字的习惯是一致的(比如读123,从左到右,先读"百位"这个高位)
  2. 小端(Little-Endian)
    1. 数字的"低位"(小头)存在内存地址较低的位置
    2. 这种方式,虽然反直觉,但在CPU硬件层面有一些计算上的便利性(比如做加法运算时,从低位字节开始处理,正好符合内存地址递增的访问顺序),历史上被Intel x86架构采用,并沿用至今
  3. 现在用的Windows电脑(x86/x64架构),内部用的是小端存储

如果不做转换,具体会出什么错

  1. 在你的小端机器上,内存里payloadLen这4个字节实际排列是03 00 00 00(低位在前)
  2. 这段数据被发到网络上,原样到达接收方
    1. 如果接收方是同样架构的小端机器,并且也没做字节序处理,直接把收到的03 00 00 00当成本机的uint32_t来读,恰好还能读出3,不会出错
    2. 但只要接收方按照"网络字节序标准(大端)"来解析这4个字节(这是所有正确实现的网络程序都应该做的事,不管它自己是什么架构),它会把03 00 00 00理解成:最高位字节是0x03,也就是把这个数字解析成0x03000000

四个转换函数

大端被选为"网络字节序标准"的原因

  1. 早期定义TCP/IP协议标准的时候(RFC文档里),就规定了所有网络协议里的多字节字段,统一使用大端表示,这个规定被称为"网络字节序"
  2. 选择大端而不是小端,更多是历史上的约定(和人类读数习惯一致,便于协议文档描述和调试),不是因为大端在技术上有什么性能优势

非阻塞IO设置

阻塞 vs 非阻塞

  1. windows

  1. linux
    1. Linux下的写法是"先读取现有标志,再用按位或加上O_NONBLOCK",而不是直接覆盖设置
    2. 因为fcntl的标志位可能同时存在好几个(比如还有O_APPEND这类其他标志),直接覆盖会不小心把其他已设置的标志清掉

非阻塞模式下,recv/send的返回值变化

  1. windows

  1. linux

IOCP场景下,还需要手动设置非阻塞吗

  1. IOCP用的是完全不同的一套异步机制,不依赖"把socket设成非阻塞"这个传统手段来实现异步行为
  2. 具体原理是:
    1. 当你调用WSARecv/WSASend,并且传入了一个OVERLAPPED结构体指针时,不管这个socket本身是阻塞还是非阻塞模式,这次调用都会立即返回(通常返回WSA_IO_PENDING,表示"操作已经在后台异步进行,还没完成"),不会卡住等待
    2. 重叠I/O(Overlapped I/O)"这个机制本身,已经内建了异步行为,不需要额外依赖socket的阻塞/非阻塞这个属性来实现"不卡住"这个效果

非阻塞+重试的组合模式

  1. select/epoll这类场景下,非阻塞模式配合"重试"逻辑,是标准的编程套路

滑动窗口

滑动窗口要解决的问题——流量控制

  1. 如果发送方"没头没脑"地把数据一股脑全发出去,而接收方处理数据的速度跟不上(比如接收方CPU繁忙、应用层没有及时调用recv把数据取走),接收方的接收缓冲区会被迅速填满,后续到达的数据会被直接丢弃
  2. 滑动窗口机制,就是让接收方能够动态地告诉发送方"我这边还能接收多少数据,你别发太多",从而实现流量控制,避免这种情况

窗口的构成——发送方视角的四个区域

已发送已确认

  1. 这部分数据,已经被对方确认收到了,可以安全地从发送缓冲区里清除,不需要再保留用于重传

已发送未确认

  1. 数据已经发出去了,但还没收到对方的ACK确认
  2. 这部分数据必须继续保留在发送缓冲区里(以备重传定时器超时后重新发送)

窗口内可以发送(但还没发)

  1. 这部分数据还没有被发出去,但根据当前的窗口大小,允许继续发送
  2. 一旦被发出去,就会从③区域转移到②区域

窗口外,暂时不能发

  1. 即使这部分数据在发送缓冲区里已经准备好了,协议不允许现在发出去,必须等窗口"滑动"之后才能发送

这四个区域的边界

  1. 对应的正是内核tcp_sock结构体时提到的字段
  2. snd_una(send unacknowledged):
    1. ①和②的分界点,"最早一个还没被确认的字节序号"
  3. snd_nxt:
    1. ②和③的分界点,"下一个要发送的字节序号"
  4. snd_una + snd_wnd:
    1. ③和④的分界点,窗口的右边界,snd_wnd就是对方通告给你的发送窗口大小

窗口是怎么"滑动"的

  1. 当发送方收到一个新的ACK(确认号比之前更大了),说明对方确实收到了一部分数据,这时候会发生两件事:
    1. snd_una往右移动,移动到这个新的确认号
      这意味着①区域(已确认)扩大了,原本②区域(已发送未确认)里被确认的部分,转移进了①区域
    2. 窗口的右边界(snd_una + snd_wnd)也跟着往右移动
      这意味着原本在④区域(窗口外,不能发)的一部分数据,现在进入了③区域(窗口内,可以发送了)
  2. 整个窗口(②+③这段范围)会随着ACK的不断到来,像一个滑块一样,持续向右移动,允许发送方不断把新的数据纳入"可以发送"的范围内

窗口大小从哪来

  1. 接收方每次发送ACK时,会在TCP头里的"窗口(Window)"字段,填上自己当前接收缓冲区还剩多少可用空间
    1. 这个数字,就是发送方看到的snd_wnd

流量控制

  1. 如果接收方处理数据很慢,导致接收缓冲区快满了,它会在下一次ACK里,通告一个更小的窗口值,发送方看到窗口变小,会相应地减慢发送速度,避免继续压垮接收方

极端情况——零窗口(Zero Window)

  1. 如果接收方缓冲区完全满了,会通告一个窗口值0,告诉发送方"你先别发了,我这边处理不过来"
  2. 发送方收到零窗口通知后,会暂停发送,但会启动一个持续定时器(Persist Timer),定期发送一个很小的"窗口探测"包,试探接收方的窗口是否已经恢复
    1. 因为如果接收方后续那个"窗口变大了"的通知包本身丢失了,双方可能会陷入死锁
    2. 发送方一直等通知,接收方以为自己已经通知过了

窗口大小的字段限制

  1. TCP头里表示窗口大小的字段,只有16位,最大只能表示65535字节
    1. 在现代高带宽网络下(尤其是长距离、高延迟的链路,俗称"长肥网络"),这个窗口上限会严重限制吞吐量
    2. 因为窗口大小,本质上限制了"发送方在收到确认之前,最多能有多少数据同时在传输路径上飞着"
    3. 如果带宽很大但窗口太小,发送方发完这一窗口的数据后,必须停下来等确认,无法充分利用带宽
  2. 窗口缩放选项(Window Scale, WS)
    1. 就是双方在握手阶段协商的一个"放大倍数"
    2. 实际的窗口大小 = TCP头里的窗口字段值 × 这个缩放因子
    3. 突破了16位字段65535字节的硬性限制,让窗口可以支持到几MB甚至更大,更好地适配高带宽高延迟的网络环境

拥塞控制

滑动窗口(流量控制) ≠ 拥塞窗口(拥塞控制)

  1. 滑动窗口(snd_wnd/rwnd):由接收方决定,目的是保护接收方自己,不被发送方发来的数据压垮
  2. 拥塞窗口(cwnd):由发送方根据网络的拥塞状况自己估算调整(慢启动、拥塞避免这些算法),目的是保护整个网络,避免发送方发太快导致网络中间节点(路由器)过载丢包

发送方实际能发送的数据量

  1. 发送方实际能发送的数据量,取这两个窗口中较小的那个
  2. 实际可发送量 = min(snd_wnd, cwnd)

拥塞控制要解决的问题

  1. 网络中间的路由器,处理能力和缓冲区都是有限的
    1. 如果所有发送方都毫无节制地拼命发数据,路由器的缓冲区会被打满,导致大量丢包,进而触发大量重传,重传又进一步加重网络负担
    2. 这是一个恶性循环,专业术语叫拥塞崩溃

核心思路

  1. 发送方需要有一种机制,自己去感知网络的拥堵程度,网络通畅时逐步提速,一旦感知到拥堵迹象(丢包),立刻大幅降速,给网络喘息的空间
  2. 这个"自己估算出来的、能发多快"的值,就是拥塞窗口(cwnd)

cwnd随时间变化的经典"锯齿图"

阶段①:慢启动(Slow Start)——指数增长

  1. 连接刚建立时,发送方对网络状况一无所知,不敢贸然发送大量数据,所以从一个很小的cwnd开始
    1. 通常是1-10MSS大小,具体数值在不同版本的TCP实现里有调整
  2. 每收到一个ACK,cwnd就增加1MSS
    1. 这个简单规则造成的实际效果是:
    2. 每经过一个完整的RTT(往返时延),cwnd大约翻一倍
    3. 因为一个RTT内能收到的ACK数量,大约等于当前发送的数据段数量,每个ACK都让窗口+1,总共加起来就约等于窗口翻倍)
  3. 这是指数级增长,增长速度很快
    1. 名字叫"慢启动"其实有点反直觉
  4. ssthresh——什么时候从"指数增长"切换到"线性增长"
    1. ssthresh(慢启动阈值,Slow Start Threshold),是一个动态调整的分界线
    2. cwnd还小于ssthresh时,处于慢启动阶段,指数增长
    3. 一旦cwnd达到或超过ssthresh,就切换进入下一个阶段——拥塞避免(线性增长),不再那么激进

阶段②:拥塞避免(Congestion Avoidance)——线性增长

  1. 进入这个阶段后,增长速度明显放缓
    1. 每经过一个完整的RTT,cwnd只增加1MSS(而不是慢启动阶段的"每个ACK都涨、翻倍式增长")
  2. 这是一种更谨慎的探测方式,持续、缓慢地试探网络的承载上限,直到真正遇到丢包为止

阶段③:检测到拥塞——两种不同的应对方式

  1. 方式1:超时重传(Timeout)
    1. 判定网络状况非常糟糕,处理最激烈
    2. 如果发送方等了很久,重传定时器都超时了还没收到确认,说明网络状况可能非常差(比如严重拥塞、甚至链路暂时中断)
    3. 这种情况下的处理最保守:
    4. ssthresh设置为当前cwnd的一半
    5. cwnd直接重置为初始的很小值,重新开始一轮慢启动
  2. 快速重传(Fast Retransmit)——收到3个重复ACK,判定为轻度丢包
    1. 如果发送方连续收到3个内容相同的重复ACK,通常意味着"只是某一个包碰巧丢了,但后续的包依然正常到达"
    2. 网络状况没有那么糟糕,不需要像超时那样"推倒重来"
    3. 这种情况下,处理方式更温和:
    4. 不用等超时定时器,直接重传那个被判定丢失的包
    5. ssthresh同样设置为当前cwnd的一半
    6. cwnd不会像超时那样打回最小值,而是直接设置成新的ssthresh值,重新从这个中等水平开始,而不是从零开始

现代TCP的演进

  1. 现代操作系统默认使用的拥塞控制算法已经演进了很多:
    1. CUBIC(Linux长期默认算法)
    2. BBR(Google研发,近年来越来越流行)

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享