必须上Trie树并常驻内存:ThinkPHP的str_replace和preg_replace本质是暴力遍历,词库超300条或文本超500字时响应飙升,且无法处理最长匹配、UTF-8切分、热更新及Redis集成,需用容器单例绑定Trie树实现O(n)匹配。

ThinkPHP 里直接用 str_replace 或 preg_replace 做敏感词过滤,线上一压测就卡住——这不是配置问题,是算法选错了。必须上 Trie 树,且不能手写裸树,得借 ThinkPHP 的生命周期和容器机制把树常驻内存。
为什么 ThinkPHP 自带的 Str::replace 不适合敏感词过滤
它本质还是封装了 str_replace,对每个词做一次全文扫描。词库超 300 条、文本超 500 字时,响应时间直线上升;更麻烦的是,它完全不处理“法轮功”和“法轮”共存时的最长匹配优先问题,容易漏判或误判。ThinkPHP 的 Validate 规则里加正则也不行:regex:/法轮功|邪教/ 这种写法在词多时会触发 PCRE 编译瓶颈,而且无法动态加载词库。
- 每次请求都重建替换逻辑,没复用树结构
- 不支持 UTF-8 多字节字符的单字切分(如“笨蛋”被切成“”“”)
- 无法对接 Redis 缓存热词或灰度开关
在 ThinkPHP 中构建常驻 Trie 树的实操要点
别在控制器里每次 new 一个树对象。利用 think\Container 绑定单例,在应用启动时一次性加载词库并建树:
- 把敏感词文件(如
app/common/sensitive_words.txt)按行读取,每行一个词,用mb_split('//u', $line)切成 Unicode 字符数组 - 插入树时统一转小写(
mb_strtolower($word, 'UTF-8')),避免 “VIP” 和 “vip” 重复建分支 - 节点结构用关联数组即可:
['children' => [], 'is_end' => true, 'word' => '原词'],不要搞递归类封装 - 注册为容器服务:
Container::getInstance()->bind('SensitiveTrie', function () { return new SensitiveTrie(...); });
这样后续 anywhere 调用 app('SensitiveTrie')->match($text) 都走同一棵树,无 IO、无重复建树开销。
立即学习“PHP免费学习笔记(深入)”;
SensitiveTrie::match() 必须支持「最长匹配」和「位置记录」
业务不止要判断有没有敏感词,还要知道在哪、替换成什么。比如“法轮功组织”得命中“法轮功”,而不是只截出“法轮”。所以搜索逻辑不能一碰到 is_end 就返回,得走到不能走为止:
- 外层用
for ($i = 0; $i 遍历起始位置 - 内层从
$i开始沿树走,用mb_substr($text, $j, 1, 'UTF-8')取当前字符 - 每走一步检查是否
is_end,记录当前长度;走不动时取最后那个is_end对应的词 - 返回结果至少含
['has_sensitive' => true, 'matches' => [['pos' => 2, 'len' => 3, 'word' => '法轮功']]]
如何让 Trie 树支持热更新而不重启服务
不能等 FPM reload 或 Swoole restart 才生效。正确做法是:把词库存在 Redis 的 SET 或 HASH 里,加个版本号字段;Trie 实例内部缓存树结构 + 版本戳,每次 match() 前先查 Redis 版本,不一致就重建树并更新本地戳。
- Redis 键建议用
sensitive:trie:version和sensitive:trie:words - 重建树操作加
lock(用 Redis 的SETNX),防并发重建 - ThinkPHP 的
Cache::store('redis')可直接调用,不用另引客户端 - 注意:Swoole 环境下,每个 Worker 进程需独立维护自己的树副本,别共用引用
最易被忽略的一点:Trie 树本身不解决“同音字”“形近字”,那是预处理的事——得在进树前,用 strtr($text, ['发' => '法', '功' => '工']) 做一次映射,而不是往树里塞一堆变体分支。



















