一、群聊的完整路径
一条公会消息要经过五跳:客户端发到网关,网关带上玩家身份转发给聊天服,聊天服校验后发布到 Broker,各网关从 Broker 收到广播,最后按本地连接推给客户端。任何一跳都不能假定“下一跳一定在线”。
| 跳 | 负责什么 | 失败时的表现 |
|---|---|---|
| 客户端 → 网关 | 建立连接、携带凭证 | 发送失败,客户端本地提示 |
| 网关 → 聊天服 | 身份校验、限流、转发 | 返回错误码,消息未产生 |
| 聊天服 → Broker | 生成序号、发布频道 | 发布失败,整条消息不投递 |
| Broker → 各网关 | 广播到订阅该频道的实例 | 未订阅的网关收不到,属预期 |
| 网关 → 客户端 | 按本地会话推送 | 单连接失败,不影响其他玩家 |
聊天服本身不要保存“谁在线”的权威状态。在线状态属于网关,聊天服只保存成员关系和离线消息。两者混在一起,网关重启后就会出现幽灵在线。
二、成员列表从哪里来
公会成员可能来自公会服务、关系服务或数据库。聊天服启动时全量拉一次,之后靠成员变更事件增量维护,是常见做法。
| 来源 | 一致性 | 延迟 | 风险 |
|---|---|---|---|
| 每次发送实时查询 | 强 | 高,热公会打爆数据库 | 聊天延迟随成员数上升 |
| 启动全量加载 | 启动时强 | 变更后过期 | 新成员收不到、退会仍收到 |
| 缓存加变更事件 | 最终一致 | 秒级 | 事件丢失需要补偿 |
| 只信网关在线表 | 弱 | 实时 | 离线成员永远收不到 |
推荐“权威服务 + 本地缓存 + 版本号”:缓存带版本,事件带版本,发现版本跳跃就重新拉取。不要用 TTL 很长的缓存兜底,成员退会后仍能收到公会消息是明确的越权。
三、在线推送与离线消息
广播只对“成员且在线”的连接推送;成员但离线的,写入该玩家的离线队列。非成员既不入队也不推送。
| 策略 | 玩家体验 | 存储成本 | 适用 |
|---|---|---|---|
| 在线推送 + 离线队列 | 上线能看到近期消息 | 中 | 公会、私聊 |
| 只在线推送 | 离线消息全丢 | 低 | 世界频道、临时房间 |
| 离线队列无上限 | 不会丢,但会爆 | 高 | 不推荐 |
| 离线只存最近 N 条 | 有上限,可控 | 低 | 大多数项目 |
离线队列必须有上限和过期时间,并按玩家维度而不是频道维度计数。一个玩家加入两百个频道,如果每个频道各存五百条,上线补发会瞬间打满带宽。登录补发时要按时间排序、分批推送,并允许客户端按序号去重。
离线消息和重连恢复不是一回事:前者补的是“你不在线时别人说了什么”,后者补的是“你在战斗里断线后状态如何”。重连恢复的细节可以对照 /game-server-in-action-833/,不要用聊天离线队列去承担战斗状态。
四、发言限制:长度、频率与重复
| 限制 | 建议口径 | 只做表面校验的后果 |
|---|---|---|
| 长度 | 按 Unicode 字符数,去首尾空白 | 中文按字节算被误杀 |
| 频率 | 滑动窗口或令牌桶,按玩家维度 | 刷屏拖垮广播与客户端 |
| 重复 | 客户端消息 ID + TTL 去重 | 重发导致同句话出现多次 |
| 内容 | 敏感词与链接策略 | 合规风险 |
长度用 utf8.RuneCountInString,不要用 len:一个汉字三字节,按字节限制会让中文玩家只能发三分之一的字数。频率限制要放在发布之前,否则被限流的消息已经进了 Broker。重复抑制的键应包含发送者,避免两个玩家碰巧用了同一个客户端消息 ID。
五、失败场景:广播给全服
第一个故障是把“频道广播”实现成“遍历全部在线玩家”。公会消息发给了所有在线玩家,客户端还得自行过滤。在线人数一多,单条消息的循环成本随全服在线数增长,聊天服 CPU 直接被打满。
第二个故障是离线队列无上限。某玩家半年未登录,积压几十万条公会消息,登录补发时网关内存暴涨。正确做法是队列封顶、超出丢最旧,并把丢弃数量记录下来用于运营观察。
第三个故障是成员缓存过期。玩家已经退会,缓存仍认为他是成员,于是继续收到内部消息。修复不能只靠缩短 TTL,而要让退会事件使缓存失效,并在推送前用版本号做一次轻量校验。
六、Go 实现:GroupChat
GroupChat 把成员表、在线连接、离线队列、限频窗口和去重表放在同一个结构里。Send 先做成员与内容校验,再发布到 Broker 供其他网关消费,最后对本地在线成员直接推送;Bind 在玩家上线时按顺序补发离线消息。
// file: ch10/hub/group.go
// tier: L1
// verify: go test ./ch10/hub/
package hub
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
)
var (
ErrNoGroup = errors.New("group chat: empty group id")
ErrNotMember = errors.New("group chat: sender is not a member")
ErrEmptyText = errors.New("group chat: empty text")
ErrTextTooLong = errors.New("group chat: text too long")
ErrRateLimited = errors.New("group chat: rate limited")
)
// Limits 控制单条长度、发言频率、离线队列深度和去重窗口。
type Limits struct {
MaxTextRunes int
Burst int
Window time.Duration
OfflineCap int
DedupeTTL time.Duration
}
func DefaultLimits() Limits {
return Limits{MaxTextRunes: 200, Burst: 5, Window: 2 * time.Second, OfflineCap: 50, DedupeTTL: 5 * time.Minute}
}
// ChatMessage 的 Seq 由聊天服分配,客户端消息 ID 只用于去重。
type ChatMessage struct {
GroupID string `json:"group"`
SenderID uint64 `json:"sender"`
ClientMsgID string `json:"client_msg_id"`
Text string `json:"text"`
Seq uint64 `json:"seq"`
At time.Time `json:"at"`
}
// MemberLink 是网关到客户端的下行通道,Push 必须非阻塞或自带异步队列。
type MemberLink interface {
Push(ChatMessage)
}
type SendRequest struct {
ClientMsgID string
Text string
}
type DeliveryReport struct {
Seq uint64
Online int
Offline int
Duplicate bool
}
type rateWindow struct {
start time.Time
count int
}
type seenEntry struct {
expires time.Time
seq uint64
}
// GroupChat 组合成员表、在线连接、离线队列和 Broker。
type GroupChat struct {
broker *Broker
limits Limits
now func() time.Time
mu sync.Mutex
members map[string]map[uint64]struct{}
online map[uint64]MemberLink
offline map[uint64][]ChatMessage
dropped map[uint64]int
rates map[uint64]*rateWindow
seen map[string]seenEntry
seq uint64
}
func NewGroupChat(b *Broker, limits Limits) *GroupChat {
return &GroupChat{
broker: b,
limits: limits,
now: time.Now,
members: make(map[string]map[uint64]struct{}),
online: make(map[uint64]MemberLink),
offline: make(map[uint64][]ChatMessage),
dropped: make(map[uint64]int),
rates: make(map[uint64]*rateWindow),
seen: make(map[string]seenEntry),
}
}
func groupChannel(groupID string) string { return "chat:guild:" + groupID }
// Join 校验组频道名后把玩家加入成员表,重复加入是幂等的。
func (g *GroupChat) Join(groupID string, playerIDs ...uint64) error {
if groupID == "" {
return ErrNoGroup
}
if err := ValidateChannel(groupChannel(groupID)); err != nil {
return err
}
g.mu.Lock()
defer g.mu.Unlock()
if g.members[groupID] == nil {
g.members[groupID] = make(map[uint64]struct{})
}
for _, pid := range playerIDs {
g.members[groupID][pid] = struct{}{}
}
return nil
}
func (g *GroupChat) Leave(groupID string, playerID uint64) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.members[groupID], playerID)
}
// Bind 绑定在线通道,并按顺序补发积压的离线消息。
func (g *GroupChat) Bind(playerID uint64, link MemberLink) int {
g.mu.Lock()
defer g.mu.Unlock()
g.online[playerID] = link
pending := g.offline[playerID]
delete(g.offline, playerID)
for _, msg := range pending {
link.Push(msg)
}
return len(pending)
}
func (g *GroupChat) Unbind(playerID uint64) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.online, playerID)
}
// Send 校验成员、长度、去重和限频后发布到 Broker,并投递给在线成员。
func (g *GroupChat) Send(groupID string, senderID uint64, req SendRequest) (DeliveryReport, error) {
if groupID == "" {
return DeliveryReport{}, ErrNoGroup
}
if err := ValidateChannel(groupChannel(groupID)); err != nil {
return DeliveryReport{}, err
}
text := strings.TrimSpace(req.Text)
if text == "" {
return DeliveryReport{}, ErrEmptyText
}
if n := utf8.RuneCountInString(text); n > g.limits.MaxTextRunes {
return DeliveryReport{}, fmt.Errorf("%w: %d > %d", ErrTextTooLong, n, g.limits.MaxTextRunes)
}
g.mu.Lock()
defer g.mu.Unlock()
now := g.now()
if _, ok := g.members[groupID][senderID]; !ok {
return DeliveryReport{}, ErrNotMember
}
key := ""
if req.ClientMsgID != "" {
key = fmt.Sprintf("%d:%s", senderID, req.ClientMsgID)
if entry, ok := g.seen[key]; ok && now.Before(entry.expires) {
return DeliveryReport{Seq: entry.seq, Duplicate: true}, nil
}
}
if !g.allow(senderID, now) {
return DeliveryReport{}, ErrRateLimited
}
g.seq++
msg := ChatMessage{
GroupID: groupID,
SenderID: senderID,
ClientMsgID: req.ClientMsgID,
Text: text,
Seq: g.seq,
At: now,
}
payload, err := json.Marshal(msg)
if err != nil {
return DeliveryReport{}, err
}
if _, err := g.broker.Publish(groupChannel(groupID), payload); err != nil {
return DeliveryReport{}, err
}
if key != "" {
g.seen[key] = seenEntry{expires: now.Add(g.limits.DedupeTTL), seq: msg.Seq}
}
report := DeliveryReport{Seq: msg.Seq}
for pid := range g.members[groupID] {
if link, ok := g.online[pid]; ok {
link.Push(msg)
report.Online++
continue
}
g.enqueueOffline(pid, msg)
report.Offline++
}
return report, nil
}
func (g *GroupChat) allow(playerID uint64, now time.Time) bool {
w := g.rates[playerID]
if w == nil || now.Sub(w.start) >= g.limits.Window {
g.rates[playerID] = &rateWindow{start: now, count: 1}
return true
}
if w.count >= g.limits.Burst {
return false
}
w.count++
return true
}
func (g *GroupChat) enqueueOffline(playerID uint64, msg ChatMessage) {
q := append(g.offline[playerID], msg)
if len(q) > g.limits.OfflineCap {
drop := len(q) - g.limits.OfflineCap
q = append([]ChatMessage(nil), q[drop:]...)
g.dropped[playerID] += drop
}
g.offline[playerID] = q
}
// Offline 返回玩家当前的离线消息副本。
func (g *GroupChat) Offline(playerID uint64) []ChatMessage {
g.mu.Lock()
defer g.mu.Unlock()
return append([]ChatMessage(nil), g.offline[playerID]...)
}
// DroppedOffline 返回因超出 OfflineCap 被丢弃的消息数。
func (g *GroupChat) DroppedOffline(playerID uint64) int {
g.mu.Lock()
defer g.mu.Unlock()
return g.dropped[playerID]
}
实现里有几个取舍。成员遍历发生在持锁期间,MemberLink.Push 因此必须是非阻塞的,否则一个慢客户端会卡住整个公会。Broker 发布失败时不入离线队列,保证“发布成功才可能有人收到”的因果顺序。离线队列超出上限时丢最旧并累加丢弃计数,而不是拒绝新消息。
七、测试覆盖
测试文件 ch10/hub/group_test.go 用假连接检查投递对象和顺序:
| 测试 | 断言 |
|---|---|
| TestGroupSendOnlyReachesMembers | 只投给本公会在线成员,非成员发送被拒 |
| TestOfflineMessagesFlushOnBind | 离线入队,上线按顺序补发并清空 |
| TestOfflineQueueDropsOldestWhenFull | 超限丢最旧,丢弃计数准确 |
| TestRateLimitAndWindowReset | 窗口内超限报错,窗口过后恢复 |
| TestTextLimitsAndDuplicateSuppression | 空白、超长被拒,重复消息只投一次 |
测试把时间注入为固定值,因此限频窗口不依赖真实等待,也不会在慢机器上偶发失败。
八、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch10/hub && go vet ./ch10/hub/ && go test ./ch10/hub/ -count=1
ok gspractice/ch10/hub 0.275s
小结
群聊的难点不在“发出去”,而在“只发给该发的人”。成员表要有权威来源和失效机制;在线推送与离线队列分开处理,队列必须有上限;长度按字符数、频率按玩家、重复按消息 ID 限制。GroupChat 把这些规则放在一处,Broker 只负责跨实例广播,职责边界才清晰。
常见错误与边界
- 广播遍历全服在线玩家,而不是公会成员表。
- 离线队列无上限,玩家回归时补发把网关打爆。
- 长度按字节校验,中文消息被误判超长。
- 限频放在发布之后,被拒绝的消息已经进了 Broker。
- 去重键不含发送者,不同玩家互相压制消息。
- 成员缓存只靠长 TTL,退会后仍收到内部消息。
- 在持锁遍历时调用可能阻塞的推送,单个慢连接拖住整个频道。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。