比特币-正确认识比特币btc-币百科,快速认识各种币区块链-下一场变更

区块链与P2P网络

2019-06-11  本文已影响1人  原本区块链

本文作者为原本区块链算法工程师,原文链接:https://zhuanlan.zhihu.com/p/68606699

一、什么是P2P网络

点对点(p2p, peer to peer)网络是一种分布式应用架构,它的主要思想是把整体任务或负载划分到对等的多个节点中去,这些节点节点在应用架构中具有平等的权利和参与度,我们称之为对等节点(peers),虽然P2P系统曾在许多应用领域使用过,但该架构最初因为1999年发布的文件共享系统Napster而得到普及。

节点主动把自身的部分资源(包括算力、存储和网络)直接供给其他节点使用,而不需要中央服务器的集中协调。对等节点既是资源的提供者,也是资源的消耗者,这与传统的客户-服务器(C/S)架构不同:在C/S架构中,资源的供应和消耗是分开的。

图1

图2

上图是C/S架构和p2p架构的一个简单示意图,原图来自wiki。图1中C/S架构被描绘成星型拓扑,这当然仅仅是特例,实际生产环境中由各种各样拓扑形状的C/S架构,但是其核心特征是不变的:C/S 网络中的个体地位和功能是不平等的,client个体主要消耗资源,发起请求,server个体主要提供资源并处理请求,这使得C/S架构天然是中心化的。

相比之下,p2p架构中最重要的特点在于:网络中的个体在地位和功能上是平等的,虽然每个个体可能处理不同的请求,实际提供的资源在具体量化后可能有差异,但它们都能同时既消耗资源又提供资源。如果把整个所处网络中的资源——包括但不限于运算能力、存储空间、网络带宽等,视为一个总量,那么p2p网络中的资源分布,是分散于各个个体中的(也许不一定均匀分布)。所以,p2p网络架构是去中心化的、分布式的。

注意上图右侧p2p网络中,节点之间的通信并非是全连接的。这是p2p网络的一个很重要的特点:节点只需要与相邻的一部分节点有通信即可,每个节点可与多少相邻节点、哪些节点有通信,这些都是p2p网络架构设计的一部分。

根据具体应用不同,可以把P2P分为以下这些类型[1]

提供文件和其它内容共享的P2P网络,例如Napster、Gnutella、eDonkey、emule、BitTorrent等;

挖掘P2P对等计算能力和存储共享能力,例如SETI@home 、Avaki、Popular Power等;

基于P2P方式的协同处理与服务共享平台,例如JXTA、Magi、Groove、.NET My Service等;

即时通讯交流,包括ICQ、OICQ、Yahoo Messenger等;

安全的P2P通讯与信息共享,例如Skype、Crowds、Onion Routing等。

二、P2P网络架构

拓扑结构是指分布式系统中各个计算单元之间的物理或逻辑的互联关系,结点之间的拓扑结构一直是确定系统类型的重要依据。点对点网络通常在物理网络拓扑之上实现某种形式的虚拟覆盖网络,网络中的节点形成一个物理网络中的子集。数据仍然通过底层的TCP/IP网络直接交换,但是在应用层,节点能够通过逻辑链路(每个链路对应于一个建立在底层物理网络上的连接)直接相互通信。通过一定的索引和节点发现算法,P2P系统能够独立于物理网络拓扑。根据网络中节点如何相互链接,以及资源如何被索引和定位,我们可以将点对点网络分为非结构化或结构化网络(或混合结构)。

2.1 非结构化网络

这种p2p网络是最普通的,不对结构作特别设计的实现方案,而是由彼此随机形成连接的节点形成的。(Gnutella、Gossip和Kazaa都是非结构化P2P网络)。因为节点之间没有特殊设计的全局结构,这种网络优点是结构简单易于组建,网络局部区域内个体可任意分布;特别是在应对大量新节点加入网络和旧节点离开网络时它的表现非常稳定。

然而,非结构化网络的主要局限性也源于结构设计的缺乏。特别地,当节点想要在网络中找到所需的数据时,搜索查询必须在网络中进行广播,以找到尽可能多的共享数据的节点。广播导致网络中信号传输流量非常高,也就消耗更多的

CPU和内存(要求每个节点处理所有搜索查询),并且不能确保搜索查询总是得到响应。此外,由于节点和由其管理的内容之间没有相关性,因此不能保证广播请求会找到具有所需数据的节点。热门的内容可能在多个节点上可用,任何节点搜索它都可能找到相同的内容。但是,如果一个节点正在寻找只有少数几个其他节点共享的冷门数据,那么搜索就不太可能成功。

2.2 结构化网络

在结构化的网络中,节点网络被组织成特定的拓扑结构,并且确保任何节点都可以高效地在网络中搜索文件/资源,即使该资源极其稀少。

提高查询效率的基本手段是对数据建立索引,结构化p2p网络最普遍的实现方案中使用了分布式哈希表(Distributed Hash Table,DHT),它会对每项数据(value)分配一个key以组成(key,value)键值对,同时网络中每个个体的分布--这里的分布主要指相互通信关系-根据key键进行关联和扩展。这样,当要查找某项数据时,只要跟据其key键就能不断的缩小查找区域,大大减少资源消耗。

然而,这样的网络结构缺点也很明显:由于每个个体需要存有数量不少的相邻个体列表,这使得它们在具有高流失率(即大量节点频繁加入和离开网络)的网络中不那么健壮,因为每个个体的很大一部分资源消耗在相邻列表更新上(包括自身相邻列表的更新,和相互之间更新所储列表),同时许多peer所在的key也需要重新定义;另外,哈希表本身容量是有使用限制的,当哈希表中存储的数据空间大于其设计容量的一半时,哈希表就会大概率出现“碰撞”事故,这样的限制也使得依据DHT建立的p2p网络的整体效率大打折扣。

2.3 混合架构

混合架构是p2p架构和C/S架构的组合。一种常见的混合模式是有一个中央服务器来帮助节点找到彼此。著名的Spotify在2014年之前采用的就是混合模式。现实生产环境中有许多种混合模型,所有这些模型都在结构化C/S网络提供的中心化功能和纯P2P非结构化网络提供的节点平等性之间进行权衡。目前,混合模型比纯非结构化网络或纯结构化网络具有更好的性能,因为某些功能(如搜索)确实需要集中式功能,同时又能受益于非结构化网络提供的节点去中心化。

三、区块链中的P2P网络

作为区块链的底层传输方式,P2P 技术帮助区块链成功实现了点对点的传播。比特币、以太坊等众多区块链项目都实现了属于自己的P2P网络协议,根据区块链的运行特点,我们可以总结出区块链客户端节点所组成p2p网络的一些需求:

节点可以任意地加入和离开网络;

每个节点所存储的数据(区块),在理想状态下是一致的(当然光凭p2p网络不能达到数据一致性,它只是提供了数据传输的逻辑通道,我们还需要共识算法来配合实现数据一致性);

在区块链网络中,查找数据时不需要向整个网络广播发送请求,正常情况下任意一个(或相邻几个)节点就可以提供完整的区块数据。

综上所述,我们对区块链中的p2p网络设计可以有个初步思路了:

经过改进的非结构化(每个节点维护相邻个体列表)网络模型可以满足需求;

需要设计节点之间的同步更新机制;

我们用以太坊为例,以太坊的 p2p 网络主要有两部分构成:节点之间互相连接用于传输数据的 tcp 网络和节点之间互相广播用于节点发现的 udp 网络。我们将在下面的章节中解释这个p2p网络通信协议是如何完善并实现的。

3.1 节点发现

以太坊的每个节点会维护一个相邻列表,表中会存储可供连接的其他节点的地址。这个列表通过基于 udp 的节点发现机制来保持更新和可用。当一个节点需要更多的TCP连接用于数据传输时,它就会从这个列表中获取待连接的地址并建立 TCP连接,与节点建立连接的流程大致如下图:

建立节点连接

为了保持当前节点维护列表的可用性,节点会周期性地执行上图中的1,2步。一旦节点需要进行tcp数据传输时,就从自身维护的列表中选择一定数量的可用url建立连接即可。

节点之间的连接分为两种类型:bound_in和bound_out。bound_in 指本节点接收到别人发来的请求后建立的tcp连接,bound_out指节点主动发起的tcp连接。假设一个节点最多只能与 6 个节点互联,那么在默认的设置中,节点最多只能主动和4个节点连接,剩余的必须留着给其它节点接入用。对于bound_in连接的数量则不做限制。

节点相邻列表的更新和维护将会在独立的goroutine中进行。这部分涉及的代码主要在 p2p/discv5 目录下的 udp.go, net.go, table.go 这三个文件中。下面我们看看 udp 这部分的主要函数和数据结构:

RPC request structures

udp.go中封装了多种RPC消息请求的数据结构。我们来看其中的两种类型:ping 和 pong。这两种请求主要用于建立节点之间的连接,ping作为通信的发起消息,pong 是ping的应答消息。

类似的还有findnode和neighbors消息,其中findnode用于向相邻节点请求离target较近的节点,而neighbors则是findnode消息的应答消息

udp.readloop()

结构体udp定义了readloop() 方法用来监听所有收到的 udp 消息。通过 ReadFromUDP() 接收收到的消息,然后在 handlePacket() 方法内将消息解码成定义好的RPC request structure(例如ping,pong等)后传递给后续的处理程序。

udp.sendPacket()

sendPacket() 用于将消息通过udp发送给目标地址。

transport interface

除了readloop()和sendPacket(),结构体udp还实现了一个transport接口,如下:

typetransportinterface{sendPing(remote*Node,remoteAddr*net.UDPAddr,topics[]Topic)(hash[]byte)sendNeighbours(remote*Node,nodes[]*Node)sendFindnodeHash(remote*Node,targetcommon.Hash)sendTopicRegister(remote*Node,topics[]Topic,topicIdxint,pong[]byte)sendTopicNodes(remote*Node,queryHashcommon.Hash,nodes[]*Node)send(remote*Node,ptypenodeEvent,pinterface{})(hash[]byte)localAddr()*net.UDPAddrClose()}

loop()

loop() 方法在 p2p/discv5/net.go 文件中,是整个 p2p 部分的核心方法。它控制了节点发现机制的大部分逻辑内容,由一个select进行各种信号通道(channel)的监控。主体代码大致如下:

func(net*Network)loop(){for{select{casepkt:=<-net.read:// 处理收到的 udp 消息  casetimeout:=<-net.timeout:// 发送的 udp ping 消息超时未收到 pong 应答  caseq:=<-net.queryReq:// 从 table 中查找距离某个 target 最近的 n 个节点  casef:=<-net.tableOpReq:// 操作 table, 这个 case 主要是为了防止 table 被异步操作  case<-refreshTimer.C:// 计时器到点,刷新 table 里的 url  }}}

nodeState

nodeState 表示了一个正在建立连接的远端节点对应的状态。在 net.go 的 init() 方法中定义了各种 nodeState:

typenodeStatestruct{namestringhandlefunc(...)(...)enterfunc(...)canQuerybool}var(unknown*nodeStateverifyinit*nodeStateverifywait*nodeStateremoteverifywait*nodeStateknown*nodeStatecontested*nodeStateunresponsive*nodeState)funcinit(){unknown=&nodeState{...}verifyinit=&nodeState{...}...}

nodeState 标识了节点的可用状态。当节点获取新节点的url时,节点首先是 unknown 状态,然后进入后续验证阶段。验证流程过完以后就会被定义为 known 状态然后保存到列表中。

enter() 和 handle()

nodeState 结构下定义了两个方法 enter() 和 handle()。

enter 是 nodeState 的入口方法,当一个节点node进入到 next状态时,它就会通过transition(node, next)调用 next状态的 enter() 方法。

handle 是 nodeState 的处理方法,当节点收到某个 url 的 udp 消息时,首先调用internNode方法从rpc消息中获取node实例,然后会调用此节点当前 nodeState 的 handle() 方法。一般 handle() 方法会针对不同的 udp 消息类型进行不同的逻辑处理。

// verifywait 的 handle 方法  func(net*Network,n*Node,evnodeEvent,pkt*ingressPacket)(*nodeState,error){switchev{casepingPacket:net.handlePing(n,pkt)returnverifywait,nilcasepongPacket:err:=net.handleKnownPong(n,pkt)returnknown,errcasepongTimeout:returnunknown,nildefault:returnverifywait,errInvalidEvent}}

handle() 方法在处理后会返回一个 nodeState,表示当前的 nodeState 在处理了这个 udp 消息后下一步将要转换的新的 state。比如在上面的代码中,verifywait 状态下的节点在收到 pong 类型的 udp 消息后就会转换成 known 状态。

新节点发现

所有定义好的nodeState连同它们的enter和handle方法组成了一个标准的有限状态机模型。每个nodeState都是其中的一个状态,enter方法定义了每个状态的进入动作,handle方法定义了每个状态的输入动作,每次接收到的rpc请求是状态的输入,节点发现的整体流程如下:

节点发现

有两种动作会触发状态机进入初始状态:

节点主动发起请求获取新的节点:通过lookup方法发送信号到queryReq通道

节点被动收到请求:通过监听read通道

Lookup主动节点发现

lookup() 方法定义在net.go文件中。此方法用于发起一次主动寻找附近节点的请求。

lookup() 需要输入一个 target 作为目标,然后寻找和 target 最近的 n 个节点。target 是由一个节点的 NodeID经过散列函数计算得来的。lookup() 方法的基本思路是先从节点本地列表获取最近的一部分邻居节点,然后再获取这些邻居节点的列表中最近的部分节点。最后从得到的节点中选距离 target 最近的 n 个节点加入到列表中。如果这些节点中有部分是之前未发现,则程序会走上文提到的新节点发现流程。

这里提到的距离并不是物理意义上的距离或者网络距离,而是由node.go文件的distcmp方法定义的一个数学距离。

funcdistcmp(target,a,bcommon.Hash)int{fori:=rangetarget{da:=a[i]^target[i]db:=b[i]^target[i]ifda>db{return1}elseifda<db{return-1}}return0}

3.2 节点传输

以太坊中,管理节点间p2p传输的顶层结构体叫eth.ProtocolManager。ProtocolManager主要成员包括:

peertSet{}类型成员用来缓存相邻节点列表,peer{}表示p2p网络中的一个节点。

各种通道(chan)和订阅(Subscribe)成员,用于接收和发送包括交易和区块在内的数据更新通知。

ProtocolManager用到的这些通道的另一端,可能是其他的peer,也可能是系统内单例的数据源比如txPool,或者是事件订阅的管理者比如Mux。

Fetcher负责从各个节点接受并保存区块通知,便于随后的获取。

Downloader成员负责所有区块同步行为。

以太坊的p2p网络中,进行数据传输的两个节点需要先经过相互的注册(register),并被添加到各自缓存的peer列表,也就是peerSet{}对象中,这样的两个peers,就可以称为“相邻”。所以,这里提到的“远端"个体,如果处于可通信状态,则必定已经“相邻”。

Start()函数是ProtocolManager的启动函数,在eth.Ethereum.Start()中被主动调用。此函数会启用4个goroutine去分别执行4个函数:

func(pm*ProtocolManager)Start(maxPeersint){pm.maxPeers=maxPeers// broadcast transactions  pm.txsCh=make(chancore.NewTxsEvent,txChanSize)pm.txsSub=pm.txpool.SubscribeNewTxsEvent(pm.txsCh)gopm.txBroadcastLoop()// broadcast mined blocks  pm.minedBlockSub=pm.eventMux.Subscribe(core.NewMinedBlockEvent{})gopm.minedBroadcastLoop()// start sync handlers  gopm.syncer()gopm.txsyncLoop()}

这四个函数对应的相对独立的业务流程的逻辑分别是:

广播交易列表Trasactions。txBroadcastLoop()会在txCh通道的监听交易事件,然后调用BroadcastTx()函数为所有的相邻接点广播它们尚未接收到的交易。

广播区块。minedBroadcastLoop()监听本节点的新区块生成事件,然后广播给部分相邻节点。minedBroadcastLoop()会连续调用两次BroadcastBlock(),两次调用仅仅一个bool型参数propagate不一样,当参数为true时,会将整个新区块依次发给相邻区块中的一小部分;而当其为false时,仅仅将新区块的Hash值和Number发送给所有相邻列表,这实际上是通知了这些节点这个新区块已经可以获取了。

定时与相邻个体进行区块同步。syncer()首先启动fetcher成员,然后进入一个无限循环,每次循环中都会向相邻peer列表中最优”的那个peer(BestPeer方法)作一次区块全链同步。这里所谓"最优"指的是peer中所维护区块链的TotalDifficulty(td)最高,由于Td是全链中从创世块到最新头块的Difficulty值总和,所以Td值最高就意味着它的区块链是最新的,跟这样的peer作区块全链同步,显然改动量是最小的,此即"最优"。触发上述同步的情况分两种:如果有新注册的相邻节点,则在整个peer列表数目大于5时触发同步;如果没有新peer到达,则以10s为间隔定时的触发。

将新出现的交易列表的同步给相邻节点。txsyncLoop负责每个新连接的初始交易同步。当出现新的相邻接点时时,我们会发送所有当前pending的事务给新的节点。为了减少出口带宽的使用,我们将交易分成一个个小的子集发送给节点。txsyncLoop()主体也是一个无限循环:首先有一个数据类型txsync{p, txs},包含peer和tx列表;通道txsyncCh用来接收txsync{}对象;txsyncLoop()每次循环时,如果从通道txsyncCh中收到新数据,则将它存入一个本地map[]结构,k为ID,v为txsync{},并将这组tx对象发送给这个peer;每次向peer发送tx对象的上限数目100*1024,如果txsync{}对象中有剩余tx,则该txsync{}对象继续存入map[]并更新tx数目;如果本次循环没有新到达txsync{},则从map[]结构中随机找出一个txsync对象,将其中的tx组发送给相应的peer,重复以上循环。

本节点(peer)向其他peer主动发起的通信中,按照数据类型可分两类:交易tx和区块block;而按照通信方式划分,亦可分为广播新的数据和同步一组同类型数据,这样简单的两两配对,便可组成上述四段流程。

Handle回调函数

除了节点主动向其他节点发起的通信,在p2p中,还有一种主要的通信方式就是本地节点对外提供给其他节点调用的传输数据的方法。也就是说,每当有新的远端节点与本地节点建立连接后,远端节点可以主动地调用这些回调方法来要求本地节点传输一些数据。

ProtocolManager.handle()就是这个回调函数。每当有新的节点要与己方建立通信时,如果对方能够支持该Protocol,那么双方就可以顺利的建立并开始通信。以下是handle()的基本代码:

// /eth/handler.go  func(pm*ProtocolManager)handle(p*peer)error{td,head,genesis:=pm.blockchain.Status()p.Handshake(pm.networkId,td,head,genesis)ifrw,ok:=p.rw.(*meteredMsgReadWriter);ok{rm.Init(p.version)}pm.peers.Register(p)deferpm.removePeer(p.id)pm.downloader.RegisterPeer(p.id,p.version,p)pm.syncTransactions(p)...for{iferr:=pm.handleMsg(p);err!=nil{returnerr}}}

handle()函数针对一个新peer做了如下几件事:

Handshake,与远端节点沟通区块状态;

初始化一个读写对象,用于数据传输;

注册远端节点,并在函数退出时,取消注册;

注册节点到Downloader;

调用syncTransactions(),将当前txpool累计的tx对象组装成一个txsync{}对象,发送到内部通道txsyncCh。Start()中启动的第四项txsyncLoop()函数会处理这些交易。

启动handleMsg()循环,处理远端节点发出的任何消息。

本文经「原本」原创认证,作者cblk,访问http://yuanben.io查询【X1T7L5DF】获取授权信息。

上一篇 下一篇

猜你喜欢

热点阅读