《游戏服务端编程实践》10.1.3 过滤与审查机制

聊天过滤不能只靠客户端,也不能把所有规则塞进一个大正则。本篇拆解长度与频率、敏感词、链接和广告的过滤层次,比较 AC 自动机与正则,讨论先发后审、误杀申诉和词表热更,并给出可测试的 Go 匹配实现,测试覆盖命中、重叠、大小写与全半角等四类变体。

一、过滤是一个分层漏斗

聊天过滤不是在消息末尾加一句“含敏感词”就结束了。真实系统要依次处理长度、频率、敏感词、链接、广告和账号信息,每一层的判断依据、误杀成本和处置动作都不同。把它们拆成独立层,才能单独调整阈值、单独统计命中率。

层次判断依据典型动作误杀代价
长度与频率字符数、单位时间条数拒绝发送低,提示明确
敏感词词表匹配拒绝、替换或转审中,可能误伤正常表达
链接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 自动机,一次扫描找出全部命中,包括重叠模式;正则只留给少量结构化规则。归一化要分级,越激进越容易误杀。词表更新要用不可变对象整体切换,并记录版本号,让拦截结果可复现、申诉可处理。

常见错误与边界

  • 只做客户端过滤,改包或直接调协议即可绕过。
  • 用一个大正则拼所有敏感词,长文本触发回溯,聊天消费者被卡死。
  • 词表原地增删,匹配过程中看到半新半旧规则,同一消息判断不一致。
  • 归一化去掉所有标点和空格,正常断句被误判为违规。
  • 命中敏感词就永久封号,没有申诉、上下文和词表版本记录。
  • 只匹配中文原词,不处理大小写和全角,英文与全角变体直接绕过。
  • 把广告识别的高概率结果当确定违规,误杀正常分享攻略的玩家。
  • 词表热更新没有版本号,事后无法复现某条消息为什么被拦。
  • 把归一化后的文本写回消息体,玩家看到的文字被改写且无法核对原话。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist