ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP敏感词过滤优化:AC自动机实现毫秒级响应

PHP敏感词过滤优化:AC自动机实现毫秒级响应 做 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读CAB节点没有C子节点于是通过 fail 跳到B节点在B节点发现C子节点走到BC节点命中BC。这个过程中文本ABC只被从左到右读了一遍没有回退。3.4 完整匹配流程手动模拟看一遍匹配开始时我们先站在根节点。每读一个字符先看当前节点有没有对应子节点有就走过去没有就沿着 fail 指针反复跳直到找到可以继续走的节点或回到根。到了新节点后还要检查这个节点以及它 fail 链上的所有节点有没有是“某个词结尾”的节点有就把词记录下来。很多人容易忽略最后一步。因为 AC 自动机不仅要匹配当前路径还要匹配所有通过 fail 链“隐含”出现的后缀词。比如词库里有北京和京城文本是北京城读北走根 -北读京走到北京节点命中北京读城北京没有城子节点通过 fail 跳到京城路径的京节点然后走到京城节点命中京城。一次扫描检出两个词这就是 AC 自动机的魔力。4. 完整 PHP 实现从零写一个 AcFilter4.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 自动机基础版没有预计算outputAC 自动机优化版本章上面的完整实现。每次测试前都把词库加载好AC 自动机的构建耗时单独统计不混在线匹配耗时里。5.2 结果和结论方案构建耗时单次搜索耗时(平均)相对暴力暴力mb_strpos循环0约 68 ms1xAC 自动机基础版约 42 ms约 0.8 ms约 85xAC 自动机优化版约 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 自动机的实现思路已经能稳稳扛住常规的敏感词过滤压力了。
返回列表