☰
PHP敏感词过滤性能优化:从暴力匹配到AC自动机毫秒级响应
2026/10/4 3:30:10 网站建设 项目流程

搞内容审核、UGC 社区、弹幕聊天室这些场景的朋友,对敏感词过滤绝对不陌生。尤其是业务量上来以后,你会发现最朴素的做法根本扛不住。我之前负责过一个弹幕互动项目,词库量级从一开始的几百条涨到上万条,每条消息都要过一遍过滤逻辑,用最开始的遍历+正则方案,线上接口 p99 延迟直接飙到了几秒量级,用户发条弹幕要转两秒才出来,体验非常糟糕。后来我花了两个晚上把过滤逻辑从暴力匹配重构到 AC 自动机,直接把单条消息的过滤耗时压到了 1ms 以内,整条接口的响应时间回到了几十毫秒的水平。

这篇文章就把这段重构经历完整记录下来,包括暴力匹配的性能瓶颈到底在哪、AC 自动机的核心原理怎么用 PHP 落地、实际编码时避开了哪些坑、以及压测数据和我后续做的几轮优化。整个过程不需要你没学过数据结构,我会用最直白的方式把 Trie 树、失败指针这些概念讲清楚,你只需要跟着代码走一遍,就能把你自己的敏感词过滤逻辑从“勉强能用”提升到“毫秒级响应”。

1. 先摸清底牌:暴力匹配为什么撑不到毫秒级

1.1 最初的需求和“看起来完全没问题”的第一版

先交代一下当时的业务场景。弹幕系统里,用户发送的消息要经过三道检查:长度校验、敏感词过滤、内容去重。其中敏感词过滤是最耗时的部分。刚开始词库很小,大概有几百个词,我图省事,直接用了 PHP 内置函数str_replace的数组批量替换特性,因为str_replace支持传入数组进行同时替换,看起来非常简洁,几行代码就完成了过滤。

$badWords = ['垃圾', '滚', '废物', '白痴']; $content = '你这个垃圾,快滚'; $filtered = str_replace($badWords, '**', $content); // 输出: 你这个**,快**

当时的想法是:PHP 内置函数都是 C 写的,性能肯定没问题。实测下来也确实还行,几百个词、几十字的消息,响应时间几乎可以忽略。但问题很快就来了。第一是词库开始膨胀,从几百条涨到几千条,str_replace的耗时明显上升。第二是出现了误伤,比如词库里有一个“删帖”,用户发“我删帖了”没问题,但如果词库里还有“帖子”,“你那个帖子上首页了”也会被咔嚓掉,这种简单替换根本没法做词组级、上下文级的判断。

后来改成用正则循环匹配,更离谱。PHP 的正则引擎是 PCRE,它处理复杂模式的能力很强,但代价是模式越长、分支越多,编译和执行时间成倍增长。我当时的做法是先把所有敏感词拼成一个大的正则分支:

$pattern = '/' . implode('|', array_map('preg_quote', $badWords)) . '/';

词库几千条的时候,这一个正则模式串就已经非常长了,PHP 每次请求进来都要重新编译一遍这个超长正则。而且更麻烦的是,这个方案还得分两步走:先用正则判断是否命中,命中之后还得再循环遍历一次词库,用strpos逐个找出具体命中了哪些词,因为正则捕获组太多会直接把 PCRE 的捕获上限打爆,根本没法精确还原每个命中词的位置。一来二去,性能直接雪崩。

1.2 暴力匹配的核心瓶颈:复杂度与重复扫描

暴力匹配的本质是:对于每一条输入文本,从文本的第一个字符开始,依次尝试匹配词库中的每一个敏感词。假设文本长度为 m,词库中所有敏感词的总长度之和为 L,那么从第一个字符开始,要做的最坏情况比较次数是 L 次。如果还要从第二个字符开始再跑一轮,就是 m × L。正则方案看着是把词库拼成了一条模式,但 PCRE 引擎在处理多分支模式时,很多分支都要回溯重试,其实际复杂度依然近似 m × L,甚至因为回溯的存在,某些极端情况下比直接两两比较还慢。

这不是 PHP 本身慢的问题,而是算法复杂度决定了它必然慢。我举个例子,假设平均每条弹幕 50 字,即 m = 50,词库 10000 条、平均每词 4 字节,L = 40000,那么最坏情况下单条消息的比较次数是 50 × 40000 = 2000000 次,也就是两百万次。一次请求就要做几百万次字符串比较,再叠加正则编译,这接口怎么可能快得起来。

更让人头疼的是,暴力匹配还有两个连锁反应。一是每来一条消息都要完整跑一遍全词库,词库只增不减,性能只会越来越差;二是它没法输出“命中了哪些词、命中位置在哪”,因为str_replace和preg_match都不会告诉你具体位置信息,而拦截策略往往需要区分等级,比如“直接封禁”和“打码替换”是用不同敏感词等级划分的。所以第一版方案从性能和功能两个维度都是死路,必须换算法。

1.3 换算法的时机:量化标准与决策依据

有段时间我一度在犹豫要不要直接上第三方云服务,比如内容安全接口,但考虑两个问题就放弃了:一是外呼延迟不稳定,弹幕场景对实时性要求极高,一次外呼少说几十毫秒;二是成本和数据隐私,弹幕内容全部发到第三方去审核,敏感内容等于裸奔给服务商,对用户也不太友好,自建成本反而是可控的。

自己写过滤算法,需要满足三个硬性指标我就上:第一,单条 200 字以内的文本,过滤耗时小于 2ms;第二,词库 1 万条以上时,内存占用控制在 50MB 以内;第三,能够返回命中的词和位置,方便业务侧做分级处理。搜了一圈,最合适的方案就是 AC 自动机。这个算法在 PHP 社区讨论不算多,但它在 Java、Go 的敏感词过滤、杀软病毒特征匹配里是标配,属于典型的多模式字符串匹配算法,专门解决“一个文本里匹配多个模式”的场景。它的厉害之处在于:无论词库多大,匹配一遍文本的时间复杂度都只跟文本长度有关,跟词库规模无关。这就从根上解决了暴力匹配的问题。

2. AC 自动机的核心思路:多模式匹配从 O(m×L) 降到 O(m)

2.1 用字典树把“逐词比较”变成“逐字符跳转”

AC 自动机是两个算法叠加的产物,第一层叫 Trie 树,中文叫字典树。它的作用是把词库中所有敏感词按字符前缀存放进一棵多叉树里。比如词库里有“垃圾”、“拉黑”、“垃圾箱”三个词,Trie 树会长成这样:

根节点 ├── 垃 │ ├── 圾 │ │ ├── (结束标记: 垃圾) │ │ └── 箱 │ │ └── (结束标记: 垃圾箱) └── 拉 └── 黑 └── (结束标记: 拉黑)

匹配一条文本时,从左到右扫描字符,拿着每个字符从根节点开始往下走树。如果文本里有“垃圾箱”,那路径就是 根->垃->圾->箱,一路顺滑到底;如果文本里是“垃级”,走到“圾”的时候发现要匹配“级”,而当前节点没有“级”这个孩子,匹配就断了,这时不需要回到文本开头重新匹配,而是借助第二层结构“失败指针”跳到另一个状态继续匹配。正是这个设计让 AC 自动机匹配全程只需要扫描一遍文本,不回头、不重试。

用生活化的类比来说:Trie 树相当于给所有词库建了一个“共享的地铁线路图”,相同的前缀共用路段,比如“垃圾”和“垃圾箱”共用“垃圾”这一段。“失败指针”相当于地铁换乘通道,当你在这条线路上走不通时,不用回到起点重新刷票进站,而是直接通过换乘通道跳到另一条线路的某个站点继续往前。

2.2 失败指针:AC 自动机“不回头”的关键机制

失败指针是 AC 自动机区别于普通 Trie 树的核心。普通 Trie 树匹配失败时,要回到根节点重新匹配,导致复杂度退化。失败指针解决了这个问题:当某个字符在当前节点匹配不到子节点时,就沿着失败指针跳到另一个节点,继续尝试匹配。

失败指针的构建设计理念很精髓:对于一个节点,它的失败指针指向的是“当前节点路径后缀能够匹配到的 Trie 树中最长前缀对应的节点”。这句话非常拗口,我拆开解释。比如词库里有“bc”和“abc”,当匹配到“abc”状态也就是路径 根->a->b->c 时,这个节点的失败指针会指向根->b->c 这个节点,因为“bc”是字符串“abc”的一个后缀,而“bc”恰好又是词库里另一个词的前缀。这样在匹配到“abc”的第三个字符时,如果下一个字符不是预期中的字符,没关系,顺着失败指针跳到“bc”状态继续匹配,不需要回到文本的第二个字符重新开始。

构建失败指针用的是广度优先遍历:从根节点出发,第一层子节点的失败指针全部指向根节点,然后逐层往下,每个节点的失败指针可以由其父节点的失败指针推导出来。整个过程就一次 BFS,对 1 万个词、每个词 4 字节,总共 4 万个节点的树来说,构建时间通常在几十毫秒级别,而且这个构建只需要做一次,之后可以复用。

2.3 复杂度承诺:为什么能毫秒级

AC 自动机的匹配时间复杂度是 O(m),m 是文本长度,构建时间是 O(总词长),空间复杂度是 O(总词长×字符集大小)。这个字符集大小对中文来说非常重要,后面第 4 节会详细解释。

匹配时,算法在 Trie 树中边扫描文本边跳转,每个字符最多访问两次:一次在当前状态找子节点,一次沿着失败指针跳转。失败指针跳转次数在整个匹配过程中均摊下来是常数级的,所以整体就是线性的 O(m)。对比暴力匹配的 O(m×L),在词库 1 万条的情况下相当于直接把计算量缩小了几千倍。这就是毫秒级响应的数学基础。

我在单位做了一次基准测试,词库 10000 词、文本长度 100~200 字,暴力正则平均耗时 158ms,AC 自动机平均耗时 0.9ms,差距接近 200 倍。这个数据在后面第 4 节会详细列出。

3. PHP 实现 AC 自动机的完整细节与避坑指南

3.1 数据结构选型:为什么 PHP 数组是最优解

网上搜 AC 自动机的实现,几乎都是 C 语言、Java 用二维数组或对象指针写的。PHP 版本数量稀少,且很多实现有个通病——直接用嵌套对象存 Trie 节点。这在节点规模只有几百个时没问题,但节点数上了 3 万以后,PHP 对象的内存开销会大到难以接受。因为 PHP 每个对象都有 zend_object 头部信息、方法表、属性哈希表等多重开销,一个简单的 node 对象随随便便就要几百字节,4 万个节点就是十几 MB,如果再叠加哈希表开销,几百 MB 都有可能。

所以我的方案是:用 PHP 内置的数组(Array)模拟 Trie 树结构。PHP 数组本质是 orderded hash table,虽然也有内存开销,但比对象小得多,而且直接支持“键名查值”这种天然的高性能哈希操作。一个节点的结构用一个关联数组表示,键是子节点字符,值是该字符对应的子节点在内层数组中的引用。为了避开 PHP 数组拷贝陷阱,我不用多层嵌套数组直接表示整棵树,而是用一维数组加索引编号的方式,把所有节点平坦存放。这是我的核心优化点。

// 节点存储结构设计 // tree[状态ID]['child'][字符] = 子状态ID // tree[状态ID]['fail'] = 失败指针指向的状态ID // tree[状态ID]['end'] = 是否是一个完整敏感词的结尾(可存词ID)

这种扁平化数组结构有两个好处:第一,所有节点放在一个数组里,PHP 不需要维护复杂的指针关系,内存占用大幅下降;第二,遍历和跳转时,通过状态 ID 快速索引,访问效率接近数组下标直取,比对象属性链快得多。

3.2 构建 Trie 树的 PHP 实现

构建 Trie 树就是把词库中的每个敏感词逐字插入到树中。每个节点用数字 ID 标识,根节点 ID 为 0。代码如下:

class SensitiveWordFilter { private array $tree = []; private array $keywords = []; public function __construct(array $keywords) { $this->keywords = $keywords; $this->buildTrie(); $this->buildFailPointers(); } private function buildTrie(): void { // 初始化根节点 $this->tree[0] = [ 'child' => [], 'fail' => 0, 'end' => [], ]; foreach ($this->keywords as $word) { $currentId = 0; $length = mb_strlen($word, 'UTF-8'); for ($i = 0; $i < $length; $i++) { $char = mb_substr($word, $i, 1, 'UTF-8'); if (!isset($this->tree[$currentId]['child'][$char])) { $newId = count($this->tree); $this->tree[$newId] = [ 'child' => [], 'fail' => 0, 'end' => [], ]; $this->tree[$currentId]['child'][$char] = $newId; } $currentId = $this->tree[$currentId]['child'][$char]; } // 标记该节点为一个敏感词的结尾 $this->tree[$currentId]['end'][] = $word; } } }

这里有几个细节我要特别说明。第一,必须用mb_strlen和mb_substr按 UTF-8 字符切分,不能用strlen和substr,否则中文会被按字节拆开,直接构建出错误的树。第二,用isset而不是array_key_exists,因为isset更快,但要注意子节点字符可能对应值为null的情况,所以我们构建过程中不允许把子节点值存成null。第三,end是一个数组而不是布尔值,因为同一路径上可能存在多个敏感词,比如“垃圾”和“垃圾箱”都终止在各自的节点上,记录词本身方便后续业务判断命中了哪个词。

3.3 广度优先构建失败指针

失败指针的构建是整个算法复杂度最难、也最容错的部分。采用 BFS 顺序逐层处理节点。根节点的失败指针指向自己,第一层子节点的失败指针全部指向根节点,后续节点的失败指针由父节点的失败指针推导:

private function buildFailPointers(): void { $queue = []; // 根节点的所有直接子节点,失败指针指向根节点(0) foreach ($this->tree[0]['child'] as $char => $childId) { $this->tree[$childId]['fail'] = 0; $queue[] = $childId; } while (!empty($queue)) { $currentId = array_shift($queue); foreach ($this->tree[$currentId]['child'] as $char => $childId) { // 先假设失败指针为父节点的失败指针 $failId = $this->tree[$currentId]['fail']; // 沿着失败指针链向上找,直到找到拥有该字符子节点的节点或到达根 while ($failId !== 0 && !isset($this->tree[$failId]['child'][$char])) { $failId = $this->tree[$failId]['fail']; } // 如果找到了,指向该节点的对应子节点;否则指向根节点 if (isset($this->tree[$failId]['child'][$char])) { $this->tree[$childId]['fail'] = $this->tree[$failId]['child'][$char]; } else { $this->tree[$childId]['fail'] = 0; } // 合并 fail 节点的结束标记,这样匹配时可以一次判断所有等价后缀 foreach ($this->tree[$this->tree[$childId]['fail']]['end'] as $word) { $this->tree[$childId]['end'][] = $word; } $queue[] = $childId; } } }

注意array_shift在数组很大时性能不佳,因为每次 shift 都会重新索引整个数组。节点数在 4 万以下时问题不大,但如果词库到了十万级,我会改成维护一个$head下标来模拟队列,避免反复array_shift。另外,我把失败节点的end标记合并到了当前节点,这一步非常重要。这么做虽然会稍微增加内存,但能让你在匹配阶段不用频繁回溯失败链去判断后缀是不是敏感词,一次命中直接结束,性能更好。

3.4 匹配过程:从输入文本到命中结果

匹配主流程比较简单,核心就是一个循环,从文本第一个字符扫描到最后一个字符,沿着 Trie 树跳转,一旦遇到带有end标记的节点,就记录命中信息。

public function filter(string $text): array { $currentId = 0; $length = mb_strlen($text, 'UTF-8'); $matched = []; for ($i = 0; $i < $length; $i++) { $char = mb_substr($text, $i, 1, 'UTF-8'); // 如果当前节点没有这个字符的子节点,顺着失败指针找 while ($currentId !== 0 && !isset($this->tree[$currentId]['child'][$char])) { $currentId = $this->tree[$currentId]['fail']; } if (isset($this->tree[$currentId]['child'][$char])) { $currentId = $this->tree[$currentId]['child'][$char]; } else { $currentId = 0; } // 当前节点如果是敏感词结尾,记录 if (!empty($this->tree[$currentId]['end'])) { foreach ($this->tree[$currentId]['end'] as $word) { $matched[] = [ 'word' => $word, 'position' => $i - mb_strlen($word, 'UTF-8') + 1, ]; } } } return $matched; }

这里有个关键点:while循环处理“当前字符匹配失败”的情况,本质上是在失败指针链上向上跳。由于我们已经把 fail 节点的end标记合并到了当前节点,所以匹配时不需要再额外检查失败指针节点的end,只要当前节点命中了,说明所有以当前路径为后缀的敏感词都已经覆盖。这个设计还顺带解决了“重叠敏感词”问题——比如词库有“AB”和“BC”,文本是“ABC”,普通实现可能会漏掉“BC”,而合并end标记后,“BC”会被正确识别。

为了方便直接替换,我又封装了一个replace方法,把命中的词全部替换为指定占位符,比如***。

public function replace(string $text, string $replacement = '***'): string { $matched = $this->filter($text); if (empty($matched)) { return $text; } foreach ($matched as $item) { $word = $item['word']; $pos = mb_strpos($text, $word); if ($pos !== false) { $text = mb_substr($text, 0, $pos) . $replacement . mb_substr($text, $pos + mb_strlen($word, 'UTF-8'), null, 'UTF-8'); } } return $text; }

这一段我写得比较保守,因为mb_strpos在大量同时命中同一个词时可能会有偏差。实际生产里我建议直接基于filter返回的 position 和 word 来做字符串重建,避免二次查找。等下第 5 节我会给出更稳健的替换实现。

4. 压测数据、优化空间与真实部署经验

4.1 基准测试:词库从几百到一万的实测对比

我在本地环境做了完整的基准测试,环境是 PHP 8.2 + Ubuntu 22.04,词库使用了我从公开渠道整理的一份 10000 词敏感词表(包含政治类、低俗类、广告类等,不必纠结具体内容,你完全可以换成自己的业务词库),测试文本是一段 150 字的中文段落,随机插入 5 个敏感词。

测试方式:每轮跑 1000 次,取平均值。

方案词库 500 词词库 3000 词词库 10000 词
str_replace 数组替换0.08ms无意义(无法定位)无意义(无法定位)
大正则 + strpos 二次遍历3.5ms22ms158ms
AC 自动机 filter0.4ms0.6ms0.9ms
AC 自动机 replace0.6ms0.9ms1.4ms

这里刻意没有对比str_replace在 3000 词以上的数据,因为它只做简单替换,无法返回命中详情和位置,不符合业务分级拦截的需求。正则方案到 10000 词时已经逼近 160ms,这个延迟对弹幕接口是灾难性的。AC 自动机在全部场景下都稳定在亚毫秒到 1.4ms 之间,完全满足“毫秒级响应”。

另外我测了 AC 自动机构建时间:10000 词构建 Trie + 失败指针,总耗时约 45ms。这个构建是一次性的,可以放在应用启动时做,或者用缓存机制存起来,详见 4.3。

4.2 字符集处理:中文场景的空间膨胀问题

AC 自动机有一个不可忽视的问题:Trie 树每个节点的child字段如果设计成“字符到子节点的映射数组”,那么每个节点都要维护一个哈希表。对于纯英文场景,子节点数量少(26 个字母),这个开销可以忽略。但对中文场景,一个节点可能有多达几千个不同汉字子节点,每个节点的child数组里都哈希着这几千个汉字,内存会急剧膨胀。

我得说清楚:中文常用字 3500 个,加上生僻字、数字、符号,实际字符集可能在 1 万以上。如果节点数量是 4 万个,每个节点的空哈希表也要占据大量内存。我在测试时干过一件事:直接打印memory_get_usage(),10000 词构建完,内存已经涨到约 28MB。这个数字还能接受,但如果词库翻到 10 万词,就要小心了。

优化方向有两个。第一是改用双数组 Trie 算法(Double-Array Trie,DAT),它用两个数组存储转移表,空间利用率极高,是工程界处理大词库的标配方案。但 DAT 对动态增删词支持差,PHP 实现也更复杂。第二是分桶:把 Trie 树的child按首字符建立索引,比如按 Unicode 编码范围分成若干桶,每个桶只存储该范围内的子节点映射,这样能显著减少空哈希表的数量。我个人在实际项目中,词库维持在 3 万以内时,简单方案加 28MB 内存完全够用,不需要过度优化。

4.3 Swoole、FPM 常驻内存与构建开销的工程权衡

AC 自动机构建不是免费的,虽然 45ms 在单次请求里看起来也不慢,但如果在传统 PHP-FPM 模式下,每个请求都重新构建一次,那 45ms 就成了拉高 p99 的元凶。这时候得考虑如何复用构建结果。

方案一:Swoole 常驻内存。在 Swoole 的onWorkerStart里构建一次 AC 自动机,整个 Worker 进程内复用,之后的请求直接读取内存里的自动机实例。这个方案最优雅,性能和内存都是最优解。如果你用的是 Swoole HTTP 服务,墙裂推荐。

方案二:FPM + 静态变量缓存。在 PHP-FPM 模式下,每个 Worker 进程是常驻的,只是请求进来后执行脚本。可以利用 PHP 的静态变量或者单例模式,把已构建的 AC 自动机挂在静态属性上,这样同一 Worker 进程内后续请求就不用重建了。需要注意静态变量在代码更新后进程切换时才会失效,热更新时需要平滑重启。

方案三:把 Trie 序列化后存文件/Redis。第一次构建完成后,将$this->tree序列化存到本地文件,后续请求直接include反序列化得到数组,省去重建过程。但要注意 PHP 序列化大数组的效率其实一般,而且反序列化本身也要几十毫秒,长远看不如静态变量缓存或 Swoole 常驻。

我实际项目用的是方案二:在类里加一个静态缓存,构建逻辑只在进程内执行一次,后续请求直接复用。这是我们 p99 能压到 30ms 以内的关键一环。

class SensitiveWordFilter { private static ?self $instance = null; public static function getInstance(array $keywords): self { if (self::$instance === null) { self::$instance = new self($keywords); } return self::$instance; } }

4.4 前置快速预检:双倍提速的黑盒优化

AC 自动机已经把单次匹配耗时压到了 1ms 以内,但对弹幕这种超高 QPS 的场景,大多数消息其实是完全无敏感词的。针对“大量无命中消息”的业务特征,我做了“快速预检”优化:先做一个简单的哈希集合判断,如果这条消息完全没有可能命中任何敏感词,直接跳过 AC 自动机主流程。

具体实现:把所有敏感词的首字、首二字组合放到一个 Set 里。匹配时先看文本中是否有任何字符出现在这个 Set 中,如果没有,直接返回“无命中”。这个检查的复杂度接近 O(m),但常数极小,因为是判断字符是否在哈希集合里,比走 AC 自动机主流程快得多。实测下来,对于干净文本,过滤耗时可再降低 40% 左右。

如果首字命中,再走 AC 自动机精确匹配。这个优化本质上是“先用粗暴手段排除大部分不可能,再用精确算法处理少数可能”,在实际工程里很常用。注意首字集合不能只取单个字,因为“垃圾”和“垃级”首字都是“垃”,如果只按单字集合,命中率会非常低,预检起不到过滤作用;用首二字符组合能大幅提高命中概率,代价是内存稍增。

private array $firstCharSet = []; private function buildFirstCharSet(): void { foreach ($this->keywords as $word) { if (mb_strlen($word, 'UTF-8') >= 2) { $key = mb_substr($word, 0, 2, 'UTF-8'); } else { $key = $word; } $this->firstCharSet[$key] = true; } } public function quickCheck(string $text): bool { $len = mb_strlen($text, 'UTF-8'); for ($i = 0; $i < $len - 1; $i++) { $key = mb_substr($text, $i, 2, 'UTF-8'); if (isset($this->firstCharSet[$key])) { return true; } } return false; }

5. 实际踩坑记录与调优经验

5.1 一个让我排查了两个小时的编码坑:UTF-8 与字节索引

最开始我把 AC 自动机直接按字节处理,因为《敏感词检测》这种高性能匹配在 Java 里通常就是按字节处理的,中文用 GBK 或 UTF-8 编码做 Trie 也能匹配。但现实给了我一个教训:PHP 的字符串索引是字节索引,不是字符索引。如果按text[$i]这种下标访问,取到的是单个字节而不是一个汉字字符,直接把中文字符串拆得七零八落,匹配结果完全是乱的。

解决方式就是我前面代码里一直用mb_strlen和mb_substr按 UTF-8 字符切分。虽然多字节函数比单字节访问慢一些,但在 200 字以内的文本上,这个开销完全可以忽略。后来我还做了一次对比:用preg_split('//u', $text)先把文本按 UTF-8 拆成字符数组,再做字符跳转,性能比用mb_substr循环可能略好一点点,因为preg_split是 C 级别一次性拆分的,但前提是你能接受多一次 O(m) 的数组拷贝。我这里优先讲mb_substr版本,因为它可读性最好,后续你可以自己替换。

5.2 消除重复命中:一次消息命中 5 次但只替换 2 处

AC 自动机的end标记合并设计有个副作用:匹配“垃圾”和“垃圾箱”时,如果文本是“垃圾箱里有垃圾”,则路径在“垃圾箱”节点和“垃圾”节点都会触发命中,会记录两条结果。如果直接按end数组替换,同一个词可能被重复处理。我后来在filter返回结果后,先做了一次去重和合并:按word + position去重,再按 position 从小到大排序,最后按位置逆序替换(避免替换后位置偏移)。

更稳妥的做法是:在filter里记录结束位置endPos = i + 1,命中词的起始位置是endPos - mb_strlen($word),同时记录结束位置,这样无论重叠词有多少,利用结束位置做区间合并就能精确还原所有不重叠命中。我最终在replace方法里,把所有命中区间按结束位置从大到小排序,然后从后往前替换,这样前面的替换不会影响后面已经替换完成的位置。

public function replace(string $text, string $replacement = '**'): string { $matched = $this->filter($text); if (empty($matched)) { return $text; } // 按结束位置降序排列,保证后替换不影响前替换 usort($matched, function ($a, $b) { $aEnd = $a['position'] + mb_strlen($a['word'], 'UTF-8'); $bEnd = $b['position'] + mb_strlen($b['word'], 'UTF-8'); return $bEnd <=> $aEnd; }); foreach ($matched as $item) { $text = mb_substr($text, 0, $item['position'], 'UTF-8') . $replacement . mb_substr($text, $item['position'] + mb_strlen($item['word'], 'UTF-8'), null, 'UTF-8'); } return $text; }

5.3 动态增删词与热更新:避开“重建全树”的笨办法

业务方经常会跑来要求“今天下午紧急加两个词”。如果每次都重建整棵 AC 自动机,虽然 45ms 也不长,但在线更新时会有瞬间的匹配窗口不一致:旧词库跑完最后一个请求,新词库才刚生效。这个窗口期可能让新词漏过。

我的做法是双缓冲 + 发布接口:维护两份自动机实例,一份 online、一份 offline。更新时先把新词加入 offline 实例并重建,重建完成后原子替换online = offline,整个切换过程在 PHP 里就是一个变量赋值,毫秒级完成,用户无感知。这个模式在 Swoole 下尤其好用,因为 Worker 是常驻的,直接$filter = new SensitiveWordFilter($newKeywords)即可替换。

如果词库更新特别频繁,可以用 XXTEA/AES 把敏感词表加密后存 Redis,每次更新时从 Redis 拉取,再原地重建自动机。这个方案带来的额外延迟主要在网络 I/O 和构建上,在词库量级 10 万以下都在可接受范围。

5.4 AC 自动机的“蒙蔽绕过”问题:组合词与同音字

再提一个纯算法层面无法解决的问题:用户输入“垃 圾”、“垃-圾”、“拉基”等变形词时,AC 自动机按字符严格匹配会漏掉。AC 自动机只识别完全等价的字符串,它不理解语义。所以工程上往往需要两道防线:第一道是 AC 自动机做标准的字符匹配,处理 90% 以上的直接命中;第二道是预处理或后处理,比如在过滤前对文本做规范化,把空白字符、标点替换成空,再用 AC 自动机跑一遍,专门应对简单绕过。对于同音字、拼音绕过,那就得引入词表关联或者向量匹配了,这块属于另一套工程体系,本文先不展开。

6. 一些工程化收尾的碎碎念

最后分享一点个人经验:算法选型重要,但你真正上线前的工程问题,往往比写 AC 自动机本身多得多。比如内存水位监控,我用memory_get_peak_usage记录每次构建后的峰值;比如日志审计,每次过滤命中敏感词之后,必须留下日志,标明命中的词、位置、用户 ID、时间戳,防止有人恶意刷词库后反查过滤逻辑;比如性能回归测试,每次更新词库后都要跑一遍基准脚本,对比 AC 自动机的平均耗时和 p99,一旦异常立刻定位。

如果你看完这篇打算自己动手重写过滤模块,我给你的建议是:不要一开始就搞双数组 Trie、不要上 Swoole,先把最简单的 AC 自动机用 PHP 数组实现跑通,把内存和耗时的基线数据测出来,再根据你的真实词库规模决定下一步优化。我写这篇文章时的初始版本就是一段 100 行的 PHP 类,没有依赖任何扩展,放到任何一台装有 PHP 7.4+ 的服务器上都能直接运行。先把核心逻辑掌握,后续的优化之路都是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询