一、过滤是一个分层漏斗
聊天过滤不是在消息末尾加一句“含敏感词”就结束了。真实系统要依次处理长度、频率、敏感词、链接、广告和账号信息,每一层的判断依据、误杀成本和处置动作都不同。把它们拆成独立层,才能单独调整阈值、单独统计命中率。
| 层次 | 判断依据 | 典型动作 | 误杀代价 |
|---|---|---|---|
| 长度与频率 | 字符数、单位时间条数 | 拒绝发送 | 低,提示明确 |
| 敏感词 | 词表匹配 | 拒绝、替换或转审 | 中,可能误伤正常表达 |
| 链接 | URL 形态、域名白名单 | 拒绝或降级为纯文本 | 中,玩家可能分享攻略 |
| 广告 | 联系方式、导流话术 | 转人工审核 | 高,需要证据 |
| 账号信息 | 身份证、手机号、银行卡 | 拒绝并告警 | 高,涉及隐私 |
客户端过滤只能作为体验优化,不能作为安全边界。客户端可以被改包、被脚本直接调用协议,甚至根本不显示输入框。所有规则必须在服务端再执行一次,客户端过滤只负责提前提示,减少无效请求。
长度和频率应该放在最前面。它们计算便宜、判断确定,能在进入词表和正则之前挡掉大部分刷屏。频率限制要按玩家和频道两个维度:按玩家防止单人刷屏,按频道防止大量玩家同时冲击同一个世界频道。
二、敏感词匹配:AC 自动机还是正则
词表小的时候,很多人用 strings.Contains 循环匹配,词表上千后每条消息要扫描上千次。正则看似优雅,把词拼成 a|b|c 确实只扫描一遍,但词表规模、特殊字符转义和回溯风险都会迅速失控。
| 方案 | 构建成本 | 单条匹配 | 词表更新 | 主要风险 |
|---|---|---|---|---|
| 逐词 Contains | 无 | O(词数 × 文本长度) | 简单 | 词表大后 CPU 高 |
| 合并正则 | 中 | 依赖引擎 | 需重新编译 | 回溯爆炸、转义错误 |
| AC 自动机 | O(总词长) | O(文本长度 + 命中数) | 整体重建 | 实现复杂、内存占用 |
| 前缀树逐字符 | O(总词长) | 最坏 O(文本长度 × 词长) | 可增量 | 长词多时退化 |
AC 自动机的优势是“一次扫描找出所有模式”,包括互相重叠的模式。它把词表构造成 Trie,再用失败指针在匹配失败时跳到最长后缀,避免从头重扫。对聊天这种短文本、高 QPS、词表相对稳定的场景,构建成本可以摊薄到启动阶段,运行时只做线性扫描。
正则并非不能用。域名、手机号这类结构化规则适合正则,因为模式数量少、边界清晰。推荐的做法是分工:固定词表走 AC 自动机,结构化规则走少量预编译正则,两者都不要在每条消息里动态拼接。
三、变体对抗:大小写、全半角、拼音与拆字
只做精确匹配的词表很容易被绕过。玩家会把字母改成全角、把中文拆成偏旁、用拼音首字母、插空格或标点。过滤层要根据游戏的语言环境选择归一化策略,而不是假设玩家会老老实实输入标准写法。
| 变体 | 例子 | 归一化手段 | 局限 |
|---|---|---|---|
| 大小写 | BadWord | 统一转小写 | 无 |
| 全角字母 | BAD | 全角转半角 | 只覆盖 ASCII 区 |
| 插入分隔符 | 外-挂 | 去掉空白和常见标点 | 可能误伤正常断句 |
| 拼音 | waigua | 拼音映射 | 多音字和缩写歧义 |
| 拆字 | 亻圭 | 偏旁组合表 | 维护成本高 |
| 谐音 | 歪挂 | 同音词表 | 需要持续运营 |
归一化越激进,误杀越多。把标点全部去掉后,“价格-1”和“价格1”会被当成同一个词;把拼音全展开后,正常英文单词也可能命中。合理做法是分级:确定安全的变体(大小写、全角)默认开启;分隔符和拼音作为可配置项,按频道风险等级启用;拆字和谐音只进入人工审核队列,不直接拒绝。
本文的实现只处理大小写和全角,这是最安全的一层。它不能解决所有绕过,但不会引入新的误杀,适合作为第一版服务端过滤。
归一化后的文本只用于匹配,用于展示和审核的仍是原文。不要把归一化结果写回消息体,否则玩家看到的文字会变样,申诉时也无法核对原话。
四、先发后审还是先审后发
审查时机决定了玩家体验和运营成本。先审后发安全,但每条消息都要等审核结果,聊天基本不可用;先发后审流畅,但违规内容已经扩散,只能事后删除和处罚。大多数游戏采用混合策略。
| 策略 | 延迟 | 违规扩散 | 适用 |
|---|---|---|---|
| 先审后发 | 高 | 无 | 系统公告、交易行描述 |
| 先发后审 | 低 | 有 | 普通聊天、世界频道 |
| 命中即拦、其余先发 | 低 | 少量 | 敏感词明确命中的消息 |
| 灰名单先审 | 中 | 低 | 有前科或新注册账号 |
混合策略的关键是“明确命中直接拦,模糊命中转审核”。AC 自动机给出确定命中时,拒绝或替换;广告识别、账号信息这类概率判断,不应该直接封禁,而应该进入审核队列并保留原文、上下文和时间。审核员看到的是证据,不是一句“疑似违规”。
无论选择哪种时机,都要保留“拦截原因”和“规则版本”。同一句话在不同版本下可能命中不同词,没有版本号就无法解释差异。
五、误杀、申诉与词表热更新
误杀不可避免。玩家讨论游戏剧情时提到某个词,或者用正常句子包含了一个短词,都可能被拦。系统必须提供申诉入口,并且申诉要能关联到具体消息 ID、命中的词和当时的词表版本。没有这三样,运营无法判断是规则问题还是玩家狡辩。
词表热更新要保证一致性。不能在遍历旧词表匹配到一半时把结构替换掉,否则同一条消息可能用两套规则各判断一半。推荐把整个匹配器做成不可变对象,更新时构建新对象,再用原子指针整体切换;正在处理的消息继续使用旧版本,新消息使用新版本。
| 更新方式 | 一致性 | 生效速度 | 风险 |
|---|---|---|---|
| 原地修改词表 | 差 | 立即 | 匹配中途看到半新半旧 |
| 加锁重建 | 好 | 较慢 | 重建期间阻塞匹配 |
| 原子切换不可变对象 | 好 | 快 | 需要额外内存 |
| 重启进程 | 最好 | 最慢 | 影响在线玩家 |
版本号也要落在日志里。一条消息被拦时记录词表版本,事后才能复现“当时为什么命中”。否则词表更新后再查,可能已经找不到对应规则,申诉处理会变成各说各话。
六、失败场景:一条正则在聊天高峰卡死
某项目把所有敏感词拼成一个巨大的正则,其中有 .* 和可选分组。平时测试没有问题,活动期间一条包含大量近似前缀的消息触发了回溯,单个匹配耗时从微秒级涨到数百毫秒。聊天服务是单线程消费队列,积压迅速上涨,最后整个频道消息延迟几分钟。
这类问题的可怕之处是测试环境很难复现。测试消息短、词表小、并发低,回溯路径没有走深。线上一条精心构造的消息就能放大。修复不是把正则改短一点,而是换掉匹配模型:固定词表用 AC 自动机,结构化规则用少量独立正则,并给每条消息设置匹配超时或长度上限。
同样的思路适用于其他过滤规则。广告识别如果依赖多层嵌套正则,也会在长文本上退化。把规则拆小、限制输入长度、对每条规则单独计时,才能在出问题时快速定位是哪一层变慢。
七、限频、链接与账号信息怎么落地
敏感词只是过滤的一部分。工程上更容易被忽略的是限频和结构化信息,它们的规则简单,但一旦缺失,后果往往比一句脏话严重。
| 规则 | 判断方式 | 处置 | 注意 |
|---|---|---|---|
| 玩家限频 | 令牌桶,按秒补充 | 超限拒绝并提示 | 重连不能重置额度 |
| 频道限频 | 频道级令牌桶 | 全频道降级 | 防止群体刷屏 |
| 重复内容 | 同玩家短窗口相似度 | 丢弃后几条 | 避免误伤连续战术指令 |
| 链接 | URL 正则 + 域名白名单 | 非白名单转纯文本 | 不要把整条消息删除 |
| 联系方式 | 手机号、QQ、微信模式 | 转人工审核 | 保留上下文 |
| 账号信息 | 身份证、银行卡校验位 | 拒绝并告警 | 不在日志里存明文 |
限频要在进入聊天队列之前完成。队列的作用是削峰,不是承接无限流量;如果限频放在消费者里,消息已经占用了队列容量,其他玩家仍会受到影响。令牌桶的额度也不要因为重连而重置,否则玩家可以通过反复重连绕过限制。
链接处理建议“降级”而不是“拒绝”。玩家分享攻略链接是正常需求,直接把整条消息删掉会引发大量申诉。更温和的做法是把非白名单 URL 替换成不可点击的文本,同时在服务端记录原始地址;白名单按游戏运营需要维护,不要做成全局开放。
账号信息属于高敏感规则,命中后不应该把原文写进普通日志。可以记录消息 ID、玩家 ID、命中类型和脱敏后的片段,原文进入受限的审核存储。这样既方便取证,也避免客服或运维在日志系统里看到玩家的身份证号。
八、Go 实现:AC 自动机的构建与匹配
下面的 Matcher 在构建阶段把词表转成 Trie,并用广度优先建立失败指针,把后缀模式的命中合并到当前状态的 outputs。匹配时只扫描一遍文本,重叠模式会同时返回。
// file: ch10/chat/filter.go
// tier: L1
// verify: go test ./ch10/chat/
package chat
import (
"strings"
"unicode"
)
// acNode 是 AC 自动机的一个状态,outputs 保存以该状态结尾的敏感词。
type acNode struct {
children map[rune]*acNode
fail *acNode
outputs []string
}
// Matcher 用 AC 自动机做多模式匹配,一次扫描即可找出全部命中。
type Matcher struct {
root *acNode
// size 是去重后的模式数,便于外部观察词表规模。
size int
}
func newACNode() *acNode {
return &acNode{children: make(map[rune]*acNode)}
}
// normalize 统一大小写和全角字符,避免用大小写或全角字母绕过词表。
func normalize(s string) string {
var b strings.Builder
for _, r := range s {
if r >= 0xFF01 && r <= 0xFF5E {
r -= 0xFEE0
}
b.WriteRune(unicode.ToLower(r))
}
return b.String()
}
func NewMatcher(words []string) *Matcher {
root := newACNode()
seen := make(map[string]struct{})
for _, word := range words {
key := normalize(strings.TrimSpace(word))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
cur := root
for _, r := range key {
next, ok := cur.children[r]
if !ok {
next = newACNode()
cur.children[r] = next
}
cur = next
}
cur.outputs = append(cur.outputs, word)
}
// 广度优先构建失败指针,把后缀模式合并到当前状态的 outputs。
queue := make([]*acNode, 0, len(root.children))
for _, child := range root.children {
child.fail = root
queue = append(queue, child)
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for r, child := range cur.children {
f := cur.fail
for f != nil {
if next, ok := f.children[r]; ok {
child.fail = next
break
}
f = f.fail
}
if child.fail == nil {
child.fail = root
}
child.outputs = append(child.outputs, child.fail.outputs...)
queue = append(queue, child)
}
}
return &Matcher{root: root, size: len(seen)}
}
// Find 返回文本中命中的敏感词,按结束位置排序;重叠模式会同时返回。
func (m *Matcher) Find(text string) []string {
cur := m.root
var hits []string
for _, r := range normalize(text) {
for cur != m.root {
if _, ok := cur.children[r]; ok {
break
}
cur = cur.fail
}
if next, ok := cur.children[r]; ok {
cur = next
} else {
cur = m.root
}
hits = append(hits, cur.outputs...)
}
return hits
}
func (m *Matcher) Contains(text string) bool {
return len(m.Find(text)) > 0
}
func (m *Matcher) Size() int {
return m.size
}
构建阶段有两个容易写错的地方。第一,根节点的直接子节点失败指针必须指向根;第二,构建某个节点的失败指针时,要沿父节点的失败链向上找,找到第一个拥有相同字符子节点的状态,找不到才回根。漏掉第二步,重叠模式不会被追加到 outputs,测试里的 abc 与 bc 就只能命中一个。
匹配阶段同样要沿失败链回退。当前状态没有对应字符时,不能直接回到根,而要先看失败指针指向的状态有没有这个字符;否则像 bc 这样作为后缀出现的模式会被漏掉。
normalize 只做全角转半角和转小写,两项都是一对一映射,所以匹配位置与原文位置仍然对应。后续如果要支持去分隔符,需要额外维护位置映射,否则命中位置会偏移,替换和审核定位都会错。
九、测试覆盖哪些边界
filter_test.go 用四个测试固定匹配器契约:
- 普通命中按结束位置返回,
Contains对干净文本返回 false。 - 重叠模式同时返回:
abc与bc匹配abcd,法轮与法轮功匹配法轮功。 - 大小写、混合大小写和全角字母都能命中同一个词。
- 词表去重和去空白后
Size正确,空字符串不会进入自动机。 - 干净文本不产生命中,避免
Contains在正常聊天里误报。 - 同一文本重复出现同一模式时,返回结果包含每一次命中,调用方可以按需去重。
这些测试只覆盖匹配,不覆盖限频、链接和广告规则。那些规则更适合独立模块和独立测试,避免一个巨大的过滤函数同时承担所有职责。
十、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch10/chat && go vet ./ch10/chat/ && go test ./ch10/chat/ -count=1
ok gspractice/ch10/chat 0.233s
小结
过滤是一个分层漏斗:先用长度和频率挡掉刷屏,再用敏感词和结构化规则判断内容,最后把模糊命中交给人工审核。客户端过滤只能优化体验,服务端必须完整执行。固定词表优先用 AC 自动机,一次扫描找出全部命中,包括重叠模式;正则只留给少量结构化规则。归一化要分级,越激进越容易误杀。词表更新要用不可变对象整体切换,并记录版本号,让拦截结果可复现、申诉可处理。
常见错误与边界
- 只做客户端过滤,改包或直接调协议即可绕过。
- 用一个大正则拼所有敏感词,长文本触发回溯,聊天消费者被卡死。
- 词表原地增删,匹配过程中看到半新半旧规则,同一消息判断不一致。
- 归一化去掉所有标点和空格,正常断句被误判为违规。
- 命中敏感词就永久封号,没有申诉、上下文和词表版本记录。
- 只匹配中文原词,不处理大小写和全角,英文与全角变体直接绕过。
- 把广告识别的高概率结果当确定违规,误杀正常分享攻略的玩家。
- 词表热更新没有版本号,事后无法复现某条消息为什么被拦。
- 把归一化后的文本写回消息体,玩家看到的文字被改写且无法核对原话。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。