分享一次巨量关键词匹配服务的性能优化过程
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
优化历程
前辈,经过我的调研感觉可以使用Aho Corasick自动机,即AC自动机来进行关键词匹配算法的优化。
AC自动机匹配算法是什么原理呢?
那ac自动机和Trie树有什么关系呢?
我还是没有明白ac自动机的原理,你能画个图举个例子说明一下吗?
1)先构建goto表
goto表实际上就是一颗trie树结构,如下图所示:
2)构建output表
因为output表比较简单,但实际实现的时候output表会在建立failure表的时候进行一次拓充。output表如下表格所示:
| i | Output(i) |
|---|---|
| 2 | he |
| 5 | she,he |
| 7 | his |
| 9 | hers |
Output表实际上就是从根节点到对应数字节点的字符输出的所有组合,并且满足其存在于关键词词库中。
3)构建failure
failure表的构建是ac自动机实现的重点,其要满足几点限制:
最终找到每个节点对应的fail跳转节点如下表格所示:
| i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| Fail(i) | 0 | 0 | 0 | 1 | 2 | 0 | 3 | 0 | 3 |
下面将整体的ac自动机的构造图画出来,实际就是在步骤1,trie树的基础上画上每个节点的fail跳转路径,以虚线绘制。Trie树的实线为goto路径。若命中output表也在图中绘制出来。整体构造图如下图所示:
我再说明一下ushers字符串匹配关键词的具体过程:
7、 尝试下一个字符r,发现goto路径,转移到节点8,没有命中output表;
8、尝试下一个字符s,发现goto路径,转移到节点9,命中output表关键词,hers。
匹配结束后,共命中三个关键词he,she,hers。
这样看来ac自动机确实比循环遍历效率要高,循环遍历的时间复杂度也可以理解为O(mn)吧?
对,但还有一个问题,前面说过构建ac自动机要先构建一个trie树,但trie树本身占用内存空间比较大,我最近看了一篇文章可以使用双数组trie树来实现ac自动机,这样可以保证ac自动机的内存消耗可控。
双数组trie树又是啥?
小菜啊,解决了算法问题,还有一个棘手的问题就是关键词词库数据增长问题,现在咱们的词库已经达百万级别了,词库数量还在不断扩展,当词库数量达到了千万级时该怎么办呢?
嗯,前辈之前的服务架构是什么样的呢?
之前的关键词服务就是单服务多节点部署的呢,把关键词词库全部导入内存,靠内存来支撑服务的。还有一个问题,咱们的关键词词库中的历史关键词会动态变化,关键词服务要能近实时的感知到变化并体现到服务中。
之前是如何做到感知关键词变化的呢?
之前服务是使用guavaCache的refreshAfterWrites机制,每隔1分钟就读取关键词词库数据库全量刷新一遍缓存中的关键词词库,来保证1分钟的近实时性。
那如果一天之内都没有关键词的变动,但却白白刷新了1440次缓存?造成不必要的资源浪费了吧?
前辈,我将服务架构设计了一下,首先进行了服务分层:
1、设计了name服务负责负载均衡、编排检测服务;
2、设计了data服务做具体的关键词检测。具体的架构图如下:
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组件做限流控制,保障了整个系统的稳定性与可靠性。
关键词匹配服务是data服务吧?能再详细介绍一下data服务的实现吗?
这是data服务的架构图:
1、服务启动时将对应数量关键词的id列表存储到内存中;
2、每分钟读取数据库对应数量的关键词,得到所有关键词的id列表;
3、判定id有没有变化,当id变化时再重建ac自动机。因关键词存储服务保证了关键词变动,其id必定发生变动,以此避免了当关键词没有变动时,重建缓存造成的资源浪费;
版本控制器也参考了guavaCache的缓存更新思路,异步的构建新版本的ac自动机,在新版本构建完成之前由旧版本提供检测服务。
说了这么多,有没有实际的证据能够证明通过算法升级,服务架构改造,关键词匹配服务的响应时间和最大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