一、四种会话,四种投递语义
聊天系统最容易犯的错误,是把所有会话都抽象成“一个字符串加一群接收者”。实际上私聊、房间、世界和系统公告的成员来源、持久化要求、限流策略完全不同。用同一套广播逻辑硬套,最后一定会在世界频道或系统公告上出事。
| 类型 | 成员来源 | 典型规模 | 消息是否落库 | 失败时的处理 |
|---|---|---|---|---|
| 私聊 | 双方显式建立会话 | 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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。