掘金 后端 ( ) • 2024-05-12 14:29

本文只讨论Kademlia算法中的K桶的分裂(Kademlia常用的实现方式),不包含节点的加入与离开等部分

概述

在去中心化底层网络中大部分使用P2P网络,特别是运用于区块链技术中。通常为了维护整个网络会用到分布式哈希表DHT(Distributed Hash Table),而Kademlia协议是其中一种DHT技术。

Kademlia协议(简称Kad)是美国纽约大学的PetarP. Maymounkov和David Mazieres. 在2002年发布的一项研究结果《Kademlia: A peerto -peer information system based on the XOR metric》。 Kad 通过独特的以异或算法(XOR)为距离度量基础,建立了一种 全新的 DHT 拓扑结构,相比于其他算法,大大提高了路由查询速度。

算法链接:

pdos.csail.mit.edu/~petar/papers/maymounkov-kademlia-lncs.pdf

前置知识点

由于异或运算的特殊性质:

即:

A XOR A=0

A XOR B= B XOR A(无向性)

A XOR B <= A + B

A XOR B != A XOR C (A,B,C均大于0,且两两之间互不相等)

所以在Kademlia算法中,一个节点与另一个节点之间的距离(这里的距离是指ID的差值)是固定的,即,我们可以确保离某个节点距离为Y的节点有且仅有一个

正文

算法中,是以满二叉树来表示这个ID的,这里为了方便,我们直接来模拟第一个节点中的K桶存放,当前网络只有这个这8个节点,k值为4

二叉树如下(有点丑,轻喷)

e299a7b8128cd01173596a1574c7223.jpg

理想的存入方式是,第0个元素(1在0号桶,2,3在1号桶...以此类推),直到存到第160个桶

示意图如下:

28dabc0d45db2e51cbc6d0da5488ad7.jpg

按照理论上的逻辑应该是:

第0号节点中,该会有160个buket,每个桶里面必须得存k个元素,那么就相当于,在这个网络中,浪费了160-4=156个桶!废弃比例来到了惊人的156/160=97.5%,并且会大大降低命中的概率(比如一个文件要访问距离为1290的桶,由于该桶没有,所以会一直向后查询桶,进行大概150次的无效查询)

如何改进K桶的存储方式?

上述提到的问题,最大的原因是,网络中的节点数量少并且存储的节点树过于稀疏,那么如何在满足Kademlia查找的logN效率基础上再进行优化呢?

我们可以尝试对Kademlia算法桶的存储内容进行改良如下:

当有节点加入时,匹配到当前最符合的桶中,即需要每个桶额外存储几个元素:

startDistanceLogic(理论最近距离)

endDistanceLogic(理论最远距离)

startDistanceReal(实际最近距离)

endDistanceReal(实际最远距离)

加入节点存储在这个(理论)距离中间的即为符合条件的桶,如果该桶的大小没满,即未达到K,则正常加入该桶,如果满了,则将桶进行分裂,再将该节点分别加入分裂(一般是一分为二)后的桶中

分裂逻辑如下:(先讨论加入节点在该桶实际距离范围内的情况)

  1. 先计算出分裂后的每个桶的分隔距离(spiltDistance):

$$ \frac{(startDistanceReal+endDistanceReal)}{2} $$

然后两个桶的理论距离分别为:

前一个桶:

startDistanceLogic=min(startDistanceReal,startDistanceReal)
endDistanceLogic=spiltDistance

后一个桶:

startDistanceLogic=spiltDistance+1
endDistanceLogic=max(spiltDistance,startDistanceReal)
  1. 将节点加入进桶中

示意图如下:

假设0号节点桶中只存入了4个节点,现在距离为5的节点要加入桶,每个桶中的中间的顺序是没有意义的(应当按照Kademlia算法对于K桶内的排序进行),这里只是方便查看

fc814a9efc8f87a1654348b83f27d80.jpg

const K = 4 // 每个桶最大节点数

type NodeID *[16]byte // 假设NodeID为16字节的byte数组
type Bucket struct {
   Nodes []*Node // 存储节点指针
   Range [2]*big.Int // 存储桶的ID范围,起始和结束
}

type Node struct {
   ID NodeID
}

// calculateBucketIndex 根据节点ID计算所属桶的索引
func calculateBucketIndex(targetID NodeID, ownID NodeID) int {
   xorResult := new(big.Int).Xor(new(big.Int).SetBytes(targetID[:]), new(big.Int).SetBytes(ownID[:]))
   bigK := big.NewInt(int64(K))
   return xorResult.BitLen() - bigK.BitLen()
}

// splitBucket 分裂桶操作
func splitBucket(buckets [][]*Node, targetID NodeID, ownID NodeID, index int) {
   midPoint := big.NewInt(0).Lsh(big.NewInt(1), uint(calculateBucketIndex(targetID, ownID)-K+1)) // 计算中间点
   newBuckets := make([][]*Node, 2)
   for i := range newBuckets {
      newBuckets[i] = make([]*Node, 0, K)
   }

   // 重分配节点到新桶
   for _, node := range buckets[index] {
      nodeIndex := calculateBucketIndex(node.ID, ownID)
      if big.NewInt(0).SetBytes(node.ID[:]).Cmp(midPoint) <= 0 {
         newBuckets[0] = append(newBuckets[0], node)
      } else {
         newBuckets[1] = append(newBuckets[1], node)
      }
   }

   // 替换原有桶为新桶
   buckets[index] = newBuckets[0]
   buckets = append(buckets, newBuckets[1]) // 实际情况下需要调整索引,这里简化处理

   // 添加新节点到正确桶
   addNode(buckets, targetID, ownID)
}

// addNode 向Kademlia网络添加节点
func addNode(buckets [][]*Node, newNodeID NodeID, ownID NodeID) {
   index := calculateBucketIndex(newNodeID, ownID)
   bucket := buckets[index]

   // 检查桶是否已满
   if len(bucket) < K {
      bucket = append(bucket, &Node{ID: newNodeID})
   } else {
      splitBucket(buckets, newNodeID, ownID, index)
   }
}

若加入节点不在该桶实际距离范围内的情况,比如加入的节点为160,则需要重新计算ER,其余分裂步骤和上述一样

推广:

该方法动态拓展了K桶的值域,在Kademlia的基础上做了大幅的优化改良

按照如上的思路,每个桶中都会至多存与该节点最近的K个节点,并且对于空间的利用发挥到了极致 => 将松散的二叉树整合成为了一棵紧密的二叉树,空间利用率接近100%并且查找的时候的速度优于理论中的logN

优缺点:

优点: 提高了查找效率,节约了空桶的地址空间,适合小的分布式P2P网络结构

缺点:由于每次节点的加入/退出都可能涉及到对于桶的重新组合,所以不适合节点下线/上线非常频繁的场景(因为对于桶的读写锁占用会涉及到3个桶对象,而理论上的Kademlia算法节点加入和删除只涉及到1个桶)