多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP敏感词过滤性能优化:从暴力匹配到AC自动机毫秒级响应

PHP敏感词过滤性能优化:从暴力匹配到AC自动机毫秒级响应 搞内容审核、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 字暴力正则平均耗时 158msAC 自动机平均耗时 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.5ms22ms158msAC 自动机 filter0.4ms0.6ms0.9msAC 自动机 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 TrieDAT它用两个数组存储转移表空间利用率极高是工程界处理大词库的标配方案。但 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 的服务器上都能直接运行。先把核心逻辑掌握后续的优化之路都是水到渠成的事。
返回列表