11.2 敏感词与限速治理
上一节把消息正确投递到了收件人手里,但没有回答两个更现实的问题:这条消息该不该发、能发多快。世界频道一旦没人管,几秒钟就会被广告、谩骂和刷屏淹没;而「治理」这件事在 MMO 里有明确的工程边界——它必须便宜到能挂在每条消息的同步路径上,必须能在不重启的情况下换词表,还必须对正常玩家几乎无感。
本节做两级治理。第一级是内容:用 AC 自动机(Aho–Corasick)一次扫描找出文本里命中的所有敏感词,支持全角、大小写折叠与重叠命中,命中后按区间替换。第二级是频率:用「每玩家 × 每频道」的令牌桶限制发言速率,桶是独立的,世界频道的消耗不影响私聊。最后把禁言、限速、过滤串成一条顺序固定的三级管线,并说明为什么顺序不能换。
敏感词匹配:AC 自动机一次扫描
最朴素的做法是维护一个词表,对每条消息逐词 strings.Contains。它在词表小的时候甚至比自动机更快——因为 Go 的 strings.Contains 底层是 SIMD 加速的,而自动机每读一个字符都要做一次 map 查找。但它的问题在于复杂度随词表线性增长:运营把敏感词从几十条加到几千条之后,每条消息的代价就跟着涨几千倍。
AC 自动机的价值正在于此:它把所有词织成一棵 Trie,再用失败指针(fail link)把这棵树串起来,扫描一遍文本就能同时匹配所有词,代价与文本长度成正比,与词表规模无关。失败指针和 KMP 的 next 数组同源,只是从「一条链」推广到了「一棵树」。
规范化是另一个必须处理的细节:玩家会用全角字符、大小写混写来绕过滤。normalize 把全角 ASCII 折成半角、大写折成小写,而且必须是「一个 rune 进、一个 rune 出」,这样匹配结果的下标才能直接映射回原文,替换时不会错位。下面这段是 L1,go test 可以直接跑:
// file: internal/chat/filter.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import "sort"
// Filter 是基于 AC 自动机(Aho–Corasick)的多模式匹配器。
// 一次扫描就能找出文本里命中的所有敏感词,复杂度是 O(文本长 + 命中数)。
type Filter struct {
root *node
}
type node struct {
next map[rune]*node
fail *node
out []string // 走到本节点时命中的模式(含沿 fail 链继承的)
}
// Match 描述一次命中:Word 是规范形后的词,[Start, End) 是原文的 rune 区间。
type Match struct {
Word string
Start int
End int
}
// normalize 把字符折叠成比较用的规范形:全角转半角、大写转小写。
// 它必须「一个 rune 进、一个 rune 出」,匹配下标才能直接映射回原文。
func normalize(r rune) rune {
// 先折叠全角,再统一大小写,两步都必须是 1:1 的 rune 映射。
switch {
case r >= 0xFF01 && r <= 0xFF5E: // 全角 ASCII
r -= 0xFEE0
case r == 0x3000: // 全角空格
r = ' '
}
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
return r
}
func normRunes(s string) []rune {
rs := []rune(s)
for i, r := range rs {
rs[i] = normalize(r)
}
return rs
}
// NewFilter 用敏感词表构建自动机。空词与重复词会被忽略。
func NewFilter(words []string) *Filter {
root := &node{next: map[rune]*node{}}
for _, w := range words {
nw := normRunes(w)
if len(nw) == 0 {
continue
}
cur := root
for _, r := range nw {
child := cur.next[r]
if child == nil {
child = &node{next: map[rune]*node{}}
cur.next[r] = child
}
cur = child
}
cur.out = append(cur.out, string(nw))
}
// BFS 建失败指针:与 KMP 的 next 数组同源,只是从「一条链」扩到「一棵树」。
queue := make([]*node, 0, len(root.next))
for _, child := range root.next {
child.fail = root
queue = append(queue, child)
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for r, child := range cur.next {
f := cur.fail
for f != nil && f.next[r] == nil {
f = f.fail
}
if f == nil {
child.fail = root
} else {
child.fail = f.next[r]
}
child.out = append(child.out, child.fail.out...)
queue = append(queue, child)
}
}
return &Filter{root: root}
}
// Match 返回文本里命中的所有敏感词及其在原文中的 rune 下标区间 [Start, End)。
func (f *Filter) Match(text string) []Match {
rs := normRunes(text)
var out []Match
cur := f.root
for i, r := range rs {
for cur != f.root && cur.next[r] == nil {
cur = cur.fail
}
if n := cur.next[r]; n != nil {
cur = n
} else {
cur = f.root
}
for _, w := range cur.out {
out = append(out, Match{Word: w, Start: i - len([]rune(w)) + 1, End: i + 1})
}
}
return out
}
// Replace 把命中的区间整段替换成 mask。重叠区间只屏蔽一次。
func (f *Filter) Replace(text string, mask rune) string {
ms := f.Match(text)
if len(ms) == 0 {
return text
}
rs := []rune(text)
for _, sp := range mergeSpans(ms) {
for i := sp[0]; i < sp[1]; i++ {
rs[i] = mask
}
}
return string(rs)
}
// mergeSpans 把可能重叠的命中区间合并成互不相交的区间。
func mergeSpans(ms []Match) [][2]int {
spans := make([][2]int, 0, len(ms))
for _, m := range ms {
spans = append(spans, [2]int{m.Start, m.End})
}
sort.Slice(spans, func(i, j int) bool {
if spans[i][0] != spans[j][0] {
return spans[i][0] < spans[j][0]
}
return spans[i][1] < spans[j][1]
})
var merged [][2]int
for _, s := range spans {
if n := len(merged); n > 0 && s[0] <= merged[n-1][1] {
if s[1] > merged[n-1][1] {
merged[n-1][1] = s[1]
}
continue
}
merged = append(merged, s)
}
return merged
}
「自动机更快」这句话要说准确。本机实测(Apple M1 Pro,同机相对量级,绝对数值不可移植),固定一段约 430 字符的文本:
| 词表规模 | AC 一次扫描 | 朴素逐词 strings.Contains |
|---|---|---|
| 5 词 | 约 5.9 µs | 约 2.4 µs |
| 5005 词 | 约 5.7 µs | 约 132 µs |
结论很清楚:词表小的时候朴素做法反而快(少了几次 map 查找),但它的代价随词表线性增长,5000 词时已经慢 20 多倍;而 AC 的耗时几乎不随词表变化。真实运营的词表是几千条起步,所以自动机是默认选择。
限速:每玩家每频道的令牌桶
限速要解决的是「同一个人短时间内刷屏」。漏桶和令牌桶都能做,这里选令牌桶:rate 是每秒补充的令牌数,burst 是桶容量,也就是允许的瞬时突发。桶的粒度是「每玩家 × 每频道」——世界频道刷屏被限了,不应该连带把私聊也限掉。
实现上只有一个细节要小心:时间不能靠 time.Now() 直接算,否则测试没法稳定复现。把 now 做成可注入的函数,测试里就能把时钟捏在手里,精确断言「桶空后需要等多久」「补一个令牌后能再发一条」。桶的清理用惰性的 Sweep,而不是常驻的后台协程。下面这段是 L1:
// file: internal/chat/ratelimit.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import (
"math"
"sync"
"time"
)
type bucketKey struct {
Player PlayerID
Kind ChannelKind
}
type bucket struct {
tokens float64
last time.Time
}
// RateLimiter 是「每玩家 × 每频道」的令牌桶限速器。
// rate 是每秒补充的令牌数,burst 是桶容量(允许的瞬时突发)。
type RateLimiter struct {
mu sync.Mutex
rate float64
burst float64
buckets map[bucketKey]*bucket
now func() time.Time
}
func NewRateLimiter(rate, burst float64) *RateLimiter {
return &RateLimiter{
rate: rate,
burst: burst,
buckets: map[bucketKey]*bucket{},
now: time.Now,
}
}
// Allow 尝试从桶里取一个令牌。失败时返回还需等待的时长。
func (l *RateLimiter) Allow(p PlayerID, k ChannelKind) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
key := bucketKey{p, k}
b := l.buckets[key]
if b == nil {
b = &bucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
if elapsed := now.Sub(b.last).Seconds(); elapsed > 0 {
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
b.last = now
}
if b.tokens >= 1 {
b.tokens--
return true, 0
}
wait := time.Duration((1 - b.tokens) / l.rate * float64(time.Second))
return false, wait
}
// Sweep 丢弃空闲超过 idle 的桶,避免长期运行后 map 无限增长。
func (l *RateLimiter) Sweep(idle time.Duration) int {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
n := 0
for k, b := range l.buckets {
if now.Sub(b.last) > idle {
delete(l.buckets, k)
n++
}
}
return n
}
三级治理管线
把两级治理和上一节的禁言拼起来,就是一条顺序固定的管线:禁言 → 限速 → 过滤。顺序不能换,理由是代价:判禁言是一次 map 查,判限速是一次浮点运算,跑过滤是一次全文扫描。把最便宜的放前面,被禁言或被限速的消息就根本不会进入最贵的过滤。反过来,先过滤再限速,会让刷屏者白白触发成千上万次自动机扫描。
管线返回三种结论:Pass(原样放行)、Masked(命中敏感词,替换后放行)、Rejected(直接拒绝,不投递)。下面是一段 snippet,把这条管线串起来:
// snippet: internal/chat/moderator.go(三级治理管线,节选)
// Verdict 是一条消息经过治理管线后的处置结论。
type Verdict uint8
const (
VerdictPass Verdict = iota // 原样放行
VerdictMasked // 命中敏感词,替换后放行
VerdictRejected // 直接拒绝,不投递
)
// Moderate 返回处置结论、处理后的正文与拒绝原因。
func (m *Moderator) Moderate(p PlayerID, k ChannelKind, text string) (Verdict, string, string) {
m.mu.Lock()
until, muted := m.muted[p]
m.mu.Unlock()
// 第一级:禁言。最便宜,且与频道无关。
if muted && m.now().Before(until) {
return VerdictRejected, "", "muted until " + until.Format(time.RFC3339)
}
// 第二级:限速。桶空时直接拒绝,并告诉客户端还要等多久。
if ok, wait := m.Limiter.Allow(p, k); !ok {
return VerdictRejected, "", "rate limited, retry in " + wait.Round(time.Millisecond).String()
}
// 第三级:过滤。命中就替换后放行,而不是整条丢弃——正常玩家不该被误伤。
if ms := m.Filter.Match(text); len(ms) > 0 {
return VerdictMasked, m.Filter.Replace(text, '*'), "masked " + strconv.Itoa(len(ms)) + " words"
}
return VerdictPass, text, ""
}
过滤选择「替换后放行」而不是「整条拒绝」,是一个产品判断:整条拒绝会把「打金」这类词出现在正常句子里的消息一并拦掉,误伤率高;替换成 * 保留了对话可读性,同时让违规内容不可见。真正需要整条拒绝的是广告链接和刷屏,那交给限速和运营侧的关键词分级策略处理。
本节小结
两级治理的边界很清楚:内容问题用自动机,频率问题用令牌桶,两者都不该碰投递逻辑。要记住的两个反直觉结论是——自动机的优势随词表规模才显现,小词表下朴素匹配更快;管线顺序由代价决定,最便宜的判断必须放最前面。下一节离开聊天管线,去看另一类共享状态:好友关系图,以及它为什么必须保证双向一致。
阅读导航:上一节:11.1 频道模型与权限 · 下一节:11.3 好友关系与关系一致性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。