目录
问题由来
原始 – grep
设计
代码
进化 – 正则
正则小坑
觉醒 – 拆词
结果
终级 – Trie树
trie树
构造trie树
匹配
他径 – 多进程
总结
首页 后端开发 php教程 优化巨量关键词的匹配

优化巨量关键词的匹配

Aug 21, 2017 am 10:19 AM
关键词 匹配


问题由来

前些天工作中遇到一个问题:

有 60万 条短消息记录日志,每条约 50 字,5万 关键词,长度 2-8 字,绝大部分为中文。要求将这 60万 条记录中包含的关键词全部提取出来并统计各关键词的命中次数。

本文完整介绍了我的实现方式,看我如何将需要运行十小时的任务优化到十分钟以内。虽然实现语言是 PHP,但本文介绍的更多的思想,应该能给大家一些帮助。

原始 – grep

设计

一开始接到任务的时候,我的小心思立刻转了起来,日志 + 关键词 + 统计,我没有想到自己写代码实现,而是首先想到了 linux 下常用的日志统计命令 grep

grep命令的用法不再多提,使用 grep 'keyword' | wc -l 可以很方便地进行统计关键词命中的信息条数,而php的 exec() 函数允许我们直接调用 linux 的 shell 命令,虽然这样执行危险命令时会有安全隐患。

代码

上伪代码:

foreach ($word_list as $keyword) {
    $count = intval(exec("grep '{$keyword}' file.log | wc -l"));
    record($keyword, $count);
}
登录后复制

在一台老机器上跑的,话说老机器效率真的差,跑了6小时。估计最新机器2-3小时吧,后面的优化都使用的新机器,而且需求又有变动,正文才刚刚开始。

原始,原始在想法和方法

进化 – 正则

设计

交了差之后,第二天产品又提出了新的想法,说以后想把某数据源接入进来,消息以数据流的形式传递,而不再是文件了。而且还要求了消息统计的实时性,一下把我想把数据写到文件再统计的想法也推翻了,为了方案的可扩展性,现在的统计对象不再是一个整体,而是要考虑拿n个单条的消息来匹配了。

这时,略懵的我只好祭出了最传统的工具- 正则。正则的实现也不难,各个语言也都封装好了正则匹配函数,重点是模式(pattern)的构建。

当然这里的模式构建也不难,/keyword1|keword2|.../,用|将关键词连接起来即可。

正则小坑

这里介绍两个使用中遇到的小坑:

  • 正则模式长度太长导致匹配失败:PHP 的正则有回溯限制,以防止消耗掉所有的进程可用堆栈, 最终导致 php 崩溃。太长的模式会导致 PHP 检测到回溯过多,中断匹配,经测试默认设置时最大模式长度为 32000 字节 左右。php.ini 内 pcre.backtrack_limit 参数为最大回溯次数限制,默认值为 1000000,修改或 php.ini 或在脚本开始时使用 ini_set(‘pcre.backtrack_limit’, n); 将其设置为一个较大的数可以提高单次匹配最大模式长度。当然也可以将关键词分批统计(我用了这个=_=)。

  • 模式中含有特殊字符导致大量warning:匹配过程中发现 PHP 报出大量 warning:unknown modifier <strong>乱码</strong>,仔细检查发现关键词中有/字符,可以使用preg_quote()函数过滤一遍关键词即可。

代码

上伪代码:

$end = 0;
$step = 1500;
$pattern = array();
// 先将pattern 拆成多个小块
while ($end < count($word_list)) {
    $tmp_arr = array_slice($word_list, $end, $step);
    $end += $step;
    $item = implode(&#39;|&#39;, $tmp_arr);
    $pattern[] = preg_quote($item);
}
 
$content = file_get_contents($log_file);
$lines = explode("\n", $content);
foreach ($lines as $line) {
    // 使用各小块pattern分别匹配
    for ($i = 0; $i < count($pattern); $i++) {
        preg_match_all("/{$pattern[$i]}/", $line, $match);
    }
    $match = array_unique(array_filter($match));
    dealResult($match);
}
登录后复制

为了完成任务,硬着头皮进程跑了一夜。当第二天我发现跑了近十个小时的时候内心是崩溃的。。。太慢了,完全达不到使用要求,这时,我已经开始考虑改换方法了。

当产品又改换了关键词策略,替换了一些关键词,要求重新运行一遍,并表示还会继续优化关键词时,我完全否定了现有方案。绝对不能用关键词去匹配信息,这样一条一条用全部关键词去匹配,效率实在是不可忍受。

进化,需求和实现的进化

觉醒 – 拆词

设计

我终于开始意识到要拿信息去关键词里对比。如果我用关键词为键建立一个 hash 表,用信息里的词去 hash 表里查找,如果查到就认为匹配命中,这样不是能达到 O(1) 的效率了么?

可是一条短消息,我如何把它拆分为刚好的词去匹配呢,分词?分词也是需要时间的,而且我的关键词都是些无语义的词,构建词库、使用分词工具又是很大的问题,最终我想到 拆词

为什么叫拆词呢,我考虑以蛮力将一句话拆分为<strong>所有可能</strong>的词。如(我是好人)就可以拆成(我是、是好、好人、我是好、是好人、我是好人)等词,我的关键词长度为 2-8,所以可拆词个数会随着句子长度迅速增加。不过,可以用标点符号、空格、语气词(如的、是等)作为分隔将句子拆成小短语再进行拆词,会大大减少拆出的词量。

其实分词并没有完整实现就被后一个方法替代了,只是一个极具实现可能的构想,写这篇文章时用伪代码实现了一下,供大家参考,即使不用在匹配关键词,用在其他地方也是有可能的。

代码

$str_list = getStrList($msg);
foreach ($str_list as $str) {
    $keywords = getKeywords($str);
    foreach ($keywords as $keyword) {
        // 直接通过PHP数组的哈希实现来进行快速查找
        if (isset($word_list[$keyword])) {
            record($keyword);
        }
    }
}
/**
* 从消息中拆出短句子
*/
function getStrList($msg) {
    $str_list = array();
    $seperators = array(&#39;,&#39;, &#39;。&#39;, &#39;的&#39;, ...);
 
    $words = preg_split(&#39;/(?<!^)(?!$)/u&#39;, $msg);
    $str = array();
    foreach ($words as $word) {
        if (in_array($word, $seperators)) {
            $str_list[] = $str;
            $str = array();
        } else {
            $str[] = $word;
        }
    }
 
    return array_filter($str_list);
}
 
/**
* 从短句中取出各个词
*/
function getKeywords($str) {
    if (count($str) < 2) {
        return array();
    }
 
    $keywords = array();
    for ($i = 0; $i < count($str); $i++) {
        for ($j = 2; $j < 9; $j++) {
            $keywords[] = array_slice($str, $i, $j); // todo 限制一下不要超过数组最大长度
        }
    }
 
    return $keywords;
}
登录后复制

结果

我们知道一个 utf-8 的中文字符要占用三个字节,为了拆分出包含中英文的每一个字符,使用简单的 split() 函数是做不到的。

这里使用了 preg_split(&#39;/(?<!^)(?!$)/u&#39;, $msg) 是通过正则匹配到两个字符之间的&#39;&#39;来将两个字符拆散,而两个括号里的 (?<!^)(?!$) 是分别用来限定捕获组不是第一个,也不是最后一个(不使用这两个捕获组限定符也是可以的,直接使用//作为模式会导致拆分结果在前后各多出一个空字符串项)。 捕获组的概念和用法可见我之前的博客 PHP正则中的捕获组与非捕获组

由于没有真正实现,也不知道效率如何。估算每个短句长度约为 10 字左右时,每条短消息约50字左右,会拆出 200 个词。虽然它会拆出很多无意义的词,但我相信效率绝不会低,由于其 hash 的高效率,甚至我觉得会可能比终极方法效率要高。

最终没有使用此方案是因为它对句子要求较高,拆词时的分隔符也不好确定,最重要的是它不够优雅。。。这个方法我不太想去实现,统计标识和语气词等活显得略为笨重,而且感觉拆出很多无意义的词感觉效率浪费得厉害。

觉醒,意识和思路的觉醒

终级 – Trie树

trie树

于是我又来找谷哥帮忙了,搜索大量数据匹配,有人提出了 使用 trie 树的方式,没想到刚学习的 trie 树的就派上了用场。我上上篇文章刚介绍了 trie 树,在空间索引 – 四叉树 里字典树这一小节,大家可以查看一下。

当然也为懒人复制了一遍我当时的解释(看过的可以跳过这一小节了)。

字典树,又称前缀树或 trie 树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。

我们可以类比字典的特性:我们在字典里通过拼音查找晃(huang)这个字的时候,我们会发现它的附近都是读音为huang的,可能是声调有区别,再往前翻,我们会看到读音前缀为huan的字,再往前,是读音前缀为hua的字… 取它们的读音前缀分别为 h qu hua huan huang。我们在查找时,根据 abc...xyz 的顺序找到h前缀的部分,再根据 ha he hu找到 hu 前缀的部分…最后找到 huang,我们会发现,越往后其读音前缀越长,查找也越精确,这种类似于字典的树结构就是字典树,也是前缀树。

设计

那么 trie 树怎么实现关键字的匹配呢? 这里以一幅图来讲解 trie 树匹配的过程。

1

其中要点:

构造trie树

  1. 将关键词用上面介绍的preg_split()函数拆分为单个字符。如科学家就拆分为科、学、家三个字符。

  2. 在最后一个字符后添加一个特殊字符`,此字符作为一个关键词的结尾(图中的粉红三角),以此字符来标识查到了一个关键词(不然,我们不知道匹配到科、学两个字符时算不算匹配成功)。

  3. 检查根部是否有第一个字符()节点,如果有了此节点,到步骤4。 如果还没有,在根部添加值为的节点。

  4. 依次检查并添加学、家 两个节点。

  5. 在结尾添加 ` 节点,并继续下一个关键词的插入。

匹配

然后我们以 <strong>这位科学家很了不起</strong>!为例来发起匹配。

  • 首先我们将句子拆分为单个字符 这、位、...

  • 从根查询第一个字符,并没有以这个字符开头的关键词,将字符“指针”向后移,直到找到根下有的字符节点;

  • 接着在节点下寻找值为 节点,找到时,结果子树的深度已经到了2,关键词的最短长度是2,此时需要在结点下查找是否有`,找到意味着匹配成功,返回关键词,并将字符“指针”后移,如果找不到则继续在此结点下寻找下一个字符。

  • 如此遍历,直到最后,返回所有匹配结果。

代码

完整代码我已经放到了GitHub上:Trie-GitHub-zhenbianshu,这里放上核心。

首先是数据结构树结点的设计,当然它也是重中之重:

$node = array(
    &#39;depth&#39; => $depth, // 深度,用以判断已命中的字数
    &#39;next&#39; => array(
        $val => $node, // 这里借用php数组的哈希底层实现,加速子结点的查找
        ...
    ),
);
登录后复制

然后是树构建时子结点的插入:

// 这里要往节点内插入子节点,所以将它以引用方式传入
private function insert(&$node, $words) {
         if (empty($words)) {
            return;
        }
        $word = array_shift($words);
        // 如果子结点已存在,向子结点内继续插入
        if (isset($node[&#39;next&#39;][$word])) {
            $this->insert($node[&#39;next&#39;][$word], $words);
        } else {
            // 子结点不存在时,构造子结点插入结果
            $tmp_node = array(
                &#39;depth&#39; => $node[&#39;depth&#39;] + 1,
                &#39;next&#39; => array(),
            );
            $node[&#39;next&#39;][$word] = $tmp_node;
            $this->insert($node[&#39;next&#39;][$word], $words);
        }
    }
登录后复制

最后是查询时的操作:

// 这里也可以使用一个全局变量来存储已匹配到的字符,以替换$matched
private function query($node, $words, &$matched) {
        $word = array_shift($words);
        if (isset($node[&#39;next&#39;][$word])) {
            // 如果存在对应子结点,将它放到结果集里
            array_push($matched, $word);
            // 深度到达最短关键词时,即可判断是否到词尾了
            if ($node[&#39;next&#39;] > 1 && isset($node[&#39;next&#39;][$word][&#39;next&#39;][&#39;`&#39;])) {
                return true;
            }
            return $this->query($node[&#39;next&#39;][$word], $words, $matched);
        } else {
            $matched = array();
            return false;
        }
    }
登录后复制

结果

结果当然是喜人的,如此匹配,处理一千条数据只需要3秒左右。找了 Java 的同事试了下,Java 处理一千条数据只需要1秒。

这里来分析一下为什么这种方法这么快:

  • 正则匹配:要用所有的关键词去信息里匹配匹配次数是 key_len * msg_len,当然正则会进行优化,但基础这样,再优化效率可想而知。

  • 而 trie 树效率最差的时候是 msg_len * 9(最长关键词长度 + 1个特殊字符)次 hash 查找,即最长关键词类似 AAA,信息内容为 AAA...时,而这种情况的概率可想而知。

至此方法的优化到此结束,从每秒钟匹配 10 个,到 300 个,30 倍的性能提升还是巨大的。

终级,却不一定是终极

他径 – 多进程

设计

匹配方法的优化结束了,开头说的优化到十分钟以内的目标还没有实现,这时候就要考虑一些其他方法了。

我们一提到高效,必然想到的是 并发,那么接下来的优化就要从并发说起。PHP 是单线程的(虽然也有不好用的多线程扩展),这没啥好的解决办法,并发方向只好从多进程进行了。

那么一个日志文件,用多个进程怎么读呢?这里当然也提供几个方案:

  • 进程内添加日志行数计数器,各个进程支持传入参数 n,进程只处理第 行数 % n = n 的日志,这种 hack 的反向分布式我已经用得很熟练了,哈哈。这种方法需要进程传参数,还需要每个进程都分配读取整个日志的的内存,而且也不够优雅。

  • 使用 linux 的 split -l n file.log output_pre 命令,将文件分割为每份为 n 行的文件,然后用多个进程去读取多个文件。此方法的缺点就是不灵活,想换一下进程数时需要重新切分文件。

  • 使用 Redis 的 list 队列临时存储日志,开启多个进程消费队列。此方法需要另外向 Redis 内写入数据,多了一个步骤,但它扩展灵活,而且代码简单优雅。

最终使用了第三种方式来进行。

结果

这种方式虽然也会有瓶颈,最后应该会落在 Redis 的网络 IO 上。我也没有闲心开 n 个进程去挑战公司 Redis 的性能,运行 10 个进程三四分钟就完成了统计。即使再加上 Redis 写入的耗时,10分钟以内也妥妥的。

一开始产品对匹配速度已经有了小时级的定位了,当我 10 分钟就拿出了新的日志匹配结果,看到产品惊讶的表情,心里也是略爽的,哈哈~

他径,也能帮你走得更远

总结

解决问题的方法有很多种,我认为在解决各种问题之前,要了解很多种知识,即使只知道它的作用。就像一个工具架,你要先把工具尽量摆得多,才能在遇到问题时选取一个最合适的。接着当然要把这些工具用是纯熟了,这样才能使用它们去解决一些怪异问题。

工欲善其事,必先利其器,要想解决性能问题,掌握系统级的方法还略显不够,有时候换一种数据结构或算法,效果可能会更好。感觉自己在这方面还略显薄弱,慢慢加强吧,各位也共勉。

以上是优化巨量关键词的匹配的详细内容。更多信息请关注PHP中文网其他相关文章!

本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn

热AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover

AI Clothes Remover

用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool

Undress AI Tool

免费脱衣服图片

Clothoff.io

Clothoff.io

AI脱衣机

AI Hentai Generator

AI Hentai Generator

免费生成ai无尽的。

热工具

记事本++7.3.1

记事本++7.3.1

好用且免费的代码编辑器

SublimeText3汉化版

SublimeText3汉化版

中文版,非常好用

禅工作室 13.0.1

禅工作室 13.0.1

功能强大的PHP集成开发环境

Dreamweaver CS6

Dreamweaver CS6

视觉化网页开发工具

SublimeText3 Mac版

SublimeText3 Mac版

神级代码编辑软件(SublimeText3)

解释一下explorer.exe进程是什么 解释一下explorer.exe进程是什么 Feb 18, 2024 pm 12:11 PM

explorer.exe是什么进程在我们使用Windows操作系统的时候,经常会听到一个名词"explorer.exe".那么,你是否好奇这个进程到底是什么?在本文中,我们将详细解释explorer.exe是什么进程以及其功能和作用。首先,explorer.exe是Windows操作系统的一个关键进程,它负责管理和控制Windows资源管理器(Window

小米 14 Ultra怎么调整光圈? 小米 14 Ultra怎么调整光圈? Mar 19, 2024 am 09:01 AM

光圈大小的调整对于拍照效果有着至关重要的影响,小米14Ultra在相机光圈调节方面提供了前所未有的灵活性。为了让大家都能顺利调节光圈,实现光圈大小的自由调节,小编在这里为大家带来了小米14Ultra怎么设置光圈的详细教程。小米14Ultra怎么调整光圈?启动相机,切换至“专业模式”,选择主摄-W镜头。点击光圈,打开光圈转盘,A为自动,按需选择f/1.9或f/4.0。

r5 5600x最高能带动什么显卡 最新用5600X搭配RX6800XT性能 r5 5600x最高能带动什么显卡 最新用5600X搭配RX6800XT性能 Feb 25, 2024 am 10:34 AM

10月29日,AMD终于发布了备受用户期待的重磅产品,即基于全新RDNA2架构的RX6000系列游戏显卡。这款显卡与之前推出的基于全新ZEN3架构的锐龙5000系列处理器相辅相成,形成了一个全新的双A组合。这一次的发布不仅使得竞争对手“双英”黯然失色,也对整个DIY硬件圈产生了重大影响。接下来,围绕笔者手中这套AMD锐龙5600X和RX6800XT的组合作为测试例子,来见证下现如今的AMD究竟有多么Yse?首先说说CPU处理器部分,上一代采用ZEN2架构的AMD锐龙3000系列处理器其实已经令用

发生0x0000004e错误代表了什么问题 发生0x0000004e错误代表了什么问题 Feb 18, 2024 pm 01:54 PM

0x0000004e是什么故障在计算机系统中,故障是一个常见的问题。当计算机遇到故障时,系统通常会因为无法正常运行而出现停机、崩溃或者出现错误提示。而在Windows系统中,有一个特定的故障代码0x0000004e,这是一个蓝屏错误代码,表示系统遇到了一个严重的错误。0x0000004e蓝屏错误是由于系统内核或驱动程序问题导致的。这种错误通常会导致计算机系统

Cheat Engine怎么设置中文?ce修改器设置中文的方法 Cheat Engine怎么设置中文?ce修改器设置中文的方法 Mar 18, 2024 pm 01:20 PM

Ce修改器(CheatEngine)是一款专用于对游戏内存进行修改和编辑的游戏修改工具,那么在CheatEngine中怎么设置中文呢?接下来小编为大伙讲述ce修改器设置中文的方法内容,希望可以帮助到有需要的朋友。在我们下载的新软件中,若发现它不是中文界面,可能会让人感到困惑。尽管这款软件不是由中国开发的,但我们仍有方法将其转换为中文版本。只需简单地应用中文补丁,就能解决这个问题。在下载并安装了CheatEngine(ce修改器)软件后,打开安装位置,找到名为languages的文件夹,如下图所示

内存频率和时序哪个对性能影响更大 内存频率和时序哪个对性能影响更大 Feb 19, 2024 am 08:58 AM

内存是计算机中非常重要的组件之一,它对计算机的性能和稳定性有着重要影响。在选择内存时,人们往往会关注两个重要的参数,即时序和频率。那么,对于内存性能来说,时序和频率哪个更重要呢?首先,我们来了解一下时序和频率的概念。时序指的是内存芯片在接收和处理数据时所需的时间间隔。它通常以CL值(CASLatency)来表示,CL值越小,内存的处理速度越快。而频率则是内

荣耀 90 GT怎么更新荣耀MagicOS 8.0? 荣耀 90 GT怎么更新荣耀MagicOS 8.0? Mar 18, 2024 pm 06:46 PM

荣耀90GT是一款性价比很高的智能手机,拥有出色的性能和出色的用户体验。然而,有时候我们可能会遇到一些问题,比如荣耀90GT怎么更新荣耀MagicOS8.0呢?这个步骤因为不同的手机不同的机型可能会有些区别,那么,让我们一起来探讨一下,如何正确地升级系统吧。荣耀90GT怎么更新荣耀MagicOS8.0?2月28日消息,荣耀今天为旗下90GT/100/100Pro三款手机推送MagicOS8.0公测更新,包版本号为8.0.0.106(C00E106R3P1)1.确保您的荣耀90GT的电池电量充足,

DaVinci Resolve Studio 已支持AMD显卡的AV1硬件编码 DaVinci Resolve Studio 已支持AMD显卡的AV1硬件编码 Mar 06, 2024 pm 10:04 PM

最近新消息,lackMagic目前推出了达芬奇DaVinciResolveStudio视频编辑软件的18.5PublicBeta2公测版更新,为AMDRadeon显卡带来了AV1编码支持。更新到最新版本后,AMD显卡用户将能够在DaVinciResolveStudio中利用硬件加速来进行AV1编码。尽管官方并未具体指明支持的架构或型号,但预计所有的AMD显卡用户都可以尝试这一功能。2018年,AOMedia发布了全新的视频编码标准AV1(AOMediaVideoCodec1.0)。AV1是由多家

See all articles