在一棵IPv4地址树中彻底理解IP路由表的各种查找过程

2023-05-16

1.IPv4地址空间树

IPv4的整个地址空间可以构成一棵完美的二叉树,因为它完全占满了整个4G的地址空间。这棵树如下所示:




需要指明的是,完全画出这幅图是不可能的,如果一个节点的直径小到1mm(这意味你要拿放大镜去看小圆圈里存储的信息[我并没有在圈圈里写任何信息,我怕它们被有损压缩了...模拟情况下用放大镜可见,数字图片一旦被有损压缩,拿放大镜看到的就是一个个方块,学名阻碍进步的马赛克-希腊/罗马的遗产]),那么最下层将会有4000km长,正好是东北黑龙江的漠河到西藏日喀则的距离,如果看完整个图,相当于环中国旅行了...所以我只能画一部分,由于不可能展示整张图,所以我也没有必要将节点直径缩至1mm了。
       通过这个图,你会发现,给定一个IP地址,你可以从ROOT开始,逐bit比较游历,最终在最底层找到它的位置,这个过程是如此快,只需要32步,以至于你根本不能想象漠河到日喀则的距离,但是你如果听说过纸张对折到月球,估计就彻底理解了,这就是指数的魅力和危险。脑子里装进这张图,然后在中间加入一些信息,你将彻底理解IP路由表的查找过程,我们现在就开始。以下的内容仅仅考虑IPv4。

2.路由查找

路由查找就是为一个目标IP地址找到一个下一跳,即找到一个路由项。路由项的key是一个bit前缀,比如192.168.0.0/16,172.16.20.0/24,1.2.3.0/28,3.4.5.6/32等,而这些key作为IP的表达方式,所不同的是它们仅仅考虑32位中的某些连续的位而不一定非要是全部,我们会发现,对于32位前缀的路由项key,它们对应最底层叶子节点的某些节点,而对于小于32位前缀的路由项key,它们正好对应一些中间结点。因此我们基于IPv4地址树而得到了一棵新的树,即带有路由节点的IP地址树:




路由查找的过程这下就很明晰了,既找到一个输入目标IP地址在游历整棵树过程中经过的那个最精确的路由项节点。我们可以发现,越靠近叶子的路由项越精确,因为它“马上就要到达目的地”了。
       路由节点作为中间结点或者叶子节点,我们有两种方式得达到它。

2.1.自叶子而上查找

假设我们已经知道了输入IP地址在最底层的位置,那么向ROOT游历,遇到的第一个路由节点肯定是最精确的。然而假设并不代表实际,这是一种执果索因法,我们并不知道输入IP的实际位置,我们也不想从漠河游历到日喀则,因此如何缩短路径就是要解决的问题。我们的问题是:
1).有32位前缀的精确路由吗?如果有,里面有没有和输入IP地址一样的呢?如果有,那就是它了,如果没有的话...
2).有31位前缀的路由吗?如果有,有哪个或者哪些(负载均衡?度量?...)的前31位值和输入IP的前31位相等呢?如果有,那就是它了,如果没有的话...
3).有30位前缀的路由吗?如果有,有哪个或者哪些(负载均衡?度量?...)的前30位值和输入IP的前30位相等呢?如果有,那就是它了,如果没有的话...
4).有29位前缀的路由吗?如果有,有哪个或者哪些(负载均衡?度量?...)的前29位值和输入IP的前30位相等呢?如果有,那就是它了,如果没有的话...
...
逻辑是简单的,问题是我们怎么知道到底有没有,算法上如何去实施。答案当然也很显然。那就是把所有的路由项按照前缀自大到小进行排序,每个前缀拥有一个链表,每个链表节点都是一个路由项,如下:
32 prefix list:node32-1,node32-2,node32-3,node32-4
31 prefix list:node31-1,node31-2,node31-3,node31-4...
30 prefix list:null
29 prefix list:node29-1,node29-2
...
最简单的方式就是拿输入IP地址依次从上到下,每个链表从左到右去遍历上述结构。然而事情到了如此明了的地步,是个人应该都可以想到使用HASH来加快计算效率的吧。因此上述结构变成了:
32 prefix hashlist:hash1(node32-1,node32-4),hash2(node32-3),hash3(node32-2)
31 prefix hashlist:hash1(node31-8,node31-5,node31-3),hash2(node31-1),hash3(...)
30 prefix hashlist:hash1(null),hahs2(null),hash3(null)
29 prefix hashlist:hash1(node29-2),hash1(null),hash2(node29-2)
...
这下就免除了每个前缀链表遍历的困境,只需要计算 bucket=hashcalc(IP_input) ,然后在每一个前缀hash表的hash[bucket]冲突链表中遍历一小部分就好了。
       自下而上查找总是从最精确的前缀开始的,旨在找到第一个匹配的路由项,而下面要详细描述的自上而下的查找则是从最不精确的前缀开始,旨在找到最后一个匹配的路由项,咋一看,自下而上占了优势,然而事实上,自上而下的方案也不赖。
       自下而上的算法是如此显然,以至于再多增加一些笔墨就显得多余,所以接下来的大部分篇幅,我想留给另外一种截然相反的算法。
注解 :自下而上的算法难道不就是Linux路由表hash查找的算法吗?

2.2.自根而下查找

从IPv4地址空间树(简称地址树)来看,由于它是一棵二叉树,精确匹配了32位IPv4地址的每一位,因此沿着根而下,最终肯定能到达目标IP地址,而这一路上最后经过的那个路由节点(黑色)就是所要查找的路由,这是很显然的,从带有路由的IPv4地址树上我们可以很容易看出这一点。
      虽然沿着IPv4地址树自上而下查找浪费不了太多时间,最多也就匹配32次,然而构建一颗完整的IPv4地址树却需要太大的空间。而这是要避免的,这么大的空间不仅仅难于利用核心缓存,同时它真的是不必要的,因为有更加优化的方式来解决路由查找问题。为此,我们需要对这棵带有路由的IPv4地址树做一些变换。
      那棵庞大的IPv4地址树仅仅是为了给出一个理解路由查找原理的方式,实际上我们关注的应该是:如何使得一个特定的IPv4地址,即目标地址能够快速的到达某个路由节点或者快速发现没有这样的路由节点。要达到这样的目的,就不得不掠过很多不带有路由项的“空心节点”,为此我们需要将这棵IP地址树进行压缩,从而最终在这棵树上仅仅保留最少数量的“空心节点”,它们存在的目的,仅仅是快速引导我们到达某一个实心的路由节点。

重新安排压缩后的路由节点的位置

在带有路由节点的IP地址图上,我们可以发现,一个目标IP地址最终使用的路由是从根部直到叶子(即它本身)的无环路径中最后一个经过的路由节点,即越靠近叶子节点的路由项目越优先被使用,因为它更加精确。最终将IP地址树压缩后,路由节点将全部被保留,此时,我们可能会有下面的子树:




我们是如何将此子树进行变换以更方便的进行上述的那个从根到叶子的游历过程呢?在此,我给出两种方式:
方式1:合并相同key路由节点为一个携带掩码链表的新节点




后面我们会谈到,这种方式更加有效。BSD和Linux都采用了这种方式。使用这种方式,所有的路由节点都被安排到了叶子,非常方便于Level压缩。
方式2:路由节点新增元信息,指示路径状况




可见,每个路由节点携带了几个不同的路径元信息:
Left more :左边子树是否还有新的路由项,如果有,且当前结果完全匹配并下一步要步入左边,那么继续匹配更精确的左子树。
Right more :同上,左换成右
Pre node idx :路径中上一个路由节点的索引(为此需要将所有路由项进行索引),如果当前节点不匹配,则直接取出上一个节点来使用,如果当前节点匹配且Left/Right more为0,则使用当前节点。
由于IP地址树存在压缩(马上就会讲到),因此这种方式不如方式1应用广泛,因为存在压缩的情况下,即便当前路由节点不匹配,上一个也不一定匹配,再者,由于存在动态的路径压缩和Level压缩,节点间的关系也会动态变化,叶子和非叶子节点也会变化,而方式2需要不断追踪节点之间的关系,开销比较高。

IPv4地址树的压缩

前面屡次提到地址树的压缩,现在终于可以单独描述它了。地址树的压缩不仅仅节省了空间,还优化了时间开销(但不经常,也不绝对,如果考虑恶性回溯,可能会恶化时间开销)。但是压缩也是有代价的,那就是在查找的时候可能需要回溯,而我们知道,在标准的完整IP地址树上查找是不需要回溯的。但是有时回溯即便在压缩的情况下也可以避免,这个不取决于算法,而是取决于你的输入IP地址,它是算法无关的。因此权衡来看,冒险是值得的。
       在我描述地址树压缩的时候,有个前提,那就是一切都是基于前面一小节的方式1来变换的。关于地址树的压缩,事实上它就是标准二叉树的压缩,方案有两类,路径压缩和Level压缩。
1.路径压缩
路径压缩很简单,看一个图自然就会明白。不过我不准备用那个及其庞大的IP地址图来开刀,它实在太大了,我只是用它的上面一部分来说明原理:



可以看出,路径压缩做的事比较简单,操作也比较简单,它的目的就是忽略和路由项无关的节点,仅仅保留路由项以及路由项引导项节点。所有的输入IP地址在查找路由的时候,不必再逐位比较,而仅仅和路由项的检查位比较即可,最终顺着一条路径到达一个叶子节点,该节点即最终找到的路由项。

      由于采用了方式1进行了路由项合并,因此需要用输入IP逐个检查叶子路由项的掩码链表,最终选出掩码最长的那个路由项。然而由于存在路径压缩,可能这一步并不会成功。我来解释一下这是为什么。请看上图的路由节点路径上的X位,对于路由项的key而言(比如192.168.1.0/24,其key就是192.168.1.0),它将所有的被压缩的bit都假设为0或者模式相同的若干位,如都是1,都是11010等,即不检查,也就是它们被忽略了,造成的结果就是,输入IP地址的这些bit可能有不是0的,这就会造成最终的叶子节点的精确匹配不会通过。而此时,就需要回溯了。这是下一节的主题。
       对于如何游历而言,很简单,每一个节点都是类似下面的结构体:
[plain]  view plain  copy
  1. struct node {  
  2.     u8 pos;  
  3.     struct node *child[2];  
  4. };  

取出node的pos,然后定位到输入IP地址的pos位,如果它是0,则向左,即取child[0],如果是1,则向右,即取child[1]。
2.Level压缩
Level压缩也不难,基本就是把一棵树从高变胖,变二叉树为N叉树。示意图如下:




这个Level压缩不必多讲,它其实都是标准的操作,对于数组形式存储的稀疏节点,还是不要做Level压缩,具体做还是不做,是有具体理论的,即只有当从一个pos开始,其后的bits位拥有接近2的bits次方的孩子节点时,Level压缩才是必要的。
       Level压缩也面临路径压缩时的bit忽略,如下图所示:




关于Level压缩后如何游历,和二叉树差不多,但是根据压缩深度不同又有些不同,每一个节点类似下面的结构体:
[plain]  view plain  copy
  1. struct node {  
  2.     u8 pos;  
  3.     u8 bits;  
  4.     struct node *child[0];  
  5. };  

取出node的pos和bits,然后定位到输入IP地址的pos位,取值idx=IP_input[pos...pos+bits],取child[idx]为node,依次向下。为了更好支持动态Level压缩,将纯二叉树路径压缩也涵盖进来,只不过其bits为1而已。
       关于Level压缩,还要说一点,那就是,我们总是希望最终的树可以规则一些,好看一点,那么在压缩之前需要对这个树做一定的变换,比如将畸形的树变成完全的树等。幸运的是,无类路由查找是最长前缀匹配的,也就是说,在IP地址树上,每个IP地址所使用的路由项是从它到树根的路径上最近的那个路由项,反过来说,每一个路由项都覆盖地址树第32层叶子节点的一个范围,根据最长前缀匹配规则,如果发生覆盖范围重叠,层次深的路由项范围自动覆盖层次浅的路由项范围,如果我们想更好的进行Level压缩,方法也很简单,步骤如下:
1).将中间路由节点下移到叶子节点。
2).动态压缩或者直接压缩。

关于这个概念,还是先看一个图:




2.1).动态Level压缩
对于像Linux Trie算法而言,它使用的是动态压缩,即每个中间节点为树根的子树叉数不是固定的,视路由项分布而定,原则就是尽可能压缩空间,比如对于稀疏的同层路由项分布而言,叉数越小越好,比如维持二叉树,如果是密集连续的同层分布,那么叉数越多越好,关于这个可以参考MMU的设计思想,到底是二级页表还是三级页表,或者为什么不是一级页表,视虚拟地址空间布局而定,考虑到多数程序不会完全占用所有的地址空间,此为一个稀疏分布,但是对于地址空间的局部部分,它的分布又是连续的,因此二级/三级页表会更好。
2.2).直接Level压缩
这个没什么好说的,直接压缩法比较直观,方便理解和硬件接管,并且你很容易将它和区间查找联系起来。事实上,它们真的是有联系的。
       从这里开始,后面提到压缩路由树的时候,指的就是压缩变换(路径压缩+Level压缩+路由项合并前缀列表变换)了的带有路由项的IPv4地址树。
注解:Linux和BSD
在这里说具体的实现有点早,不过可以说一点。不要拘泥于名称,Radix树,Trie树等(所谓:道 可道,非 常道![一般人断不好这句子,会念做“道可道,非常 道...”]),其实Linux的Trie树采用的就是路径压缩+Level压缩+合并前缀链表的混合LC-Trie算法,而BSD曾一度是标准纯二叉树路径压缩+合并前缀链表算法,虽然它也被称作Radix树算法。
3).压缩带来的问题
前面说过,压缩会带来问题。压缩是一次冒险,冒险值得与否需要权衡。当采用压缩时,就会有冒险失利的情况,因此在带有压缩的路由地址树上查找路由项的过程分为两步骤:
a.路由项的精确匹配过程
目标IP地址ip1作为路由地址表的输入,按照标准的查找过程最终到达一个孩子节点,即:
1).取出root的pos和bits,计算ip1的[pos...pos+bits]值作为孩子index,进入root->child[index];
2).如果不是叶子,则将root->child[index]赋值给root,继续1);
3).如果是叶子,则根据叶子的掩码链表的从长到短排序的每一个前缀prefix,依次做ip1 & prefix,与叶子节点的key做比较,如若相等,则成果返回,若不等,进入b。
现在问题是,如果上述第3)步匹配成功,能保证此路由结果的前缀是所有匹配结果的最长的吗?答案当然是肯定的,其依据在于,假设没有压缩,我们到达的最后的一个路由节点肯定是最精确的,现在虽然采用了压缩,但是注意我们是在冒险,我们假设路由项中所有压缩掉的bit和我们的输入ip1的对应bit是相等的,只有真的相等,才会最终匹配到叶子节点,而当最终真的匹配成功时,我们就会知道,我们冒险成功了,它们确实是相等的。而最终的叶子节点可能是诸多上层的路由节点合并的结果,然而我们已经将前缀进行了排序,保证首先匹配最长前缀的那个。如果冒险失败呢?那就回溯,这是下面要详谈的内容。
b.前缀递减的回溯匹配过程
这个又叫最长前缀匹配,但是为了避免和路由查找的最长前缀匹配混淆,我使用前缀递减过程来替代。这个过程只有在步骤a最终失败的情况下才会使用。冒险失败的惩罚就是回溯,它用额外的时间开销抵偿了压缩带来的空间收益,但是仔细想想,也不,如果不压缩,时间开销不也很大么?难道不需要逐位比较么?毕竟恶性回溯是可以通过平衡操作避免掉的,比如根据上述的理论,把树压得比较胖的时候,就能避免恶性回溯。

压缩路由树的回溯过程

关于这个主题,我依然采用局部子树的方式描述,因为从漠河到日喀则的路途太遥远了。
       我一向喜欢通过一个例子来说明问题,对于这个回溯,也是一样,首先还是先看个例子,这个例子包含了精确匹配和前缀递减匹配两个过程:




好了,这就是一个完整的例子。我尤其喜欢在阐述完一个例子后给出一个一般性的解释,但是我发现总结出一个一般的算法简直太难了,因此不得不将Linux的代码再次拿出来,抛弃掉一些细节,展示一个全面的算法:
[plain]  view plain  copy
  1. 路由树查找算法  
  2. {  
  3.     pn = 树根;  
  4.     chopped_off = 0;  
  5.     IP_prefix = 32;  
  6.     while (pn) {  
  7.         pos = pn.pos;  
  8.         bits = pn.bits;  
  9.   
  10.         if (!chopped_off)  
  11.             cindex = (IP_input & IP_prefix)[pos...pos+bits];  
  12.   
  13.         n = 获取pn的cindex孩子;  
  14.   
  15.         if (n不存在) {  
  16.             回溯;  
  17.         }  
  18.   
  19.         if (n是一个叶子路由节点) {  
  20.             // Lambda表达式  
  21.             遍历n的前缀链表prefix(prefix->{IP_input & prefix == n.key ? 匹配});  
  22.             if (没有匹配)  
  23.                 回溯;  
  24.             找到并退出;  
  25.         }  
  26.   
  27.         cn = n;  
  28.         // 回溯时遇到非叶子节点时显然应该一路向左  
  29.         if (IP_prefix < pos+bits) {  
  30.             if (cn.key & IP_prefix[IP_prefix...pos]  
  31.                 || !(cn.child[0]))  
  32.                 回溯;  
  33.         }  
  34.   
  35.         pn = n;  
  36.         chopped_off = 0;  
  37.         continue;  
  38.   
  39. 回溯:  
  40.         chopped_off++;  
  41.   
  42.         // 移除0的影响,bit0不会影响结果,此后依次化1为0  
  43.         // 比如当前的cindex为0101100  
  44.         // cindex更新的步骤依次为:  
  45.         // 0101000 -> chopped_off = 3;IP_prefix = pos+7-3  
  46.         // 0100000 -> chopped_off = 4;IP_prefix = pos+7-4  
  47.         // 0000000 -> chopped_off = 5;IP_prefix = pos+7-5  
  48.   
  49.         while ((chopped_off <= pn.bits)  
  50.                && !(cindex & (1<<(chopped_off-1))))  
  51.             chopped_off++;  
  52.   
  53.         // 更新IP_prefix  
  54.         if (IP_prefix > pn.pos + pn.bits - chopped_off)  
  55.             IP_prefix = pn.pos + pn.bits - chopped_off;  
  56.   
  57.         if (chopped_off <= pn.bits) {  
  58.             // 一次消除1个最右边的1  
  59.             cindex &= ~(1 << (chopped_off-1));  
  60.         } else {  
  61.             parent = 获取pn的parent;  
  62.             cindex = pn.key[parent.pos...parent.pos + parent.bits];  
  63.             pn = parent;  
  64.             chopped_off = 0;  
  65.             回溯;  
  66.         }  
  67.     }  
  68. }  

关于回溯,最后我想给出一种没有回溯的方案。
       看来回溯是为了纠正由于压缩而导致的错误,错误在于压缩掉了的bit属于模糊匹配而非精确匹配,事实上就是走错路了,如下图所示:




为了弥补这个错误,前面讲到,我们需要回溯,请看下面的两种弥补方式,其中之一没有回溯,为了隔离问题,此例中我没有将路由项变换下压到叶子节点:




右边的方式是比较容易理解的,它不用回溯,而是将所有曾经覆盖整个位置的路由项按照从下层到上层,也就是按照精确性递减的顺序排列在了最终的节点中,这样即便是走错路了,我们只需要取整个链表中的“下一个元素”就可以了。事实上这个有点像HASH冲突链表。但是这么显而易见的方式为何没有被广泛使用呢?原因在于它的维护成本太高,所有的路由项之间相互关联,插入/删除/更新一个路由项,最坏的情况可以会触及所有路由项的变动,毕竟这个算法是基于覆盖关系的,因此还是将路由项保持独立比较好。
       马上我们讲到的区间匹配,也是基于覆盖关系的,但是它的方式却截然不同,区间匹配是对路由项进行了拆分重映射,而不是简单的整体关联。

2.3.IP地址区间查找

如果再次看一下那棵带有路由节点的IP地址树,就会发现,自根向下, 最靠近叶子的路由节点,会隐含掉其到根的路径上的所有其它路由节点,即它自己全权负责了自它而下直到叶子层的所有到达叶子IP地址的路由。很明显,这是一个区间。很容易证明,每一个IP地址都唯一对应了一个这样的区间,一个区间由且仅由一个路由项负责。
       那么,为一个IP地址查找路由的问题就转换成了为一个IP地址查找它所对应的区间。示意图如下所示,我依然用子树说明问题,再次给出Level压缩那一小节给出的图:




可见,只要有下面的路由节点存在,就会覆盖上面已经找到的路由项,很显然,default路由项就是ROOT。
       我们不是早就抛弃完整的地址树了吗?是的!但是对于区间查找,还真就需要一棵完整的地址树,但是我们有更好的方式构建高效的查找结构。起初,我们肯定可以构建一个静态的数组,其中元素为:
[plain]  view plain  copy
  1. struct element {  
  2.     u32 start;  
  3.     u32 end;  
  4.     struct rt_node *node;  
  5. };  

表示一个区间对应一个路由项。这个表是在路由插入期间静态构建的。果真如此,我们可以以目标IP地址作为输入,查该IP地址属于哪个地址区间,然后取出node就是结果。果真就是这样,如果路由项为基准,那么总的数组大小就是路由项的数目,一般不会超过256(一跳可达的IP地址),如果以区间为基准,那么数组的大小就是路由项们将整个IP地址空间分割成的区间数量,这个数量受路由条目的数量以及汇总情况,路由分布的影响,总之如果配置得当的话,也不会是个很大的数字。剩下的就是区间查找算法了。
       其实还有更好,更优化的方案,即DxR算法,关于这个主题,我已经写了好几篇文章了《 从模拟MMU设计一个路由表的失败到DxR的回归 》,《 模拟MMU设计一个将IPv4地址索引化的路由表,不同于DxR 》等,因此就不再赘述了,在这些文章中,我从无到有详细描述了DxR的思想。总之就是分割子区间的方案造就了高效的时间和空间利用率。
       在这个小节的最后,关于区间查找,我不想描述它是怎么用到路由查找中的,仅仅知道一个输入IP地址对应一个区间,而一个区间对应一个下一跳就够了。我想描述一下给定一个IP地址,如何为其定位区间,或者更加广泛的,将一个连续的域划分为若干区间,给定该域的一个数,如何定位它属于哪个区间。我们先看一下区间:




假设这些区间都是前面闭后开的区间,即每个边界点都属于后面的那个区间。问题现在变成了如何构造一个单一的入口用以开启那一发不可收拾的查找过程,显然,我是带有偏见的,因为如果使用HASH算法,是不需要构造单一入口的,HASH函数已经将界限给界定了,只需要解决冲突即可,在比key少得多的冲突元素的情况下,遍历冲突链表也不是什么坏主意。但是本文通篇都是二叉树,所以我的偏见在于,我想构建一棵二叉树,将这个区间映射到这棵树中,然后从根部开启这个自动的查找过程,或者说,二分查找过程。首先我将这些区间的边界点构造成叶子,然后从叶子开始,到根部为止,逆向构建这棵树:




其实,为了高效查找区间,还有超级多的优化方案,比如抽象哈夫曼区间树等等,我这里给出的只是一种易于理解的简单原理。关于区间匹配,到此为止了。本来想就着这个小节引申到HiPac查找,但是我觉得还是另外写一篇吧。

2.4.压缩路由树查找与区间查找

在这一小节,我们来讨论一下压缩路由树查找(比如LC-Trie等)与区间查找(比如DxR)背后的东西。这两者是截然对立的,我们可以从两个参量来分析:

1).模糊性存在于哪里

压缩路由树的回溯完全是因为压缩,因为压缩将路由项之外的节点尽可能压缩掉了,这就带来输入IP地址自根而下游历时的模糊性,这个游历过程如果在不压缩的IP地址树上进行时,最终会在第32层精确对应一个位置,然而由于压缩的存在,路经在到达某个叶子路由项之前就有可能存在差错,即存在“走错路”的可能性,如下图所示:




该错误必须靠回溯重试来给与纠正,但是由于该匹配失败的节点已经是“尽可能”精确的节点了,所以在回溯的时候要从右到左不断化1为0不断扩大匹配范围。
       区间匹配算法由于分离了路由项的key前缀和下一跳,可以多对一共享下一跳,它不需要压缩,因为模糊性给了区间,即根本不需要输入IP地址对应到第32层叶子节点的精确位置,对应到一个区间即可,而这个区间对应一个已经和路由项分离了的下一跳,这就算是找到了路由-其实是找到了下一跳,而事实上,此时已经没有完整的路由项了,路由项早就化身为了一个区间和一个下一跳。
       不管怎样,模糊性是肯定要存在的,因为你不可能保存一棵完整的IP地址树,所以就需要牺牲它的精确性,带来一点模糊性,这个模糊性存在于哪里,带来的是算法的本质不同。
       对于自下而上的查找而言,模糊性在于,你事先根本不知道输入IP地址会落在哪个位置,甚至哪个区间都不知道,因此你必须将所有的路由项按照前缀自长而短一个一个试,而HASH算法,只是帮助这个尝试的过程更快结束而已,它并不是查找算法必须的,你完全可以将HASH算法更换成任何一种你认为比HASH更快的查找算法。自下而上查找的本质在于路由项按照前缀长度递减的顺序被尝试,而不是HASH算法。

2).以路由项为主还是以输入IP为主

压缩路由树的查找用输入IP地址去迎合路由项,旨在找到一个完整的最精确的路由项(包括前缀和下一跳)和它匹配,由于路由项的数量是确定的,因此在回溯的时候,不断以路由项的POS为基准,将输入IP地址的bits位从右到左不断化1为0,期待匹配到某个路由项,而路由项的分布保证了一旦匹配到,肯定是最精确的。
       区间查找算法将路由项拆分,每一区间对应唯一的下一跳,因此只要将输入IP地址对应到某个区间,查找过程就结束了,因为完整的没有压缩过且没有变换过的IP地址树构造保证了一个区间唯一对应一个最精确的下一跳,即最靠近叶子层的那个路由项的下一跳。

2.5.路由查找的后续

我们听说过很多操作系统的路由查找算法,也许你会认为BSD的Radix查找以及Linux的Hash/Trie查找只是未竟全功,只实现了逻辑,没有考虑编码,也许你会觉得只有DxR才是一个高效完美的路由表编码方案!是这样吗?我认为不!
       BSD和Linux实现的方案都是通用的方案。它并没有假设你有任何的Cache可以利用,它们都是纯算法级别的,它们可以让你分析其时间复杂度和空间复杂度,但是DxR却不能,DxR更多的是一种实现方式而非算法本身,因为它利用了太多可以让你利用的东西。如果我将DxR算法作为转发表的实现方案,或许会更好些吧。
       此外,关于名称,我是拒绝讨论的。如果有面试官问起你来,你就说你只懂算法不知道叫什么名字。很早以前,我听说BSD的算法叫做Radix树算法,后来又有人叫它PC-Trie,而Linux的算法叫做LC-Trie,后来我被这些名字搞晕了,索性也就不在乎它们到底叫什么了,爆炸!

3.纷繁的查找实例

3.1.分类

哈希查找
路径压缩Trie树
Level压缩Trie树
Oh,NO!!!爆炸

3.2.查找时间复杂度总结

我认为查找的时间复杂度最具有意义,空间复杂度意义相对不重要,因为好则越好,不好则越不好,占用空间越小,越容易利用Cache,从而带来时间的收益。我同样认为数据结构的构建时间复杂度也不重要,因为除非路由器中了毒,否则相比查找操作,插入,删除,更新永远是一些稀有事件。因此,为了在查找的时候高效,牺牲一些构建,更新成本还是必要的。
       具体的时间复杂度请自行搜索,总之对于二叉树,基本就是N*logN,logN级别的,当然不要考虑最坏情况,那种情况早就被OS发现了,发现了之后就会进行平衡操作了,对于Level压缩的树,就要考虑每次索引的bit长度了。对于HASH算法,基本要看你选择的HASH函数有多好以及如何解决冲突。本文考虑的情况几乎全部是关于查找的,没有说如何插入,构建的,这不是本文的主题,因为主流软件路由器的关注的是查找性能,它们几乎也不会运行复杂的动态路由协议,使能动态路由协议的几乎都是核心路由器,起码也是个接入层的了,它们关注的是硬件的设计而不是软件的算法。在软件路由器上,查找性能必须优化到起码每秒接近千万次,对于上述的算法而言,足够了。再一个,IP路由是有规律可循的,它们并不是随机的一组数让你排序什么的。另外就是,如果你真的爱上了DxR,那么你暂且把Cache寻址作为内部排序,而把内存里的排序操作权当外部排序吧,事实上,转发表真的就不把内存当“希腊公民”看待,对于转发表来讲,内存就是磁盘...
       当然,也可以混合使用各种算法,比如DxR算法,它事实上就是先构建了一棵两层的65535叉树,然后再在这棵树的节点上挂接一棵区间树,这种组合的方案往往可以达到惊人的效果。
1).步骤分解
在本文中,我直接简单的给出了路由表的查询结构,但是并没有说明这些结构是如何构建起来的,其实,说真的,这还真的很麻烦,但是无关紧要,为什么呢?因为这些事情可以“慢慢来做”,这就意味着你有足够的时间去想办法假于物也。
2).模糊性/效率权衡
时间和空间并不是二元对立的,有的时候,比如在数据统计的时候,如果并不需要特别精确的统计,你大可同时获得一些时间和空间收益,代价就是冒错误统计之险。此举可以先用模糊匹配过滤掉大多数的不匹配的情况,然后再用少量的精确匹配纠正错误,事实上,即便不谈Bloom,路经压缩树本身就采用了这个思想。

4.路由表和转发表,路由缓存

我不得不再次扯到OpenVPN!
       OpenVPN被我搞成了多线程的,偶尔会segment fault。然而整个multi_instance表还是全局的。我们知道,OpenVPN全靠multi_instance表来路由数据包,这个表就是一张路由表!每一个mi中存放着某个客户端的虚拟IP地址,真实IP地址,虚拟MAC地址(tap模式)...过来一个TUN字符设备的数据包,需要用目标MAC地址或者目标虚拟IP地址(或者iroute地址)作为key查找multi_instance表,以便获取对应客户端的真实IP地址以及端口,反过来也是一样的。可以看到,multi_instance表在OpenVPN中的地位就是一张路由表。
       多线程版本的OpenVPN中多个线程同时操作这张表是经常的,鉴于它不会经常被写入,我使用了读写锁对其进行保护,这是一种很好的思想,起码我当时觉得。然而,如果系统中保留一份全局的multi_instance表,然后将这个表优化成更加利于查找的结构,复制给每一个线程,岂不是更好吗?这样就没有锁操作了。这个无锁的思想也正是ZeroMQ的思想:别指望一群喝醉的人可以安全共享一瓶酒。这个复制后的本地表称为转发表,而那个全局的表称为路由表。转发表由路由表生而成之。在我预计启动的OpenVPN重构计划中,我将设计一个全局的multi_insance表,然后在各个线程中基于它构造一个转发表,完全使用ZeroMQ...说的有点跑题了...爆炸
       这将把代码带到一个无锁的世界,既然有一群醉汉,干嘛不给他们一人一瓶酒呢?事实上,在核心的路由器上,正是采用了这样的设计。
       路由缓存的意义大吗?这是和应用类型有关的,在以往固定节点网络的TCP长连接的年代,比如Telnet,FTP等,数据流的时间局部性非常明显,甚至在像HTTP这类多报文短连接(长连接更好)协议,时间局部性也是可以利用的,但是现在以及未来就有所不同了,P2P网络,机会网络,随机网络,自组织网络,移动网络,这类网络中节点与节点的通信路径是让人捉摸不定的,即便是长连接协议,也会因为节点的快速移动而使在上一次的位置节点路由器上缓存下来的路由项再也不会有机会命中,甚至,即便我们不考虑移动性,考虑一些信令协议,或者纷繁复杂的应用...
       Linux现在已经取消了路由缓存的支持,而我本人在它取消路由缓存的支持之前也因为另外的原因采用另外的办法取消了路由缓存的支持,路由缓存的取消主要有两方面的原因,其一是路由项已经没有必要被缓存-它们八辈子不会用到一次,其二-需要维护缓存一致性?哦,这第二个原因是不合理的,因为,因为IP协议本身就不需要维护状态,因此也就不需要什么缓存一致性。而我,正是因为这第二个原因取消了路由缓存,但是我也不是明知故犯,而是,而是我使用了Linux Netfilter的conntrack机制,而conntrack机制和IP配合的总是不太好。因此,我承认,我为此付出了代价。
...

后记

这篇作文我本来应该继续写得更长一些,就像我昨天中午和家人一起吃小火锅时喝了点酒后炫耀的那样:如今提笔就是8000字...想当初,每当周三上语文课,一想到要写600字的作文,我就极端的郁闷,感觉像是这道坎过不去了...相信很多人都和我一样。不过,现在不了,真的,我真的提笔8000字。如今没人逼着自己写东西了,倒是挺怀念中学时光,其实现在想一想,600字算什么,那多多少少只是一个历练...没有想法怎么都不行啊。如今我们没什么机会写文字了,真的,估计也就不外乎年终总结,项目总结,会议纪要,述职报告,入党申请书,求职简历,忏悔录...还有像我这样的时不时写写博客。但是,你真的会写吗?虽然,搬砖的认为写文字的都是不现实的,但是他们没看过《天将雄师》里的罗马军团不用搬砖就能盖出城堡;虽然,商人卖东西的觉得数字才是重要的,那是他们的偏见;虽然,程序员觉得我写的这些都没用;虽然,军人觉得只要能打就行;虽然,也有人觉得,只要能喊即可...但是,所有这些难道不是文字传播的吗?思想借助文字可以表达一切,剩下的只是编码问题,这又扯到了路由表和转发表的问题。道 可道,非 常道......这也可以作为我终于在历时将近两个月结掉的《希腊人》的读后感。
       提一下我自己,我不会编程,但也不是绝对不会,我稍微会一些,我会编程,但不狠,也编得不好,我精通世界历史,但不是什么都知道,也有不懂的,我深谙世事懂厚黑,但平时什么都说,我不拘小节,但心里什么都明白,我爱喝酒,曾经天天喝,但喝多了也吐,我于人不解,与人不聊,酒不聚众,聚不沾酒,厚德博学,止于至善,始终修元正本,造福苍生,不敢说为人师表,起码是懂hello world,真的懂,且很精通,甚至知道不用main和_start启动它,或者,直接在BIOS里启动它,甚至,直接拿个粉笔在我家小区广场的地面上启动它,前两个,我估计你也可以,但是最后一个,我估计你不敢。
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

在一棵IPv4地址树中彻底理解IP路由表的各种查找过程 的相关文章

  • React useCallback 函数使用说明

    React 中useCallback的作用 xff1a 函数相等性检查 useCallback 的函数原型 xff1a useCallback callbackFun deps 如果deps给出的依赖值不变 xff0c 则useCallba
  • thinkpad t400在fedora 17上风扇转速调整

    作者 xff1a bigluo 转自 xff1a http blog chinaunix net uid 796091 id 3282943 html 在t400上安装了fedora 17 在编译代码的时候经常碰到下面的严重警告 xff0c
  • Python 把秒数转换为xx:xx:xx的时间格式

    题目要求是将给出的秒数转化为xx xx xx的格式 xff0c 最大秒数默认不超过359999 xff0c 即99 59 59 解题思路是利用除法的取整和取余运算 xff0c 从最高位计算到最低位 xff0c 只需根据题设注意时分秒各自的进
  • warning : 无法找到 v142 的生成工具。安装 v142 可使用 v142 生成工具进行生成。

    我使用的是vs2017 xff0c 同伴的是vs2019 xff0c 他发送了他写的项目给我 xff0c 因为使用的vs版本不同 工具集不同 xff0c 导致项目在我的电脑上编译会有如下报错 xff1a warning 无法找到 v142
  • 用栈判断是否是回文

    用栈判断是否是回文 栈 xff1a 仅在表尾进行插入和删除操作的线性表 先进后出 用例 xff1a 1 上海自来水来自海上 2 1234321 3 123321 4 112233 5 123332 思路 xff1a 直接入栈一半的元素 xf
  • VirtualBox安装Arch Linux

    xff08 转载自http www aichengxu com view 34792 xff0c 略有改动 xff09 所有步骤用于指导新手完成archlinux在虚拟机上的安装 xff0c 安装选择未必最优 xff0c 但尽力做到减少新手
  • KEIL UV5 一模一样的程序,编译突然就有问题了

    原来是系统时间调到2000年 xff0c 没有调回来 把时间调回来就可以了
  • 简单选择排序——C语言实现

    选择排序思想 xff1a 若按照递增顺序对顺序表进行排列 xff0c 在n个元素的顺序表中 xff0c 从第i xff08 i 61 1 xff09 个元素开始遍历到第n 1个元素 xff0c 在遍历过程中都将第i个元素依次与第i 43 1
  • php7+操作 MongoDB4.0

    php7 43 操作 MongoDB4 0 一 连接MongoDB服务 mongo 61 new MongoDB Driver Manager 34 mongodb localhost 27017 34 二 添加数据 实例化一个添加类 bu
  • centos图形界面的开启和关闭

    centos图形界面的开启和关闭 一般来说centos主要用于服务器端 xff0c 所以很少开启图形化界面 xff0c 但是有时候为了工作方便也会偶尔开启图形界面 xff0c 下面就让简单谈谈如何开启图形化界面 xff0c 当然简化安装是没
  • 远程连接——NoMachine

    参考文章 安装并使用NoMachine关于nomachine无法连接NX的问题 小贴士 在使用NoMachine的时候 xff0c 需要主机和从机都需要开启NoMachine软件长时间没连接NoMachine xff0c 可能会出现NoMa
  • c++ regex的一个错误?

    下面的代码怎么了 xff1f 为何for换内部不执行 xff1f include lt string gt include lt iostream gt include lt regex gt include lt fstream gt u
  • 如何使用bat脚本批量创建txt文档

    如何使用bat脚本批量创建txt文档 有时候需要批量创建自定义名字的txt文件一遍后续写入数据 xff1a 64 echo off span class token keyword for span f span class token s
  • 转载_debian图形界面安装[转]

    安装GNOME中文桌面环境 安装基本的X系统 apt get install x window system core 安装GNOME桌面环境 apt get install gnome 到现在为止 xff0c 我们已成功安装完成gnome
  • C语言之printf输出中文乱码

    C语言之printf输出中文乱码 不同编辑器问题解决方案Clion编辑器 不同编辑器问题解决方案 根据不同的编辑器的表现进行针对性的处理 Clion编辑器 按照以下步骤操作编辑器设置即可 xff1a File gt Setting xff0
  • win11 系统暂无可用音频设备导致播放失败/音频服务未响应

    win11 系统暂无可用音频设备导致播放失败 音频服务未响应 win11再一次更新后音频突然用不了了 xff0c 驱动和输出设备都显示正常 xff0c 但每次播放就会出现下面的问题 xff0c 重启和更新驱动也没用 最后百度了好久终于解决了
  • C语言-结构体+文件操作+排序练习

    目录 相关定义主函数数据写入结构体函数结构体数据遍历写入文件函数读取文件数据函数数据排序函数数据遍历输出函数 运行程序打开终端输入一组员工数据 xff0c 输入自定的文件名进行存储 读取该文件保存的内容 xff0c 进行排序并输出 相关定义
  • V4L2 ,VIDIOC_DQBUF,Invalid argument

    使用V4L2库打开摄像头 xff0c 获取摄像头数据 xff0c ioctl fd VIDIOC DQBUF amp buf 错误 VIDIOC DQBUF Invalid argument 错误代码 xff1a bool quit 61
  • Linux安装Docker及DockerCompose Docker常用命令 CentOS 7.4

    Docker安装 安装gcc环境 yum y span class token function install span gcc yum y span class token function install span gcc c 43
  • IDEA报错:Error:java: Compilation failed: internal java compiler error 解决办法

    项目启动报错Error java Compilation failed internal java compiler error 解决办法 xff1a 查看这些配置 xff0c 确保版本一样 如果还没有解决 xff0c 那么就去Settin

随机推荐