《MMO 服务器开发》11.2 敏感词与限速治理

敏感词与限速是聊天管线的两级治理:前者挡内容,后者挡频率,两者都必须便宜到能挂在每条消息的同步路径上。本节用 AC 自动机实现一次扫描命中所有敏感词、用每玩家每频道的令牌桶限速,再把禁言、限速、过滤串成一条顺序固定、可 go test 的三级管线,并给出本机实测的匹配开销对照。

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 好友关系与关系一致性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标