《游戏服务端编程实践》10.1.1 私聊 / 公聊 / 频道模型

私聊、房间、世界和系统公告的投递语义并不相同。本篇拆解频道成员、权限、禁言、离线消息与多端同步,给出带成员校验和发言权限的 Go 实现与测试边界,并总结世界频道广播风暴、私聊绕过黑名单、离线消息无限堆积等常见错误,以及可运行的频道模型测试。

一、四种会话,四种投递语义

聊天系统最容易犯的错误,是把所有会话都抽象成“一个字符串加一群接收者”。实际上私聊、房间、世界和系统公告的成员来源、持久化要求、限流策略完全不同。用同一套广播逻辑硬套,最后一定会在世界频道或系统公告上出事。

类型成员来源典型规模消息是否落库失败时的处理
私聊双方显式建立会话2 人通常落库对方离线则存离线消息
房间 / 公会加入关系几人到几百人按需落库成员不在线跳过
世界当前在线玩家数千到数万人一般不落库直接丢弃或抽样
系统公告运营后台全服必须落库重试直到确认

私聊的“频道”是双方共享的会话,频道 ID 必须由两个玩家 ID 稳定推导,不能由客户端随便传。否则同一个玩家可以伪造一个频道 ID,把消息塞进别人的私聊里。

二、在线投递与消息持久化要分开

一条聊天消息有两个独立动作:写入存储,以及投递给当前在线的接收者。两者不是同一次操作,失败语义也不同。先落库再投递,可以保证离线用户之后能补;先投递再异步落库,延迟低,但进程崩溃会丢最近的消息。

顺序延迟崩溃时的影响适用
先落库再投递较高已落库未投递,可补发私聊、公会、系统公告
先投递再异步落库低最近若干条可能丢失世界频道、表情、快捷语
只投递不落库最低离线即丢纯在线氛围消息

关键是把两类消息的通道分开。系统公告和私聊走可靠通道,世界频道走尽力而为通道。如果世界频道也同步落库,一次全服刷屏就会把存储打满;反过来,系统公告走内存广播,维护重启时玩家会完全不知道发生过什么。

三、成员、权限与禁言

频道成员关系至少要回答四个问题:谁在频道里、谁能发言、谁能管理、被禁言的人是否还占着位置。推荐把“成员身份”和“角色”分开:成员决定消息能不能送到,角色决定能不能执行管理动作。

动作普通成员频道管理员非成员
接收消息可以可以不可以
发言未禁言时可以可以拒绝
禁言他人拒绝可以拒绝
查看成员可以可以拒绝

禁言不是踢出。被禁言者仍然应该在频道里,能看别人说话,只是 Speak 返回明确错误。实现上不要用“把成员从 map 删除再标记禁言”这种写法,否则重连后成员关系丢失,还会触发重复加入。

系统频道需要额外限制:只有管理员或系统账号能发言。普通成员即使加入系统频道,也只能接收。这条规则不能只靠客户端不显示输入框,服务端必须拒绝。

四、离线消息与多端同步

私聊和公会消息通常需要离线补发,但“离线消息”不是无限信箱。每个玩家的离线队列必须有上限和过期时间,否则一个长期不上线的账号会持续吞掉存储,攻击者也可以借此刷爆别人的信箱。

策略优点风险建议
只保留最近 N 条存储可控老消息丢失私聊默认 200 条
按时间过期语义自然长时间离线全丢保留 7 天
按会话保留每个会话都有上下文会话数可能很多限制会话总数
不限量实现简单存储和登录拉取都可能失控不要使用

多端同步要区分“投递”和“已读”。同一账号在手机和 PC 同时在线时,消息应该投递到所有在线端,但已读位置只能取各端最大值,并且服务端要按会话记录,不能用一个全局游标。否则在手机上读过的私聊,PC 端会显示未读。未读数只适合作为提示,真正拉取时仍要以服务端游标为准。

五、失败场景:世界频道变成广播风暴

某个活动开启后,世界频道同时在线人数从两千涨到两万。每条消息都遍历全部成员、序列化一次、逐个连接写入。一个玩家刷屏,服务端 CPU 立刻打满,连登录和战斗消息都开始超时。

更隐蔽的问题是慢连接。遍历投递时如果对每个连接同步写,一个网络状况差的客户端就能把整个广播协程卡住。正确的做法是:世界频道按连接分片,每个连接有独立发送队列,队列满就丢弃该连接上的旧聊天消息,绝不阻塞广播。世界消息本身也可以按频道做令牌桶限流,超过阈值直接拒绝发送者。

私聊也有类似的放大点:如果每次私聊都查一次好友和黑名单,高频私聊会把数据库打穿。好友与黑名单应该在登录时加载成内存快照,变更时同步更新,而不是每条消息查库。

六、Go 实现:Channels 的加入、退出与发言校验

下面的 Channels 把成员关系、角色和禁言放在同一把锁下,Speak 依次校验频道存在、成员身份、禁言、系统频道权限和消息长度。私聊频道 ID 由两个玩家 ID 排序生成,双方从任意一端打开都得到同一个键。

// file: ch10/chat/channel.go
// tier: L1
// verify: go test ./ch10/chat/

package chat

import (
	"errors"
	"fmt"
	"strings"
	"sync"
	"unicode/utf8"
)

var (
	ErrEmptyChannel    = errors.New("chat: empty channel id")
	ErrChannelNotFound = errors.New("chat: channel not found")
	ErrAlreadyJoined   = errors.New("chat: already joined")
	ErrNotMember       = errors.New("chat: not a member")
	ErrMuted           = errors.New("chat: member muted")
	ErrNoPermission    = errors.New("chat: no permission")
	ErrEmptyMessage    = errors.New("chat: empty message")
	ErrMessageTooLong  = errors.New("chat: message too long")
)

// ChannelKind 决定投递范围与发言权限,私聊、房间、世界和系统公告不能共用一套规则。
type ChannelKind string

const (
	KindPrivate ChannelKind = "private"
	KindRoom    ChannelKind = "room"
	KindWorld   ChannelKind = "world"
	KindSystem  ChannelKind = "system"
)

type Role int

const (
	RoleMember Role = iota
	RoleAdmin
)

// MaxMessageRunes 是单条消息的字符上限,按 rune 而不是字节计算。
const MaxMessageRunes = 200

type member struct {
	role  Role
	muted bool
}

type Channel struct {
	ID      string
	Kind    ChannelKind
	members map[uint64]*member
}

// Message 是校验通过、等待路由的消息,不包含连接信息。
type Message struct {
	ChannelID string
	Kind      ChannelKind
	From      uint64
	Text      string
}

// Channels 管理所有频道的成员关系,并发读写由一把读写锁保护。
type Channels struct {
	mu       sync.RWMutex
	channels map[string]*Channel
}

func NewChannels() *Channels {
	return &Channels{channels: make(map[string]*Channel)}
}

func (c *Channels) Create(id string, kind ChannelKind) error {
	if strings.TrimSpace(id) == "" {
		return ErrEmptyChannel
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if _, ok := c.channels[id]; ok {
		return nil
	}
	c.channels[id] = &Channel{ID: id, Kind: kind, members: make(map[uint64]*member)}
	return nil
}

// PrivateID 把两个玩家 ID 排序后拼成稳定的频道键,双方各自打开都得到同一个频道。
func PrivateID(a, b uint64) string {
	if a > b {
		a, b = b, a
	}
	return fmt.Sprintf("private:%d:%d", a, b)
}

// OpenPrivate 按需创建私聊频道并让双方加入;重复调用不会重复加入。
func (c *Channels) OpenPrivate(a, b uint64) (string, error) {
	id := PrivateID(a, b)
	if err := c.Create(id, KindPrivate); err != nil {
		return "", err
	}
	for _, playerID := range []uint64{a, b} {
		if err := c.Join(id, playerID, RoleMember); err != nil && !errors.Is(err, ErrAlreadyJoined) {
			return "", err
		}
	}
	return id, nil
}

func (c *Channels) Join(channelID string, playerID uint64, role Role) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return ErrChannelNotFound
	}
	if _, exists := ch.members[playerID]; exists {
		return ErrAlreadyJoined
	}
	ch.members[playerID] = &member{role: role}
	return nil
}

func (c *Channels) Leave(channelID string, playerID uint64) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return ErrChannelNotFound
	}
	if _, exists := ch.members[playerID]; !exists {
		return ErrNotMember
	}
	delete(ch.members, playerID)
	return nil
}

// SetMute 只允许频道管理员操作,被禁言者仍然留在频道里,只是不能发言。
func (c *Channels) SetMute(channelID string, actorID, targetID uint64, muted bool) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return ErrChannelNotFound
	}
	actor, ok := ch.members[actorID]
	if !ok {
		return ErrNotMember
	}
	if actor.role != RoleAdmin {
		return ErrNoPermission
	}
	target, ok := ch.members[targetID]
	if !ok {
		return ErrNotMember
	}
	target.muted = muted
	return nil
}

// Speak 依次检查频道存在、成员身份、禁言、系统频道权限和消息长度。
func (c *Channels) Speak(channelID string, playerID uint64, text string) (Message, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return Message{}, ErrChannelNotFound
	}
	m, ok := ch.members[playerID]
	if !ok {
		return Message{}, ErrNotMember
	}
	if m.muted {
		return Message{}, ErrMuted
	}
	if ch.Kind == KindSystem && m.role != RoleAdmin {
		return Message{}, ErrNoPermission
	}
	if strings.TrimSpace(text) == "" {
		return Message{}, ErrEmptyMessage
	}
	if utf8.RuneCountInString(text) > MaxMessageRunes {
		return Message{}, ErrMessageTooLong
	}
	return Message{ChannelID: ch.ID, Kind: ch.Kind, From: playerID, Text: text}, nil
}

func (c *Channels) IsMember(channelID string, playerID uint64) bool {
	c.mu.RLock()
	defer c.mu.RUnlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return false
	}
	_, exists := ch.members[playerID]
	return exists
}

func (c *Channels) IsMuted(channelID string, playerID uint64) bool {
	c.mu.RLock()
	defer c.mu.RUnlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return false
	}
	m, exists := ch.members[playerID]
	return exists && m.muted
}

// Members 返回成员快照,调用方无法通过返回值改动频道内部状态。
func (c *Channels) Members(channelID string) []uint64 {
	c.mu.RLock()
	defer c.mu.RUnlock()
	ch, ok := c.channels[channelID]
	if !ok {
		return nil
	}
	ids := make([]uint64, 0, len(ch.members))
	for id := range ch.members {
		ids = append(ids, id)
	}
	return ids
}

Speak 的检查顺序是有意安排的。先判断成员身份,才能防止非成员借禁言接口探测频道;先判断禁言,再判断系统频道权限,可以让被禁言的管理员得到“禁言”而不是“无权限”的准确错误。错误码越具体,排查和申诉越容易。

Members 返回新切片而不是内部 map,调用方排序或删改都不会影响频道状态。这个小细节在多处并发读取时很重要,尤其是管理后台需要分页展示成员时。

七、测试覆盖哪些边界

实验包中的 channel_test.go 覆盖了六类行为,都是线上真实出过问题的路径:

  • 重复加入返回 ErrAlreadyJoined,成员数量不增加。
  • 退出后再次退出返回 ErrNotMember,退出后发言同样被拒绝。
  • 普通成员禁言管理员返回 ErrNoPermission;管理员禁言后,目标仍留在频道内但不能发言。
  • 系统频道中普通成员发言被拒绝,非成员也拿不到发言资格。
  • 私聊频道从两个方向打开得到同一个 ID,双方自动成为成员。
  • 空白消息和超过 MaxMessageRunes 的消息分别在进入路由前被拒绝。

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch10/chat && go vet ./ch10/chat/ && go test ./ch10/chat/ -count=1
ok  	gspractice/ch10/chat	0.233s

gofmt -l 没有输出说明格式一致;go vet 通过;测试覆盖频道模型、队列和过滤器三个文件。

小结

聊天频道模型的核心不是“把消息发给一串人”,而是先定义每种会话的成员来源、持久化要求和失败语义。成员身份决定能不能收到,角色决定能不能管理,禁言是成员状态而不是移除成员。私聊频道 ID 要稳定可推导,离线消息要有上限和过期,多端同步要以服务端游标为准。实现上把校验顺序写清楚,错误码区分成员、禁言和权限,后续接入队列和过滤时才能各司其职。

常见错误与边界

  • 世界频道每条消息遍历全部在线连接并同步写,慢连接拖垮整个广播。
  • 私聊不校验好友、黑名单和双方关系,任何玩家都能给任意 ID 发消息。
  • 离线消息无上限、无过期,长期不上线的账号持续吞存储。
  • 把禁言实现成踢出频道,重连后成员关系丢失并触发重复加入。
  • 只靠客户端隐藏输入框限制系统频道发言,服务端没有二次校验。
  • 用全局已读游标处理多端,手机读过的消息在 PC 上仍显示未读。
  • 频道成员列表直接返回内部 map,调用方修改导致并发读写 panic。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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