聊天是最便宜的社交,也是最贵的扇出
《裂隙战场》的世界频道在版本更新后几分钟内涌进大量组队信息,局内则主要靠标记和快捷语;《群岛纪元》的联盟频道是指挥系统,一条集结令要在一秒内到达几百人;《泡泡工坊》只有房间内的快捷表情,几乎没有持久频道。三款产品都要处理“一句话发给谁”,但扇出规模差了几个数量级。
最早的实现把所有频道塞进一个广播循环:世界频道每句话遍历全部在线玩家,公会频道也走同一条路径,只是多一次过滤。结果世界频道一热闹,公会和私聊一起延迟。另一个问题是限速只按频道计:玩家在世界频道被限后切到战区频道继续刷,令牌桶各自重置。
本节把聊天拆成三件事:频道决定扇出边界,限速决定单位时间能说多少,审核决定哪些话根本不该发出去。三者顺序不能乱,否则限速会挡住正常玩家,审核会变成事后删帖。
频道是扇出边界,不是标签
| 频道 | 成员来源 | 扇出规模 | 顺序要求 | 审核策略 |
|---|---|---|---|---|
| 世界 | 当前分线全部玩家 | 最大,数千到数万 | 弱,允许抽样 | 前置轻量规则加异步复核 |
| 战区 | 同战区玩家 | 中,数百到数千 | 中,按战区序号 | 前置规则加抽样 |
| 公会 | 公会成员 | 小,几十到几百 | 强,公会内有序 | 前置规则,重点保留证据 |
| 私聊 | 双方角色 | 最小,一对一 | 强 | 举报后复核为主 |
频道 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 榜单模型与分段 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。