《游戏服务端架构·核心系统与契约》8.3 聊天频道、审核与限速

《裂隙战场》《群岛纪元》《泡泡工坊》的聊天频道在扇出、隔离和治理上完全不同。本节比较世界、战区、公会与私聊四种频道的路由边界,说明令牌桶、跨频道去重和全局桶如何拦住刷屏,并讨论发送前、发送后与异步复核三种审核位置及误封申诉,附可运行的频道路由与审核钩子实现。

聊天是最便宜的社交,也是最贵的扇出

《裂隙战场》的世界频道在版本更新后几分钟内涌进大量组队信息,局内则主要靠标记和快捷语;《群岛纪元》的联盟频道是指挥系统,一条集结令要在一秒内到达几百人;《泡泡工坊》只有房间内的快捷表情,几乎没有持久频道。三款产品都要处理“一句话发给谁”,但扇出规模差了几个数量级。

最早的实现把所有频道塞进一个广播循环:世界频道每句话遍历全部在线玩家,公会频道也走同一条路径,只是多一次过滤。结果世界频道一热闹,公会和私聊一起延迟。另一个问题是限速只按频道计:玩家在世界频道被限后切到战区频道继续刷,令牌桶各自重置。

本节把聊天拆成三件事:频道决定扇出边界,限速决定单位时间能说多少,审核决定哪些话根本不该发出去。三者顺序不能乱,否则限速会挡住正常玩家,审核会变成事后删帖。

频道是扇出边界,不是标签

频道成员来源扇出规模顺序要求审核策略
世界当前分线全部玩家最大,数千到数万弱,允许抽样前置轻量规则加异步复核
战区同战区玩家中,数百到数千中,按战区序号前置规则加抽样
公会公会成员小,几十到几百强,公会内有序前置规则,重点保留证据
私聊双方角色最小,一对一强举报后复核为主

频道 ID 必须由服务端签发,客户端只提交“我要发到哪个频道”的意图。若客户端可以自造公会 ID 或私聊对象,攻击者就能订阅不属于自己的频道,或者在私聊里冒充系统消息。加入频道时校验成员关系,发送时再校验一次,因为加入和发送之间可能已经被踢出公会。

世界频道的扇出不能和公会频道共用一条同步路径。世界消息允许合并、抽样和延迟,公会与私聊不允许;把两者放在同一个队列里,世界频道的峰值会把私聊一起拖慢。常见的做法是每个频道一个发送队列,世界频道单独限流并允许丢弃过期消息。

限速:两个桶,一个顺序

限速用令牌桶:每个发送者有一个全局桶,每个“发送者 + 频道”还有一个频道桶。全局桶先扣,频道桶后扣。这样玩家从世界频道切到战区频道时,频道桶是满的,但全局桶已经空了,仍然会被拦住。

参数是产品决策,不是越严越好。以《群岛纪元》为例(示例值):全局桶容量 5、每秒回填 0.5 个令牌;公会频道桶容量 5、每秒回填 1 个;世界频道桶容量 3、每秒回填 0.2 个。容量决定能连续说几句,回填速度决定长期频率。

重复消息单独判断,而且要跨频道。玩家把同一句话依次发到世界、战区、公会,频道桶各自有令牌,但文本相同;去重窗口按发送者记录最近一条文本,窗口内重复直接拒绝。去重放在扣令牌之前,重复消息不消耗额度,也不会进入审核。

审核放在限速之后是刻意的:被拒绝的消息仍然消耗令牌。否则攻击者可以用大量必然被拒的文本探测审核规则,却不付任何频率代价。这个取舍会让误判的正常玩家多等几秒,所以申诉通道必须能返还额度。

三种审核位置

位置延迟覆盖误判代价适用
发送前同步最低增加一次判断只覆盖明确规则直接阻断,体验差辱骂、广告、敏感词等硬规则
发送后同步增加一次回写可覆盖上下文已送达,需撤回高风险频道、私聊举报
异步复核不增加发送延迟覆盖率最高可能已扩散世界频道抽样、申诉复核

我们的选择是组合:发送前只做轻量规则,命中就拒绝;边界内容标记为 Review,照常投递但进入异步队列;已经产生的举报走事后复核。审核模型本身不在本节讨论范围,这里只关心钩子放在哪、返回值如何影响投递。

误封与申诉的关键不是“再审一次”,而是保留证据:原文、频道、时间、命中规则、当时的限速状态。处罚要可逆,优先用禁言而不是封号;申诉成功时返还被误扣的令牌和禁言时长。没有证据链的复审只能靠玩家描述,最后往往变成谁声音大谁赢。

可运行片段:频道路由、双桶与审核钩子

下面的 internal/chatmod 把成员校验、跨频道去重、全局与频道双桶、审核钩子收在一条 Publish 路径里。它不实现具体的审核规则,只定义 Moderator 接口和三种决策,方便替换。

// file: internal/chatmod/chatmod.go
// tier: L1
// verify: go test ./internal/chatmod/
package chatmod

import (
	"errors"
	"fmt"
	"sort"
	"sync"
	"time"
)

// Kind is a channel scope. Each scope has a different fanout and isolation
// boundary; the router never derives one scope from another.
type Kind int

const (
	World Kind = iota + 1
	Zone
	Guild
	Private
)

// Channel identifies one scope. World normally uses the fixed ID "global";
// zone, guild and private use their own identifier.
type Channel struct {
	Kind Kind
	ID   string
}

func (c Channel) key() string { return fmt.Sprintf("%d/%s", c.Kind, c.ID) }

// Message is an inbound chat line.
type Message struct {
	Sender  string
	Channel Channel
	Text    string
	At      time.Time
}

// Decision is the synchronous moderation result.
type Decision int

const (
	Allow Decision = iota
	Review
	Reject
)

// Moderator runs before fanout. Reject drops the line; Review delivers it and
// queues it for delayed inspection; Allow delivers normally.
type Moderator interface {
	Inspect(Message) Decision
}

// Config holds the two rate buckets and the duplicate window. Burst applies per
// sender globally and per sender/channel; refill is in tokens per second.
type Config struct {
	Burst           int
	RefillPerSecond float64
	DuplicateWindow time.Duration
	MaxTextRunes    int
}

// Delivery is the fanout decision returned to the caller.
type Delivery struct {
	Message    Message
	Recipients []string
	Review     bool
}

var (
	ErrConfig      = errors.New("chatmod: invalid config")
	ErrEmpty       = errors.New("chatmod: empty sender, text or channel id")
	ErrChannel     = errors.New("chatmod: unknown channel kind")
	ErrTooLong     = errors.New("chatmod: text exceeds limit")
	ErrNotMember   = errors.New("chatmod: sender is not a member")
	ErrNoRecipient = errors.New("chatmod: no recipient in channel")
	ErrDuplicate   = errors.New("chatmod: duplicate message")
	ErrRateLimited = errors.New("chatmod: rate limited")
	ErrRejected    = errors.New("chatmod: rejected by moderation")
)

type bucket struct {
	tokens float64
	last   time.Time
}

type rateSet struct {
	burst   float64
	rate    float64
	buckets map[string]*bucket
}

func newRateSet(burst int, rate float64) *rateSet {
	return &rateSet{burst: float64(burst), rate: rate, buckets: make(map[string]*bucket)}
}

func (r *rateSet) allow(key string, now time.Time) bool {
	b := r.buckets[key]
	if b == nil {
		b = &bucket{tokens: r.burst, last: now}
		r.buckets[key] = b
	}
	if elapsed := now.Sub(b.last).Seconds(); elapsed > 0 {
		b.tokens += elapsed * r.rate
		if b.tokens > r.burst {
			b.tokens = r.burst
		}
		b.last = now
	}
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

type stamp struct {
	text string
	at   time.Time
}

// Router owns membership, rate state and the moderation hook. The lock covers
// one Publish call; it is not held across network sends.
type Router struct {
	mu      sync.Mutex
	cfg     Config
	mod     Moderator
	members map[string]map[string]struct{}
	global  *rateSet
	scoped  *rateSet
	last    map[string]stamp
	pending int
}

type allowAll struct{}

func (allowAll) Inspect(Message) Decision { return Allow }

// New validates the config and returns a router. A nil moderator means allow.
func New(cfg Config, mod Moderator) (*Router, error) {
	if cfg.Burst < 1 || cfg.RefillPerSecond <= 0 || cfg.DuplicateWindow <= 0 || cfg.MaxTextRunes < 1 {
		return nil, ErrConfig
	}
	if mod == nil {
		mod = allowAll{}
	}
	return &Router{
		cfg:     cfg,
		mod:     mod,
		members: make(map[string]map[string]struct{}),
		global:  newRateSet(cfg.Burst, cfg.RefillPerSecond),
		scoped:  newRateSet(cfg.Burst, cfg.RefillPerSecond),
		last:    make(map[string]stamp),
	}, nil
}

// Join adds role to a channel. Joining is idempotent.
func (r *Router) Join(role string, ch Channel) error {
	if role == "" || ch.ID == "" {
		return ErrEmpty
	}
	if ch.Kind < World || ch.Kind > Private {
		return ErrChannel
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	key := ch.key()
	if r.members[key] == nil {
		r.members[key] = make(map[string]struct{})
	}
	r.members[key][role] = struct{}{}
	return nil
}

// Publish checks membership, duplicates, both rate buckets and moderation in
// that order. The global bucket is spent before the scoped one, so switching
// channels cannot buy extra messages.
func (r *Router) Publish(m Message) (Delivery, error) {
	if m.Sender == "" || m.Text == "" || m.Channel.ID == "" {
		return Delivery{}, ErrEmpty
	}
	if m.Channel.Kind < World || m.Channel.Kind > Private {
		return Delivery{}, ErrChannel
	}
	if len([]rune(m.Text)) > r.cfg.MaxTextRunes {
		return Delivery{}, ErrTooLong
	}
	if m.At.IsZero() {
		m.At = time.Now()
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	members := r.members[m.Channel.key()]
	if _, ok := members[m.Sender]; !ok {
		return Delivery{}, ErrNotMember
	}
	recipients := make([]string, 0, len(members))
	for role := range members {
		if role != m.Sender {
			recipients = append(recipients, role)
		}
	}
	if len(recipients) == 0 {
		return Delivery{}, ErrNoRecipient
	}
	sort.Strings(recipients)
	if prev, ok := r.last[m.Sender]; ok && prev.text == m.Text && m.At.Sub(prev.at) <= r.cfg.DuplicateWindow {
		return Delivery{}, ErrDuplicate
	}
	if !r.global.allow(m.Sender, m.At) {
		return Delivery{}, ErrRateLimited
	}
	if !r.scoped.allow(m.Sender+"|"+m.Channel.key(), m.At) {
		return Delivery{}, ErrRateLimited
	}
	switch r.mod.Inspect(m) {
	case Reject:
		return Delivery{}, ErrRejected
	case Review:
		r.pending++
		r.last[m.Sender] = stamp{text: m.Text, at: m.At}
		return Delivery{Message: m, Recipients: recipients, Review: true}, nil
	default:
		r.last[m.Sender] = stamp{text: m.Text, at: m.At}
		return Delivery{Message: m, Recipients: recipients}, nil
	}
}

// PendingReviews is the number of delivered lines waiting for async review.
func (r *Router) PendingReviews() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.pending
}

Publish 的顺序是:参数校验、成员校验、收件人计算、重复判断、全局桶、频道桶、审核。成员校验放在限速之前,避免为幽灵请求扣令牌;收件人为空直接返回,不进入审核;重复判断在扣令牌之前,重复文本不消耗额度。

Review 仍然返回 Delivery,只是把 Review 置为 true 并递增待复核计数。真正的异步队列由调用方在投递后写入,Router 不持有网络连接,也不在锁内做发送。这个边界让限速和审核可以单测,不需要起网关。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/chatmod
$ go vet ./internal/chatmod/...
$ go test -count=1 ./internal/chatmod/
ok  	gsarchlab/internal/chatmod

本机 go test 退出码为 0,6 个表驱动测试通过;gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
世界频道扇出过大网关抖动,私聊延迟独立队列、抽样、丢弃过期消息
换频道绕过限速跨频道刷屏全局桶先扣,频道桶后扣
同一文本换频道重复广告按发送者跨频道去重
审核同步阻塞发送延迟升高前置只做轻量规则,重复核异步
误封无证据申诉无法复核保留原文、命中规则、时间与限速状态
私聊被第三方订阅隐私泄露频道 ID 服务端签发,发送时再校验成员
审核拒绝不扣令牌规则被免费探测拒绝也扣令牌,申诉返还

常见错误

第一,把频道当成消息上的一个标签。标签只影响展示,不改变扇出;频道必须决定成员集合、发送队列和限速键。

第二,只做频道级限速。玩家切一次频道就重置额度,跨频道刷屏几乎无成本;全局桶是必需品。

第三,把审核放在发送之后就算完成。事后删除无法阻止已经扩散的内容,硬规则必须在发送前拦住。

第四,审核和限速共用一个队列。审核慢会拖慢发送,发送高峰又会饿死审核;两条路径要分开。

第五,申诉只改状态,不留证据。没有原文和命中规则,复审无法判断原处罚是否合理,也无法发现规则退化。

小结

聊天系统的三条边界是:频道决定扇出,限速决定频率,审核决定是否投递。世界频道可以抽样和延迟,公会与私聊不行;限速先全局后频道,重复判断跨频道;审核前置拦硬规则,边界内容投递后复核,拒绝也要计费。

到这里,社交与公会的三件事——关系、资产、消息——都落在同一组原则上:把隐含假设写成显式契约,把并发窗口用版本或票券暴露出来,把失败路径当成一等公民。下一节离开社交,进入竞争:榜单如何分段、积分如何结算,才能让头部玩家和长尾玩家都觉得自己在进步。

阅读导航:上一节:8.2 公会、权限与仓库 · 下一节:9.1 榜单模型与分段 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查