这是坑
系列的最后一弹了,这篇文章非常长,希望你能看完,要是看完有很酣畅的感觉就最好了。这一篇的坑
主要来说说架构中时间和空间的平衡
吧,这里的时间指代比较广,可能是开发时间
,但大部分指的是执行时间,也就是算法的时间复杂度
了,而空间
就是算法中经常说的空间换时间
中的空间
了,一个好的系统,设计出来必然是各种时间复杂度
和空间复杂度
平衡出来的结果,架构设计的过程,并不仅仅是模块的堆叠,在走到岔路口的时候,更多的是时间和空间平衡
之后选的一个技术方案,这一篇,我会用一个搜索提示服务设计
的实际例子,来说一下架构设计的过程中,时间和空间
的各种矛盾,怎么分析,怎么选择,最后淌过这些时空的坑
。
搜索提示是搜索引擎的重要组成部分,虽然一般是作为一个单独的服务来对外提供服务,但在一个搜索系统中,搜索提示是非常重要的组成部分,我还没看到哪个比较成熟的搜索引擎没有搜索提示功能的。
首先,我们看看搜索提示是什么,大家肯定都用过,就是下面这些个东西
搜索提示一般情况下是为了提高用户的搜索体验,更快的选择合适的搜索词,提高检索的效率的,但是因为搜索框的流量实在是太大了,所以搜索提示也扮演着广告变现的责任,互联网嘛,有流量就有变现,比如下面这个图,明显就是一个广告啦。
要实现一个搜索提示系统,首先需要确定的是需要提示出来什么东西,有两种提示方式。
既然知道需求了,那么开始选择技术栈了。
整个系统的结构图应该是下面这个样子,离线模块处理完日志数据以后,推送到API模块中,给前面的前端提供服务。
好了,框框设计好了。也就是架构图完成了哦,真是牛逼的架构啊,三个框,离线,在线,前端全齐了。
接下来,我们来看看在线API部分的设计吧,我们先假设离线数据都已经准备好了,就是一堆用户的搜索词,如何快速的前缀匹配这些词就成了API设计部分的关键了,有这么几种实现方式。
用redis保存所有信息,每条信息类似
{KEY:北 VALUE:北京,北京大学,北大,北京遇上西雅图}
{KEY:北京 VALUE:北京,北京大学,北京遇上西雅图}
….
每次来了请求的话,直接查询redis给出结果返回,就是占点空间,最好还需要一台单独的服务器。
前缀匹配嘛,最先想到的数据结构就是
Trie树
了,所以所有的Key可以用Trie树
来保存和检索,速度也挺快的,而且空间占用比较少。
既然是检索嘛,就直接用搜索引擎的倒排索引技术来实现嘛,速度也够,而且数据量也可以支持得很大。
实际工程应用中,这三种实现方式我都见过,而且有些实现方式是把这三种结合起来使用了,后面的文章我会说到。
具体使用哪一种需要看你的实际场景,这三种实现方式差不多正好对应三种场景。
这种方案是个存空间的选择了,用空间换取了检索时间和开发时间,多亏有redis这种神器。
这种方式用长期的开发时间和检索速度上稍微的降低换取了内存空间,如果从头开始做的话,时间成本比较高。
这种方式用算法换取了内存空间,用O(n)替代了O(1),换取了内存空间,也是标准的计算机领域的时间换空间了。
通过一番分析下来,决定使用第二种实现方式,就是Trie树的方式了,好了,API的基本选型确定了,那么开始设计,准备写代码吧。
既然确定了Trie树
的实现方式,那么首先要了解一下Trie树
吧,以及Trie树
的各种结构,看看具体用哪个吧。
Trie树
又叫字典树
,本质上是一个多叉树,每一个节点就是一个多叉的结构,如果是英文的匹配,那么是一个26叉树
,每个节点一个26长度的数组,每个节点的数据结构如下
1
2
3
4
5
|
type
TrieNode
struct
{
flag
bool
//是否是一个完整的词
hasNext
bool
//是否还有后继字符
nexts
[
26
]
*
TrieNode
}
|
而Trie树
画出来就是下面这个样子。
从画出来的图,很直观的可以看出来这棵树的构造方法和遍历方法,如果是纯英文的话,每个节点都有一个26长度的数组,来了一个字符,通过字符的编号直接就可以遍历到下一个节点,查找的时候复杂度就是O(K),K表示查找的字符串长度,这种数据结构简单明了,实现起来也很容易。
基本Trie树的数据结构有个问题,就是内存使用得太多了,如果是中文查找的话,需要把所有的中国字都编号到这个数组中,内存就爆了,于是有一种优化方法,就是把数组变成变长的,这种Trie树的节点数据结构变成下面的样子了,节点查找变成一个顺序查找或者二分查找了。
1
2
3
4
5
|
type
TrieNode
struct
{
flag
bool
//是否是一个完整的词
hasNext
bool
//是否还有后继字符
nexts
[
]
*
TrieNode
//变成变长数组了
}
|
所谓双数组Trie树,当然就是通过两个数组来实现这棵树了,这两个数组分别叫base数组
和check数组
,一个是基础数组,一个是检查数组。
Trie树实际上是一种有限状态机
,通过状态转移矩阵
在各个状态之间跳转,双数组Trie树极大的节省了空间,大致就是下面这个样子,我后面会有一篇专门的文章来说Trie树实现的,这里就不详细展开了,实在等不及的可以自己先搜索一下相关资料看看双数组Trie树吧。
OK,三种Trie树的实现方式都说了,现在要开始抉择了,我们先看看这三种数据结构的时间和空间。
第一种空间占用大,特别是中文的情况,检索的时间效率为O(n),其中n为
每次请求
的字符串的长度,这种实现方式基本上属于新人练手的水平,纯粹为了了解这个数据结构或者大学生做做课程设计,工程化的可能性几乎为0。第二种空间基本不浪费,但检索的时间效率如果按照二分进行每个节点的查找的话,每个节点的查找时间变成了O(lg(n)),整体的查找时间变成K*O(log(n)),同样插入效率也变低了。
第三种情况空间不浪费,时间效率也为O(n)。
初看,肯定选第三种了,但是!!第三种实现方式有个致命的缺陷,就是无法向下遍历(具体可以自己看看双数组的实现方式
),也就是说我输入北京
,找不到北京大学,北京爱上西雅图
,因为它已经不是一个树型结构了,无法向下遍历了。所以如果不对第三种结构进行改造的话,是无法满足我们的功能的。
要改造,最简单的办法就是在每个词后面挂一个链表,表示这个词的后继词都是什么,像下图这样。
如果按上图那么来的话,需要辅助的空间来存储后继词,那么问题又来了,又是一次时间和空间的抉择了,是选择K*O(log(n))
的第二种方案,然后后继词实时遍历树来获取(又要耗费一定的时间),还是选择选择第三种方案,用空间换取时间呢?
好,既然这样,我们来仔细算算这个账,我们以每个节点都存一个中文来算,虽然常用的汉字大概2500个,但其中最常用的才500左右。
先看第二种方案,那么我们大概估算出,每个节点的平均数组长度大概600(实际上除了第一层的节点,后面的节点数组长度完全达不到这个量级,用600属于极限估算了)
,600的二分查找大约需要7到8次,取个平均值4次,那么每次查询的时间就是4*K(K是字符串的长度)
,如果我们定好最长的提示词不超过8个字(太长也没意义),那么首先这个树的高度就是8了,如果50万的词量的话,使用多少内存大概能算出来,然后每次遍历下级节点的时间就是600^(8-K)
(如果数组的每个元素都有值),我去,这么大,吓死了,好,我们即便假设每个节点的数组长度平均为60,要遍历完也要60^(8-K)
,也吓尿了,所以实时遍历所有子节点的方式不可取,而且后继词最多也就提示出10个,遍历出这么多词还要排序,遍历全部节点实在是没有必要,所以,第二种方案要么放弃,要么也要改造,如何改造呢?
因为词基本上都是离线算好的,稍微把节点的数据结构优化一下,在节点中加一个字段,表示哪个子节点有需要的数据(排序前10的词),这样往下遍历的时候就直接遍历相应的下标就可以了,就能把60^(8-K)
这种遍历减少到几十次,从而找到10个提示词,我们把这个结构叫二次优化的Trie树
。
这一轮的时间和空间的比拼,第三个方案感觉就要胜利了,但第二个方案的优化版貌似也还能接受,一个耗费空间,查询速度快,一个节省空间,查询速度慢点。
这里多说一下,其实上面只是预估的办法比较搓,这么写是为了说预估的技能,最直接的就是拿着日志统计一遍,得到一堆不超过8位长度的搜索词,同时也能算法两个方案的内存使用规模和大概的查找效率,这样的预估办法最准确,但是在大部分时候我们并没有这么多数据,所以只能做一些基本的预估。
好了,我们先把检索部分放一放,来看看离线数据处理部分吧。我们先要确定一下什么东西需要在离线部分算好,什么东西需要在线处理?
Trie树
的构建是离线构建好还是实时往服务推送由服务端去构建呢?虽然是离线处理,但一样有时间和空间的选择。
我们先来看构建部分,Trie树的构建是离线构建好还是实时往服务推送由服务端去构建
,首先我们需要确定的是这个搜索提示
服务需不需要实时更新,一般情况下,搜索提示
没有那么强的实时性要求,一般一天或者两天更新一次体验也不会太差,所以做实时更新的搜索提示,要不就是你实在是太蛋疼了,要不就是遇到了一个特别让人蛋疼的产品经理(卧槽,黑了一下产品经理啊
)。所以我们使用离线构建的方式构建好两个数组和辅助的数据结构,都存在磁盘上,服务端启动的时候读取文件就行了,这是用离线时间换取的服务端的时间,是很划得来的。
再来看看排序的部分,很明显,排序离线做好也比较合适,排序的位置基本不会有太大的变化,但是如果排序离线做好的话,那么辅助的数据结构就会比较大了,因为每个前缀后面跟着的10个词都要排好序放在辅助结构中,但如果我们只是把每个词打个分(比如就按热度给个分),然后用第二个方案(优化的Trie树)
的存储方式,在线的时候去排序,那么辅助结构就会小很多,两种情况的结构大概就是下面这样的区别。
左边的是全排序好了的,直接使用,双数组Trie树+辅助结构方式;右边的是只是打了分的,优化的Trie树,遍历出结果以后实时排序的。
离线排序的空间占用大,即便优化一下,把词都放一个地方单独存着,辅助结构中只保存词的编号,一样也比较占地方,但是查询速度快啊。在线排序的方式不怎么占地方,就是每个节点多了一个分数的字段,需要实时排序一下,虽然是实时排序,但个数就10个,不管是快排还是堆排,都很快的,所以时间效率也慢不到哪去。
综合衡量一看,我个人觉得两种方式都能接受,具体选哪一个就仁者见仁了。
双数组Trie树+辅助数据结构+离线构建Trie树+离线排序
的方式更合适。二次优化的Trie树+离线构建Trie树+离线打分+实时排序
的实现方式更合适,因为能节省更多的内存给后续扩充词语用或者给其他数据用。架构设计没有好坏,只有合适不合适。
上面分析了这么一大堆,淌过三个的时间与空间的坑
,终于基本确定了技术方案了,这其实也是系统架构设计中经常会要遇到的选择了,架构师们把这些选择做完以后,可以开始细分模块设计开发了,所以,一个小小的系统就这么多选择,各种空间和时间的平衡,你说架构师哪那么好当?呵呵,你以为就画完这篇文章的第一图就架构结束了啊。
这里只是用搜索提示
作为一个例子来说明系统设计的时候需要时时刻刻关注时间
和空间
这两个因素的平衡,现在很多人设计系统的时候基本上不太关注时间
,因为高配的服务器,几十上百GB的内存随便用,所以大多数都把设计往空间
上去靠,用更多的空间来换取执行效率,这本身并没有什么问题,谁不希望更快啊,但是有时候预估一下,有可能虽然牺牲了一点时间效率,但是换来了不少的空间,这样的系统在数据量变大时有更多的可扩展空间,我觉得是非常值得的交换。
再有,对数据结构和算法的了解
以及预估算能力
其实是平衡时间和空间
的重要技能,也是架构设计中避坑的基本技能,所以有公司的面试题会出现请你估算一下黄河出海口的面积这类估算题,因为预估算能力
也是重要的架构技能吧。
上面只是这个系统的一小部分,搜索提示需要做的远不止如此,想想下面几个场景,如果是你,你要如何设计呢?如何平衡时间和空间呢?欢迎讨论哈:)
这个坑
系列算是结束了,现在我正在做一些推荐广告相关的工作,后续也会分享一些相关的东西给大家,搜索部分也不会停,后面还有分词,相关搜索,分布式的东西会依次出来,欢迎关注哈。
友情推荐:四海电子解码 老猫