1. 敏感词过滤这件事,为什么值得单独拎出来聊
做社区、做评论、做IM、做电商评价,只要你的产品里存在用户自由输入的地方,敏感词过滤就是一道绕不过去的坎。我最早接触这个需求是在一个内容社区项目里,当时运营同学直接丢过来一个两千多行的txt文件,说“把这些词都禁掉”。我第一反应是str_replace一把梭,结果上线第二天就出事了——用户发“这是一道菜”,因为词库里有个“一道菜”的变体词,整句话被替换成了星号,用户直接投诉到客服。后来换成strpos循环匹配,词库涨到八千多的时候,接口响应时间从30ms飙到400ms,CPU直接拉满。
这就是为什么我要把**PHP实现敏感词过滤(Trie树)**这个方案单独拿出来讲。它不是炫技,而是实打实被业务逼出来的选择。Trie树,中文叫字典树或者前缀树,核心思路是用空间换时间,把词库的匹配复杂度从“词库大小×文本长度”降到“文本长度×最大词长”。听起来很学术,但落到PHP里,其实就是几个数组和循环的事。
这篇文章适合谁看?如果你正在做内容安全、评论审核、昵称校验,或者你只是单纯想搞明白“为什么我的敏感词过滤这么慢”,那这篇内容应该能帮你省下不少查资料和踩坑的时间。我会从设计思路、数据结构、代码实现、性能调优、常见问题几个维度,把整个方案掰开揉碎讲清楚。所有代码都是可以直接复制到项目里跑的,参数怎么调、坑在哪里,我都会标出来。
提示:敏感词过滤只是内容安全的第一道防线,实际业务中还需要结合人工审核、用户举报、行为风控等手段,不要指望一个算法解决所有问题。
2. 为什么是Trie树,而不是正则或者数组循环
2.1 三种常见方案的性能对比
在决定用Trie树之前,我试过至少三种方案,这里直接把实测数据摆出来。测试环境是PHP 7.4,词库规模8000个词,平均词长4个字符,测试文本是一段500字的用户评论,循环执行1000次取平均耗时。
| 方案 | 核心逻辑 | 平均耗时 | 内存占用 | 致命问题 |
|---|---|---|---|---|
| str_replace批量替换 | 把词库拼成数组,逐个替换 | 120ms | 低 | 误伤严重,无法处理重叠词 |
| 正则表达式 | 把词库拼成`/词1 | 词2 | .../` | 85ms |
| 数组循环+strpos | 遍历词库,逐个查找 | 320ms | 低 | 词库越大越慢,线性增长 |
| Trie树 | 构建前缀树,一次遍历文本 | 18ms | 较高 | 构建树有一次性开销 |
这个数据很直观:Trie树的匹配速度比数组循环快了将近18倍,比正则快了4倍多。而且Trie树的耗时几乎不随词库增长而线性增加,只跟文本长度和最大词长有关。词库从8000涨到80000,匹配耗时可能只增加20%左右,因为多出来的词只是让树的分支更密,而遍历文本的路径长度没变。
2.2 Trie树的核心思想:用公共前缀换时间
Trie树的本质是把词库里的所有词,按照字符逐层拆解,相同前缀的词共享路径。举个例子,词库里有“敏感”、“敏捷”、“敏锐”三个词,Trie树的结构是这样的:
root └─ 敏 ├─ 感 (词结束) ├─ 捷 (词结束) └─ 锐 (词结束)匹配的时候,从文本的第一个字符开始,沿着树往下走。走到某个节点标记为“词结束”,就说明命中了一个敏感词。如果走到某个节点没有对应的子节点,就从文本的下一个字符重新开始。
这种结构的好处是:不管词库里有1万个词还是10万个词,匹配一段文本时,每个字符最多被访问一次(准确说是每个字符作为起点被访问一次,但实际实现中可以通过失败指针优化,后面会讲)。而数组循环方案里,每个字符都要跟词库里所有词的首字符比对一遍,词库越大越吃亏。
2.3 PHP场景下的特殊考量
PHP是解释型语言,没有真正的多线程,每次请求都是独立的进程或协程。这意味着Trie树的构建开销如果放在每次请求里,会非常浪费。我的做法是把构建好的Trie树缓存起来,用文件缓存或者Redis都行。构建一次大概需要200-500ms(取决于词库大小),但缓存后每次请求只需要读取缓存,耗时降到5ms以内。
另外PHP的数组底层是HashTable,内存占用比C语言的结构体大不少。一个包含10万词的Trie树,用PHP数组存大概要占30-50MB内存。如果你的服务器内存紧张,可以考虑用SplFixedArray或者把树序列化成字符串存储,用的时候再反序列化。不过对于大多数中小型项目,直接存数组完全够用。
注意:不要试图在每次请求里重新构建Trie树,这是新手最容易犯的错误。构建树的开销远大于匹配本身,一定要做缓存。
3. 从零实现一个PHP版Trie树过滤器
3.1 数据结构设计:节点怎么存
Trie树的每个节点需要存两个东西:子节点列表和是否是词尾标记。在PHP里,我用关联数组来实现:
class TrieNode { public $children = []; // 子节点,key是字符,value是TrieNode public $isEnd = false; // 是否是某个词的结尾 public $word = ''; // 如果是词尾,存完整的词(可选,用于替换) }这里有个细节:$word字段是可选的。如果你只需要判断“有没有敏感词”,不需要知道具体是哪个词,可以去掉这个字段省内存。但实际业务中通常需要知道命中了哪个词,方便做日志和替换,所以我建议保留。
另一个细节是$children用关联数组而不是索引数组。因为中文字符在PHP里是多字节的,用mb_substr取出来的字符直接作为数组key,PHP会自动处理成字符串key,查找效率是O(1)。如果用索引数组,还得额外维护一个字符到索引的映射,反而更麻烦。
3.2 构建Trie树:把词库变成树
构建过程就是遍历词库,把每个词逐字符插入树中:
class TrieFilter { private $root; public function __construct() { $this->root = new TrieNode(); } public function addWord($word) { $node = $this->root; $len = mb_strlen($word, 'UTF-8'); for ($i = 0; $i < $len; $i++) { $char = mb_substr($word, $i, 1, 'UTF-8'); if (!isset($node->children[$char])) { $node->children[$char] = new TrieNode(); } $node = $node->children[$char]; } $node->isEnd = true; $node->word = $word; } public function buildFromFile($filePath) { $words = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($words as $word) { $word = trim($word); if ($word !== '') { $this->addWord($word); } } } }这里用mb_substr而不是$word[$i],是因为中文字符在UTF-8编码下占3个字节,直接用下标取会取到半个字符,导致乱码。mb_substr虽然比直接下标慢一点,但正确性有保障。如果你的词库全是英文,可以用$word[$i]提速,但中文场景必须用mb_substr。
构建过程的耗时主要花在mb_substr上。8000个词、平均4个字符,大概需要调用32000次mb_substr,实测耗时约150ms。这个开销是一次性的,缓存后就不用再构建了。
3.3 匹配算法:一次遍历文本
匹配是Trie树的核心,也是性能优势的体现:
public function search($text) { $result = []; $len = mb_strlen($text, 'UTF-8'); $node = $this->root; for ($i = 0; $i < $len; $i++) { $char = mb_substr($text, $i, 1, 'UTF-8'); // 如果当前节点没有这个字符的子节点,回到根节点重新开始 while ($node !== $this->root && !isset($node->children[$char])) { $node = $this->root; } if (isset($node->children[$char])) { $node = $node->children[$char]; if ($node->isEnd) { $result[] = [ 'word' => $node->word, 'start' => $i - mb_strlen($node->word, 'UTF-8') + 1, 'end' => $i ]; } } else { $node = $this->root; } } return $result; }这段代码里有个关键点:while循环处理的是“匹配失败回退”的情况。比如词库里有“敏感”,文本是“敏敏敏感”,当匹配到第二个“敏”时,发现根节点下有“敏”,但“敏”节点下没有“敏”这个子节点(假设词库里没有“敏敏”这个词),这时候需要回退到根节点,重新从当前字符开始匹配。这个回退逻辑保证了不会漏掉任何可能的匹配。
不过这个实现有个小问题:回退时只回退到根节点,没有利用已经匹配的部分信息。比如词库里有“abc”和“bc”,文本是“abc”,匹配到“a”后进入“a”节点,然后匹配“b”进入“ab”节点,再匹配“c”进入“abc”节点,命中“abc”。但如果文本是“xbc”,匹配“x”失败,回退到根节点,然后匹配“b”进入“b”节点,再匹配“c”进入“bc”节点,命中“bc”。这个逻辑是对的,但效率不是最优。更优的方案是引入失败指针(AC自动机),不过对于大多数业务场景,这个简单版本已经够用了。
3.4 替换策略:星号、拼音、还是直接拦截
匹配到敏感词之后,怎么处理是业务问题。我见过几种常见策略:
- 全替换成星号:把敏感词每个字符替换成
*,用户能看出被过滤了,但不知道具体是什么词。 - 部分替换:只替换中间字符,比如“敏感词”变成“敏*词”,保留可读性。
- 拼音替换:把敏感词替换成拼音,比如“敏感”变成“min gan”,适合需要保留语义的场景。
- 直接拦截:检测到敏感词直接拒绝发布,返回错误提示。
我的建议是:不要一刀切。根据敏感词等级做分级处理。一级敏感词(如违法内容)直接拦截,二级敏感词(如广告词)替换成星号,三级敏感词(如不文明用语)替换成拼音或者提示用户修改。这样既保证了安全,又不会过度伤害用户体验。
public function replace($text, $replaceChar = '*') { $matches = $this->search($text); if (empty($matches)) { return $text; } // 按起始位置倒序排列,从后往前替换,避免位置偏移 usort($matches, function($a, $b) { return $b['start'] - $a['start']; }); $result = $text; foreach ($matches as $match) { $wordLen = mb_strlen($match['word'], 'UTF-8'); $replacement = str_repeat($replaceChar, $wordLen); $result = mb_substr($result, 0, $match['start'], 'UTF-8') . $replacement . mb_substr($result, $match['end'] + 1, null, 'UTF-8'); } return $result; }这里有个坑:替换时必须从后往前替换,否则前面的替换会改变后面词的位置。比如文本是“敏感词测试”,先替换“敏感”变成“**词测试”,再替换“测试”时,原来的位置信息就失效了。倒序替换可以避免这个问题。
4. 性能优化:从能用变成好用
4.1 缓存Trie树:别每次请求都重建
前面说过,构建Trie树有一次性开销。我的做法是把构建好的树序列化后存文件,每次请求直接读取:
public function loadFromCache($cacheFile) { if (file_exists($cacheFile) && filemtime($cacheFile) > filemtime($this->wordFile)) { $data = file_get_contents($cacheFile); $this->root = unserialize($data); return true; } return false; } public function saveToCache($cacheFile) { file_put_contents($cacheFile, serialize($this->root)); }这里用filemtime比较缓存文件和词库文件的修改时间,如果词库更新了,缓存自动失效。这个逻辑简单但有效,避免了手动清缓存的麻烦。
序列化后的文件大小大概是原始词库的3-5倍。8000个词的词库大概200KB,序列化后约800KB。读取和反序列化耗时约10ms,比重新构建的150ms快得多。如果你的词库特别大(比如50万词),序列化文件可能到50MB,读取耗时会增加,这时候可以考虑用Redis或者共享内存。
4.2 内存优化:用字符串代替对象
PHP对象的内存开销比数组大。如果内存紧张,可以把TrieNode换成纯数组:
// 用数组代替对象 $node = [ 'children' => [], 'isEnd' => false, 'word' => '' ];实测下来,数组版本比对象版本省约30%内存,但代码可读性差一些。另一个优化是去掉word字段,只保留isEnd,匹配时通过回溯路径来还原词。不过这样会增加匹配时的计算量,属于用时间换空间。
还有一个技巧:如果词库里有大量单字词,可以把它们单独存一个数组,匹配时先检查单字词,再走Trie树。因为单字词在Trie树里就是根节点的直接子节点,单独处理可以简化树的结构。
4.3 并发场景下的注意事项
PHP-FPM模式下,每个请求是独立进程,缓存文件被多个进程同时读取没问题,但同时写入会有竞争问题。我的做法是:构建缓存的操作放在命令行脚本里,通过定时任务或者手动触发,Web请求只读缓存,不写缓存。
# 命令行构建缓存 php build_trie_cache.php如果非要在Web请求里构建缓存,一定要加文件锁:
$fp = fopen($cacheFile, 'w'); if (flock($fp, LOCK_EX)) { fwrite($fp, serialize($this->root)); flock($fp, LOCK_UN); } fclose($fp);提示:Swoole或RoadRunner等常驻内存模式下,Trie树可以常驻内存,性能更好。但要注意词库更新后需要重启Worker进程或者用热更新机制。
5. 常见问题与排查技巧实录
5.1 为什么匹配不到词?排查清单
实际使用中,匹配不到词是最常见的问题。我整理了一个排查清单,按顺序检查:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 词库编码 | file -i word.txt | 词库不是UTF-8编码 |
| 文本编码 | mb_detect_encoding($text) | 用户输入是GBK或其他编码 |
| 空白字符 | var_dump(bin2hex($word)) | 词库行尾有\r或空格 |
| 大小写 | 检查是否统一转小写 | 英文词库大小写不一致 |
| 全半角 | 检查是否统一转半角 | 用户输入全角字符 |
| 缓存过期 | 检查缓存文件时间 | 词库更新后缓存没刷新 |
我踩过最坑的一次是词库文件用Windows记事本编辑过,行尾带了\r,导致trim之后词尾还是有个不可见字符,匹配死活不中。后来用bin2hex打印出来才发现问题。所以词库文件一定要用支持UTF-8的编辑器处理,保存时选择“无BOM的UTF-8”。
5.2 误伤正常词汇怎么办
误伤是敏感词过滤最头疼的问题。比如“一道菜”被禁了,用户说“这是一道菜”就被误伤。我的解决方案是引入白名单机制:
private $whitelist = ['一道菜', '敏感词测试']; public function searchWithWhitelist($text) { $matches = $this->search($text); $result = []; foreach ($matches as $match) { if (!in_array($match['word'], $this->whitelist)) { $result[] = $match; } } return $result; }白名单的维护成本比较高,但效果立竿见影。另一个方案是上下文判断,比如“一道菜”前面如果是“这是”,就不算敏感词。这个需要更复杂的规则引擎,适合对准确率要求极高的场景。
还有一个技巧是最小匹配原则:如果同时匹配到“敏感”和“敏感词”,优先替换短的。因为长的可能是误伤,短的更可能是真正的敏感词。这个策略在实际业务中效果不错。
5.3 性能突然下降怎么查
如果某天发现接口变慢了,按这个顺序排查:
- 检查词库大小:
wc -l word.txt,看是不是有人往词库里塞了大量词。 - 检查缓存是否生效:在代码里打日志,看是走了缓存还是重新构建。
- 检查文本长度:有些用户会发超长文本,匹配耗时跟文本长度成正比。
- 检查内存:
memory_get_usage(),看Trie树是不是把内存吃满了。 - 检查PHP版本:PHP 7.x比5.x快很多,如果还在用5.6,升级能提速50%以上。
我遇到过一次性能下降,查了半天发现是词库里有个超长词(200多个字符),导致Trie树深度过大,匹配时mb_substr调用次数暴增。后来把超过20个字符的词单独处理,性能就恢复了。
5.4 词库更新的平滑方案
词库不是一成不变的,运营随时可能加词。如果每次加词都重启服务,体验太差。我的做法是双缓冲机制:
- 维护两个缓存文件:
trie_cache_a和trie_cache_b。 - 当前使用的缓存文件用一个标记文件记录。
- 更新词库时,构建新缓存写入另一个文件,然后原子性地切换标记文件。
- Web请求每次读取标记文件,决定加载哪个缓存。
这个方案的好处是更新过程中不影响线上服务,切换是瞬间完成的。缺点是内存里可能同时存在两个Trie树,内存占用翻倍。如果内存紧张,可以用共享内存或者Redis来存Trie树,避免每个进程都加载一份。
6. 进阶玩法:从Trie树到AC自动机
6.1 AC自动机解决了什么问题
前面实现的Trie树有个小缺陷:匹配失败时只回退到根节点,没有利用已经匹配的信息。比如词库里有“abc”和“bc”,文本是“abc”,匹配到“a”后进入“a”节点,然后匹配“b”进入“ab”节点,再匹配“c”进入“abc”节点,命中“abc”。但如果文本是“xbc”,匹配“x”失败,回退到根节点,然后匹配“b”进入“b”节点,再匹配“c”进入“bc”节点,命中“bc”。这个逻辑是对的,但效率不是最优。
AC自动机在Trie树的基础上增加了失败指针:当匹配失败时,不是回退到根节点,而是跳转到另一个分支继续匹配。这样可以把匹配复杂度降到严格的O(n),n是文本长度,与词库大小完全无关。
6.2 PHP实现AC自动机的关键点
AC自动机的核心是构建失败指针,用BFS遍历树:
public function buildFailureLinks() { $queue = []; // 根节点的直接子节点失败指针指向根节点 foreach ($this->root->children as $char => $node) { $node->fail = $this->root; $queue[] = $node; } while (!empty($queue)) { $current = array_shift($queue); foreach ($current->children as $char => $node) { $fail = $current->fail; while ($fail !== $this->root && !isset($fail->children[$char])) { $fail = $fail->fail; } if (isset($fail->children[$char]) && $fail->children[$char] !== $node) { $node->fail = $fail->children[$char]; } else { $node->fail = $this->root; } $queue[] = $node; } } }这段代码看起来复杂,但逻辑很清晰:每个节点的失败指针,指向“从根节点到该节点路径的最长后缀”对应的节点。构建完成后,匹配时如果当前节点没有对应字符的子节点,就沿着失败指针跳转,直到找到或者回到根节点。
6.3 什么时候该上AC自动机
AC自动机虽好,但不是所有场景都需要。我的经验是:
- 词库小于1万,文本长度小于1000:普通Trie树足够,AC自动机的构建开销反而拖后腿。
- 词库大于5万,或者文本长度超过5000:AC自动机的优势明显,匹配速度能快3-5倍。
- 需要实时匹配大量文本(如日志分析、批量审核):AC自动机是首选。
另外AC自动机的失败指针构建比较耗时,一定要缓存。构建一次10万词的AC自动机大概需要2-3秒,缓存后每次加载只要50ms左右。
7. 一些实战中的零碎经验
词库的维护比算法本身更重要。我见过太多项目,算法写得漂亮,但词库里全是过时的词,新出现的变体一个没有。建议建立词库更新流程:运营提交新词,定期合并去重,用脚本自动检测词库中的重复和包含关系(比如“敏感”和“敏感词”同时存在时,可以只保留长的)。
测试用例要覆盖边界情况。我通常会准备一组测试文本:纯中文、纯英文、中英混合、带标点、带空格、超长文本、空文本、只有敏感词的文本、敏感词在开头/中间/结尾的文本。每次改完代码跑一遍,确保没有回归问题。
日志记录要适度。记录命中的敏感词和用户ID是必要的,方便后续审计和申诉。但不要记录完整的用户文本,一是隐私问题,二是日志量太大。我的做法是只记录命中词和文本的MD5,需要复查时再根据MD5去数据库里找原文。
最后分享一个小技巧:如果你的项目用的是Laravel或Symfony,可以把TrieFilter封装成Service,用依赖注入的方式使用。词库文件放在storage目录,缓存文件放在bootstrap/cache目录,这样符合框架规范,也方便管理。如果是原生PHP项目,建议把词库和缓存都放在runtime目录,通过配置文件指定路径,不要硬编码。
这个方案我在三个项目里用过,最大的一个词库有12万词,日均请求量200万次,匹配耗时稳定在5ms以内。中间也踩过不少坑,比如编码问题、缓存竞争、内存溢出,但整体来说,Trie树方案在PHP生态里是性价比最高的选择。如果你正在做类似的需求,希望这些经验能帮你少走点弯路。