SLG 游戏的聊天频道是玩家社区的命脉,也是运营的噩梦——一条未过滤的敏感词可能导致投诉、下架甚至法律风险。slg-go 的聊天过滤系统经历了三次迭代:最初用 strings.Contains 暴力匹配,后来换正则,最终落地到 Aho-Corasick 自动机 。这篇记录最终方案的实现和踩坑。
为什么不用 strings.Contains? 初版代码很简单:
1 2 3 4 5 6 7 func filter (content string , words []string ) string { for _, w := range words { content = strings.ReplaceAll(content, w, "***" ) } return content }
问题在于:100 个敏感词 × 1000 条消息 = 10 万次字符串扫描。词库涨到 500+ 时,单条消息过滤延迟从 0.1ms 涨到 2ms,1000 人频道就是 2 秒。
正则好一点,但 regexp.Compile 构建 500 个 pattern 的启动时间太长,而且 Go 的正则引擎不支持预编译多模式自动机。
Aho-Corasick:O(n) 多模式匹配 Aho-Corasick(AC 自动机)是 1975 年贝尔实验室提出的算法,核心思想是把所有敏感词构建成一棵 Trie 树 ,再通过 BFS 计算 fail 指针 (类似 KMP 的 next 数组),实现一次扫描匹配所有模式 。
时间复杂度:构建 O(Σ|w|),匹配 O(n + m),其中 n 是文本长度,m 是匹配数。和词库大小无关 。
1 2 3 4 5 6 7 8 9 10 11 12 type ACAutomaton struct { root *acNode mu sync.RWMutex } type acNode struct { children map [rune ]*acNode fail *acNode depth int isEnd bool }
用 rune 而不是 byte 做 key,因为中文是多字节的。"傻逼" 两个字各占一个 rune,直接用 map[rune]*acNode 天然支持中英文混合。
构建:Trie 树 + BFS fail 指针 第一步:插入所有敏感词到 Trie 树
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func (ac *ACAutomaton) Build(words []string ) { ac.mu.Lock() defer ac.mu.Unlock() ac.root = newACNode() for _, word := range words { w := lowerWord(word) if w == "" { continue } node := ac.root for _, r := range w { if _, ok := node.children[r]; !ok { child := newACNode() child.depth = node.depth + 1 node.children[r] = child } node = node.children[r] } node.isEnd = true } ac.buildFail() }
第二步:BFS 计算 fail 指针
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func (ac *ACAutomaton) buildFail() { queue := make ([]*acNode, 0 , 256 ) for _, child := range ac.root.children { child.fail = ac.root queue = append (queue, child) } for len (queue) > 0 { current := queue[0 ] queue = queue[1 :] for r, child := range current.children { failNode := current.fail for failNode != ac.root { if _, ok := failNode.children[r]; ok { break } failNode = failNode.fail } if n, ok := failNode.children[r]; ok && n != child { failNode = n } child.fail = failNode queue = append (queue, child) } } }
fail 指针的作用:当 Trie 树上某条路径匹配失败时,不需要从头开始,而是跳到 fail 指针指向的节点继续匹配——这就是 KMP 思想在多模式匹配上的推广。
匹配:一次扫描,标记所有命中位置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 func (ac *ACAutomaton) Filter(content string , mask rune ) string { ac.mu.RLock() defer ac.mu.RUnlock() runes := []rune (content) maskArr := make ([]bool , len (runes)) lower := make ([]rune , len (runes)) for i, r := range runes { if r >= 'A' && r <= 'Z' { lower[i] = r + 32 } else { lower[i] = r } } node := ac.root for i, r := range lower { for node != ac.root { if _, ok := node.children[r]; ok { break } node = node.fail } if n, ok := node.children[r]; ok { node = n } for matched := node; matched != ac.root; matched = matched.fail { if !matched.isEnd { continue } start := i - matched.depth + 1 for j := start; j <= i; j++ { maskArr[j] = true } } } for i := range runes { if maskArr[i] { runes[i] = mask } } return string (runes) }
关键设计:用 maskArr 标记数组,先扫描一遍标记所有命中位置,最后统一替换。这比边扫描边替换更安全——不会出现替换后字符数变化导致的偏移错误。
热更新:SHA-256 指纹去重 词库不能写死在代码里。运维需要随时更新敏感词,不能重启服务。filter_reload.go 实现了热更新机制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 type chatFilterReloader struct { source chatFilterWordSource lastFingerprint string lastReloadAt time.Time } func (r *chatFilterReloader) reloadIfChanged(hub *chat.Hub, force bool ) error { words, fingerprint, err := r.source.LoadWords() if err != nil { return err } if !force && fingerprint == r.lastFingerprint { return nil } count := hub.ReplaceSensitiveWords(words) r.lastFingerprint = fingerprint r.lastReloadAt = time.Now() return nil }
三种数据源,支持 Fallback 链:
数据源
场景
指纹计算
文件
本地开发、小规模部署
SHA-256(文件内容)
Nacos
生产环境、多实例同步
SHA-256(配置内容)
Fallback
Nacos 不可用时降级到文件
primary → fallback
自动轮询间隔默认 10 秒,通过 ctx.Done() 控制退出:
1 2 3 4 5 6 7 8 9 10 11 12 go func () { ticker := time.NewTicker(opts.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: reloader.reloadIfChanged(hub, false ) } } }()
UTF-8 处理:rune 级别的大小写归一化 Go 的 strings.ToLower 对 ASCII 没问题,但对中文无效(中文没有大小写)。AC 自动机里用了自写的 lowerWord:
1 2 3 4 5 6 7 8 9 10 11 12 func lowerWord (word string ) string { var result []rune for _, r := range word { if r == ' ' || r == '\t' { continue } if r >= 'A' && r <= 'Z' { result = append (result, r+32 ) } else { result = append (result, r) } } return string (result) }
只处理 ASCII 大小写,中文原样保留。空白字符(空格、Tab)在插入时直接去除,防止 "f u c k" 绕过过滤。
性能数据
指标
strings.Contains
Aho-Corasick
500 词库 × 100 字符消息
~2ms
~0.05ms
构建时间(500 词)
0
~0.3ms
内存占用(500 词)
~20KB
~150KB
AC 自动机用 150KB 内存换来了 40 倍的匹配速度提升。对于 SLG 聊天这种高频场景,完全值得。
总结 整个过滤系统分三层:
算法层 :Aho-Corasick 自动机,O(n) 多模式匹配
管理层 :ChatFilter 封装了增删改查、sync.RWMutex 并发安全
运维层 :chatFilterReloader 支持文件/Nacos 双数据源 + SHA-256 指纹去重
敏感词过滤不是”写了就行”的功能,是需要持续迭代的基础设施。词库要热更新,性能要 O(n),并发要安全,三者缺一不可。
你们项目里敏感词过滤是怎么做的?遇到过绕过过滤的 trick 吗?