现有的P2P实现可以分为三种类型。它们分别是:基于目录服务器P2P,非结构化P2P和结构化P2P。基于目录服务器这一类系统中设置目录服务器,用于保存用户节点的地址信息和该节点上共享文件的描述信息,文件本身是分散存贮在各个节点上的,实际的文件传输也是在对等节点之间进行,目录服务器仅仅起到中介作用,为节点提供发布和查询文件索引服务。鉴于集中式目录服务器不仅可能成为系统的瓶颈,而且还可能引发法律纠纷,因此出现了以Gnutella为代表的非结构化P2P系统,在这种P2P结构中,文件索引信息不再由集中式的目录服务器存储和管理,而是分散到网络中,由节点自己保存,该类系统采用分布式的索引查找策略,为了查找网络中的文件,节点要随机地维护网络中的其他一些节点作为邻居,以便通过邻居节点广播查询报文。非结构化P2P系统中由于不存在目录服务器,所以没有单点瓶颈问题,不存在单一故障点。然而其缺点也是明显的:在网络中广播查询报文加重了网络通信负担,其查询机制在系统规模扩大时不具有可扩展性。另外,由于查询报文被限制在特定的范围内,所以并不能保证一定可以找到网络中存在的目的数据。上面介绍的两类P2P系统都缺乏有效的、可扩展的索引查找机制。为此,近年来许多研究小组在设计可扩展的查找机制方面做了大量的研究工作,提出了Chord、Pastry、CAN和Tapestry等用于构建结构化P2P的分布式哈希表系统(Distributed Hash Table,DHT)。DHT的主要思想是:首先,每条文件索引被表示成一个(K, V)对,K称为关键字,可以是文件名(或文件的其他描述信息)的哈希值,V是实际存储文件的节点的IP地址(或节点的其他描述信息)。所有的文件索引条目(即所有的(K, V)对)组成一张大的文件索引哈希表,只要输入目标文件的K值,就可以从这张表中查出所有存储该文件的节点地址。然后,再将上面的大文件哈希表分割成很多局部小块,按照特定的规则把这些小块的局部哈希表分布到系统中的所有参与节点上,使得每个节点负责维护其中的一块。这样,节点查询文件时,只要把查询报文路由到相应的节点即可(该节点维护的哈希表分块中含有要查找的(K,V)对)。这里面有个很重要的问题,就是节点要按照一定的规则来分割整体的哈希表,进而也就决定了节点要维护特定的邻居节点,以便路由能顺利进行。这个规则因具体系统的不同而不同,CAN,Chord,Pastry和Tapestry都有自己的规则,也就呈现出不同的特性。基于分布式哈希表(DHT)的分布式检索和路由算法因为具有查找可确定性、简单性和分布性等优点,正成为国际上结构化P2P网络研究和应用的热点。自2002年起,美国国家科学基金会(NSF)提供了1200万美元的资金启动了一个为期5年的研究项目IRIS,该项目集中了MIT和UC Berkeley等5所著名高等院校的强大科研力量,为下一代大规模分布式应用研制基于DHT的新型基础设施。
Chord是UC Berkeley和MIT共同提出的一种分布式查找算法,目的是为了能在P2P网络中查找数据。给定一个关键字,Chord可以有效地把该关键字映射到网络中某个节点上。因而在P2P网络中只要给每个数据V都赋予一个关键字K,就可以利用Chord在该关键字映射的节点上存储或提取相应的(K, V)对。Chord的突出特点是算法简单,而且可扩展 - 查询过程的通信开销和节点维护的状态随着系统总节点数增加成指数关系。Chord的路由性能优于CAN,而节点加入过程和维护开销又优于Tapestry和Pastry。
上图给出了一个m=6的Chord环,环中分布了10个节点,存储了5个关键字,节点标识前加上N而关键字前加上K以示区别。因为successor(10)=14,所以关键字10存储到节点14上。同理,关键字24和30存储到节点32上,关键字38存储到节点38上,而关键字54则存储到节点56上。当网络中的参与节点发生变动时,上面的映射规则仍然要成立。为此,当某节点n加入网络时,某些原来分配给n的后继节点的关键字将分配给n。当节点n离开网络时,所有分配给它的关键字将重新分配给n的后继节点。除此之外,网络中不会发生其他的变化。以上图为例,当标识为26的节点接入时,原有标识为32的节点负责的标识为24的关键字将转由新节点存储。显然,为了能在系统中转发查询报文,每个节点要了解并维护chord环上相邻节点的标识和IP地址,并用这些信息构成自身的路由表。有了这张表,Chord就可以在环上任意两点间进行寻路。
Chord的路由:Chord中每个节点只要维护它在环上的后继节点的标识和IP地址就可以完成简单的查询过程。对特定关键字的查询报文可以通过后继节点指针在圆环上传递,直到到达这样一个节点:关键字的标识落在该节点标识和它的后继节点标识之间,这里的后继节点就是存储目标(K, V)对的节点。
上图给出了一个示例,节点8发起的查找关键字54的请求,通过后继节点依次传递,最后定位到存储有关键字54的节点56。在这种简单查询方式中,每个节点需要维护的状态信息很少,但查询速度太慢。若网络中有N个节点,查询的代价就为O(N)数量级。因而在网络规模很大时,这样的速度是不能接受的。
为了加快查询的速度,Chord使用扩展的查询算法。为此,每个节点需要维护一个路由表,称为指针表(finger table)。如果关键字和节点标识符用m位二进制位数表示,那么指针表中最多含有m个表项。节点n的指针表中第i项是圆环上标识大于或等于n+2i-1的第一个节点(比较是以2m为模进行的)。例如若s=successor(n+2i-1), 1≤i≤m,则称节点s为节点n的第i个指针,记为n.finger[i]。n.finger[1]就是节点n的后继节点。指针表中每一项既包含相关节点的标识,又包含该节点的IP地址(和端口号)。
上图给出了节点8的指针表,例如节点14是环上紧接在(8+20) mod 26=9之后的第一个节点,所以节点8的第一个指针是节点14;同理因为节点42是环上紧接在(8+25) mod 26=40之后的第一个节点,所以节点8的第6个指针是节点42。维护指针表使得每个节点只需要知道网络中一小部分节点的信息,而且离它越近的节点,它就知道越多的信息。但是,对于任意一个关键字K,节点通常无法根据自身的指针表确定的K的后继节点。例如,下图中的节点8就不能确定关键字34的后继节点,因为环上34的后继节点是38,而节点38并没有出现在节点8的指针表中。
节点加入和退出:为了应对系统的变化,每个节点都周期性地运行探测协议来检测新加入节点或失效节点,从而更新自己的指针表和指向后继节点的指针。新节点n加入时,将通过系统中现有的节点来初始化自己的指针表。也就是说,新节点n将要求已知的系统中某节点为它查找指针表中的各个表项。在其他节点运行探测协议后,新节点n将被反映到相关节点的指针表和后继节点指针中。这时,系统中一部分关键字的后继节点也变为新节点n,因而先前的后继节点要将这部分关键字转移到新节点上。当节点n失效时,所有指针表中包括n的节点都必须把它替换成n的后继节点。为了保证节点n的失效不影响系统中正在进行的查询过程,每个Chord节点都维护一张包括r个最近后继节点的后继列表。如果某个节点注意到它的后继节点失效了,它就用其后继列表中第一个正常节点替换失效节点。
Pastry的设计:Pastry是自组织的重叠网络,每个节点都被分配一个128位的nodeId。nodeId用于在圆形的节点空间中(从0到2128-1)标识节点的位置,它是在节点加入系统时随机分配的,随机分配的结果是使得所有的nodeId在128位的节点号空间中均匀分布。nodeId可以通过计算节点公钥或者IP地址的哈希函数值来获得。
nodeId为10233102的Pastry节点维护的状态示意图
上图给出了一个节点维护的数据示意图,b取值为2,所有的数均是4进制的。其中路由表的最上面一行是第0行。路由表中每行的阴影项表示当前节点号中相应的数位。路由表中每项节点的nodeId表示格式是“相同前缀 + 下一数位 + nodeId的剩余位”。图中没有列出相关节点的IP地址。
新节点加入时需要初始化自身的状态表,并通知其他节点自己已经加入系统。假定新加入节点的nodeId为X,同时假定X在加入Pastry之前知道系统中和自己距离相近的节点A。新节点X首先请求A路由一条“加入”消息,消息的关键字就是X。这条消息最终会到达nodeId和X最接近的节点Z。作为应答,节点A、节点Z以及从A到Z的路径上所有经过的节点都会把自己的状态表发送给节点X。节点X利用这些信息初始化自己的状态表,然后节点X再通知其他节点它已经加入了系统。从交换的消息数量上说,节点加入操作的复杂度为O(log2bN)。
下图给出了一个2维的[0, 1]×[0, 1]的笛卡儿坐标空间划分成五个节点区域的情况。虚拟坐标空间采用下面的方法保存(K, V)对。当保存(K1, V1)时,使用统一的哈希函数把关键字K1映射成坐标空间中的点P。那么这个值将被保存在该点所在区域的节点中。当需要查询关键字K1对应的值时,任何节点都可以使用同样的哈希函数找到K1对应的点P,然后从该点对应的节点取出相应的值V1。如果此节点不是发起查询请求的节点,CAN将负责将此查询请求转发到P所在区域的节点上。因此,有效的路由机制是CAN中的一个关键问题。
五个节点维护的CAN虚平面
CAN中的路由很简单,沿着坐标空间中从发起请求的点到目的点之间的一条路径转发即可。为此,每个CAN节点都要保存一张坐标路由表,其中包括它的邻居节点的IP地址和其维护的虚拟坐标区域。两个节点互为邻居是指:在d维坐标空间中,两个节点维护的区域在d-1维的坐标上有重叠而在剩下的一维坐标上相互邻接。例如,图2.6中D和E是邻接节点,而D和B就不是邻接节点, 因为D和B在X轴和Y轴上都邻接。每条CAN消息都包括目的点坐标。路由时节点只要朝着目标节点的方向把消息转发给自己的邻居节点即可。下图给出了查找过程的一个简单的例子。
因为整个CAN空间要分配给系统中现有的全部节点,当一个新的节点加入网络时必须得到自己的一块坐标空间。CAN通过分割现有的节点区域实现这一过程。它把某个现有节点的区域分裂成同样大小的两块,自己保留其中的一块而另一块分给新加入的节点。整个过程分为以下三步:
1. 新节点首先找到一个已经在CAN中的节点。
2. 新节点使用CAN的路由机制找到一个区域将要被分割的节点。
3. 执行分割操作,然后原有区域的邻接区域必须被告知发生了分割,这样新节点才能被别的节点路由到。
当节点离开CAN时,必须保证它的区域被系统中剩余的节点接管,也即分配给其他仍然在系统中的节点。一般是由某个邻居节点来接管这个区域和所有的索引数据(K,V)对。如果某个邻居节点负责的区域可以和离开节点负责的区域合并形成一个大的区域,那么将由这个邻居节点执行合并操作。否则,该区域将交给其邻居节点中区域最小的节点负责。也就是说,这个节点将临时负责两个区域。
Tapestry中的每个节点都保存有邻居映射表。邻居映射表可以用于把消息按照目的地址一位一位地向前传递,比如从4***=>42**98=>42A*=>目的节点42AD(这里*表示通配符)。这种方式类似于IP分组转发过程中的最长前缀匹配。节点N的邻居映射表分为多个级别,每个级别包含的邻居节点的数量等于标识符表示法的基数,而每个级别中邻居节点标识符和本节点标识符的相同前缀都比前一级别多一个数位。也就是说,第j级邻居表的第i项是标识符以prefix(N, j-1) + “i”为前缀而且离当前节点最近的邻居节点。例如,节点325AE的邻居映射表中第4级第9项是系统中标识符以325 + “9” =3259为前缀的某个节点。
Tapestry采用的基本查找和路由机制:当一条查找消息到达传递过程中的第n个节点时,该节点和目的节点的共同前缀长度至少大于n。为了进行转发,该节点将查找邻居映射表的第n+1级中和目的标识符下一数位相匹配的邻居节点。转发过程将在每个节点中依次进行直到到达目的节点。这种方法可以保证路由至多经过logbN个节点就可以到达目的节点,这里N是节点标识符名字空间的大小,而b是标识符使用的基数。同样,由于每个节点的邻居映射表的每个级别只需要保存b个表项,因此,邻居映射表的空间为blogbN。
上图给出了Tapestry中一个查询消息转发的例子。图中节点标识符的基数是4,查询消息从5230发出,目的节点是42AD。
Tapestry中的节点在共享数据时被称为服务器,请求数据时被称为客户,转发消息时被称为路由器。也就是说每个节点可以同时具有客户、服务器和路由器的功能。
服务器S通过向对象O(GUID为OG)的根节点OR定期的发送消息来报告S保存有对象O。在这条发布路径上的每个节点都保存关于这个对象O的位置信息指针
当需要定位一个对象O时,客户向对象O的根节点发出查询消息,查询消息转发路径上的每个节点都检查自己是否存有对象O的位置指针,如果有,该节点直接把查询消息转发个服务器S,否则,消息将到达O的根节点,然后由根节点把查询消息转发给服务器。
节点加入和退出:Tapestry的节点加入算法和Pastry类似。节点N在加入Tapestry网络之前,也需要知道一个已经在网络中的节点G。然后N通过G发出路由自己的节点ID的请求,根据经过的节点的对应的邻居节点表构造自己的邻居节点表。构造过程中还需要进行一些优化工作。构造完自己的数据结构后,节点N将通知网络中的其他节点自己已经加入网络。通知只针对在N的邻居映射表中的主邻居节点和二级邻居节点进行。