美文网首页HiBlock区块链社区区块链入门2019c
【深度知识】区块链P2P网络原理及以太坊P2P实现分析

【深度知识】区块链P2P网络原理及以太坊P2P实现分析

作者: 笔名辉哥 | 来源:发表于2020-07-25 20:33 被阅读0次

    1. 摘要

    区块链节点的网络模块主要负责节点之间的点对点(P2P)的通信,具有管理节点、节点间的数据收发等功能。本文介绍P2P点对点网络传输的原理以及以太坊网络节点发现和传输的原理。

    2. 区块链P2P网络原理

    2.1 区块链其他节点如何加入到现有P2P网络中

    在区块链的全网节点中存在一类叫“种子节点”的节点(以下简称种子节点),该类节点是网络中的最早出现的节点,负责对其他节点的分享工作。



    在区块链的安装程序中会初始化配置种子节点的信息,包括节点的IP地址和端口号,所有新加入的节点在安装区块链程序并启动时会与初始化的种子节点建立连接,将自身的IP地址和端口号告知种子节点,种子节点也将现有P2P网络中的其他节点信息告知新节点,并将新加入的节点告知其他节点。

    新节点收到其他节点信息后,与这些现有节点建立连接。自此,新加入的节点就加入到P2P网络中。

    2.2 网络模块如何与种子节点沟通的?

    2.3 新节点的网络模块如何与其他兄弟节点沟通呢?

    2.4 全网时间同步工作

    区块链的业务与时间戳密不可分,因此必须保证各个节点的时间是同步的,通过计算本地时间与网络时间的时间偏差(以下称为网络时间偏移量),利用该网络时间偏移量对每个节点的本地时间进行修正来保证全网时间的一致性。在参与区块链的业务时,本地节点的时间计算方式为本地系统时间加上网络时间偏移量即得到当前节点系统时间。

    每个节点从公共的NTP服务器获取格林威治标准时间(代号GMT时间),计算本地时间与GMT时间的网络时间偏移量,利用该时间偏移量参数调整本地节点的时间,并且每隔2分钟就重新计算时间偏移量;如果计算时间偏移量的时间消耗较大(大于3秒)时要立即重新计算。

    网络时间偏移量的计算方法如下:每个节点从至少三个公共的NTP服务器获取网络时间,然后分别减去本地时间得到网络时间偏移量参数,为了防止网络震动导致个别网络时间的偏差较大而影响最终时间偏移量的计算,要排除掉偏移量中相互之间偏差较大的偏移量参数(如大于500ms),最后取这些偏移量参数的算术平均值,即为本地节点网络时间偏移量。

    如果从公共的NTP服务器无法获取到网络时间,则向全网其他节点获取时间偏移量,然后取获取到的时间偏移量参数的算术平均值,即为本地节点网络时间偏移量。

    2.5 节点间消息转发

    为了提高处理能力,与节点之间的交互可以采取异步消息的方式,当与其他节点进行异步消息交互时,将消息放入消息转发队列中,在后台完成将消息转发给其他目的节点的消息转发工作。

    不断地监听每个节点的消息转发队列,查看是否有待发送的消息,如果存在则将待发送的消息发送给该节点。为了防止因节点待转发的消息过多而引起其他节点的消息堵塞,限制每个节点每轮最多只转发10条消息,发完后等待下一轮。

    2.6 节点内部模块之间消息转发

    区块链节点中分为网络模块、账本模块、账户模块、区块模块、交易模块、合约模块等11个模块,各个模块都各司其职,负责处理各类消息。当网络模块收到其他节点发送过来的消息后,根据消息名称转交其他不同的模块进行处理。模块划分如下所示:



    网络模块只负责处理网络通信协议部分的消息,包括版本消息、确认消息、地址消息、获取地址消息、获取时间消息、响应时间消息、心跳消息等与网络相关的消息名称,当网络模块收到这些消息名称的消息之后,由该模块的相应处理器进行业务处理。

    当网络模块收到其他消息后,首先根据消息名称查找能处理该消息的模块名称,然后将此消息封装成内部命名的消息格式,再通过websocket通信方式转发给处理模块;但是如果与该模块之间的通道被占满,则就将该消息放入消息转发列表中,由网络模块在后台进行消息转发,接收模块在接收到此消息后由消息处理器进行处理。

    3. 以太坊P2P原理及实现

    以太坊的p2p网络主要有两部分构成:节点之间互相连接用于传输数据的tcp网络涉及的算法有Gossip 协议算法;节点之间互相广播用于节点发现的udp网络,涉及的算法有kademlia DHT算法。

    3.1 分布式哈希表DHT(Kademlia算法)介绍

    以太坊网络的节点发现使用了结构化的kad算法,而上层的节点广播是以无结构的形式进行。先了解下Kademlia算法的原理。
    Kademlia算法是一种分布式存储及路由的算法。什么是分布式存储?试想一下,一所1000人的学校,现在学校突然决定拆掉图书馆(不设立中心化的服务器),将图书馆里所有的书都分发到每位学生手上(所有的文件分散存储在各个节点上)。即是所有的学生,共同组成了一个分布式的图书馆。

    由中心图书馆到分布式图书馆

    在这种场景下,有几个关键的问题需要回答。

    1)关键问题

    1. 每个同学手上都分配哪些书。即如何分配存储内容到各个节点,新增/删除内容如何处理。
    2. 当你需要找到一本书,譬如《分布式算法》的时候,如何知道哪位同学手上有《分布式算法》(对1000个人挨个问一遍,“你有没有《分布式算法》?”,显然是个不经济的做法),又如何联系上这位同学。即一个节点如果想获取某个特定的文件,如何找到存储文件的节点/地址/路径。

    如何寻找需要的书籍?

    接下来,让我们来看看Kademlia算法如何巧妙地解决这些问题。

    2)节点的要素

    首先我们来看看每个同学(节点)都有哪些属性:

    • 学号(Node ID,2进制,160位)
    • 手机号码(节点的IP地址及端口)

    每个同学会维护以下内容:

    • 从图书馆分发下来的书本(被分配到需要存储的内容),每本书当然都有书名和书本内容(内容以<key, value>对的形式存储,可以理解为文件名和文件内容);
    • 一个通讯录,包含一小部分其他同学的学号和手机号,通讯录按学号分层(一个路由表,称为“k-bucket”,按Node ID分层,记录有限个数的其他节点的ID和IP地址及端口)。

    根据上面那个类比,可以看看这个表格:

    概念对比

    (Hash的概念解释,可参见百度百科-哈希算法

    关于为什么不是每个同学都有全量通讯录(每个节点都维护全量路由信息):其一,分布式系统中节点的进入和退出是相当频繁的,每次有变动时都全网广播通讯录更新,通讯量会很大;其二,一旦任意一个同学被坏人绑架了(节点被黑客攻破),则坏人马上就拥有了所有人的手机号码,这并不安全。

    3)文件的存储及查找

    原来收藏在图书馆里,按索引号码得整整齐齐的书,以一种什么样的方式分发到同学们手里呢?大致的原则,包括:1)书本能够比较均衡地分布在同学们的手里,不会出现部分同学手里书特别多、而大部分同学连一本书都没有的情况;2)同学想找一本特定的书的时候,能够一种相对简单的索引方式找到这本书。
    Kademlia作了下面这种安排:
    假设《分布式算法》这本书的书名的hash值是 00010000,那么这本书就会被要求存在学号为00010000的同学手上。(这要求hash算法的值域与node ID的值域一致。Kademlia的Node ID是160位2进制。这里的示例对Node ID进行了简略)
    但还得考虑到会有同学缺勤。万一00010000今天没来上学(节点没有上线或彻底退出网络),那《分布式算法》这本书岂不是谁都拿不到了?那算法要求这本书不能只存在一个同学手上,而是被要求同时存储在学号最接近00010000的k位同学手上,即000100010001001000010011…等同学手上都会有这本书。

    同样地,当你需要找《分布式算法》这本书时,将书名hash一下,得到 00010000,这个便是索书号,你就知道该找哪(几)位同学了。剩下的问题,就是找到这(几)位同学的手机号。

    书籍搜索定位

    4)节点的异或距离

    由于你手上只有一部分同学的通讯录,你很可能并没有00010000的手机号(IP地址)。那如何联系上目标同学呢?

    通讯录上并没有目标同学的情况

    一个可行的思路就是在你的通讯录里找到一位拥有目标同学的联系方式的同学。前面提到,每位同学手上的通讯录都是按距离分层的。算法的设计是,如果一个同学离你越近,你手上的通讯录里存有ta的手机号码的概率越大。而算法的核心的思路就可以是:当你知道目标同学Z与你之间的距离,你可以在你的通讯录上先找到一个你认为与同学Z最相近的同学B,请同学B再进一步去查找同学Z的手机号。

    上文提到的距离,是学号(Node ID)之间的异或距离(XOR distance)。异或是针对yes/no或者二进制的运算.

    异或的运算法则为:0⊕0=0,1⊕0=1,0⊕1=1,1⊕1=0(同为0,异为1)
    百度百科-异或

    举2个例子:
    0101000001010010距离(即是2个ID的异或值)为00000010(换算为十进制即为2);
    0100000000000001距离为01000001(换算为十进制即为26+1,即65);
    如此类推。

    那通讯录是如何按距离分层呢?下面的示例会告诉你,按异或距离分层,基本上可以理解为按位数分层。设想以下情景:
    0000110为基础节点,如果一个节点的ID,前面所有位数都与它相同,只有最后1位不同,这样的节点只有1个——0000111,与基础节点的异或值为0000001,即距离为1;对于0000110而言,这样的节点归为“k-bucket 1”
    如果一个节点的ID,前面所有位数相同,从倒数第2位开始不同,这样的节点只有2个:00001010000100,与基础节点的异或值为00000110000010,即距离范围为3和2;对于0000110而言,这样的节点归为“k-bucket 2”
    ……
    如果一个节点的ID,前面所有位数相同,从倒数第i位开始不同,这样的节点只有2(i-1)个,与基础节点的距离范围为[2(i-1), 2i);对于0000110而言,这样的节点归为“k-bucket i”

    按位数区分k-bucket

    对上面描述的另一种理解方式:如果将整个网络的节点梳理为一个按节点ID排列的二叉树,树最末端的每个叶子便是一个节点,则下图就比较直观的展现出,节点之间的距离的关系。

    k-bucket示意图:右下角的黑色实心圆,为基础节点(按wiki百科的配图修改)

    回到我们的类比。每个同学只维护一部分的通讯录,这个通讯录按照距离分层(可以理解为按学号与自己的学号从第几位开始不同而分层),即k-bucket1, k-bucket 2, k-bucket 3…虽然每个k-bucket中实际存在的同学人数逐渐增多,但每个同学在它自己的每个k-bucket中只记录k位同学的手机号(k个节点的地址与端口,这里的k是一个可调节的常量参数)。
    由于学号(节点的ID)有160位,所以每个同学的通讯录中共分160层(节点共有160个k-bucket)。整个网络最多可以容纳2^160个同学(节点),但是每个同学(节点)最多只维护160 * k 行通讯录(其他节点的地址与端口)。

    5)节点定位

    我们现在来阐述一个完整的索书流程。

    A同学(学号00000110)想找《分布式算法》,A首先需要计算书名的哈希值,hash(《分布式算法》) = 00010000。那么A就知道ta需要找到00010000号同学(命名为Z同学)或学号与Z邻近的同学。
    Z的学号00010000与自己的异或距离为 00010110,距离范围在[24, 25),所以这个Z同学可能在k-bucket 5中(或者说,Z同学的学号与A同学的学号从第5位开始不同,所以Z同学可能在k-bucket 5中)。
    然后A同学看看自己的k-bucket 5有没有Z同学:

    • 如果有,那就直接联系Z同学要书;
    • 如果没有,在k-bucket 5里随便找一个B同学(注意任意B同学,它的学号第5位肯定与Z相同,即它与Z同学的距离会小于24,相当于比Z、A之间的距离缩短了一半以上),请求B同学在它自己的通讯录里按同样的查找方式找一下Z同学:
      -- 如果B知道Z同学,那就把Z同学的手机号(IP Address)告诉A;
      -- 如果B也不知道Z同学,那B按同样的搜索方法,可以在自己的通讯录里找到一个离Z更近的C同学(Z、C之间距离小于23),把C同学推荐给A;A同学请求C同学进行下一步查找。
    查询方式示意

    Kademlia的这种查询机制,有点像是将一张纸不断地对折来收缩搜索范围,保证对于任意n个学生,最多只需要查询log2(n)次,即可找到获得目标同学的联系方式(即在对于任意一个有[2(n−1), 2n)个节点的网络,最多只需要n步搜索即可找到目标节点)。

    每次搜索都将距离至少收缩一半

    以上便是Kademlia算法的基本原理。以下再简要介绍协议中的技术细节。

    6)算法的三个参数:keyspace,k和α

    • keyspace
      -- 即ID有多少位
      -- 决定每个节点的通讯录有几层
    • k
      -- 每个一层k-bucket里装k个node的信息,即<node ID, IP Adress, port>
      -- 每次查找node时,返回k个node的信息
      -- 对于某个特定的data,离其key最近的k个节点被会要求存储这个data
    • α
      -- 每次向其他node请求查找某个node时,会向α个node发出请求

    7)节点的指令

    Kademlia算法中,每个节点只有4个指令

    • PING
      -- 测试一个节点是否在线
    • STORE
      -- 要求一个节点存储一份数据
    • FIND_NODE
      -- 根据节点ID查找一个节点
    • FIND_VALUE
      -- 根据KEY查找一个数据,实则上跟FIND_NODE非常类似

    8) k-bucket的维护及更新机制

    • 每个bucket里的节点都按最后一次接触的时间倒序排列
    • 每次执行四个指令中的任意一个都会触发更新
    • 当一个节点与自己接触时,检查它是否在K-bucket中
      -- 如果在,那么将它挪到k-bucket列表的最底(最新)
      -- 如果不在,PING一下列表最上面(最旧)的一个节点
      -- a) 如果PING通了,将旧节点挪到列表最底,并丢弃新节点
      -- b) 如果PING不通,删除旧节点,并将新节点加入列表

    该机制保证了任意节点加入和离开都不影响整体网络。

    9)总结

    Kademlia是分布式哈希表(Distributed Hash Table, DHT)的一种。而DHT是一类去中心化的分布式系统。在这类系统中,每个节点(node)分别维护一部分的存储内容以及其他节点的路由/地址,使得网络中任何参与者(即节点)发生变更(进入/退出)时,对整个网络造成的影响最小。DHT可以用于构建更复杂的应用,包括分布式文件系统、点对点技术文件分享系统、合作的网页高速缓存、域名系统以及实时通信等。
    Kademlia算法在2002年由Petar Maymounkov 和 David Mazières 所设计,以异或距离来对哈希表进行分层是其特点。Kademlia后来被eMule、BitTorrent等P2P软件采用作为底层算法。Kademlia可以作为信息安全技术的奠基之一。
    Kademlia的优点在于:

    • 对于任意一个有[ 2(n−1) ,2𝑛)个节点的网络,最多只需要n步搜索即可找到目标节点;
    • K-bucket的更新机制一定程度上保持了网络的活性和安全性。

    3.2 节点发现的udp网络

    (1)P2P整体结构

    在以太坊中,节点之间数据的传输是通过tcp来完成的。但是节点如何找到可以进行数据传输的节点?这就涉及到P2P的核心部分节点发现了。每个节点会维护一个table,table中会存储可供连接的其他节点的地址。这个table通过基于udp的节点发现机制来保持更新和可用。当一个节点需要更多的TCP连接用于数据传输时,它就会从这个table中获取待连接的地址并建立TCP连接。
    与节点建立连接的流程大致如下图:

    上图中,第一和第二步每隔一段时间就会重复一次。目的是保持当前节点的table所存储的url的可用性。当节点需要寻找新的节点用于tcp数据传输时,就从一直在更新维护的table中取出一定数量的url进行连接即可。

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

    (2)UDP部分

    前文提到过,以太坊会维护一个table结构用于存储发现的节点url,当需要连接新的节点时会从table中获取一定数量的url进行tcp连接。在这个过程中,table的更新和维护将会在独立的goroutine中进行。这部分涉及的代码主要在 p2p/discvV5 目录下的 udp.go, net.go, table.go 这三个文件中。下面我们看看udp这部分的主要逻辑结构:

    readloop()
    readloop()方法在 p2p/discvV5/udp.go 文件中,用来监听所有收到的udp消息。通过 ReadFromUDP() 接收收到的消息,然后在 handlePacket() 方法内将消息序列化后传递给后续的消息处理程序。

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

    UDP msg type
    所有通过 udp 发出的消息都分为两种类型:ping 和 pong。ping 作为通信的发起类型,pong 作为通信的应答类型。一个完整的通讯应该是:

    1. Node A 向 Node B 发送 ping 类型的 udp 消息。
    2. Node B 收到此消息后进行消息处理。
    3. Node B 向 Node A 发送 pong 类型的消息作为之前收到的 ping 消息的应答。

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

    nodeState
    nodeState 表示了一个url对应的状态。在 net.go 的 init() 方法中定义了各种 nodeState:

    nodeState主要是为了记录新地址的可用状态。当节点接触新的 url 时,此 url 会首先被定义为 unknown 状态,然后进入后续 ping pong 验证阶段。验证流程过完以后 就会被定义为 known 状态然后保存到 table 中。

    enter() 和 handle()

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

    • enter 是 nodeState 的入口方法,当一个 url 从 nodeState1 转到 nodeState2 时,它就会调用 nodeState2 的 enter() 方法。
    • handle 是 nodeState 的处理方法,当节点收到某个 url 的 udp 消息时,会调用此 url 当前 nodeState 的 handle() 方法。一般 handle() 方法会针对不同的 udp 消息类型进行不同的逻辑处理:

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

    (3)新节点加入流程

    新节点的加入分两种:
    1,获取到新的 url 后主动向对方发起 udp 请求,在完成一系列验证后将此 url 加入到 table 中。
    2,收到陌生 url 地址发来的 udp 请求,在互相做完验证后加入到 table 中。
    这两种情况是相对应的,NodeA 获取到 NodeB 的 url 后向 NodeB 发起 udp 请求(情况1),NodeB 收到来自 NodeA 的陌生 url 后做应答(情况2)。双方验证完成后互相将对方的 url 加入到table中。整个流程步骤如下:

    1,作为验证发起方,NodeA 在收到 url 后会将这个 url 的 nodeState 设置为 verifyinit 并通过 verifyinit 的 enter() 方法向 nodeB 的 url 发起 ping 消息。

    2,NodeB 收到此 ping 消息后先获取此 url 的 nodeState,发现未找到于是将其设置为 unknown 然后调用 unknown 的 handle() 方法。

    3,在 handle() 方法中,NodeB 会先向 NodeA 发送一个 pong 消息表示对收到的 ping 的应答。然后再向 NodeA 发送一个新的 ping 消息等待 NodeA的应答。同时将 NodeA 的 state 设置为 verifywait。

    4,NodeA 收到 NodeB 发来的 pong 消息后调用 verifyinit 状态的 handle() 然后将 NodeB 设置为 remoteverifywait 状态。remoteverifywait 的意思就是 NodeB 在我这个节点(NodeA)这里已经经过 ping pong 验证通过了,但是我还没在 NodeB 那边经过 ping pong 验证。

    5,将 NodeB 的 state 转换为 remoteverifywait 后会调用 remoteverifywait 的 enter() 方法。此方法开启一个定时器,定时器到点后向本节点发送一个 timeout 消息。

    6,处理完 pong 消息后,NodeA 又收到了来自 NodeB的 ping 消息,此时调用 remoteverifywait 的 handle() 向 NodeB 发送一个 pong 应答。

    7,NodeB 收到 NodeA 的 pong 应答后调用 verifywait 的 handle() 方法,此方法会将 NodeA 的 state 设置为 known。在 state 转换后调用新 state 也就是 known state 的 enter() 方法。known 的 enter() 中会将 NodeA url 加入到 NodeB 的 table 中。

    8,Step5 中 NodeA 的定时器到点并向 NodeA 自己发送 timeout 消息。此消息会调用 remoteverifywait 的 handle() 然后将 NodeB 的状态设置为 known。和 Step7 中一样,更改状态为 known 后 NodeB 的 url 会加入到 NodeA 的 table 中。

    p.s. 以上步骤结合源码会更清晰

    (4)lookup节点发现

    上文中,NodeA 收到了 NodeB 的 url 从而发起两者间的联系。那么 NodeA 是如何得到这个 url 的呢?答案是通过 lookup()方法来发现这个 url。

    lookup() 方法源码在 p2p/discvV5/net.go 文件中。此方法在需要获取新的 url 或者需要刷新 table 时被调用。

    lookup() 需要输入一个 target 作为目标,然后寻找和 target 最近的 n 个节点。target 是由一个节点的 NodeID 经过哈希计算得来的。在以太坊中,每次生成新的target 都会虚构一个节点 url 然后 hash 计算得到 target。lookup() 方法的基本思路是先从本地 table 获取和 target 最近的一部分邻居节点,然后再获取这些邻居节点的 table 中和 target 最近的部分节点。最后从得到的节点中选距离 target 最近的 n 个节点 url 作为新的 table 内容。如果这些 url 中有部分是之前未接触的,则程序会走上文提到的新节点加入流程来建立关系。

    这里提到的距离最近不是指物理距离,而是数理上的最近。程序会计算节点 NodeID 经过 hash 计算后和 target 的差异大小来判断数理上的距离大小,具体可以参考 closest() 方法。

    (5)结论

    以太坊的P2P大致就是上述的实现方式。总的来说就是通过 udp 发现可供使用的节点 url 并将这些 url 维护在本地的 table 中,通过 tcp 和其他节点进行连接并进行数据传输,当需要新的节点时从 table 中获取未接触过的 url 建立新的 tcp 连接。

    3.3 GOSSIP协议

    以太坊节点通信采用GOSSIP协议。
    gossip 协议(gossip protocol)又称 epidemic 协议(epidemic protocol),是基于流行病传播方式的节点或者进程之间信息交换的协议,在分布式系统中被广泛使用,比如我们可以使用 gossip 协议来确保网络中所有节点的数据一样。gossip protocol 最初是由施乐公司帕洛阿尔托研究中心(Palo Alto Research Center)的研究员艾伦·德默斯(Alan Demers)于1987年创造的。

    从 gossip 单词就可以看到,其中文意思是八卦、流言等意思,我们可以想象下绯闻的传播(或者流行病的传播);gossip 协议的工作原理就类似于这个。gossip 协议利用一种随机的方式将信息传播到整个网络中,并在一定时间内使得系统内的所有节点数据一致。Gossip 其实是一种去中心化思路的分布式协议,解决状态在集群中的传播和状态一致性的保证两个问题。

    我们通过一个具体的实例来深入体会一下 Gossip 传播的完整过程

    为了表述清楚,我们先做一些前提设定

    (1)Gossip 是周期性的散播消息,把周期限定为 1 秒
    (2)被感染节点随机选择 k 个邻接节点(fan-out)散播消息,这里把 fan-out 设置为 3,每次最多往 3 个节点散播。
    (3)每次散播消息都选择尚未发送过的节点进行散播
    (4)收到消息的节点不再往发送节点散播,比如 A -> B,那么 B 进行散播的时候,不再发给 A。

    这里一共有 16 个节点,节点 1 为初始被感染节点,通过 Gossip 过程,最终所有节点都被感染:


    在以太坊,超级账本 Fabric 和 Cassandra 数据库中,Gossip 协议在其信息同步中都发挥了重要作用。

    (1)Gossip协议类型

    传播协议/谣言协议(Dissemination Protocols / Rumor-Mongering Protocols):
    通过网络中的泛洪代理来工作,节点收到广播的数据后直接转发给所有的邻居节点;此方式可以提高网络的健壮性,但是容易造成广播风暴。
    谣言传播,广泛地散播谣言,它指的是当一个节点有了新数据后,这个节点变成活跃状态,并周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据:

    从图中你可以看到,节点 A 向节点 B、D 发送新数据,节点 B 收到新数据后,变成活跃节点,然后节点 B 向节点 C、D 发送新数据。其实,谣言传播非常具有传染性,它适合动态变化的分布式系统。

    反熵协议(Anti-Entropy Protocols):用于修复复制数据,通过比较复制和协调差异进行操作;Hyperledger Fabric中的数据同步就是使用此方式实现。

    计算聚合的协议(Protocols that Compute Aggregates):通过对网络中节点的信息进行采样,并将这些值组合起来得到系统范围内的值,从而计算出网络范围内的集合 ;之后将建立一种全面的信息流模式。

    (2)Gossip数据传输(Gossip Messaging)

    Gossip的两个节点A、B之间有三种交互模式:
    1、Push 模式:B节点将数据(key,value,version)及对应的版本号推送给 A 节点,A 节点更新 B 节点中比自己新的数据。在 Push 模式中,发起信息交换的节点随机选取节点并向其发送自己的信息,一般拥有新信息的节点才会作为发起节点。

    假设存在 n 个节点,采用 Push 方式,在数据传播的初期,已收到消息的节点数目呈指数增长,直到有一半节点收到该消息。此后,未收到消息的节点集合将会在每一轮以一个常数因子进行收缩。当消息传播过程结束时该常数因子为,因为在一轮中没有收到消息的节点的占。因此,收缩过程将会通过轮直到所有的节点均收到消息,Push 模式在每一轮中发送的消息数量为。

    2、Pull 模式:A 仅将数据 key、version 推送给 B,B 将本地比 A 新的数据(key,value,version)推送给 A,A 更新本地。

    在 Pull 模式中,发起数据交换的节点随机选择节点并获取所选节点的数据,一般无新数据的节点才会作为发起节点。Pull 方式与 Push 方式相反,在数据传播的初期,收到消息的节点的数量增长缓慢;当已有一半节点收到消息之后,每轮过后未收到消息的节点占比将呈平方收缩。

    这是因为在一轮开始时假设有个未收到消息的节点,每一个节点将有的概率收到消息,因此节点保持未收到消息状态的概率为,这一轮结束后未收到消息的节点数量为。因此,未收到消息的节点数量的收缩过程仅需要轮,该过程中每一轮传递的消息数量为。

    3、Push-Pull 模式:在 Pull 的基础上,A 再将本地比 B 新的数据推送给 B,然后 B 再更新本地数据。也就是在 Pull 之后,A 再对比自己掌握的信息,更新 B 手中掌握的信息。

    Push-Pull 模式是结合了具备可预测性的 Push 机制和具备平方收缩特性的 Pull 机制,在这种模式下,消息双向发送。发起信息交换的节点先初始化一个时间计数器来代表信息的生命值,初始计数为 0。

    信息的生命值会随着每次传播而递增。发起信息交换的节点随机选取节点并向其发送自己的消息,同时从所选取的节点上获取该节点的消息,这个过程将持续进行直到消息的生命值超过。

    Gossip协议:“八卦版”区块链通信协议

    假设存在 n 个节点,在全连通网络中采用 Push-Pull 的方式,那么完成 Gossip 过程需要轮和次数据交换。

    • 想象 A、B 是老同学,毕业后工作在同一家公司,朝夕相处, A 想要了解 B 的信息,B 就只需要把 A 不知道的事情告诉他,类似于“推”模式。
    • 倘若只是工作在同一栋楼,两人掌握的信息依然有一定的交集,A 与 B 在简单的交流后,B 就可以告诉 A 所不知道的信息,就类似于“拉”模式。
    • 如果两个人毕业后在不同的国家,在信息内容上基本没有交集,这时候就需要将自己知道的全部信息告诉彼此,也就类似于“推-拉”模式。

    3.4 交易广播和区块广播的TCP部分

    eth/handle.go中的ProtocolManager管理节点之间通信。节点与节点之间的通信,也就是区块和交易的广播或同步。
    这里先介绍广播。提及广播,要先说一个有趣的协议:gossip,对,就是流言蜚语。如果有关于明星的八卦或是负面新闻,不用多长时间,可能满大街的人们就都知道了。广播就类似于流言蜚语的传播,一传十,十传百的扩散出去,最后整个网络都知晓了。

    以下是ProtocolManager实现区块和交易的广播的流程图:

    接下来会一步一步介绍。

    0.索引

    01.广播和同步的启动
    02.区块广播
    03.区块广播相关源码
    04.交易广播以及源码
    05.异步发送区块和交易的说明
    06.总结

    1.广播和同步的启动

    区块和交易的广播与同步由ProtocolManager协议管理控制。启动方法为Start

    func (pm *ProtocolManager) Start(maxPeers int) {
        pm.maxPeers = maxPeers
    
        // 广播交易
        pm.txsCh = make(chan core.NewTxsEvent, txChanSize)
        pm.txsSub = pm.txpool.SubscribeNewTxsEvent(pm.txsCh)
        go pm.txBroadcastLoop()
    
        // 广播区块
        pm.minedBlockSub = pm.eventMux.Subscribe(core.NewMinedBlockEvent{})
        go pm.minedBroadcastLoop()
    
        // 开始同步
        go pm.syncer()
        go pm.txsyncLoop()
    }
    
    

    开启了4个协程:

    • 1.创建了新交易事件的通道,然后开始pm.txBroadcastLoop() 广播交易。
    • 2.创建了新区块事件的通道,然后开始pm.minedBroadcastLoop() 广播区块。
    • 3.同步区块,同步的过程在eth/sync.go里,下一次介绍。
    • 4.同步交易。

    先从区块广播开始。

    2.区块广播

    区块广播指的是矿工挖出新的区块后,将新区块告知并发给p2p网络中的所有节点。这里涉及到两个广播过程:

    • 1.矿工广播新区块
    • 2.其他的中继节点广播新区快

    如下图:

    image

    第一轮:
    黄色的节点表示矿工,矿工挖到区块后,接下来要将区块广播出去,也就是发送给相邻的节点,这里相邻的节点有5个,两个红色的节点和三个蓝色的节点。红色的节点表示收到区块的节点,蓝色的节点表示收到区块哈希的节点。

    这里红色的节点是有一定数量要求的。取的是,要广播的节点数量的平方根。要广播5个节点,5取平方根再取整为2个。也就是说矿工向这两个红色节点直接发送了区块,然后向剩余的节点发送了区块哈希

    第二轮:
    接收到区块哈希的蓝色节点向发来区块哈希的节点(也就是矿工)请求下载区块,下载完区块后,就跟接收到区块的红色节点一样,向它的相邻节点发送区块区块哈希,如第一轮的过程。

    这其中会有一种情况产生,如果提前接收到了未来的区块,比如说,区块A->区块B->区块C,需要的是区块B,但是接收到了区块C的情况,这时候会将区块哈希进行广播。

    第n轮:
    同第二轮,直到整个网络都知晓广播的区块。

    注意:下面的源码介绍的是以矿工挖出区块后的第一次广播,也就是进行第一轮操作。交易广播亦是如此。)

    3.区块广播相关源码

    首先是区块广播循环minedBroadcastLoop()
    func (pm *ProtocolManager) minedBroadcastLoop() {
        // 自动停止,如果退订了通道。
        for obj := range pm.minedBlockSub.Chan() {
            if ev, ok := obj.Data.(core.NewMinedBlockEvent); ok {
                // 广播区块。
                pm.BroadcastBlock(ev.Block, true)  // 首先广播区块。
                pm.BroadcastBlock(ev.Block, false) // 然后只广播区块哈希。
            }
        }
    }
    
    

    区块广播循环minedBroadcastLoop()开启了之后,会一直读取区块事件的通道,也就是如果有新的区块事件产生,就能立即知晓。

    然后进行区块广播。调用了两次pm.BroadcastBlock方法。第一次标志位为true,给部分节点广播区块。第二次标志位为false,只广播区块哈希。

    关于pm.BroadcastBlock方法。

    根据propagate标志位的不同设置,对应不同的区块广播方式。

    func (pm *ProtocolManager) BroadcastBlock(block *types.Block, propagate bool)
    
    
    • 1.先获取新的区块的哈希hash,和本地节点的相邻节点中,未知这个区块的节点列表peers

      hash := block.Hash()
      peers := pm.peers.PeersWithoutBlock(hash)
      
      
    • 2.如果 propagate 字段为true。广播区块,节点的数量为peers的长度的平方根。

      transferLen := int(math.Sqrt(float64(len(peers))))
      transfer := peers[:transferLen]
      for _, peer := range transfer {
        peer.AsyncSendNewBlock(block, td)
      }
      
      
    • 3.如果 propagate 字段为false。广播区块哈希,节点的数量为peers剩余的节点数量。(即没有收到区块的节点。)

      for _, peer := range peers {
        peer.AsyncSendNewBlockHash(block)
      }
      
      
    异步发送区块或区块哈希

    (代码在eth/peer.go里)
    发送区块。
    在远程节点的广播队列里加入了区块事件,如果远程节点的广播队列 queuedProps 满了,则无法收到。然后标注该远程节点已知该区块。

    func (p *peer) AsyncSendNewBlock(block *types.Block, td *big.Int) {
        select {
        case p.queuedProps <- &propEvent{block: block, td: td}:
            p.knownBlocks.Add(block.Hash())
        default:
            p.Log().Debug("Dropping block propagation", "number", block.NumberU64(), "hash", block.Hash())
        }
    }
    
    

    发送区块哈希。
    与发送区块类似。在远程节点的区块哈希通知队列里加入了区块事件。然后标注该远程节点已知该区块。

    func (p *peer) AsyncSendNewBlockHash(block *types.Block) {
        select {
        case p.queuedAnns <- block:
            p.knownBlocks.Add(block.Hash())
        default:
            p.Log().Debug("Dropping block announcement", "number", block.NumberU64(), "hash", block.Hash())
        }
    }
    
    

    4.交易广播以及源码

    由于交易的数量比较多,所以每次广播的是一批交易。交易的广播也相对比较简单,一批新的交易,直接传给相邻节点即可。

    交易广播循环 txBroadcastLoop()

    循环读取交易事件通道,如果接收到新的一批交易,则广播出去。

    func (pm *ProtocolManager) txBroadcastLoop() {
        for {
            select {
            case event := <-pm.txsCh:
                pm.BroadcastTxs(event.Txs)
            case <-pm.txsSub.Err():
                return
            }
        }
    }
    
    
    pm.BroadcastTxs 方法

    广播交易,由于是一批交易,所以要先知道相邻的节点缺少这一批交易里的哪一些交易。定义了交易集合的映射,即远程节点对应该远程节点缺少的交易列表。然后发送交易。

    func (pm *ProtocolManager) BroadcastTxs(txs types.Transactions) {
        var txset = make(map[*peer]types.Transactions)
    
        // 广播给无该交易的节点
        for _, tx := range txs {
            peers := pm.peers.PeersWithoutTx(tx.Hash())
            for _, peer := range peers {
                txset[peer] = append(txset[peer], tx)
            }
            log.Trace("Broadcast transaction", "hash", tx.Hash(), "recipients", len(peers))
        }
        // 发送交易
        for peer, txs := range txset {
            peer.AsyncSendTransactions(txs)
        }
    }
    
    
    异步发送交易

    在远程节点的交易队列里加入了交易事件,如果远程节点的交易队列 queuedTxs 满了,则无法收到。然后标注该远程节点已知该交易。(一批交易。)

    func (p *peer) AsyncSendTransactions(txs []*types.Transaction) {
        select {
        case p.queuedTxs <- txs:
            for _, tx := range txs {
                p.knownTxs.Add(tx.Hash())
            }
        default:
            p.Log().Debug("Dropping transaction propagation", "count", len(txs))
        }
    }
    
    

    5.异步发送区块和交易的说明

    在进行区块广播和交易广播的时候,都是采用异步发送的形式,每个远程节点都设置了三个广播的通道,queuedProps,区块通道,缓存为4个区块;queuedAnns,区块哈希通道,缓存为4个区块哈希;queuedTxs交易通道,缓存为128个交易。

    需要进行区块或交易的广播的时候,将区块或交易放入远程节点相应的通道中。

    远程节点读取通道内容

    (代码在eth/peer.go里)

    在上层网络的peerSet中加入新的远程节点,也就是Register注册节点的时候,会开一个单独的协程,启动远程节点的广播方法,即go p.broadcast()

    func (ps *peerSet) Register(p *peer) error {
        ...
        ps.peers[p.id] = p
        go p.broadcast()
        return nil
    }
    
    

    go p.broadcast()方法,是一个异步读取循环,每次从交易通道,或区块通道,或区块哈希通道中读取内容,然后执行对应的发送方法。

    func (p *peer) broadcast() {
        for {
            select {
            case txs := <-p.queuedTxs:
                if err := p.SendTransactions(txs); err != nil {return}
            ...
            case prop := <-p.queuedProps:
                if err := p.SendNewBlock(prop.block, prop.td); err != nil {return}
            ...
    
            case block := <-p.queuedAnns:
                if err := p.SendNewBlockHashes([]common.Hash{block.Hash()}, []uint64{block.NumberU64()}); err != nil {return}
            ...
            case <-p.term:
                return
            }
        }
    }
    
    

    这里以发送区块为例,先标注该远程节点已知该区块,然后调用p2p.Send方法,将区块发送给远程的节点。

    func (p *peer) SendNewBlock(block *types.Block, td *big.Int) error {
        p.knownBlocks.Add(block.Hash())
        return p2p.Send(p.rw, NewBlockMsg, []interface{}{block, td})
    }
    
    

    然后是Send方法,将区块数据进行rlp编码后置入r中,size为rlp编码后的数据长度。调用w.WriteMsg方法将要发送的数据写入w通道。

    func Send(w MsgWriter, msgcode uint64, data interface{}) error {
        size, r, err := rlp.EncodeToReader(data)
        ...
        return w.WriteMsg(Msg{Code: msgcode, Size: uint32(size), Payload: r})
    }
    
    

    6.总结

    • 1.区块和交易的广播是一种gossip的传播方式,每个节点都向相邻的节点传播,最后蔓延开去,整个p2p网络就都知晓了广播的消息。
    • 2.区块广播有两个内容,分别为区块和区块哈希的广播。
    • 3.交易广播的对象是一批交易。

    4. 参考

    (1)解读区块链中P2P网络结构工作机制
    https://blog.csdn.net/qq_43721475/article/details/104662564

    (2)硬核干货 | 区块链的基石:以太坊的 P2P 网络实现
    https://blog.csdn.net/weixin_42934313/article/details/84590384

    (3)以太坊源码(01):P2P网络及节点发现机制
    https://www.cnblogs.com/blockchain/p/7943962.html

    (4)以太坊源码解读(9)以太坊的P2P模块解析——底层网络构建和启动
    https://blog.csdn.net/lj900911/article/details/84027202

    (5)分布式哈希表DHT(Kademlia算法)——通俗易懂
    https://blog.csdn.net/qq_26720653/article/details/106496916

    (6)[以太坊源码分析][p2p网络01]:什么是以太坊p2p网络
    https://www.jianshu.com/p/83ddaede545f

    (7)[以太坊源码分析][p2p网络02]:启动底层网络以及监听TCP连接
    https://www.jianshu.com/p/dbb8c4fc5a11

    (8)[以太坊源码分析][p2p网络04]:基于UDP的节点发现
    https://www.jianshu.com/p/b232c870dcd2

    (9)[以太坊源码分析][p2p网络05]:底层节点如何与上层节点联系
    https://www.jianshu.com/p/3db90d1b045e

    (10)[以太坊源码分析][p2p网络06]:交易广播和区块广播
    https://www.jianshu.com/p/76d2a28468ce

    (11)[以太坊源码分析][p2p网络07]:同步区块和交易
    https://www.jianshu.com/p/c0580a4b3d28

    (12)分布式原理:一文了解 Gossip 协议
    https://blog.csdn.net/dongli0/article/details/98848710

    (13)Gossip协议:“八卦版”区块链通信协议
    https://www.codercto.com/a/43380.html

    相关文章

      网友评论

        本文标题:【深度知识】区块链P2P网络原理及以太坊P2P实现分析

        本文链接:https://www.haomeiwen.com/subject/sfwmlktx.html