千万别!很多人这样说,也包括我。
Linux内核早就把HASH路由表去掉了,现在就只剩下TRIE了,不过我还是希望就这两种数据结构展开一些形而上的讨论。
1.hash和trie/radix
hash
和tire其实是可以统一在一起的。具有相同hash值的多个项具有一个共同的特征,这个特征怎么提取呢?无疑这就是hash函数的工作。而trie树
(或者radix树,管它呢)的一棵子树也有共同的特征,这个特征怎么提取呢?无疑这就是该子树根节点的父节点指示的某些bits在这棵子树的每一个节点
都具有相同的值。
实际上,trie树就是hash的一种特殊形式,其hash函数为:取某些bits
trie_hash(value, level) { return value & level.bits; }
那么,这么看来,子树的所有节点都应处在一个“冲突链表”里面了...trie树的做法就是“再次hash”,hash函数随之改
变,变成取level.bits更低的某些bits了。如此看来,hash路由表解决海量路由项情况下冲突链表变长的方案就是再次hash了,hash函
数变成什么呢?我们后面再谈。
2.TCAM的hash
TCAM在很多地方被用到,它用来根据内容查索引,常被用于路由查
询,CPU Cache查询等,以CPU
Cache为例,输入TCAM的内容就是一个内存地址,而输出的结果是一个索引,cache匹配的过程就是取到索引指示的cache
line,然后比较输入内容(地址)和该cache line指示的地址是否一致,一致就是命中。
那么TCAM中最核心的过程就是根据地址得到索引的过程,一般的做法就是hash,由于硬连线实现,hash函数绝对不能有太多的计算,因此一般的做法就
是“取地址某些bits”,比如取4到7位一共4位,将一个32位(32位系统,物理地址索引cache为例为例)的慢速物理内存地址映射到4位快速
cache索引,形成一个金字塔存储结构。32位到4位的映射,丢失了的28位会形成很大可能性的冲突,而这个就是时间局部性和空间局部性来尽力弥补了,
了解列维飞行的应该知道局部性的伟大含义,它构建了我们整个人类文明。
最简单的hash函数就是取模,实际上也是“取某些bits”,它更加特殊,它是“取最低N bits"。
3.hash和trie树的统一
trie树实际上是从高位到低位逐步hash的过程构建的,其hash函数就是”取某些bits“。
4.查字典的例子-查英文和查汉字
我们小学的时候查字典一般分为音序查法和部首查法,它就形象能体现hash和trie的不同。为了简便,我以英文单词查法和汉字部首查法为例。
英文单词是严格一维度顺序排列的,且仅有26个字母组成,因此它可以按照trie树的方式查询,比如what,who,where,前两个字符都是wh,
因此说它们具有这么一个共同特征,如果将取这个共同特征作为hash函数,那么在
aaa,cc,sahidad,fwfwew,what,qwert,azsx,who,eee,ooo,where中查询
who,what,who,where将形成冲突链表,但是一步运算大大减少了匹配的数量,从11个减为3个,然后再进一步hash,按照字母顺序可知
at,wre,o这个顺序,直接取第三个孩子节点。因此英语词典的查询方式非常简便,就是一个不断hash定位的过程,hash函数就是”取某些连续字符
“。
我们再看看汉字部首查询法,它是一个典型的计算型hash函数的不断hash的过程,比如在杨,林,棵,马,牛,猪,过,皮这几个字中查”林“字,由于汉
字不是一维结构而是二维结构,它的构成是笔画,不是排序的,因此”取某些字符“的方式完全失效(从哪个方向开始取?...怎么算一个字符?...),因此
就需要重新构造hash函数了,长期的历史形成的汉子具有某种象形的意义,通过观察,我们发现”木“字旁是一个特征,这个计算过程,也就是hash函数执
行过程是我们的大脑来完成的,如果说”取某些字符“更加适用于硬件实现,那么发现偏旁部首则更加适合软件实现,从中我们也可以分析出中国人和西方人的思维
之区别。继续往下说,发现”木字旁“之后,杨,林,棵形成了冲突链表,但大大减少了匹配候选字的数量,不想遍历的话,需要再次hash,新华字典设计了笔
画数这个再hash函数,”林“字除了偏旁之外还剩下4笔画,于是定位到了”林“,如果还冲突,那就需要遍历了,因为商务印书馆可能想不出什么hash函
数了(我不知道这种汉字部首查字法是谁发明的,就当是出版社的杰作吧...)。反过来看英文查法,总是可以最终确定性定位,因为它的不断hash的
hash函数是”取连续字符“,加之单词长度有限且一维排列顺序递进,总是可以到最后一个字符的。
看出区别了吗?看出trie树查询和hash查询的区别了吗?
5.hash路由表和trie路由表
对
于hash路由表查询而言,最长前缀匹配逻辑并没有包含在hash过程中,它来自于一种冒险行为,前提是对hash函数的足够自信。hash路由表查找直
接从32位前缀hash表开始,逐步回归到0位前缀hash表,期望在这个过程中能快速得到第一个结果,这第一个匹配结果就是最终结果。
对于trie路由表查询而言,最长前缀匹配逻辑包含在不断再hash的逻辑中,它匹配的是最后一个结果而不是第一个,因为”顺序取某些bits“不断
hash的过程,最后匹配到的显然是最精确的。这是和hash路由查询的本质区别。trie查询没有冒险行为,它不需要冒遍历超长冲突链表之险,因为老老
实实地执行顺序取bits这个过程总能将查询过程引到目的地。
6.海量路由项的情况
Linux之所以用了那么久hash路由表
组织,是因为它足够了。因为在大部分时间,路由表项数量是不多的。即便是遍历也不会有太大的开销,而hash的计算会大大减少遍历的开销,所谓的冒险最坏
情况就是遍历整个路由项,这不是为题。但是一旦遍历整个路由表的所有路由项真的成了一个大风险的时候,或者说即使遍历一半也吃不消的时候,用hash就不
明智了。这和狮子追羚羊时的博弈类似,一个风险是一顿饭,一个风险是一条命,这是严格不对称的,所以总是看到羚羊胜利(还真不能把这个当零和游戏,因为狮
子有时真的不在乎)。
现在的问题是,如何使用hash路由表并降低风险。我们先看一下Linux自己的hash函数:
static inline u32 fn_hash(__be32 key, struct fn_zone *fz) { u32 h = ntohl(key)>>(32 - fz->fz_order); h ^= (h>>20); h ^= (h>>10); h ^= (h>>5); h &= FZ_HASHMASK(fz); return h; }
可见它将输入的非0项散列得足够开,但是hash的本质就是大空间往小空间映射,冲突在所难免。有人提出(比如我)在海量路由表项
时将长冲突链表组织成trie树的形式,但是这有意义吗?如果是一个完整的trie路由表,最长32步(考虑压缩和回溯)就能找到结果,如果采用
hash+trie的方式,每一步的最坏结果都是32步,一共进行32步...这样做没有意义。
海量路由表项时,hash小空间是严格有范围的,可以认为它是固定的,平均情况很容易通过地址空间和hash空间求得,最坏情况则是完全遍历。平均情况如果都不能接受,难道值得为最好情况去冒险吗?因此,千万别用hash表存储海量路由表项。
但是,还没完
7.局部性利用以及DoS
32
位系统,CPU Cache相比内存而言非常小,怎么可以带来如此大的优化?所有映射到同一个cache
line的地址都是冲突的啊...这是因为CPU
Cache利用了程序的时间/空间局部性,而对于路由而言,则没有空间局部性。时间局部性可以用于路由cache,然而用于路由表本身则有难度。路由表和
CPU
Cache的区别在于它是完全的,不存在被替换和老化的问题,因此可以把好的hash函数用于单独的路由cache,而路由表仅仅用于路由cache不命
中的情况下去匹配。
理想情况分析完了,剩下的只是悲哀了。
网络访问的时间局部性真的可以利用吗?虽然一个5元组的数据流一般会随着时间持续经过路由器,但是如果hash冲突的另一个数据流也经过的话,就会造成
cache抖动,在CPU Cache看来,这个问题可以通过控制task切换或者增加cache
line唯一键值来解决,可是对于网络访问,你没法阻止任何一个数据包的到来,只要到来就要查询路由表,就有可能导致cache抖动。更严重的,路由
cache很容易受到精心构造的数据包的攻击造成不可用,频繁的替换或者无限的加长链表,平添了查询开销。
因此设计一个完全的转发表而不是利用路由cache更加能提升转发效率。这又一次为我的DxR Pro结构作了一个广告。