P2P 网络技术详解

一、P2P技术概述

1.1 什么是P2P技术

P2P的定义与特点

P2P(Peer-to-Peer,点对点)技术是一种网络通信模型,它允许网络中的参与者(节点)直接相互通信和共享资源,而无需依赖中央服务器作为中介。在P2P网络中,每个节点既可以是资源的提供者(服务器),也可以是资源的消费者(客户端)。

P2P网络的主要特点:

  • 去中心化:没有中央控制节点,网络中的每个节点地位平等
  • 自组织性:网络能够自动适应节点的加入和离开
  • 资源共享:计算能力、存储空间和网络带宽等资源可以在节点间共享
  • 可扩展性:随着节点数量增加,网络整体资源和服务能力也相应增加
  • 冗余性:数据和服务通常在多个节点上复制,提高了系统的容错能力

与传统C/S架构的区别

特性传统C/S(客户端/服务器)架构P2P(点对点)架构
中心化程度高度中心化,服务器作为核心去中心化,节点平等
资源分布资源集中在服务器端资源分布在所有参与节点
可扩展性受限于服务器性能,扩展成本高随节点增加自然扩展,成本分摊
稳定性服务器故障导致整体服务中断单个节点故障影响有限
带宽利用服务器带宽易成为瓶颈带宽需求分散到各节点
实现复杂度相对简单,架构清晰较为复杂,需处理各种网络环境
安全控制集中式安全控制,相对容易分散式安全控制,挑战较大

P2P网络的优势与挑战

优势:

  1. 高效利用资源:充分利用边缘节点的计算能力、存储空间和网络带宽
  2. 降低成本:减少对中央服务器的依赖,降低基础设施和运维成本
  3. 提高可靠性:去中心化结构消除了单点故障风险
  4. 自然扩展:网络容量随着参与节点的增加而自然增长
  5. 抗审查性:分布式特性使得网络更难被审查或关闭

挑战:

  1. 网络穿透问题:NAT和防火墙环境下的连接建立困难
  2. 节点发现与管理:如何高效发现和管理动态变化的节点
  3. 安全与信任:缺乏中央权威,节点间的信任建立机制复杂
  4. 服务质量保障:节点性能和连接质量参差不齐,难以保证一致的服务质量
  5. 法律与合规:在某些地区面临法律监管挑战,特别是涉及版权内容时
  6. 资源不均衡:“搭便车"现象(部分节点只消费不贡献)影响网络效率

1.2 P2P技术的发展历程

早期P2P应用(1999-2005)

Napster时代(1999-2001)

  • 1999年,Shawn Fanning创建了Napster,这是第一个广为人知的P2P文件共享应用
  • Napster采用了中心化索引 + P2P传输的混合架构,用户通过中央服务器查找音乐文件,但文件传输直接在用户之间进行
  • 2001年因版权诉讼而被迫关闭,但它开创了P2P文件共享的先河

去中心化探索(2000-2003)

  • Gnutella(文件共享网络):2000年发布,首个完全去中心化的P2P网络,采用泛洪(Flooding)搜索机制
  • Freenet:2000年推出,注重匿名性和抗审查能力的 P2P 网络
  • Kazaa/FastTrack:2001年推出,引入了超级节点(Supernode)概念,形成两级架构
  • eDonkey/eMule:2002年出现,引入了服务器网络和文件哈希技术

BitTorrent革命(2003-2005)

  • 2001年,Bram Cohen设计了BitTorrent协议,2003年开始广泛应用
  • 创新性地引入了分片下载、稀有优先和互惠机制(tit-for-tat)
  • 显著提高了大文件分发效率,至今仍是最成功的P2P协议之一

现代P2P技术的演进(2005-2020)

结构化P2P网络(2005-2010)

  • **分布式哈希表(DHT)**技术成熟:Kademlia、Chord、Pastry等算法广泛应用
  • BitTorrent网络引入DHT,摆脱了对Tracker服务器的依赖
  • 学术界和工业界对P2P网络路由和查找算法进行了深入研究

P2P流媒体时代(2008-2015)

  • P2P技术在视频直播领域得到应用:PPTV、PPS影音等
  • P2P-CDN混合架构出现,如迅雷看看、阿里云PCDN
  • WebRTC(2011年发布)标准化了浏览器中的P2P通信能力

区块链与去中心化应用(2009-至今)

  • 2009年比特币网络上线,将P2P技术与密码学、共识机制相结合
  • 2015年以太坊推出,支持智能合约,催生了大量去中心化应用(DApps)
  • IPFS(星际文件系统)在2015年推出,致力于构建去中心化的文件存储和访问系统

未来发展趋势

Web3.0与去中心化互联网

  • 基于区块链的去中心化身份和数据所有权
  • 去中心化存储和计算平台的普及
  • 用户直接控制和变现自己的数据和创作内容

边缘计算与P2P结合

  • 利用边缘设备的闲置计算资源构建分布式计算网络
  • 降低云计算中心的负载和延迟
  • 物联网设备间的直接P2P通信和协作

5G/6G网络中的应用

  • 高带宽、低延迟网络环境为P2P应用提供更好基础
  • 设备直连(Device-to-Device,D2D)通信技术与P2P结合
  • 移动边缘计算(MEC)中的P2P资源共享

安全与隐私增强

  • 零知识证明等密码学技术在P2P网络中的应用
  • 去中心化隐私保护机制的发展
  • 抗量子计算的P2P安全协议研究

二、NAT类型详解

3.1 NAT基础知识

NAT(Network Address Translation,网络地址转换)是一种在IP数据包通过路由器或防火墙时重写来源IP地址或目的IP地址的技术。它主要用于解决IPv4地址短缺问题,允许多个设备通过单个公网IP地址访问互联网。

NAT的主要作用:

  • 地址复用:允许多个私有IP地址共享一个公网IP地址
  • 网络安全:隐藏内部网络结构,防止外部直接访问内部主机
  • 负载均衡:在某些实现中用于分发流量
  • 端口复用:通过端口号区分不同内部主机的连接

NAT的工作原理

NAT通常部署在网络边界设备(如路由器)上,当内部主机向外部发送数据包时,NAT设备会:

  1. 来源地址转换:将私有来源IP和端口替换为公网IP和临时端口
  2. 维护转换表:记录转换关系(内部IP:端口 -> 公网IP:端口)
  3. 转发数据包:将修改后的数据包发送到外部网络
  4. 反向转换:当响应数据包返回时,根据转换表还原为内部IP和端口

基本NAT转换流程示例:

  • 内部主机A (192.168.1.10:1234) 发送数据到外部服务器B (8.8.8.8:53)
  • NAT设备将来源改为公网IP (203.0.113.1:5678)
  • 响应从B返回到203.0.113.1:5678
  • NAT根据表转换为192.168.1.10:1234并转发

公网IP与私网IP

公网IP(Public IP)

  • 由IANA分配的全球唯一IP地址
  • 可在互联网上直接路由
  • 示例:203.0.113.1(IPv4),2001:db8::1(IPv6)
  • 特点:唯一性、全球可达性、需注册分配

私网IP(Private IP): RFC 1918定义的保留地址段,不在互联网上路由

  • 常见范围:
  • 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
  • 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
  • 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
  • 特点:可重复使用、仅限内部网络、需NAT转换才能访问互联网

公网IP资源有限,导致NAT的广泛应用,而私网IP允许多个网络独立使用相同地址空间。

3.2 NAT类型分类

image.png

3.3 NAT类型检测原理

image.png

四、不同网络环境下的P2P连接原理

4.1 公网 - 公网

image.png

在当前网络模型中,客户端 A 和客户端 B 都位于公网。客户端 A 和 客户端 B 通过以下步骤即可建立 P2P 连接:

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 A 根据从中心服务器获取的信息发现 B 具有公网地址,于是 A 直接向 B 发起连接;(PS: 可以互换连接顺序)
  3. 客户端 B 根据从中心服务器获取的信息发现 A 具有公网地址,因此 B 等待 A 进行连接;

4.2 NAT - 公网

image.png

在当前网络模型中,客户端 B 位于公网,有公网 IP,客户端 A 位于任意 NAT 后。客户端 A 和 客户端 B 通过以下步骤即可建立 P2P 连接:

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 B 根据从中心服务器获取的信息发现 A 位于 NAT 后,因此 B 等待 A 进行连接;
  3. 客户端 A 根据从中心服务器获取的信息发现 B 位于公网,于是 A 直接向 B 发起连接;

4.3 客户端位于同一NAT后

image.png

在当前网络模型中,客户端 A 和客户端 B 位于同一任意 NAT 后。客户端 A 和 客户端 B 通过以下步骤即可建立 P2P 连接:

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 A 根据从中心服务器获取的信息发现 B 的公网地址和自身相同,猜测 B 可能与自己位于同一内网中,于是 A 尝试直接向 B 发起连接;(PS: 可以互换连接顺序)
  3. 客户端 B 根据从中心服务器获取的信息发现 A 的公网地址和自身相同,猜测 A 可能与自己位于同一内网中,因此 B 等待 A 进行连接;

4.4 客户端分属与不同NAT下

image.png

在当前网络模型中,客户端 A 和客户端 B 都位于 NAT 后。客户端 A 和 客户端 B 能否建立 P2P 连接和各自所属 NAT 类型有关。

4.4.1 任意 NAT - (Full Cone NAT或Restricted Cone NAT)

image.png

Full Cone NAT、Restricted Cone NAT和Port Restricted Cone NAT都有同样的映射规则:本地地址和端口不变时,映射到 NAT 上的端口不变

当一端位于 Full Cone NAT或Restricted Cone NAT 下,另一端为任意 NAT 时,通过以下方式可以建立 P2P 连接:

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 A 持续向客户端 B 在 NAT 网关2 上映射的公网地址和端口发送数据。对于 Restricted Cone NAT,由于NAT 网关2上还没有放开相应的过滤规则,因此前面客户端A发向客户端B的部分数据包会被丢失;
  3. 客户端 B 向客户端 A 的公网地址和端口发送数据,用以更新 NAT 网关2的过滤规则;
  4. 当NAT 网关2的过滤规则被刷新后,客户端 A 发向客户端B的数据便会被 NAT 网关2 接收,并转发给客户端 B;
  5. 接收数据时,客户端 B 就会知道客户端在 NAT 网关1上映射的端口和地址,此时客户端 B向NAT 网关1上映射的端口和地址发包,客户端A即可收到。此时客户端 A 和客户端 B 成功建立 P2P连接。

4.4.2 Easy NAT - Easy NAT

Easy NAT 代指 RFC3489 所定义的 Full Cone NAT、Restricted Cone NAT、Port Restricted Cone NAT。

image.png

当两端都位于 Easy NAT 下时,通过以下方式可以建立 P2P 连接(任意 NAT - (Full Cone NAT或Restricted Cone NAT) 流程类似):

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 A 持续向客户端 B 在 NAT 网关2 上映射的公网地址和端口发送数据,由于NAT 网关2上还没有放开相应的过滤规则,因此前面客户端A发向客户端B的部分数据包会被丢失;
  3. 客户端 B 向客户端 A 的公网地址和端口发送数据,用以更新 NAT 网关2的过滤规则;
  4. 当NAT 网关2的过滤规则被刷新后,客户端 A 发向客户端B的数据便会被 NAT 网关2 接收,并转发给客户端 B;
  5. 接收数据时,客户端 B 就会知道客户端在 NAT 网关1上映射的端口和地址,此时客户端 B向NAT 网关1上映射的端口和地址发包,客户端A即可收到。此时客户端 A 和客户端 B 成功建立 P2P连接。

4.4.3 Symmetric NAT - Port Restricted Cone NAT

image.png

当客户端 A 位于 Symmetric NAT 下,客户端 B 位于 Port Restricted Cone NAT 时,通过以下方式可以建立 P2P 连接:

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 A 持续向客户端 B 在 NAT 网关2 上映射的公网地址和端口发送数据。由于 NAT 网关2 没有放开相应的过滤规则,因此客户端 A 发往客户端 B 的数据包会被 NAT 网关2拦截,无法到达客户端 B;;
  3. 由于客户端 A 在NAT 网关1上映射的端点(IP:Port 中 Port 未知),因此客户端 B 无法向一个明确的端点发送数据包来更新 NAT 网关2 过滤规则。

此时就需要通过以下几种方案来让碰撞,让客户端 A 发向客户端 B 的包顺利通过 NAT 网关2。

全端口开放

虽然我们不知道客户端 A 在映射NAT 网关1上映射的端口是多少,但是我们知道,他映射的端口一定是 1024 - 65535 内其中一个,并且一定不是 A 连接中心服务器时使用的端口。

我们可以顺序构造目的端口为 1024 - 65535 的短 TTL 包(短 TTL 包可以让包不走到公网,仅仅用于打开防火墙规则,以免被识别为 Dos 攻击),让 NAT 网关2 开放所有可能端口(相当于将 Port Restricted Cone NAT 变为 Restricted Cone NAT)。

但是经过实际测试发现该方法效果不佳,主要有以下原因:

  • 需要构造大量数据包:平均需要发包 32256 个包 才能碰撞到 客户端 A 在 NAT 网关1上映射的端口。假设客户端 B 的发包速率为 100 p/s,那么就需要五分半才能碰撞到端口;
  • 容易触发运营商 QoS 限制:经过实际测试,发现一定时间内无效数据包过多时,运营商会对客户端 B 的包进行大量丢弃,导致丢包率上升。严重情况下运营商会直接全部丢弃客户端 B 的数据包。

端口预测

在部分 NAT 上,端口映射具有一定规律 。

比如发往目的 IP 1 时,映射的端口为 22001;发往目的 IP 2 时,映射的端口为 22002;那么我们可以猜测,发往目的 IP 3时使用的端口可能为 22003。

使用此种方案,需要客户端 A 向多个服务器请求来确认自身映射端口,从这些映射的端口中找到可能存在的端口变化规律。

此种方法具有一定可行性,但和 NAT 行为有关,不是一个通用解决方案。

生日攻击

在前面的“全端口开放”方法中,客户端 A 只在 NAT 网关1 上映射了一个端口。但是实际情况下,客户端 A 可以在 NAT 网关1 上映射多个端口。

根据概率论的 生日悖论,可以写发现客户端 A 映射的端口、客户端 B 发包数量与成功概率之间的关系,公式如下所示:

$$ \begin{array}{c} P_{success} = 1 - \frac{C_{64,512}^{ports} \times C_{64,512-ports}^{packets }}{C_{64,512}^{ports} \times C_{64,512}^{packets}} \end{array} $$

根据上面的公式绘制出三维图如图所示: data.svg

根据函数图我们可以发现,当客户端 A 映射在公网上的端口越多时,建立连接所需的发包数越少,下表中列举了部分数据:

使用端口数\发包数50%80%90%99%
10432095901326823807
50888204329035675
100446103014682902
2002235177381467
300149345493981

根据表中数据可以发现:假设客户端 A 占用100个端口,客户端 B 以 100 p/s 的速度进行探测,那么要达到50%的概率仅需 6s,达到 99% 也只需 29s!

根据上面的数据分析可以发现生日攻击是在 Symmetric NAT - Port Restricted Cone NAT 进行 P2P 打洞时一个较为优秀的方案。

4.4.4 Symmetric NAT - Symmetric NAT

当两端都是 Symmetric NAT 时,复杂度比 Symmetric NAT - Port Restricted Cone NAT 有了成倍的增长。

假设使用上面的“全端口开发”方案,那么就需要发包 4,161,798,144 次,耗费时间需要一年以上;

即使是使用“生日攻击”方案,一端打开 256 个端口,要达到 50% 的概率需要 54,000 次发包,按照 100 p/s 的发包速率需要9分钟;达到 99% 的成功率需要 170,000 次发包,时间上需要30分钟左右。

即使时间上可以忍受,但 NAT 网关却无法忍受这么多次的发包行为。因为每发一次包,就需要在 NAT 的 session 表上记录一条,想创建一条成功的连接,大部分情况下都会打爆 NAT 的session 表。

因此对于 Symmetric NAT - Symmetric NAT 网络类型,连接建立方案只能选择中继服务器模式

4.5 客户端位于同一大 NAT 下,但不属于同一内网

image.png

在当前网络模型中,客户端 A 和客户端 B 位于同一任意大 NAT 后,但是分属于大 NAT 下的两个小子网中。客户端 A 和 客户端 B 需要通过以下步骤建立连接:

  1. 客户端 A 向中心服务器上报自身信息,并获取客户端 B 信息;客户端 B 向中心服务器上报自身信息,并获取客户端 A 信息;(PS: 忽略两端获取对端信息时的时间差)
  2. 客户端 A 根据从中心服务器获取的信息发现 B 的公网地址和自身相同,猜测 B 可能与自己位于同一内网中,于是 A 尝试直接向 B 发起连接。但是由于 A 和 B不在同一内网中,因此连接建立失败。客户端 A 通过中心服务器通知 B 连接建立失败,需要进行下一步尝试;
  3. 客户端 A 向 B 暴露的公网 IP 和端口直接发送请求。如果NAT 网关不支持 Hairpin 模式,那么这个数据包会被直接丢弃,导致数据包无法到达 NAT 网关2;如果 NAT 网关开启了 Hairpin 模式,A 发向 B 的流量会被 NAT 网关转发给 NAT 网关2,流程进入下一步;
  4. 当数据包到达 NAT 网关2后,下面的流程就和 “客户端分属与不同NAT下”时的打洞行为一致了。

4.6 多层 NAT

image.png

在网络中,存在设备在多层NAT后的情况。

对于这种多层NAT而言,真正有影响的是最靠近公网的那一个 NAT 网关和最靠近设备的那一个 NAT 网关,其余的 NAT 对于客户端和服务端来说都是不可见的,连接不会关心到底经过了多少层NAT。

但是多层 NAT 并非完全没有影响,准确来说,多层NAT 影响的是客户端的端口映射行为。客户端发出的端口映射请求,只有最靠近客户端的那层 NAT 设备会做出响应。其他的NAT设备不会收到客户端的端口映射请求。但是端口映射要产生作用的话,需要的是最靠近公网的 NAT 网关执行端口映射才行。

五、TCP P2P实现原理

5.1 TCP P2P 的优势

市面上实现 P2P 的产品主要都是以 UDP 协议为主。因为 UDP 是无连接的,能够往任意地址发包,便于实现 P2P 的能力。

但是 UDP 同时也是不可靠的,如果想要实现可靠传输得自己基于 UDP 去实现可靠传输协议,例如 QUIC、KCP、SCTP 等基于 UDP 实现的可靠连接。

但是基于 UDP 实现的可靠传输是位于应用层,运行在用户态的。

而 TCP 协议是操作系统网络栈原生支持的,而且经过这么多年在操作系统内核层面的优化,TCP 性能是十分能打的,如果我们能够基于 TCP 建立 P2P 连接,对于我们应用层来说就会省事很多了。

5.2 实现原理

要想实现基于 TCP 的 P2P,那么 TCP 也必须像 UDP 那样向不同地址建立连接时使用同一个 Src IP + Src Port。

要实现这个效果就需要使用 Linux 中的端口重用技术。端口重用技术运行我们使用同一个 Src IP + Src Port 向不同的目的地址发起 TCP 连接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"syscall"
	"time"
)

func main() {
	addr, err := net.ResolveTCPAddr("tcp", ":34567")
	if err != nil {
		panic(err)
	}

	dialer := net.Dialer{
		LocalAddr: addr,
		Control: func(network, address string, c syscall.RawConn) error {
			return c.Control(func(fd uintptr) {
				syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
				syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, 0xf, 1)
			})
		},
	}

	go func() {
		listen := net.ListenConfig{
			Control: func(network, address string, c syscall.RawConn) error {
				return c.Control(func(fd uintptr) {
					syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
					syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, 0xf, 1)
				})
			},
		}

		tcp, err := listen.Listen(context.Background(), "tcp", ":34567")
		if err != nil {
			panic(err)
		}
		log.Printf("start listen at %s ...", tcp.Addr())

		for {
			conn, err := tcp.Accept()
			if err != nil {
				panic(err)
			}

			log.Printf("accept new conn: %s -> %s", conn.RemoteAddr(), conn.LocalAddr())
		}
	}()

	conn1, err := dialer.Dial("tcp", "stun server")
	if err != nil {
		panic(err)
	}
	log.Printf("%s -> %s", conn1.LocalAddr(), conn1.RemoteAddr())

	log.Println("输入对端地址: ")
	var peer string
	fmt.Scanln(&peer)
	log.Printf("对端地址: %s", peer)

	for i := 0; i < 10; i++ {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		conn, err := dialer.DialContext(ctx, "tcp", peer)
		cancel()
		if err != nil {
			log.Printf("第 %d 次连接 %s 失败: %s", i, peer, err)
			time.Sleep(5 * time.Second)
			continue
		}
		log.Printf("%s -> %s", conn.LocalAddr(), conn.RemoteAddr())
        break
	}
}

上面的 Demo 中,TCP dial 和 listen 在同一个 Src IP + Src Port 上,进行多次尝试之后就能达到与 UDP 一样的 P2P 打洞效果。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计