简介:点对点(P2P)技术绕开传统客户端-服务器模型,让每个节点同时充当服务端与客户端,直接进行资源共享和通信。这份p2pDemo示例正是围绕该技术打造的实操演示,适合网络编程学习者和分布式系统开发者,帮助理解连接建立、NAT穿透和数据交换的核心过程。压缩包共24个文件,包含C++源码(.cpp/.h)、静态库(.lib)、动态链接库(.dll)以及可直接运行的exe程序,另有readme说明文档辅助上手;整包只有2.09MB,部署轻量,便于实验。目前已有446人学习下载。示例重点讲解了P2P服务参数(IP地址、端口、密钥)的配置、P2P服务器的协调与发现机制,以及穿透访问的实际应用;配套工程文件(.sln/.vcxproj)支持逐步调试,读者通过观察UDP打洞、STUN/TURN等流程,可以直观掌握不同NAT环境下的通信策略,并延伸到DHT网络、安全加密等进阶主题,为日后开发文件共享、音视频通话等真实P2P应用提供扎实的实践基础。
1. p2pDemo 示例拆解:它演示的不是传文件,而是"在 NAT 后面找到对方"
第一次接触 p2pDemo 这类示例的开发者,十有八九是抱着"点对点直接传数据"的预期来的。装上依赖、跑起两个节点,结果两边一直在日志里打"等待候选者",于是开始怀疑代码写错了。实际上 p2pDemo 想演示的能力,从来不是传输层那点读写,而是一整套"找人和开门"的过程:信令服务器负责让两个互不知晓的节点完成身份交换,NAT 穿透负责在两个内网之间凿开一条直连通道,数据通道最后才负责搬运字节。这套东西适合正在做局域网互传、设备直连、去中心化同步,或者准备接 WebRTC 数据通道的开发者。先把 p2pDemo 跑通并且把每一行日志都看懂,你会对 P2P 通信里哪些环节可靠、哪些环节大概率翻车,有一个非常具体的判断,而不是停留在"P2P 就是不用服务器"的幻想里。
2. 拆开 p2pDemo 的三个核心模块:信令、NAT 穿透与数据通道的分工
几乎所有 p2pDemo 示例都可以拆成三个模块:信令、穿透、数据。它们的分工非常干净——信令管"我是谁、要找谁",穿透管"怎么把路径打通",数据通道管"打通之后怎么可靠地传"。我建议先把这张分工图画在纸上再去看代码,否则你会一直困惑"这个 socket 到底是发给谁的、那个端口又是给谁用的"。
2.1 信令服务器:只牵线不碰数据,职责边界必须画清楚
P2P 是拓扑上的点对点,不是部署上的无中心。两个节点要建立数据连接,必须先知道对方的地址和身份,这个"互相介绍"的动作需要一个双方都能访问到的中间点,也就是信令服务器。它的职责边界很小:只交换连接信息,绝不转发业务数据。这是 p2pDemo 里最容易被误解的地方,有人把信令服务器当成数据库或者消息中间件,把文件块也往里面塞,最终把 demo 写成了一个伪 P2P——所有数据都过中心,性能和成本全部回到中心化老路。
至于信令通道用什么协议,我一般直接选 WebSocket,而不是 HTTP 轮询。打洞过程需要近乎实时的双向推送,HTTP 轮询最快也要几百毫秒一圈,节点一多还会把信令服务器打成热点。p2pDemo 选 WebSocket 不是炫技,是消息模型刚好匹配。一个最小可用的信令服务器只需要支持三种消息:identify(注册上线)、signal(转发候选者)、peers(广播在线列表)。下面这段代码就是 p2pDemo 里最常见的信令端实现,我习惯把它控制在 60 行以内,超过这个量说明职责边界开始失守。
// signaling-server.js —— p2pDemo 的最小信令服务器:只做登记和转发 const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 9000 }); const peers = new Map(); // 在线节点表:id -> socket wss.on('connection', (socket) => { socket.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'identify') { // 节点上线:登记 id,并回给它当前在线列表(排除自己) peers.set(msg.id, socket); socket.id = msg.id; socket.send(JSON.stringify({ type: 'peers', ids: [...peers.keys()].filter(id => id !== msg.id) })); // 反向通知老节点:有新节点上线,让它们也尝试建连 broadcastExcept(msg.id, { type: 'peer-online', id: msg.id }); } else if (msg.type === 'signal') { // 转发候选者:从 A 收到的 signal,原样转给 B // 转完就忘,不落库、不缓存、不重试 const target = peers.get(msg.to); if (target && target.readyState === WebSocket.OPEN) { target.send(JSON.stringify({ type: 'signal', from: socket.id, data: msg.data })); } } }); socket.on('close', () => { // 断线必须立刻移出在线表,否则后面全在给死连接转发 if (socket.id) peers.delete(socket.id); }); }); function broadcastExcept(id, payload) { for (const [pid, sock] of peers) { if (pid !== id && sock.readyState === WebSocket.OPEN) { sock.send(JSON.stringify(payload)); } } }这段代码有三个值得注意的设计。第一,identify 只回在线列表,不回业务数据,节点之间的实际沟通全部走 P2P 通道,信令服务器不碰业务负载。第二,signal 转发是即转即忘的,目标节点不在线就直接丢弃。demo 阶段无伤大雅,但生产环境需要让源节点知道"对方没收到",否则会一直傻等。第三,close 事件里必须把 id 从 Map 里删掉,这是最常见的内存泄漏和"幽灵转发"来源。
还有一点是时序坑。假设 A 先上线,B 后上线,A 触发 identify 后可能立刻给 B 发候选者,但此时 B 还没注册,转发就丢了。所以我在 identify 分支里加了一个反向广播 peer-online,让老节点知道"有人来了,可以开始打洞"。这个细节是 p2pDemo 从双节点 demo 走向多节点真实场景的必经之路。
2.2 UDP 打洞:数据通道能不能建起来,取决于 NAT 类型而不是代码
信令交换完毕,两个节点拿到彼此的候选者,接下来最核心的动作是 UDP 打洞。要理解打洞,先要理解 NAT 的行为:内网设备发出的每个 UDP 包,都会在 NAT 设备上建立一条"内部地址 → 出口地址"的映射,外部设备要访问这个内网设备,只能往出口地址发,NAT 再把包转进来。问题在于,如果对方从没先给你发过包,你的 NAT 上没有它对应的反向映射,它发来的包会被 NAT 当成陌生流量直接丢掉。
打洞的原理就是让双方"同时"往对方的出口地址发包,各自在 NAT 上建立映射,凑成一条双向通路。这里最关键的不是代码,而是 NAT 类型。我经常跟同事说,打洞能不能成有时候看玄学,本质上取决于 NAT 设备的映射策略。常见的四类 NAT 行为差别非常大:
| NAT 类型 | 映射行为 | 打洞难度 | 典型环境 |
|---|---|---|---|
| 全锥形(Full Cone) | 建立过一次映射后,任意外部地址都能回包 | 最容易,一次即通 | 部分老路由器、有公网 IP 的主机 |
| 限制锥形(Restricted Cone) | 只有我发包去过的外部 IP 能回包 | 较容易,先互发即可 | 大部分家用路由器 |
| 端口限制锥形(Port Restricted Cone) | 只有我发包去过的 IP+端口 能回包 | 中等,需要同时互发 | 多数运营商 NAT |
| 对称 NAT(Symmetric) | 每次发包都分配新端口和映射 | 几乎打不了,需要中继 | 部分移动网络、企业网关 |
所以一个合格的 p2pDemo 在打洞之前,一定会先做一次 STUN 探测,问出自己的出口地址,并把这个出口地址作为候选者交给对方。这里有一个新手最容易忽略的硬性要求:STUN 探测用的本地 UDP socket 和打洞用的 socket 必须是同一个。如果你开了两个 socket,STUN 问到的映射端口是 socket A 的,打洞时用 socket B 发包,NAT 为 B 分配的映射端口完全不同,对端按 A 的地址打回来,永远打不到 B 上。p2pDemo 里这个 bug 极其隐蔽,因为日志和代码都看不出问题,只有抓包才能发现。
STUN 问到的出口地址还有时效问题,UDP 映射在 NAT 上的寿命一般是 30 秒到 2 分钟,这直接决定了后面心跳参数的取值,下一章会展开。简单说:候选者不是永久的,拿到之后要尽快用,拖久了就只能重新探测。
2.3 对称 NAT 与 TURN 降级:p2pDemo 为什么必须留一条后路
当你连续打洞失败,或者探测到对端是对称 NAT 时,唯一可靠的办法是走 TURN 中继:双方都把数据发给一台公网中继服务器,由它做 UDP 包的搬运。注意这不是业务层面的转发,它不解析内容、不落盘,只做字节搬运。代价非常直接:出口带宽翻倍(一份上行一份下行),延迟增加,中继服务器成为单点瓶颈。
我一般会建议在 demo 里把连接策略写成这样:先尝试打洞,8 轮之内没通就降级到 TURN;如果事先识别出对称 NAT,直接跳过打洞,不要浪费那 20 秒。降级的决策要用数据说话,不要靠感觉。在代码里保留一个字段记录每个连接的最终路径,取值只有 direct 和 relay 两种,并输出到日志。这个字段在后面对接业务系统时非常有用:中继带宽是要花钱的,看到 direct 占比低于 60%,你就该去优化打洞参数或者换 STUN 服务器了。这个"连接路径记录"是 p2pDemo 从玩具走向工具的第一步。
3. 跑通 p2pDemo 最小闭环:信令服务器加两个节点的完整操作步骤
这一章按下面的顺序操作,全部跑通大概需要 15 分钟。环境要求:两台能互通的机器(同一局域网也行,但要注意外网环境的差异),Node.js 16 以上,npm 可用。全程只有三个进程:一个信令服务器、两个节点进程。
3.1 部署信令服务器:安装依赖、绑定地址、放行端口
把上一章的信令服务器代码保存为 signaling-server.js,然后安装唯一的依赖 ws 并启动。
mkdir p2p-demo && cd p2p-demo npm init -y >/dev/null 2>&1 npm install ws@8 node signaling-server.js # 期望输出:signal server listening on 9000这里有两个参数要留意。第一,ws 默认绑定在所有网卡上,如果在云主机上跑,安全组和防火墙都要放行 9000 端口;如果只想本机调试,把监听地址显式改成host: '127.0.0.1',外部打不进来,也能少很多干扰流量。第二,确认信令服务器真的在监听,用ss -ulnp | grep 9000看端口状态,或者直接在浏览器地址栏访问ws://127.0.0.1:9000,看到"不支持 GET"的报错反而说明端口活着——WebSocket 不是 HTTP,浏览器的普通访问本来就会失败。
3.2 启动两个节点:看懂候选者交换与打洞日志
节点端是 p2pDemo 里代码量最大的部分,但核心逻辑只有三段:连信令、问 STUN、循环打洞。节点从 STUN 拿到自己的出口地址后,通过信令服务器把候选者发给对端,消息结构长这样:
{ "type": "signal", "from": "peer-a", "to": "peer-b", "data": { "candidate": { "ip": "203.0.113.5", "port": 50001 } } }注意 data 里放的必须是 STUN 问出来的出口地址,不是本机ipconfig看到的内网地址。两者混用是 p2pDemo 里最常见的错误:两个节点在同一个局域网时,内网地址也许能通;一旦跨网,内网地址就是废纸。
拿到对端候选者后,进入打洞核心循环。这是我建议反复读十遍的一段代码:
// peer.js 中的打洞核心循环:两个节点都会执行,形成"同时互发" const MAX_ROUND = 8; // 最多打 8 轮,超过降级 TURN const PUNCH_INTERVAL_MS = 2000; // 每轮间隔 2 秒 const udp = dgram.createSocket('udp4'); function startPunch(remoteCand) { let round = 0; const timer = setInterval(() => { if (round >= MAX_ROUND) { clearInterval(timer); console.log('[punch] failed, fallback to turn relay'); fallbackToTurn(remoteCand); // 降级路径必须存在 return; } // 向对端出口地址发一个空 UDP 报文。 // 作用 1:让自己的 NAT 记住"我在往这个地址发包",建立正向映射; // 作用 2:对端 NAT 收到包后,也会为它自己的回包建立反向映射。 udp.send(Buffer.from('punch|probe'), remoteCand.port, remoteCand.ip); console.log(`[punch] round ${round + 1} -> ${remoteCand.ip}:${remoteCand.port}`); round++; }, PUNCH_INTERVAL_MS); // 收包判定放在统一的 message 事件里,而不是在定时器回调里 udp.on('message', (msg, rinfo) => { const fromRemote = `${rinfo.address}:${rinfo.port}` === `${remoteCand.ip}:${remoteCand.port}`; if (fromRemote && msg.toString().startsWith('punch')) { clearInterval(timer); console.log('[p2p] direct connection established'); enterDataChannel(rinfo); // 进入数据收发阶段 } }); }逻辑上要注意三点。第一,打洞必须是"同时互发",如果只有一方在发、另一方只被动收,被动一方的 NAT 不会建立反向映射,回包依然会被丢掉。2 秒一轮的节奏是给双方留同步窗口的,间隔太短会在 NAT 设备上产生大量被丢弃的包,太长则拖慢建连速度。第二,收到对端报文后要立即停止循环,继续发既浪费带宽,还可能被 NAT 设备限流。第三,降级路径必须存在——MAX_ROUND 到点就走 TURN,这是 p2pDemo 最容易漏掉的部分,漏掉的后果是用户在弱网环境里永远连不上。
启动两个节点的命令如下,分别在两个终端执行:
node peer.js --id=peer-a --signal=ws://127.0.0.1:9000 node peer.js --id=peer-b --signal=ws://127.0.0.1:9000期望的日志顺序是:identify 成功 → 收到 peers 列表 → 通过 signal 交换候选者 → 打洞循环开始 → 出现 direct connection established。如果卡在"等待候选者",问题八成在信令链路,直接看第五章的避坑记录。
3.3 用 tcpdump 验证直连:确认数据没有被中间节点转手
日志说"直连建立了"还不够,我习惯用抓包验证。在节点 A 所在机器上执行:
# 抓节点 A 打洞端口上的 UDP 包,只关心 IP 和端口,看 30 个就够 sudo tcpdump -i eth0 udp port 50001 -n -c 30关键看两点。第一,对端 IP 必须是节点 B 的出口地址(公网 IP 或同网段内网 IP),绝不能是信令服务器的地址。只要看到信令服务器 IP 出现在数据流里,说明数据走了中转,日志里那句 direct 是在自欺欺人。第二,抓包里必须同时有"发出去"和"收进来"两个方向的包——单方向发包不是直连,双向都有流量才是打洞成功。接口名记得按实际环境替换,云服务器上是 eth0,很多虚拟机是 ens33,可以先ip addr看一眼。
想看得更细就用 Wireshark,过滤表达式写udp.port == 50001,然后看会话的流方向统计。这一步花两分钟,能帮你避开 p2pDemo 里最典型的"假直连":日志打出来了,实际数据全在绕信令服务器。
4. p2pDemo 从 demo 到可用的 5 个必调参数:心跳、超时与缓冲区
p2pDemo 能跑通不难,能扛住真实网络环境才是分水岭。真实网络里有三件躲不开的事:NAT 映射会过期、节点会掉线、UDP 会丢包。这三件事分别对应三组参数,下面是经验值,按优先级排列:
| 参数 | demo 默认值 | 生产建议值 | 判定依据 |
|---|---|---|---|
| 心跳间隔 | 10 秒 | 5~15 秒 | 必须小于 NAT 映射寿命的一半 |
| 死亡判定 | 连续 3 次无响应 | 3~5 次 | 容忍偶发丢包,不误杀 |
| 打洞轮数 | 8 轮 | 8~12 轮 | 超过后成功率不再提升 |
| 打洞间隔 | 2 秒 | 1~3 秒 | 给双方留同步窗口 |
| 报文长度 | 1400 字节 | 1200 字节内 | 避开 MTU 分片 |
| 重传次数 | 3 次 | 3~5 次 | 指数退避,500ms/1s/2s |
4.1 心跳间隔与节点死亡判定:NAT 映射寿命决定下限
UDP 打洞建立的映射不是永久的。NAT 设备上的 UDP 映射一般在 30 秒到 2 分钟没有流量后就会被回收,回收之后外部包就进不来了,直连通道名存实亡。所以心跳间隔的上限由 NAT 映射寿命决定,下限由带宽和信令服务器压力决定。
我一般这样设置:心跳间隔 10 秒,连续 3 次无响应判定节点死亡,也就是 30 秒内没有任何包到达就触发重连或降级。这个组合在绝大多数家用路由器上够用,因为映射寿命基本在 60 秒以上。在移动网络环境测试时,把心跳压到 5 秒,部分运营商 NAT 的映射寿命只有 30 秒。心里记住这个换算关系:心跳间隔必须小于 NAT 映射寿命的一半,这样即使丢一到两个心跳包,映射也不会刚好过期。
节点死亡的判定要放在数据通道层面,不要放在信令层面。信令 WebSocket 断线不等于数据通道断线,反过来也一样。p2pDemo 里最常见的误判是用信令心跳代替数据通道心跳,结果信令还活着、数据通道早死了,对端还一直往黑洞里发数据包。我的习惯是信令心跳和数据心跳分开计时,并在日志里打不同前缀,排查时一眼能定位。
4.2 打洞并发与总超时:识别"打不了洞"的网络,别死磕
打洞不是越猛越好。一轮一个包、发 10 轮也就 10 个包,但 NAT 设备对高频小包可能触发限流。经验值是:轮间隔 1 到 3 秒,总轮数 8 到 12 轮,总耗时控制在 20 到 30 秒。超过这个上限,继续发不会提升成功率,只会让用户等得更久。
更聪明的做法是先识别对端 NAT 类型再决定要不要打。连发两次 STUN 探测,间隔 3 秒,如果两次返回的出口端口不一样,基本可以判定是对称 NAT,直接走 TURN,省下那 20 秒。这个判断逻辑在第六章给出完整代码。还有一个细节:STUN 探测本身也有超时,我一般给 STUN 请求 2 秒超时、重试 2 次,三次都不回就认为当前网络禁止 UDP 出站,直接走 TURN。记住一个原则:打洞超时不是 bug,是网络特征,代码要能体面地承认这一点。
4.3 数据通道的发送队列与重传:UDP 不丢包是幻觉
打洞成功只是开始,UDP 数据通道上没有任何内置可靠机制。p2pDemo 里传小字符串没问题,传大文件就会同时看到丢包、乱序、重复三兄弟。应用层必须自己实现 ACK 和重传,这是绕不开的。
我通常在 p2pDemo 里这样配置:每个连接维护一个发送队列,高水位线设为 64KB,队列满了就触发背压,让上层暂停写入。发送方给每个数据包编序号,接收方每收一个包回一个 ACK;发送方 ACK 超时后重传,重传次数 3 次,退避间隔 500ms、1s、2s。超过 3 次就断线重连,不要无限重试。单个 UDP 报文控制在 1200 字节以内,留出 IP 头和 UDP 头的余量,避免分片——分片包在 NAT 设备上是丢包重灾区,这个坑第六章还会再提。
这三个参数组改起来都不难,难的是理解它们彼此耦合:心跳决定映射存活,映射决定打洞建连,重传决定数据完整性。改一个参数必须重新评估另外两个,比如把心跳从 10 秒改成 30 秒,看起来省带宽了,但如果 NAT 映射寿命只有 45 秒,这组参数就会让连接频繁假死。
5. p2pDemo 复现踩坑记录:五个常见问题的现象、原因与解决
这几条是我在不同网络环境里跑 p2pDemo 反复踩出来的,每条都按现象、原因、解决写清楚,你可以直接对号入座。
5.1 现象:同一局域网内两个节点也连不上
现象:两台机器在同一局域网,IP 互相能 ping 通,信令服务器也正常,但数据通道就是建不起来,日志卡在打洞循环里反复超时。
原因通常是两个:一是节点程序绑定了 127.0.0.1 而不是 0.0.0.0,外部机器的包根本进不来,信令能通是因为 WebSocket 是主动外连,但 UDP 打洞是双向的,绑定一错就废;二是防火墙拦了 UDP 端口,ICMP 的 ping 能通不代表 UDP 放行,很多防火墙默认静默丢弃 UDP。
解决:udp.bind 时显式写'0.0.0.0',并放行打洞端口段。
# 放行打洞端口段(示例 50000-51000),同时确认节点绑定的是 0.0.0.0 sudo ufw allow 50000:51000/udp这个坑的隐蔽之处在于日志完全正常,看起来是协议问题,实际是绑定和防火墙问题。排查时先用ss -ulnp | grep 50000看监听地址,写着 127.0.0.1 就立即改。
5.2 现象:"等待候选者"卡住,打洞循环根本没启动
现象:节点日志停在 identify 之后,一直打印"等待候选者",打洞循环从未触发,信令服务器那边也没有收到 signal 转发。
原因分三处:信令连接地址写错,比如 ws:// 地址在网关环境里被改写成了 http://,WebSocket 握手根本没成功;identify 消息格式不对,服务端登记失败,后续转发全部落空;signal 转发时 from 字段取错,节点收到一条"来自自己"的消息然后按规则丢弃。
解决:先在终端用 wscat 直连信令服务器,手动模拟一次 identify 和 signal,确认服务端行为正常,再回来看节点代码里消息字段名是否一致。这个坑我踩得最多,每次浪费半小时,现在的习惯是第一步永远核对字段名,而不是改 NAT 参数。
5.3 现象:打洞成功但传大文件时数据大量丢失
现象:日志显示 direct connection established,小消息没问题,一传 10MB 以上的文件就缺一大块,文件校验不过。
原因:UDP 报文超过 MTU 产生分片,分片包在 NAT 设备上被丢弃;同时应用层没有 ACK 重传,丢一个分片等于丢整个文件块,而且发送方完全不知道。
解决:把应用层报文长度压到 1200 字节以内,并实现 ACK + 重传机制。一个小技巧是抓包看丢包分布:如果丢的总是大包,MTU 和分片的嫌疑最大;如果小包也丢,那就是网络质量本身的问题,重传参数需要更激进。
5.4 现象:节点一多,信令服务器响应明显变慢
现象:超过 20 个节点在线后,信令服务器 CPU 升高,候选者交换开始延迟,日志里出现大量转发超时。
原因:节点没有做信令心跳,服务端 Map 里堆了大量半开连接,close 事件迟迟不来;同时每次节点上线就全量广播 peers,节点数一多就变成 O(n²) 的广播风暴。
解决:信令层加 ping/pong,超时主动清连接;peers 广播改成增量推送,只在节点加入和离开时推送差异事件。这个优化对双节点 demo 不是必须的,但如果你想验证多节点组网,建议提前做。顺手把信令服务器的日志加上分级,候选者交换的 debug 日志和生产警告分开,排查时不用盯着满屏刷。
5.5 现象:对称 NAT 下打洞必然全部超时,用户白等 20 秒
现象:两边都在移动网络或者有企业防火墙的环境,打洞 8 轮全部超时,最后走 TURN 才连上,白白浪费 20 秒连接时间。
原因:对称 NAT 每次发包分配的端口和映射都不同,对端打回来的包找不到正确的映射,打洞在原理上就不成立,发再多轮也是徒劳。
解决:提前做 NAT 类型判定,发现对称 NAT 直接跳过打洞。a 连续两次 STUN 探测的出口端口发生变化,就认定是对称 NAT。这是最能提升用户体验的一项优化,用户不会想每次连接都等 20 秒,尤其是在弱网环境里。
6. 让 p2pDemo 更可靠的进阶技巧:连接建立前先做 NAT 体检
p2pDemo 跑通之后,我做的第一件事永远是加一个"连接体检":在打洞之前用三次 STUN 探测判定对端 NAT 类型,然后决定走直连还是直接中继。这个开关能把对称 NAT 场景的连接时间从 20 多秒压到 1 秒以内,是性价比最高的一项改造。
// nat-check.js —— 三次 STUN 探测判定 NAT 类型,间隔 3 秒观察端口变化 const RESULTS = []; // 每次探测返回的 { ip, port } async function detectNatType() { for (let i = 0; i < 3; i++) { RESULTS.push(await askStunOnce()); // 单个 Binding Request await sleep(3000); // 间隔 3 秒,给 NAT 重新分配映射留出时间 } const uniquePorts = new Set(RESULTS.map(r => r.port)); const uniqueIps = new Set(RESULTS.map(r => r.ip)); if (uniquePorts.size > 1) return 'symmetric'; // 端口每次都在变,判定对称 NAT if (uniqueIps.size > 1) return 'unstable'; // 出口 IP 在变,多半是多线 NAT return 'cone'; // 端口固定,建议尝试打洞 }判定逻辑只有一条硬规则:三次探测端口只要发生过变化,就按对称 NAT 处理。端口固定但 IP 在变的情况很少见,多半是多线运营商,此时打洞成功率也不高,建议直接走中继。注意探测间隔不要小于 3 秒,太短的话 NAT 还没来得及分配新映射,你会误判成锥形 NAT,后面照样打洞失败。
配合体检的还有一个习惯:让每个连接在建立后打印自己的路径类型。我自己的做法是在连接状态里记一个 path 字段,取值只有 direct 或 relay,每次建立连接输出一行,累积一段时间就能统计出直连成功率。直连率低于 60% 的环境,要么 STUN 服务器选得不对,要么打洞参数太保守,这两个方向值得逐项排查。如果你把 p2pDemo 接进真实业务,这个字段还能直接对接监控告警,中继带宽异常上涨时第一时间就能发现。我自己的习惯是 demo 一跑通就立刻加体检和路径日志,不然这个示例永远只是示例,永远停留在"能连上"的阶段。希望这个思路帮到你,也希望你下次跑通 p2pDemo 时,日志里那个 direct connection established 是真的直连——抓过包才算数。
本文还有配套的精品资源,点击获取