
为什么要进行节点发现呢?
因为要加入一个p2p网络,并且与网络中的节点交互,需要知道这个p2p网络中的一些节点信息。节点发现,使本地节点得知其他节点的信息,进而加入到p2p网络中。节点发现就是一个寻找邻居节点的过程。
这里有一点跟去中心化违背的地方,就是节点第一次启动的时候,节点会与硬编码在以太坊源码中的bootnode进行连接,这个bootnode有一种中心化服务器的感觉,因为所有的节点加入几乎都先连接了它。然而,只有一个可以通信的节点,明显是不足够的。连接上bootnode后,获取bootnode部分的邻居节点,然后进行节点发现,获取更多的活跃的邻居节点。
以太坊的节点发现
Kademlia - wiki
kademlia
以太坊的节点发现基于类似的kademlia算法,源码中有两个版本,v4和v5。v4适用于全节点,通过discover.ListenUDP使用,v5适用于轻节点通过discv5.ListenUDP使用,这里主要介绍v4版本,较为简单的版本。
接下来是以太坊节点发现v4的源码分析部分,分为udp发现和k桶刷新。
0.索引
01.p2p服务开启节点发现
02.udp节点发现
03.k桶刷新
1.p2p服务开启节点发现
在p2p.server.go中的Start方法中:
if !srv.NoDiscovery {
cfg := discover.Config{
PrivateKey: srv.PrivateKey,
AnnounceAddr: realaddr,
NodeDBPath: srv.NodeDatabase,
NetRestrict: srv.NetRestrict,
Bootnodes: srv.BootstrapNodes,
Unhandled: unhandled,
}
ntab, err := discover.ListenUDP(conn, cfg)
if err != nil {
return err
}
srv.ntab = ntab
}
- 其中
discover.ListenUDP方法即开启了节点发现的功能。discover.ListenUDP方法创建了一个新的udp对象(在discover/udp.go中),用于节点发现,和Table对象(在discover/table.go中),用于维护k桶。func ListenUDP(c conn, cfg Config) (*Table, error) { tab, _, err := newUDP(c, cfg) ... return tab, nil }
2.udp节点发现
新建一个udp对象
首先要从newUDP方法看起,newUDP方法如下:
func newUDP(c conn, cfg Config) (*Table, *udp, error)

源码中三个主要的过程:
- 1.
newTable方法新建Table对象,并且开启了k桶刷新(即更新路由表)的功能。这部分在下面的内容再做介绍。 - 2.
go udp.loop()协程,循环的监听4个通道。-
case <-t.closing,检测是否停止。 -
case p := <-t.addpending,检测是否有添加新的待处理消息。 -
case r := <-t.gotreply,检测是否接收到其他节点的回复消息。 -
case now := <-timeout.C,检测是否延时。
-
- 3.
go udp.readLoop(cfg.Unhandled)协程。- 循环接收其他节点发来的udp消息。
nbytes, from, err := t.conn.ReadFromUDP(buf) - 处理接收到的udp消息。
t.handlePacket(from, buf[:nbytes])
- 循环接收其他节点发来的udp消息。
udp消息有4种:
-
ping,用于判断远程节点是否在线。 -
pong,用于回复ping消息的响应。 -
findnode,查找与给定的目标节点相近的节点。 -
neighbors,用于回复findnode的响应,与给定的目标节点相近的节点列表。
节点发现的过程
(假设节点A要进行节点发现,向节点B进行查询)
- 1.节点A向节点B发送
ping消息,判断节点B是否在线。节点A加入与该ping消息对应的pending。(这里使用了t.addpending通道。)
对应的方法:func (t *udp) ping(toid enode.ID, toaddr *net.UDPAddr) error - 2.节点A收到节点B发来的
pong消息,确认节点B在线。将pong与pending进行匹配。(这里使用了t.gotreply通道。)
对应的方法:func (req *pong) handle(t *udp, from *net.UDPAddr, fromKey encPubkey, mac []byte) error - 3.节点A向节点B发送
findnode消息,想要获取与目标节点相近的节点。发送findnode消息时,会先检测上次收到节点B的pong消息是否超过24小时,超过则发送ping消息,接收到pong消息后再发送findnode消息。同时也记录一个与findnode消息对应的pending。(这里使用了t.addpending通道。)
对应的方法:func (t *udp) findnode(toid enode.ID, toaddr *net.UDPAddr, target encPubkey) ([]*node, error) - 4.节点A收到节点B发来的
neighbors消息,获取到几个与目标节点相近的节点。将neighbors与pending进行匹配。(这里使用了t.gotreply通道。)
对应的方法:func (req *neighbors) handle(t *udp, from *net.UDPAddr, fromKey encPubkey, mac []byte) error - 5.节点A向新获取的节点继续进行上述4个步骤,直到查找完成。
其中,节点B启动了loop和readloop两个单独的协程来处理节点A发送来的消息。
3.k桶刷新
以太坊的k桶设置:
alpha为3
nBuckets,k桶数量为17
bucketSize,k桶中最多存16个节点
maxReplacements,每个k桶的候选节点列表最多存10个节点
新建一个Table对象
newTable方法如下:
func newTable(t transport, self *enode.Node, db *enode.DB, bootnodes []*enode.Node)(*Table, error)

主要介绍三个方法:
- 1.
tab.setFallbackNodes(bootnodes)方法,设置初始引导节点,验证其完整性,然后加入引导节点列表。
初始引导节点的作用,如开头所说,首次启动并且没有加入到此p2p网络的节点,要加入到网络中,必须知道网络中一些节点的信息。初始引导节点的作用便是如此:引导初始启动的节点加入到p2p网络中。 - 2.
tab.loadSeedNodes()方法,从保留已知节点的数据库中随机的抽取30个节点,再加上引导节点列表中的节点,放置入k桶中。 - 3.
go tab.loop()协程,刷新k桶。下面介绍。
k桶刷新的过程
也就是go tab.loop()协程具体做了什么,如下:

主要介绍三个协程:
- 1.
go tab.doRefresh(refreshDone),刷新的协程。
doRefresh
主要的查找逻辑在lookup里,lookup会对3个异或距离较近的节点进行查询,查询方法用到的是udp.findnode。将每一次的查询结果,根据距离范围,加入到k桶中,如果k桶未满。 - 2.
go tab.doRevalidate(revalidateDone),重新验证的协程。选取随机的k桶中的最后一个节点,使用udp的ping消息,如果ping通了,将该节点移动在k通中的最前面。如果ping不通,删除该节点,从replacements候选节点列表中选取节点加入k桶。 - 3.
go tab.copyLiveNodes(),节点入数据库的协程。将k桶中的节点存入数据库中,选取节点的条件是节点在k桶中存在5分钟以上。
4.总结
- 1.以太坊的节点发现分为两个部分,基于udp的节点发现和k桶的刷新维护机制。
- 2.基于udp的节点发现使用
ping消息去确定远程节点是否在线,以及通过findnode消息去查找到更多的远程节点。其中使用了两个独立的协程,loop和readloop,用于对接收到远程节点的udp消息进行处理。 - 3.k桶的刷新维护机制,也就是时时更新节点的路由表,每30分钟进行一次查找新的节点,每10秒进行一次k桶中节点的重新验证,每30秒进行一次k桶节点的入库操作。其中使用了三个独立的协程。
