《游戏服务端编程实践》10.3.2 实现群聊与频道广播

沿着客户端到网关再到聊天服的完整链路,拆解群聊成员表来源、在线推送与离线转存、发言长度与频率限制、重复消息抑制,并给出可测试的 GroupChat 实现,说明广播必须只落在真正的成员身上;文中用假连接验证投递边界、离线补发顺序与限频窗口的边界。

一、群聊的完整路径

一条公会消息要经过五跳:客户端发到网关,网关带上玩家身份转发给聊天服,聊天服校验后发布到 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,退会后仍收到内部消息。
  • 在持锁遍历时调用可能阻塞的推送,单个慢连接拖住整个频道。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist