群聊实现与扩散策略落地

群聊的技术难点从来不是「能不能发出去」,而是「一条消息要不要变成 N 条」。500 人的群如果照单聊的做法给每个成员都写一份、投一次,单条消息的写入放大就是 500 倍,一次热群刷屏就能把数据库写满。本章按成员规模分档:小群走写扩散(write fanout),大群走读扩散(read fanout),并给出两者的落地代码与存储落点。

本章目标

把群 group_id 接进第 07 章的单聊链路:SendMessage 收到 conv_type=2 时走群流程,成员数 ≤ 200 的群为每个成员写一份收件箱并投递,成员数更大的群只落一份群消息、由成员按游标增量拉取。

两种扩散方式

维度写扩散(小群,≤ 200 人)读扩散(大群,> 200 人)
落库代价O(成员数):每个成员一行收件箱O(1):群消息只在 msg 留一份
离线留存成员各自的收件箱,确认后回收群会话日志本身,按位点增量拉
拉取路径offline_msg_xx(按 uid 分片)msg_xx(按 conv_id 分片)+ user_sync_cursor
主要风险成员接近上限时插入变慢位点丢失导致重放或漏拉
典型场景家庭群、项目群万人直播群、通知群

两种方式在在线投递这一层完全一样,只是「离线留存」的存法不同,因此可以共用一套投递代码,只在落库与拉取处按规模分岔。

复用的四张表

msg_00 ~ msg_15        会话日志,按 conv_id % 16 分片(第 11 章),群消息只落一份
offline_msg_00 ~ 15    收件箱,按 uid % 16 分片,主键 (uid, conv_id, seq)(第 09 章)
user_sync_cursor       设备拉取位点,主键 (uid, device_id, conv_id)(第 09 章)
conv_member            群成员表,主键 (conv_id, uid) + KEY (uid, join_at)(第 11 章)

群聊不需要新的存储结构:写扩散就是把 offline_msg 多写几行,读扩散就是完全不写收件箱。

分档入口

const fanoutThreshold = 200 // 成员数 ≤ 200 走写扩散,> 200 走读扩散

// Send 群消息入口:无论哪种扩散,都先把群消息落进会话日志拿到 seq,
// 再按成员规模决定扩散方式——两种方式对客户端保持一致的可见语义。
func (s *GroupService) Send(ctx context.Context, in GroupSendInput) (*SendResult, error) {
	if err := s.groups.CheckMember(ctx, in.GroupID, in.FromUID); err != nil {
		return nil, err // ErrNotMember:非群成员直接拒绝,不能靠客户端自觉
	}
	// 每个群对应一个会话:conv_id 在建群时生成,成员关系记在 conv_member(第 11 章)
	convID, err := s.convs.ByGroup(ctx, in.GroupID)
	if err != nil {
		return nil, fmt.Errorf("load group conv group=%d: %w", in.GroupID, err)
	}
	msg, err := s.msgs.Insert(ctx, convID, in.FromUID, in.Payload) // 事务内 conv_seq 取号
	if err != nil {
		return nil, fmt.Errorf("insert group message conv=%d: %w", convID, err)
	}
	members, err := s.groups.MemberCount(ctx, convID)
	if err != nil {
		return nil, fmt.Errorf("count members conv=%d: %w", convID, err)
	}
	if members <= fanoutThreshold {
		if err := s.fanoutWrite(ctx, msg); err != nil {
			// 写扩散失败不回滚:群消息已落库,成员下次拉取会按位点补齐
			slog.Error("write fanout failed", "conv_id", convID, "msg_id", msg.MsgID, "err", err)
		}
	} else {
		slog.Info("large group, read fanout", "conv_id", convID, "members", members)
	}
	return &SendResult{MsgID: msg.MsgID, Seq: msg.Seq, SentAt: msg.CreatedAt}, nil
}

小群写扩散

// fanoutWrite 小群写扩散:为每个成员写一行收件箱,并给在线成员逐端投递。
func (s *GroupService) fanoutWrite(ctx context.Context, msg *model.Message) error {
	members, err := s.groups.MemberIDs(ctx, msg.ConvID, fanoutThreshold)
	if err != nil {
		return fmt.Errorf("load members conv=%d: %w", msg.ConvID, err)
	}
	if err := s.inbox.BatchInsert(ctx, members, msg); err != nil { // 一次多值 INSERT,别逐条插
		return fmt.Errorf("batch insert inbox conv=%d: %w", msg.ConvID, err)
	}
	routes, err := s.routes.LookupBatch(ctx, members) // pipeline:N 次 HGETALL 压成一次往返
	if err != nil {
		return fmt.Errorf("lookup batch routes conv=%d: %w", msg.ConvID, err)
	}
	for uid, byDevice := range routes { // 只投在线成员,离线成员靠收件箱留痕
		for _, rt := range byDevice {
			if err := s.deliver.Push(ctx, rt, msg.AsPushFrame()); err != nil {
				slog.Warn("group deliver failed", "uid", uid, "conn_id", rt.ConnID, "err", err)
			}
		}
	}
	return nil
}

大群一侧则是「落库即结束」,不再扇出:成员上线或进入会话时调 PullGroup(ctx, uid, convID, sinceSeq, limit) 从 msg_xx 按会话 seq 增量拉取(limit 钳到 500 以内),客户端确认收下后再推进 user_sync_cursor——UPDATE user_sync_cursor SET ack_seq = GREATEST(ack_seq, ?) WHERE uid = ? AND device_id = ? AND conv_id = ?,GREATEST 保证位点只增不减,补拉旧消息不会把进度打回去(第 09 章)。

验证方式

# 造一个小群(conv_id=88001,5 人)和一个大群(conv_id=88000,500 人)
mysql -uroot -p im -e "INSERT INTO conv_member (conv_id, uid, join_at) SELECT 88000, uid, NOW() FROM user LIMIT 500;"
# 小群发一条:期望收件箱新增 5 行(每个成员一行),消息落在 msg_01(88001 % 16)
grpcurl -plaintext -d '{"client_msg_id":"g-1","from_uid":"1001","group_id":"1001",
  "conv_type":2,"payload":"aGk="}' 127.0.0.1:9090 im.v1.LogicService/SendMessage
mysql -uroot -p im -e "SELECT COUNT(*) FROM offline_msg_09 WHERE uid=1001 AND conv_id=88001;" # 期望:1(1001 % 16 = 9)
# 大群发一条:日志出现 large group, read fanout;收件箱不新增,消息只在 msg_00
mysql -uroot -p im -e "SELECT COUNT(*) FROM offline_msg_09 WHERE uid=1001 AND conv_id=88000;" # 期望:0
mysql -uroot -p im -e "SELECT seq,from_uid FROM msg_00 WHERE conv_id=88000 ORDER BY seq DESC LIMIT 1;"

常见坑

坑现象正确做法
阈值拍脑袋写死成员接近上限的群忽然变慢用「成员数 × 单条插入耗时」实测后定档,并单独压测临界群
逐条 INSERT 扇出200 人就是 200 次网络往返一次多值 INSERT,路由用 pipeline 批量查
大群一次查出全部成员内存与 GC 抖动,OOMMemberIDs 必须带 limit 与游标分批
只靠 Redis 缓存群成员退群的人还一直收到消息缓存带版本号或短 TTL,写路径以 DB 为准
拉取时全量重放客户端一进群就卡死必须带 sinceSeq 与 limit,位点丢失才按时间回捞尾页
退群后位点未清理重新入群时从旧位点续拉,漏掉中间消息退群删 user_sync_cursor 对应行,重新入群从当前 seq 开始

小结:群聊按成员规模分档——小群写扩散,往 offline_msg 多写成员数行收件箱,换读路径的一次单分片扫描;大群读扩散,消息只在 msg_xx 留一份,成员按 user_sync_cursor 只增不减的位点增量拉取;两种方式共用同一套在线投递代码,对客户端保持一致的可见语义。

笔记加载中…