1.IPv4地址空间树
IPv4的整个地址空间可以构成一棵完美的二叉树,因为它完全占满了整个4G的地址空间。这棵树如下所示:通过这个图,你会发现,给定一个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地址树:路由节点作为中间结点或者叶子节点,我们有两种方式得达到它。
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路由节点为一个携带掩码链表的新节点
方式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的,这就会造成最终的叶子节点的精确匹配不会通过。而此时,就需要回溯了。这是下一节的主题。对于如何游历而言,很简单,每一个节点都是类似下面的结构体:
- struct node {
- u8 pos;
- struct node *child[2];
- };
取出node的pos,然后定位到输入IP地址的pos位,如果它是0,则向左,即取child[0],如果是1,则向右,即取child[1]。
2.Level压缩
Level压缩也不难,基本就是把一棵树从高变胖,变二叉树为N叉树。示意图如下:Level压缩也面临路径压缩时的bit忽略,如下图所示:
- struct node {
- u8 pos;
- u8 bits;
- struct node *child[0];
- };
取出node的pos和bits,然后定位到输入IP地址的pos位,取值idx=IP_input[pos...pos+bits],取child[idx]为node,依次向下。为了更好支持动态Level压缩,将纯二叉树路径压缩也涵盖进来,只不过其bits为1而已。
关于Level压缩,还要说一点,那就是,我们总是希望最终的树可以规则一些,好看一点,那么在压缩之前需要对这个树做一定的变换,比如将畸形的树变成完全的树等。幸运的是,无类路由查找是最长前缀匹配的,也就是说,在IP地址树上,每个IP地址所使用的路由项是从它到树根的路径上最近的那个路由项,反过来说,每一个路由项都覆盖地址树第32层叶子节点的一个范围,根据最长前缀匹配规则,如果发生覆盖范围重叠,层次深的路由项范围自动覆盖层次浅的路由项范围,如果我们想更好的进行Level压缩,方法也很简单,步骤如下:
1).将中间路由节点下移到叶子节点。
2).动态压缩或者直接压缩。
关于这个概念,还是先看一个图:
对于像Linux Trie算法而言,它使用的是动态压缩,即每个中间节点为树根的子树叉数不是固定的,视路由项分布而定,原则就是尽可能压缩空间,比如对于稀疏的同层路由项分布而言,叉数越小越好,比如维持二叉树,如果是密集连续的同层分布,那么叉数越多越好,关于这个可以参考MMU的设计思想,到底是二级页表还是三级页表,或者为什么不是一级页表,视虚拟地址空间布局而定,考虑到多数程序不会完全占用所有的地址空间,此为一个稀疏分布,但是对于地址空间的局部部分,它的分布又是连续的,因此二级/三级页表会更好。
2.2).直接Level压缩
这个没什么好说的,直接压缩法比较直观,方便理解和硬件接管,并且你很容易将它和区间查找联系起来。事实上,它们真的是有联系的。
从这里开始,后面提到压缩路由树的时候,指的就是压缩变换(路径压缩+Level压缩+路由项合并前缀列表变换)了的带有路由项的IPv4地址树。
注解:Linux和BSD
在这里说具体的实现有点早,不过可以说一点。不要拘泥于名称,Radix树,Trie树等(所谓:道 可道,非 常道![一般人断不好这句子,会念做“道可道,非常 道...”]),其实Linux的Trie树采用的就是路径压缩+Level压缩+合并前缀链表的混合LC-Trie算法,而BSD曾一度是标准纯二叉树路径压缩+合并前缀链表算法,虽然它也被称作Radix树算法。
3).压缩带来的问题
前面说过,压缩会带来问题。压缩是一次冒险,冒险值得与否需要权衡。当采用压缩时,就会有冒险失利的情况,因此在带有压缩的路由地址树上查找路由项的过程分为两步骤: