搜狐技术产品

分享一次巨量关键词匹配服务的性能优化过程

01

服务背景

话说小菜刚到新公司报到,就从前辈大牛手里接手了一个实时关键词匹配服务性能优化的任务。于是他们两人展开了如下的对话:

小菜

大牛前辈,这个关键词匹配服务具体是干什么的?

大牛

小菜啊,我们这个服务的业务场景很简单,1、调用方输入多段长文本,2、关键词服务遍历关键词词库,找到输入文本中命中了哪些关键词,3、返回命中的所有关键词。

小菜

前辈,那做这个关键词匹配服务有什么用呢?

大牛

小菜,命中不同的关键词有不同的处置策略,比如:用于视频上传阶段的禁止上传策略;用于审核阶段的关键词过滤策略、审核删除策略等。关键词匹配服务返回关键词与相应的策略,视频上传服务和审核服务再根据相应的策略做出相应的处理。

小菜

前辈,那我明白了,可为啥要做性能优化呢?

大牛

小菜,咱们现在的关键词词库已经有上百万了,在qps不高时服务还能保证实时性,但在qps比较高时比如达到上百个qps时,服务已经不能满足实时性要求了,rt时间达到了几秒,调用方对咱们这个服务的性能十分不满。

小菜

前辈,目前咱们的关键词匹配使用的是什么算法呢?

大牛

算法?不就是两层循环遍历,1、循环多段输入文本,2、循环遍历词库,3、判定输入文本是否包含关键词。示例伪码如下:

for (input : inputs) {
for (keyword : keywords) {
if (input.contains(keyword.name)&&keyword.products.contains(input.product)) {
} else {
}
}
}
小菜

第3步判定输入文本包含关键词时,为啥还要加一个判定输入文本和关键词的适用产品是否一致?即keyword.products.contains(input.product)判定?

大牛

小菜观察的很仔细嘛,输入文本的适用产品指的是来自不同的产品线的调用;而不同的产品线的关键词词库并不相同,比如咱们有十几种产品线,这十几种产品线包含有视频、图片、文字等产品,他们的关键词词库有共享的,也有独享的。而我们设计的关键词词库的数据结构如下:

CREATE TABLE `keyword` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`type` int(5) NOT NULL COMMENT '关键词策略',
`name` varchar(100) NOT NULL COMMENT '关键词内容',
`description` varchar(1000) COMMENT '关键词描述',
`products` varchar(1200) COMMENT '适用产品,多个产品,分隔',
`status` int(11) DEFAULT NULL
)

我们的关键词服务为了提高匹配速度,会在服务启动时将全量词库按以上数据结构载入内存的list列表中,用于循环匹配。

小菜

。。。前辈,我感觉匹配算法的时间复杂度太高了,我想一想怎么优化。。。

02

优化历程

1、算法升级
小菜

前辈,经过我的调研感觉可以使用Aho Corasick自动机,即AC自动机来进行关键词匹配算法的优化。

大牛

AC自动机匹配算法是什么原理呢?

小菜
前辈,要解释ac自动机我需要先解释一下Trie树的原理,Trie是一种高效的索引方法,它实际上是一种确定有限自动机(DFA)。在树的结构中,每一个结点对应一个DFA状态,每一个从父结点指向子结点(有向) 标记的边对应一个DFA转换。遍历从根结点开始,然后从head到tail,由关键词的每个字符来决定下一个状态,标记有相同字符的边被选中做移动。注意每次这种移动会从关键词中消耗一个字符并走向树的下一层,如果这个关键字符串空了,并且走到了叶子结点,那么我们达到了这个关键词的出口。如果我们被困在了一个结点,比如因为没有分枝被标记为当前我们有的字符,或是因为关键字符串在中间结点就空了,这表示关键字符串没有被Trie认出来。
大牛

那ac自动机和Trie树有什么关系呢?

小菜
trie树做匹配的时候,需要从根节点向下搜索,一直往下匹配,当匹配不下去的时候,做一个标记,继续从根节点进行再一次匹配。可以看出trie树匹配的缺点是匹配的时候每次都从根节点开始,这个时间复杂度高了。trie树做匹配的时候,如果文本长度为m,待查找的字符串长度为n,则匹配的时候时间复杂度为O( mn) 。简单来说,AC自动机可以看做是trie+kmp的结合,流程是先给关键词构建trie树,然后在trie树上加fail表,然后再进行匹配。其中类似于KMP算法的next表跳转的思路,它的跳转是直接跳转到树的另外一个分支上。也就是在前缀树的基础上,为前缀树上的每个节点建立一颗后缀树,从而节省了大量查询。关键词匹配时,扫描文本一遍就能结束,其复杂度为O( n),即与模式串的数量和长度无关。
大牛

我还是没有明白ac自动机的原理,你能画个图举个例子说明一下吗?

小菜
好的,还是用经典的ushers字符串来举例子吧,假设我们的关键词词库有he,she,his,hers四个关键词,我们看看如何用这4个关键词来构建ac自动机结构,以及如何经过一次遍历就匹配查找出字符串ushers中包含的所有关键词。
首先来构建ac自动机,主要是构建3个表:
1、goto表即从一个状态成功转移到另外一个状态的表;
2、failure表即如果从一个状态不能继续转移到另外一个状态时,应该跳转到哪个节点继续转移,有一个前提就是从根节点到跳转到的这个节点需要满足其路径恰好是转移失败前字符串的一部分;
3、output表即命中的关键词列表。

1)先构建goto表

goto表实际上就是一颗trie树结构,如下图所示:

Image

2)构建output表

因为output表比较简单,但实际实现的时候output表会在建立failure表的时候进行一次拓充。output表如下表格所示:

iOutput(i)
2he
5she,he
7his
9hers

Output表实际上就是从根节点到对应数字节点的字符输出的所有组合,并且满足其存在于关键词词库中。

3)构建failure

failure表的构建是ac自动机实现的重点,其要满足几点限制:

1、要满足状态与状态的一对一关系,即从每个节点发出的失败跳转路线只有一条,每个节点失败跳转时只能跳转到一个节点,满足严格的一对一关系;
2、定义与根节点即0号节点深度为1的所有节点的失败跳转节点必须为根节点,即1号和3号节点的失败跳转路线为0号节点;
3、对于其他深度的节点,设当前状态是S1,跳转路线为求fail(S1)函数。假设S1的前一状态是S2,S2转换到S1的条件为接受字符C,则测试S3=goto(fail(S2), C)是否成立;
4、若成立,则fail(S1)=goto(fail(S2), C) = S3找到s1的失败跳转节点;
5、若不成立,则继续测试S4 = goto(fail(S3), C) 是否成立,直到找到成立的状态Sn。最终找到S1的跳转节点Sn即fail(S1)=Sn。

最终找到每个节点对应的fail跳转节点如下表格所示:

i123456789
Fail(i)000120303

下面将整体的ac自动机的构造图画出来,实际就是在步骤1,trie树的基础上画上每个节点的fail跳转路径,以虚线绘制。Trie树的实线为goto路径。若命中output表也在图中绘制出来。整体构造图如下图所示:

Image

我再说明一下ushers字符串匹配关键词的具体过程:

1、 从根节点开始,首先尝试按goto路径转移,即图中实线,若没有goto路径则按fail路径转移,即图中虚线;
2、首先第一个字符u,未发现goto路径,按fail路径,转移到节点0,没有命中output表;
3、 尝试下一个字符s,发现goto路径,转移到节点3,没有命中output表;
4、尝试下一个字符h,发现goto路径,转移到节点4,没有命中output表;
5、 尝试下一个字符e,发现goto路径,转移到节点5,命中output表两个关键词,he,she;
6、尝试下一个字符r,未发现goto路径,按fail路径,转移到节点2,命中output表关键词,he;

7、 尝试下一个字符r,发现goto路径,转移到节点8,没有命中output表;

8、尝试下一个字符s,发现goto路径,转移到节点9,命中output表关键词,hers。

匹配结束后,共命中三个关键词he,she,hers。

大牛

这样看来ac自动机确实比循环遍历效率要高,循环遍历的时间复杂度也可以理解为O(mn)吧?

小菜

对,但还有一个问题,前面说过构建ac自动机要先构建一个trie树,但trie树本身占用内存空间比较大,我最近看了一篇文章可以使用双数组trie树来实现ac自动机,这样可以保证ac自动机的内存消耗可控。

大牛

双数组trie树又是啥?

小菜
双数组Trie (Double-Array Trie)结构是由日本人JUN-ICHI AOE于1989年提出的,下面是《基于双数组Trie树算法的字典改进和实现》一文中的介绍:双数组Trie是Trie结构的压缩形式,仅用两个线性数组来表示Trie树,该结构有效结合了数字搜索树(Digital Search Tree)检索时间高效的特点和链式表示的Trie空间结构紧凑的特点。双数组Trie是一个确定有限状态自动机(DFA),每个节点代表自动机的一个状态,根据变量不同,进行状态转移,当到达结束状态或无法转移时,完成一次查询操作。在双数组所有键中包含的字符之间的联系都是通过简单的数学加法运算表示,不仅提高了检索速度,而且省去了链式结构中使用的大量指针,节省了存储空间。
双数组Trie树能高速O(n)
完成单串匹配,并且内存消耗可控,然而软肋在于多模式匹配,如果要匹配多个模式串,必须先实现前缀查询,然后频繁截取文本后缀才可多匹配,这样一份文本要回退扫描多遍,性能低。但如果使用双数组Trie树表达AC自动机,就能集合两者的优点。
2、服务架构升级
大牛

小菜啊,解决了算法问题,还有一个棘手的问题就是关键词词库数据增长问题,现在咱们的词库已经达百万级别了,词库数量还在不断扩展,当词库数量达到了千万级时该怎么办呢?

小菜

嗯,前辈之前的服务架构是什么样的呢?

大牛

之前的关键词服务就是单服务多节点部署的呢,把关键词词库全部导入内存,靠内存来支撑服务的。还有一个问题,咱们的关键词词库中的历史关键词会动态变化,关键词服务要能近实时的感知到变化并体现到服务中。

小菜

之前是如何做到感知关键词变化的呢?

大牛

之前服务是使用guavaCache的refreshAfterWrites机制,每隔1分钟就读取关键词词库数据库全量刷新一遍缓存中的关键词词库,来保证1分钟的近实时性。

小菜

那如果一天之内都没有关键词的变动,但却白白刷新了1440次缓存?造成不必要的资源浪费了吧?

3、解决数据扩展问题
小菜

前辈,我将服务架构设计了一下,首先进行了服务分层:

1、设计了name服务负责负载均衡、编排检测服务;

2、设计了data服务做具体的关键词检测。具体的架构图如下:

Image

1、 首先name服务和data服务都将自身注册到注册中心上;

2、外部系统通过客户端负载均衡组件ribbon从注册中心获取到name服务的信息,向name服务发起请求调用。ribbon会按负载均衡策略比如按流量负载将流量转发到相对空闲的name节点;

3、Name节点同样通过客户端负载均衡组件ribbon从注册中心获取到data服务的信息,将流量转发到data节点进行关键词匹配。 

大牛

为啥data服务有data1、data2、data3,是干啥的呢?

小菜

由于咱们的关键词词库数据量太大,而且还需要考虑到扩容问题,我目前设计的是每个data服务承载50万个关键词,这样内存占用、匹配性能都能保证,当有新的关键词扩展时,再适量的增加data4、data5节点,以此来解决数据扩展问题。

大牛

那为什么data1有多个节点呢? 

小菜

这是为了服务的高可用设计的,同样name节点也设计成了多个都是为了服务的高可用。

大牛

有新的data节点,比如data4上线后,name节点如何自动发现新的data节点,并将流量转发其上呢?

小菜

1、首先所有的data服务都会将自身注册到注册中心中,注册的服务名统一为keyword.check.act.1(2,3);

2、name服务中设计了一个discover组件会定时扫描注册中心前缀为keyword.check.act.的服务;

3、当请求到达name服务时,name服务会将请求转发到所有的前缀为keyword.check.act.的服务上,并汇总结果;

4、将检测结果返回给外部系统。从而name服务拥有了自动发现data服务的能力,而name服务转发流量时会通过ribbon做负载均衡,并且通过sentinel组件做限流控制,保障了整个系统的稳定性与可靠性。

4、解决感知变化问题
大牛

关键词匹配服务是data服务吧?能再详细介绍一下data服务的实现吗?

小菜

这是data服务的架构图:

Image

data服务关键要解决的是如何近实时的感知关键词变化,而不带来额外刷新缓存的问题。因使用了ac自动机算法来进行关键词匹配,构建ac自动机是有一定耗时的,如果仍然像之前那样每分钟重建ac自动机,就会带来更严重的资源浪费,甚至会影响到整个系统的鲁棒性。
data服务设计了一个版本控制器组件:

1、服务启动时将对应数量关键词的id列表存储到内存中;

2、每分钟读取数据库对应数量的关键词,得到所有关键词的id列表;

3、判定id有没有变化,当id变化时再重建ac自动机。因关键词存储服务保证了关键词变动,其id必定发生变动,以此避免了当关键词没有变动时,重建缓存造成的资源浪费;

版本控制器也参考了guavaCache的缓存更新思路,异步的构建新版本的ac自动机,在新版本构建完成之前由旧版本提供检测服务。

5、优化结果
大牛

说了这么多,有没有实际的证据能够证明通过算法升级,服务架构改造,关键词匹配服务的响应时间和最大qps都得到了提升呢? 

小菜

前辈,升级前后系统的指标数据我整理了一下,分为命中关键词和未命中关键词两种情况。 

1、命中关键词升级之前,当qps达到50多时,平均rt时间达到了4s,服务不满足实时性要求;

2、命中关键词升级之后,当qps达到1k时,平均rt时间为2ms,满足实时性要求;

3、未命中关键词升级之前,当qps达到20时,平均rt时间为10s,不满足实时性要求;

4、未命中关键词升级之后,qps达到1k时,平均rt时间为2ms,满足实时性要求。

03

总结

1、关键词匹配算法的优化:使用双数组trie树实现的ac自动机来替换双层循环遍历 ;

2、服务架构的优化:通过服务分层,name服务负责负载均衡与服务路由,data服务通过分布式部署以支撑更大的数据量,提高了系统整体的负载能力。 

大牛

嗯,通过算法和架构双升级,咱们的关键词匹配服务优化的比较成功。

参考:

  • Aoe, J. I. (1989). An efficient implementation of static string pattern matching machines. IEEE Transactions on SoftwareEngineering, 15(8), 1010-1016.

  • https://www.hankcs.com/program/algorithm/implementation-and-analysis-of-aho-corasick-algorithm-in-java.html

  • https://www.hankcs.com/program/algorithm/aho-corasick-double-array-trie.html

  • https://github.com/hankcs/AhoCorasickDoubleArrayTrie

Image