初链主网Beta版于新加坡时间2018年09月28日08:00正式上线,在此期间,有不少的区块链爱好者对初恋主网Beta版的源码的不同的模块,依据其个人对初链公链源码的理解写了博客,供大家学习和借鉴。今天我就说一说我根据初链源码所理解的节点发现机制。
初链是基于kademlia协议实现其节点自动发现机制,完成整个网络拓扑关系的构建刷新
所以我们要先来了解什么是kademlia?
Kademlia在2002年由美国纽约大学的PetarP.Manmounkov和DavidMazieres提出,是一种分布式哈希表(DHT)技术,Kademlia 通过独特的以异或算法(XOR)为距离度量基础,建立了一种全新的 DHT 拓扑结构,相比于其他算法,大大提高了路由查询速度
现在我们用一个例子来了解什么是kademlia分布式存储及路由的算法。
试想一下,一所1000人的学校,现在学校突然决定拆掉图书馆(不设立中心化的服务器),将图书馆里所有的书都分发到每位学生手上(所有的文件分散存储在各个节点上)。即是所有的学生,共同组成了一个分布式的图书馆。
这就面临几个问题:
- 每个同学手上都分配哪些书。即如何分配存储内容到各个节点,新增/删除内容如何处理。
- 当你需要找到一本书,譬如《C++》的时候,如何知道哪位同学手上有《C++》(对1000个人挨个问一遍,“你有没有《C++》?”,显然是个不经济的做法),又如何联系上这位同学。即一个节点如果想获取某个特定的文件,如何找到存储文件的节点/地址/路径。
让我们来看看Kademlia算法如何巧妙地解决这些问题
节点的要素:
首先我们来看看每个同学(节点)都有哪些属性:
- 学号(Node ID,2进制,160位)
- 手机号码(节点的IP地址及端口)
每个同学会维护以下内容:
- 从图书馆分发下来的书本(被分配到需要存储的内容),每本书当然都有书名和书本内容(内容以<key, value>对的形式存储,可以理解为文件名和文件内容);
- 一个通讯录,包含一小部分其他同学的学号和手机号,通讯录按学号分层(一个路由表,称为“k-bucket”,按Node ID分层,记录有限个数的其他节点的ID和IP地址及端口)。
根据上面那个类比,可以看看这个表格:
关于为什么不是每个同学都有全量通讯录(每个节点都维护全量路由信息):
- 其一,分布式系统中节点的进入和退出是相当频繁的,每次有变动时都全网广播通讯录更新,通讯量会很大;
- 其二,一旦任意一个同学被坏人绑架了(节点被黑客攻破),则坏人马上就拥有了所有人的手机号码,这并不安全。
文件的存储及查找
原来收藏在图书馆里,按索引号码得整整齐齐的书,以一种什么样的方式分发到同学们手里呢?大致的原则如下:
- 1)书本能够比较均衡地分布在同学们的手里,不会出现部分同学手里书特别多、而大部分同学连一本书都没有的情况;
- 2)同学想找一本特定的书的时候,能够一种相对简单的索引方式找到这本书。
Kademlia作了下面这种安排:
假设《C++》这本书的书名的hash值是 00010000,那么这本书就会被要求存在学号为00010000的同学手上。(这要求hash算法的值域与node ID的值域一致。Kademlia的Node ID是160位2进制。这里的示例对Node ID进行了简略)
但还得考虑到会有同学缺勤。万一00010000今天没来上学(节点没有上线或彻底退出网络),那《C++》这本书岂不是谁都拿不到了?那算法要求这本书不能只存在一个同学手上,而是被要求同时存储在学号最接近00010000的k位同学手上,即00010001、00010010、00010011…等同学手上都会有这本书。
同样地,当你需要找《C++》这本书时,将书名hash一下,得到00010000,这个便是索书号,你就知道该找哪(几)位同学了。剩下的问题,就是找到这(几)位同学的手机号。
节点的异或距离
由于你手上只有一部分同学的通讯录,你很可能并没有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个例子:
- 01010000与01010010距离(即是2个ID的异或值)为00000010(换算为十进制即为2);
- 01000000与00000001距离为01000001(换算为十进制即为26+1,即65);
如此类推。
那通讯录是如何按距离分层呢?下面的示例会告诉你,按异或距离分层,基本上可以理解为按位数分层。设想以下情景:
以0000110为基础节点,如果一个节点的ID,前面所有位数都与它相同,只有最后1位不同,这样的节点只有1个——0000111,与基础节点的异或值为0000001,即距离为1;对于0000110而言,这样的节点归为“k-bucket 1”;
如果一个节点的ID,前面所有位数相同,从倒数第2位开始不同,这样的节点只有2个:0000101、0000100,与基础节点的异或值为0000011和0000010,即距离范围为3和2;对于0000110而言,这样的节点归为“k-bucket 2”;
……
如果一个节点的ID,前面所有位数相同,从倒数第n位开始不同,这样的节点只有2(i-1)个,与基础节点的距离范围为[2(i-1), 2i);对于0000110而言,这样的节点归为“k-bucket i”;
对上面描述的另一种理解方式:如果将整个网络的节点梳理为一个按节点ID排列的二叉树,树最末端的每个叶子便是一个节点,则下图就比较直观的展现出,节点之间的距离的关系。
回到我们的类比。每个同学只维护一部分的通讯录,这个通讯录按照距离分层(可以理解为按学号与自己的学号从第几位开始不同而分层),即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 行通讯录(其他节点的地址与端口)。
节点定位
我们现在来阐述一个完整的索书流程。
A同学(学号00000110)想找《C++》,A首先需要计算书名的哈希值,hash(《C++》) = 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算法的基本原理
初链的Kademlia-like协议
初链的kademlia网(简称kad)和标准kad网有部分差异.
下面对照初链源码,阐述下kad网里几个概念:
源码位于p2p/discover/table.go
const (
alpha = 3 // Kademlia并发参数
bucketSize = 16 // Kademlia K桶大小(可容纳节点数)
maxReplacements = 10 // Size of per-bucket replacement list
// We keep buckets for the upper 1/15 of distances because
// it's very unlikely we'll ever encounter a node that's closer.
hashBits = len(common.Hash{}) * 8 // 每个节点ID长度,32*8=256, 32位16进制
nBuckets = hashBits / 15 // K桶个数
bucketMinDistance = hashBits - nBuckets // k桶的最短距离
// IP address limits.
bucketIPLimit, bucketSubnet = 2, 24 // at most 2 addresses from the same /24
tableIPLimit, tableSubnet = 10, 24
maxFindnodeFailures = 5 // Nodes exceeding this limit are dropped
refreshInterval = 30 * time.Minute
revalidateInterval = 10 * time.Second
copyNodesInterval = 30 * time.Second
seedMinTableTime = 5 * time.Minute
seedCount = 30
seedMaxAge = 5 * 24 * time.Hour
)
- alpha,是系统内一个优化参数,控制每次从K桶最多取出节点个数,初链取值3
- bucketSize,K桶大小,初链取16
- hashBits,节点长度256位
- nBuckets,K桶个数,目前取17
初链Kad网络中节点间通信基于UDP,Kad网络总共定义了4种消息类型:
源码位于p2p/discover/udp.go
// RPC packet types
const (
pingPacket = iota + 1 // zero is 'reserved' // ping操作
pongPacket // pong操作
findnodePacket // find node节点查询
neighborsPacket // neighbors邻居回应
)
ping和pong是一对操作,用于检测节点活性,节点收到ping消息后立即回复pong响应:
源码位于p2p/discover/udp.go
// 收到ping消息的响应函数
func (req *ping) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
if expired(req.Expiration) {
return errExpired
}
// 向ping消息发送方回复pong
t.send(from, pongPacket, &pong{
To: makeEndpoint(from, req.From.TCP),
ReplyTok: mac,
Expiration: uint64(time.Now().Add(expiration).Unix()),
})
t.handleReply(fromID, pingPacket, req)
// 成功完成一次ping-pong,更新K桶节点信息
n := NewNode(fromID, from.IP, uint16(from.Port), req.From.TCP)
if time.Since(t.db.lastPongReceived(fromID)) > nodeDBNodeExpiration {
t.sendPing(fromID, from, func() { t.addThroughPing(n) })
} else {
t.addThroughPing(n)
}
t.db.updateLastPingReceived(fromID, time.Now())
return nil
}
findnode
和neighbors
是一对操作,findnode
用于查找与某节点相距最近的节点,查找到后以neighbors类型消息回复查找发起者
源码位于p2p/discover/udp.go
// 收到findnode消息的响应函数
func (req *findnode) handle(t *udp, from *net.UDPAddr, fromID NodeID, mac []byte) error {
if expired(req.Expiration) {
return errExpired
}
if !t.db.hasBond(fromID) {
// No endpoint proof pong exists, we don't process the packet. This prevents an
// attack vector where the discovery protocol could be used to amplify traffic in a
// DDOS attack. A malicious actor would send a findnode request with the IP address
// and UDP port of the target as the source address. The recipient of the findnode
// packet would then send a neighbors packet (which is a much bigger packet than
// findnode) to the victim.
return errUnknownNode
}
target := crypto.Keccak256Hash(req.Target[:])
t.mutex.Lock()
// 从本节点路由表里查找于target节点相距最近的bucketSize的节点
closest := t.closest(target, bucketSize).entries
t.mutex.Unlock()
p := neighbors{Expiration: uint64(time.Now().Add(expiration).Unix())}
var sent bool
// Send neighbors in chunks with at most maxNeighbors per packet
// to stay below the 1280 byte limit.
// 回复查询发起方
for _, n := range closest {
if netutil.CheckRelayIP(from.IP, n.IP) == nil {
p.Nodes = append(p.Nodes, nodeToRPC(n))
}
if len(p.Nodes) == maxNeighbors {
t.send(from, neighborsPacket, &p)
p.Nodes = p.Nodes[:0]
sent = true
}
}
if len(p.Nodes) > 0 || !sent {
t.send(from, neighborsPacket, &p)
}
return nil
}
初链节点发现机制
看看初链节点发现机制怎么流转起来的?
首先,在节点启动时启动UDP”端口监听”:server.Start()
==>discover.ListenUDP
==> newUDP()
newUDP()
分叉出去三个流程,三个流程均是无限循环:
- func (tab *Table) doRefresh()
- func (t *udp) loop()
- func (t *udp) readLoop(unhandled chan ReadPacket)
doRefresh()
该流程每隔1小时或按需刷新K桶,核心逻辑实现位于doRefresh函数,源码位于p2p/discover/table.go
:
func (tab *Table) doRefresh(done chan struct{}) {
defer close(done)
// Load nodes from the database and insert
// them. This should yield a few previously seen nodes that are
// (hopefully) still alive.
tab.loadSeedNodes()
// Run self lookup to discover new neighbor nodes.
// 以自身作为目标节点,寻找新的邻居节点,刷新K桶
tab.lookup(tab.self.ID, false)
// The Kademlia paper specifies that the bucket refresh should
// perform a lookup in the least recently used bucket. We cannot
// adhere to this because the findnode target is a 512bit value
// (not hash-sized) and it is not easily possible to generate a
// sha3 preimage that falls into a chosen bucket.
// We perform a few lookups with a random target instead.
for i := 0; i < 3; i++ {
var target NodeID
// 和标准Kademlia协议选取最旧的K桶进行刷新不同,初链选取一个随机节点ID作为刷新基点
crand.Read(target[:])
// lookup函数是最kad网最核心函数,查询离target最近一批节点
tab.lookup(target, false)
}
}
tab.lookup
函数虽然关键,然而其逻辑其实是很简单的
首先查询离target最近一批节点,距离计算即对kad网络XOR(异或)距离计算的实现, 源码位于p2p/discover/table.go
func (tab *Table) closest(target common.Hash, nresults int) *nodesByDistance {
// This is a very wasteful way to find the closest nodes but
// obviously correct. I believe that tree-based buckets would make
// this easier to implement efficiently.
close := &nodesByDistance{target: target}
// 遍历本地路由节点表
for _, b := range &tab.buckets {
for _, n := range b.entries {
close.push(n, nresults)
}
}
return close
}
close.push最终调用distcmp进行异或计算,源码位于p2p/discover/node.go
// distcmp compares the distances a->target and b->target.
// Returns -1 if a is closer to target, 1 if b is closer to target
// and 0 if they are equal.
func distcmp(target, a, b common.Hash) int {
for i := range target {
da := a[i] ^ target[i]
db := b[i] ^ target[i]
if da > db {
return 1
} else if da < db {
return -1
}
}
return 0
}
然后迭代上一步查到的所有节点,向这些节点发起findnode操作查询离target节点最近的节点列表,将查询得到的节点进行ping-pong测试,将测试通过的节点落库保存
经过这个流程后,节点的K桶就能够比较均匀地将不同网络节点更新到本地K桶中。
loop()
和readLoop()
这两个循环流程放在一起说,它们主要是一个工程实现,将异步调用代码通过channel串接成同步。业务上主要是负责处理ping,pong,findnode,neighbors四个消息类型的收发。
唯一值得稍加阐述的可能只有pending结构:
// pending实现了一种延迟处理逻辑
//
// 它主要有两个作用:
// 1. 提供回调机制,当某一个操作发起异步请求时,就使用pending结构封装一个闭包,当收到异步回复后从pending列表取出这个闭包,执行回调,因此在这个回调里可以完成数据包校验等后处理
// 如findnode操作将更新k桶的操作暂存,再获取到异步回复后执行这个闭包完成k桶更新
// 2. 提供多个回复接收功能,一个RPC请求可能会对应多个回复包,比如findnode对应多个neigbours回复包,此时可以提供多个pending进行逐个包校验
type pending struct {
// 来源节点
from NodeID
ptype byte
// 调用超时丢弃pending结构
deadline time.Time
// 回调函数,简单而强大
callback func(resp interface{}) (done bool)
errc chan<- error
}
综述,邻居节点发现流程:
1) 系统第一次启动随机生成本机节点NodeId,即为LocalId,生成后固定不变.
该节点为第一次启动时生成,以后重新启动后不会变化。各个节点都会有一个唯一的标志NodeId。A和B都有各自NODEid
2) 系统读取公共节点信息,ping-pang握手完成后,将其写入K桶
读取公共节点,也就是说大家都知道,各个节点都有相同的公共节点信息。设为C。也就是说A不知道B,B不知道A,但A和B都知道C.这个是C点也就是知道了A点和B点。
3) 进入刷桶循环
-
a) 随机生成目标节点Id,记为TargetId,从1开始记录发现次数和刷新时间。各个节点都会生成目标节点id,也就是说每个节点同时发起刷桶操作。
-
b) 计算TargetId与LocalId的距离,记为Dlt计算本地节点与目标节点的距离。
-
c) k桶中节点NodeId记为KadId,计算KadId与TargetId的距离,记为Dkt各个节点中k通中初始化为公共节点信息,
-
d) 找出K桶中Dlt大于Dkt的节点,记为K桶节点,向k桶发送FindNODE命令,FindNODE命令包括TargetId,因为K桶节点已经知道发送FindNode命令的本地节点,所以现在需要记录本地节点信息。也就是说A向C点发送消息,C无需在记录A点信息。
-
e) k桶节点收到FindNODE命令后,统一执行b-d的过程,将从K通中找到的节点使用Neighboras命令发回给本机节点。C点将B点的信息发给A点。
-
f) 本机节点收到Neighbour后,将收到的节点写入到K桶中,A点将B点信息进行记录,这样A点就知道B点了。同样的过程B点通过C点知道A点。
内网穿透
初链是基于p2p通信的,所有的操作都有可能涉及到内网穿透,而目前内网穿透最常用的方法是udp打洞,这也是kad网络使用udp作为基础通信协议的原因。
具体代码没找到,但是可以在节点初始化函数里看出痕迹nat.Map()方法
端口映射:源码位于p2p/server.go
func (srv *Server) Start() (err error) {
srv.lock.Lock()
defer srv.lock.Unlock()
if srv.running {
return errors.New("server already running")
}
.........
.........
.........
if !srv.NoDiscovery || srv.DiscoveryV5 {
addr, err := net.ResolveUDPAddr("udp", srv.ListenAddr)
if err != nil {
return err
}
conn, err = net.ListenUDP("udp", addr)
if err != nil {
return err
}
realaddr = conn.LocalAddr().(*net.UDPAddr)
if srv.NAT != nil {
// 进行内网网端口映射
if !realaddr.IP.IsLoopback() {
go nat.Map(srv.NAT, srv.quit, "udp", realaddr.Port, realaddr.Port, "ethereum discovery")
}
// TODO: react to external IP changes over time.
if ext, err := srv.NAT.ExternalIP(); err == nil {
realaddr = &net.UDPAddr{IP: ext, Port: realaddr.Port}
}
}
}
.........
.........
.........
首先,初链tcp/udp共用了一个端口,然后使用uPnp协议簇进行内外网端口映射,完成链路打通,从而穿透内网.
具体封装位于nat
模块,但具体实现也是使用了三方库goupnp.
以上就是个人理解的初链主网Beta版的p2p模块节点发现机制部分。