纲要
- 消息存储模型 :基于读扩散,一条消息只存一份,通过
type字段区分私聊/群聊,receiver_id在群聊时指向群ID。 - 会话管理 :用户创建群或加入群时,由
im服务创建群会话,并维护用户与群的会话关系。 - 消息推送与并发优化 :利用
go-zero内置的线程工具实现群消息的并发发送,避免因群成员数量大导致的延迟。 - 消息队列处理 :在
taskMQ中增加群聊分支,调用社交服务获取群成员列表,完成消息扩散与落地。 - 服务协作 :社交
API服务在创建群、申请进群、处理群申请等成功回调中,通过RPC调用im服务建立会话。 - 涉及技术栈 :
go-zero、go-zero/core/threading、WebSocket、Redis、MySQL、RPC。
消息存储与扩散模型
群聊消息采用读扩散 方案:所有群成员共享同一条消息记录,避免为每个用户存储一份副本。与私聊相同,消息记录在同一张 chat_log 表中,通过两个字段区分场景:
type:消息类型,枚举值为private(私聊)和group(群聊)。receiver_id:接收者 ID,私聊时为对方的用户 ID,群聊时替换为群 ID。
这样,客户端拉取群历史消息时,只需按群 ID 和消息类型查询即可获得完整的群聊记录,无需在写路径上为每个成员维护独立的收件箱。
会话的建立与管理
创建时机
会话的触发来源于两个入口:
- 创建群 :创建者发起创建群操作后,社交服务需要同时为群本身 和创建者与群之间建立会话。
- 加入群:新成员通过申请并被批准后,社交服务需要为该用户与群建立会话。
无论在哪个入口,最终都通过 im 服务提供的 RPC 接口完成会话的初始化。
时序梳理
数据库 IM RPC 社交 RPC 社交 API 客户端 数据库 IM RPC 社交 RPC 社交 API 客户端 #mermaid-svg-q8SkvoBl2BF9UaNG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q8SkvoBl2BF9UaNG .error-icon{fill:#552222;}#mermaid-svg-q8SkvoBl2BF9UaNG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q8SkvoBl2BF9UaNG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q8SkvoBl2BF9UaNG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q8SkvoBl2BF9UaNG .marker.cross{stroke:#333333;}#mermaid-svg-q8SkvoBl2BF9UaNG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q8SkvoBl2BF9UaNG p{margin:0;}#mermaid-svg-q8SkvoBl2BF9UaNG .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q8SkvoBl2BF9UaNG text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-q8SkvoBl2BF9UaNG .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-q8SkvoBl2BF9UaNG .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-q8SkvoBl2BF9UaNG #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-q8SkvoBl2BF9UaNG .sequenceNumber{fill:white;}#mermaid-svg-q8SkvoBl2BF9UaNG #sequencenumber{fill:#333;}#mermaid-svg-q8SkvoBl2BF9UaNG #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-q8SkvoBl2BF9UaNG .messageText{fill:#333;stroke:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q8SkvoBl2BF9UaNG .labelText,#mermaid-svg-q8SkvoBl2BF9UaNG .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .loopText,#mermaid-svg-q8SkvoBl2BF9UaNG .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-q8SkvoBl2BF9UaNG .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-q8SkvoBl2BF9UaNG .noteText,#mermaid-svg-q8SkvoBl2BF9UaNG .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-q8SkvoBl2BF9UaNG .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q8SkvoBl2BF9UaNG .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q8SkvoBl2BF9UaNG .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q8SkvoBl2BF9UaNG .actorPopupMenu{position:absolute;}#mermaid-svg-q8SkvoBl2BF9UaNG .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-q8SkvoBl2BF9UaNG .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q8SkvoBl2BF9UaNG .actor-man circle,#mermaid-svg-q8SkvoBl2BF9UaNG line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-q8SkvoBl2BF9UaNG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt会话不存在会话已存在 创建群/审批加入执行群业务逻辑返回群 IDCreateGroupConversation(groupId, userId)查询群会话是否已存在插入群会话记录为用户插入群会话关系成功直接返回操作完成
项目结构速览
dir
apps/
├─ social/
│ ├─ api/ # 社交 API 服务
│ │ ├─ internal/
│ │ │ ├─ config/
│ │ │ ├─ logic/ # 创建群、申请群、处理申请等逻辑
│ │ │ └─ svc/
│ │ └─ social.api
│ └─ rpc/ # 社交 RPC 服务
│ ├─ internal/
│ │ ├─ logic/ # GetGroupUserList 等
│ │ └─ svc/
│ └─ social.proto
└─ im/
└─ rpc/ # IM RPC 服务
├─ internal/
│ ├─ config/
│ ├─ logic/ # CreateGroupConversation 等
│ ├─ mq/ # taskMQ 消费者
│ ├─ server/ # WebSocket 连接管理、并发推送
│ └─ svc/
├─ model/ # 会话、用户会话模型
└─ im.proto
代码实现:IM 服务中的会话逻辑
以下代码位于 im 的 RPC 服务中,负责创建群会话并关联用户会话列表。
文件:internal/logic/creategroupconversationlogic.go
go
package logic
import (
"context"
"database/sql"
"github.com/pkg/errors"
"go-zero-shop/apps/im/rpc/internal/svc"
"go-zero-shop/apps/im/rpc/pb"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateGroupConversationLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewCreateGroupConversationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateGroupConversationLogic {
return &CreateGroupConversationLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
// CreateGroupConversation 创建群会话
func (l *CreateGroupConversationLogic) CreateGroupConversation(in *pb.CreateGroupConversationReq) (*pb.CreateGroupConversationResp, error) {
// 1. 检查群会话是否已存在
existing, err := l.svcCtx.ConversationModel.FindOneByConversationId(l.ctx, in.GroupId)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
l.Logger.Errorf("查询群会话失败: %v", err)
return nil, errors.Wrap(err, "查询会话失败")
}
if existing != nil {
return &pb.CreateGroupConversationResp{}, nil
}
// 2. 创建群会话
groupConv := &model.Conversation{
ConversationId: in.GroupId,
Type: constant.ChatTypeGroup,
}
if _, err := l.svcCtx.ConversationModel.Insert(l.ctx, groupConv); err != nil {
l.Logger.Errorf("创建群会话失败: %v", err)
return nil, errors.Wrap(err, "创建会话失败")
}
// 3. 为创建者添加群会话关系
userConv := &model.UserConversation{
UserId: in.CreatorId,
ConversationId: in.GroupId,
Type: constant.ChatTypeGroup,
}
if _, err := l.svcCtx.UserConversationModel.Insert(l.ctx, userConv); err != nil {
l.Logger.Errorf("为用户添加群会话失败: %v", err)
return nil, errors.Wrap(err, "添加用户会话失败")
}
return &pb.CreateGroupConversationResp{}, nil
}
说明:代码中 ConversationModel 和 UserConversationModel 为 go-zero 生成的 model 层对象;constant.ChatTypeGroup 是定义在常量包中的枚举值。
并发推送消息
群聊消息需要推送给所有在线成员,如果采用串行方式逐个发送,延迟会随着人数线性增长。为此,我们引入 go-zero 提供的线程工具进行并发控制。
并发限制与配置
在 im 服务的 Server 结构体中,通过 Option 模式暴露并发度参数,方便运维调整。
go
// internal/config/config.go
type Config struct {
// ... 其他配置
ConcurrencyLimit int `json:"ConcurrencyLimit"`
}
go
// internal/server/option.go
type Option struct {
ConcurrencyLimit int
}
func WithConcurrencyLimit(limit int) Option {
return func(s *Server) {
s.concurrencyLimit = limit
}
}
消息发送逻辑重构
推送方法原先只处理私聊,现在通过类型判定的方式分流,群聊部分使用 TaskRunner 并发调用私聊推送方法。
go
// internal/server/message.go
package server
import (
"context"
"fmt"
"go-zero-shop/apps/im/rpc/internal/constant"
"go-zero-shop/apps/im/rpc/internal/svc"
"go-zero-shop/apps/im/rpc/pb"
"github.com/zeromicro/go-zero/core/threading"
)
type MessageCenter struct {
svcCtx *svc.ServiceContext
concurrencyLimit int
taskRunner *threading.TaskRunner
}
func NewMessageCenter(svcCtx *svc.ServiceContext, limit int) *MessageCenter {
return &MessageCenter{
svcCtx: svcCtx,
concurrencyLimit: limit,
taskRunner: threading.NewTaskRunner(limit),
}
}
// Push 消息推送入口
func (m *MessageCenter) Push(ctx context.Context, msg *pb.ChatMessage) error {
switch msg.Type {
case constant.ChatTypePrivate:
return m.pushPrivate(ctx, msg, msg.ReceiverId)
case constant.ChatTypeGroup:
return m.pushGroup(ctx, msg)
default:
return fmt.Errorf("不支持的消息类型: %d", msg.Type)
}
}
// pushPrivate 私聊推送
func (m *MessageCenter) pushPrivate(ctx context.Context, msg *pb.ChatMessage, receiverId string) error {
conn, err := m.svcCtx.ConnectionManager.Get(receiverId)
if err != nil {
// 用户离线可记录日志或丢弃
return nil
}
// 假设存在 packResponse 将消息序列化为 WebSocket 帧
data, err := packResponse(msg)
if err != nil {
return err
}
return conn.WriteMessage(data)
}
// pushGroup 群聊推送
func (m *MessageCenter) pushGroup(ctx context.Context, msg *pb.ChatMessage) error {
// msg.Receivers 由上游填充,包含剔除发送者后的所有成员 ID
for _, uid := range msg.Receivers {
uid := uid // 防止闭包引用问题
m.taskRunner.Schedule(func() {
if err := m.pushPrivate(ctx, msg, uid); err != nil {
logx.WithContext(ctx).Errorf("群聊推送失败, receiver=%s, err=%v", uid, err)
}
})
}
return nil
}
注释 :ConnectionManager 是我们实现的局部连接管理组件,负责根据用户 ID 查找对应的 WebSocket 连接。TaskRunner.Schedule 使用 channel 控制并发数,当队列满时调用方会被阻塞,从而实现反压。
消息队列的群聊支持
为了提高可靠性,消息先被投递到消息队列,由 taskMQ 异步消费并完成持久化与推送。需要在消费端增加群聊类型的处理,并通过社交 RPC 服务获取群成员列表。
消费端骨架
go
// internal/mq/task.go
package mq
import (
"context"
"encoding/json"
"go-zero-shop/apps/im/rpc/internal/constant"
"go-zero-shop/apps/im/rpc/internal/svc"
"go-zero-shop/apps/im/rpc/pb"
"github.com/zeromicro/go-zero/core/logx"
)
type TaskHandler struct {
svcCtx *svc.ServiceContext
pushService *server.MessageCenter
}
func (h *TaskHandler) Handle(ctx context.Context, raw []byte) error {
var msg pb.ChatMessage
if err := json.Unmarshal(raw, &msg); err != nil {
return err
}
switch msg.Type {
case constant.ChatTypePrivate:
return h.handlePrivate(ctx, &msg)
case constant.ChatTypeGroup:
return h.handleGroup(ctx, &msg)
default:
return nil
}
}
func (h *TaskHandler) handlePrivate(ctx context.Context, msg *pb.ChatMessage) error {
// 存储消息记录...
return h.pushService.Push(ctx, msg)
}
func (h *TaskHandler) handleGroup(ctx context.Context, msg *pb.ChatMessage) error {
// 1. 获取群成员
rpcResp, err := h.svcCtx.SocialRpc.GroupUserList(ctx, &social_pb.GroupUserListReq{
GroupId: msg.ReceiverId,
})
if err != nil {
logx.WithContext(ctx).Errorf("获取群成员失败: %v", err)
return err
}
// 2. 过滤发送者,构建接收列表
var receivers []string
for _, user := range rpcResp.Users {
if user.UserId != msg.SenderId {
receivers = append(receivers, user.UserId)
}
}
msg.Receivers = receivers
// 3. 存储消息记录...
// 4. 并发推送
return h.pushService.Push(ctx, msg)
}
配置社交 RPC 客户端
在 im 的 config 和 service context 中引入社交 RPC 客户端。
go
// internal/config/config.go
type Config struct {
// ...
SocialRpc zrpc.RpcClientConf
}
go
// internal/svc/servicecontext.go
type ServiceContext struct {
Config config.Config
SocialRpc socialpb.SocialClient
// ...其他依赖
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
SocialRpc: socialpb.NewSocialClient(zrpc.MustNewClient(c.SocialRpc).Conn()),
}
}
社交服务触发会话建立
im 服务的会话创建接口需要通过具体业务行为触发。在社交 API 服务中,当创建群、申请入群、处理入群申请成功后,应异步回调 im RPC 建立会话。
社交 API 中的调用逻辑
以创建群为例,其余两个场景类似。
go
// internal/logic/creategrouplogic.go (社交 API)
func (l *CreateGroupLogic) CreateGroup(req *types.CreateGroupReq) (*types.CreateGroupResp, error) {
// ... 创建群业务逻辑,获得 groupId
groupId := "xxx"
// 调用 IM RPC 创建群会话
_, err := l.svcCtx.ImRpc.CreateGroupConversation(l.ctx, &im_pb.CreateGroupConversationReq{
GroupId: groupId,
CreatorId: req.CreatorId,
})
if err != nil {
l.Logger.Errorf("创建群会话失败, groupId=%s, err=%v", groupId, err)
// 通常这里可容忍失败,通过定时任务补偿
}
return &types.CreateGroupResp{GroupId: groupId}, nil
}
社交服务的 IM RPC 配置
go
// internal/config/config.go (社交 API)
type Config struct {
// ...
ImRpc zrpc.RpcClientConf
}
go
// internal/svc/servicecontext.go (社交 API)
type ServiceContext struct {
Config config.Config
ImRpc impb.ImClient
// ...
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
ImRpc: impb.NewImClient(zrpc.MustNewClient(c.ImRpc).Conn()),
}
}
总结
群聊功能的实现本质上复用了私聊的存储与推送链路,核心差异体现在三处:
- 会话建模:在群创建/加入时通过 im 服务统一管理群会话与用户‑会话关系。
- 消息扩散 :服务端根据群 ID 查询成员列表,借助
go-zero的并发工具高效推送。 - 异步处理:消息队列消费端区分消息类型,调用社交服务获取最新成员列表,保证成员变动的实时性。
整套方案在保持代码简洁的同时,充分利用了 go-zero 框架的微服务能力(RPC 调用、线程池、消息队列),可以平稳支撑较大规模的群组聊天场景。