21、P2P网络编程:NAT穿透原理、UDP打洞、TCP打洞、P2P文件传输

P2P网络编程,说白了就是让两台设备直接对话。不用经过服务器中转。听起来简单,但现实很骨感——大部分设备都在NAT后面,你根本找不到对方。

我刚开始做P2P项目时,天真地以为只要知道对方IP就能连上。结果呢?两台电脑都在各自的路由器后面,互相看不见。嗯,这就是NAT的“功劳”。

NAT穿透的核心问题

NAT(网络地址转换)让多个设备共享一个公网IP。这解决了IPv4地址短缺的问题,但也带来了麻烦——外部设备无法主动连接内部设备。

举个例子:你家路由器有一个公网IP,比如8.8.8.8。你的电脑在内网,IP是192.168.1.100。当你的电脑访问百度时,路由器会把你的内网IP和端口映射到公网IP的某个端口上。但百度想主动连你?不行,路由器不知道往哪转发。

为什么会这样?因为NAT只维护“出站”的映射关系。外部发来的数据包,如果没有对应的映射,路由器就直接丢弃了。

核心矛盾:P2P需要双向通信,但NAT只允许单向发起。

NAT的四种类型

我在项目中遇到过各种NAT设备,总结下来分四种。了解它们很重要,因为打洞策略取决于NAT类型。

NAT类型 特点 打洞难度
完全锥形NAT 只要映射过一次,任何外部IP都能通过这个端口访问 容易
受限锥形NAT 只允许之前通信过的外部IP访问 中等
端口受限锥形NAT 不仅限制IP,还限制端口 较难
对称NAT 每次连接都分配不同的端口,映射关系严格 极难

对称NAT是最头疼的。我记得有一次调试,客户端A和B都是对称NAT,折腾了两天都没打通。最后只能走中继方案。

UDP打洞原理

UDP打洞是最常用的P2P穿透技术。它的思路很简单:让双方同时往对方的公网地址发数据包。

具体流程是这样的:

  1. 客户端A和B都先连接信令服务器,服务器记录下它们的公网地址(IP:端口)
  2. 服务器把A的公网地址告诉B,把B的公网地址告诉A
  3. A往B的公网地址发UDP包,B也往A的公网地址发UDP包
  4. 双方的路由器都收到了“来自对方”的数据包,于是建立了映射关系
  5. 后续通信就畅通了

你想想看,为什么这样能行?因为当A往B发数据时,A的路由器会记录“这个端口正在和B通信”。B发来的数据就能通过。同理,B那边也一样。

小技巧:UDP打洞时,双方要同时发数据。如果一方先发,另一方还没发,先发的那个包会被丢弃。但没关系,多试几次就行。我一般会连续发5个包,间隔100毫秒。

UDP打洞代码示例

// 客户端A:向B的公网地址发送UDP包
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in addr_b;
addr_b.sin_family = AF_INET;
addr_b.sin_port = htons(b_port);  // B的公网端口
inet_pton(AF_INET, b_ip, &addr_b.sin_addr);

// 连续发送5个打洞包
for (int i = 0; i < 5; i++) {
    sendto(sock, "hole", 4, 0, 
           (struct sockaddr*)&addr_b, sizeof(addr_b));
    usleep(100000);  // 100ms间隔
}

// 同时监听本地端口,等待B的数据
char buf[1024];
struct sockaddr_in from;
socklen_t from_len = sizeof(from);
recvfrom(sock, buf, sizeof(buf), 0,
         (struct sockaddr*)&from, &from_len);
printf("收到B的数据: %s\n", buf);

这段代码看起来简单,但实际坑很多。我曾经遇到过一个问题:客户端A的本地端口和信令服务器通信时用的端口,跟打洞用的端口不一样。结果打洞包发出去,路由器根本不认。后来我统一了端口,问题才解决。

注意:UDP打洞时,客户端必须使用固定的本地端口。不要依赖系统随机分配。否则路由器无法建立正确的映射关系。

TCP打洞原理

TCP打洞比UDP复杂得多。因为TCP是面向连接的,三次握手要求双方都能收到对方的SYN包。但NAT会拦截外部的SYN包。

怎么办?有个技巧:使用TCP的“同时打开”特性。

正常情况下,TCP连接是一方主动连接(调用connect),另一方被动监听(调用bind+listen+accept)。但“同时打开”是双方都调用connect,同时往对方发SYN包。

流程如下:

  1. 客户端A和B都从信令服务器获取对方的公网地址
  2. 双方都创建一个TCP socket,绑定到本地端口
  3. 双方同时调用connect,连接对方的公网地址
  4. 两个SYN包在网络上交叉,NAT设备看到“出站SYN”,就允许“入站SYN-ACK”
  5. 连接建立成功

说白了,就是利用NAT对“出站包”的宽容,让双方都扮演“主动方”。

TCP打洞代码示例

// 客户端A:TCP同时打开
int sock = socket(AF_INET, SOCK_STREAM, 0);

// 绑定到固定本地端口
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(12345);  // 固定端口
local.sin_addr.s_addr = INADDR_ANY;
bind(sock, (struct sockaddr*)&local, sizeof(local));

// 设置非阻塞,因为connect可能不会立即成功
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);

// 连接B的公网地址
struct sockaddr_in addr_b;
addr_b.sin_family = AF_INET;
addr_b.sin_port = htons(b_port);
inet_pton(AF_INET, b_ip, &addr_b.sin_addr);
connect(sock, (struct sockaddr*)&addr_b, sizeof(addr_b));

// 等待连接建立(用select或poll)
fd_set write_fds;
FD_ZERO(&write_fds);
FD_SET(sock, &write_fds);
struct timeval tv = {5, 0};  // 5秒超时
select(sock + 1, NULL, &write_fds, NULL, &tv);

if (FD_ISSET(sock, &write_fds)) {
    // 连接建立成功
    send(sock, "hello", 5, 0);
}

关键点:TCP打洞必须使用非阻塞connect。因为connect可能不会立即返回成功,需要等待对端的SYN-ACK。我一般会配合select或epoll来检测连接状态。

P2P文件传输实现

打通了P2P连接,文件传输就水到渠成了。但有几个细节要注意:

  • 分块传输:大文件要分成小块,每块大小建议64KB。太小了效率低,太大了丢包重传成本高
  • 校验机制:每块数据都要带CRC32或MD5校验。我遇到过网络不稳定导致数据损坏的情况,没有校验根本发现不了
  • 断点续传:记录已传输的块索引,中断后可以继续
  • 流量控制:根据RTT动态调整发送窗口,避免拥塞
// 文件传输:发送方
#define BLOCK_SIZE 65536  // 64KB

void send_file(int sock, const char* filename) {
    FILE* fp = fopen(filename, "rb");
    fseek(fp, 0, SEEK_END);
    long file_size = ftell(fp);
    fseek(fp, 0, SEEK_SET);
    
    // 先发送文件信息
    send(sock, &file_size, sizeof(file_size), 0);
    
    char buffer[BLOCK_SIZE];
    int block_index = 0;
    int bytes_read;
    
    while ((bytes_read = fread(buffer, 1, BLOCK_SIZE, fp)) > 0) {
        // 发送块索引和数据
        send(sock, &block_index, sizeof(block_index), 0);
        send(sock, &bytes_read, sizeof(bytes_read), 0);
        send(sock, buffer, bytes_read, 0);
        
        // 等待接收方确认
        int ack;
        recv(sock, &ack, sizeof(ack), 0);
        
        block_index++;
    }
    fclose(fp);
}

NAT穿透的常见问题

我总结了一些实战中踩过的坑:

  • 端口预测:对称NAT每次分配端口都不同,需要预测下一个端口。我试过几种算法,准确率大概70%左右,不太靠谱
  • 防火墙干扰:有些企业防火墙会拦截UDP包。遇到这种情况,我建议直接走TCP中继
  • IPv6环境:如果双方都支持IPv6,其实不需要打洞。但现实中IPv6普及率还不够
  • NAT重启:路由器重启后映射关系会消失。需要重新打洞

避坑指南:我曾经遇到一个案例,客户端A是锥形NAT,B是对称NAT。UDP打洞时,A能收到B的数据,但B收不到A的。这是因为对称NAT要求源IP和端口必须精确匹配。最后我们用了“端口预测+多端口同时发送”的方案,才勉强打通。

P2P穿透的整体流程

下面这张图展示了完整的P2P穿透流程。从信令服务器交换地址,到双方同时打洞,最后建立直接连接。

P2P NAT穿透完整流程 信令服务器 客户端A 内网: 192.168.1.100:12345 公网: 8.8.8.8:50000 客户端B 内网: 10.0.0.50:54321 公网: 9.9.9.9:60000 ① 注册公网地址 ② 注册公网地址 ③ 返回B的地址 ④ 返回A的地址 ⑤ 同时发送UDP打洞包 双方NAT建立映射关系 ✅ P2P连接建立 ⑥ 直接P2P文件传输 注册/控制 地址交换 打洞 数据传输

从图中可以看到,信令服务器只负责交换地址信息,不参与实际数据传输。打洞成功后,客户端A和B直接通信,这就是P2P的精髓。

总结

NAT穿透是P2P编程的必修课。UDP打洞相对简单,成功率也高。TCP打洞复杂一些,但更可靠。对称NAT是个硬骨头,实在打不通就上中继吧。

我个人建议:在实际项目中,先尝试UDP打洞,如果失败再降级到TCP中继。这样既保证了效率,又有兜底方案。

最后说一句:P2P文件传输的核心不是传输本身,而是怎么打通连接。连接通了,剩下的就是普通的socket编程。所以,花80%的精力在NAT穿透上,绝对值得。

公众号:蓝海资料掘金营,微信deep3321