纲要
- 核心概念与流程梳理
- 消息记录的两种查询方式
- 会话管理:列表、未读计算、更新与建立
- 离线消息拉取的整体时序
- 项目代码结构
- 消息记录查询实现
- 按消息 ID 单条查询
- 按时间范围分段查询
- 会话列表获取与未读消息计算
- 拉取用户会话列表
- 批量获取会话详情
- 计算未读数量与显示状态
- 会话更新逻辑
- 批量更新已读序号
- 幂等性与初始化保护
- 会话建立流程
- 生成唯一会话 ID
- 存在性校验与幂等
- 双向记录添加(封装的公共方法)
- WebSocket 与消息收发回顾(简要)
- 连接鉴权与心跳
- 私聊推送模型
- Kafka 异步解耦与 ACK 机制
- 离线消息拉取完整链路
- 时序图与关键步骤
- 测试与常见问题
- 字段类型匹配
- 会话 ID 与发送者缺失
- 发送时间处理
- 总结
核心概念与流程梳理
在即时通讯系统中,"离线消息"指的是当目标用户不在线时,为其暂存的消息。用户下次上线后需要主动拉取这些消息,并更新会话已读状态。整体涉及以下子模块:
- 消息记录查询:支持按单 ID 查询和历史时间段分页查询。
- 会话管理:会话(Conversation)是用户与目标(单聊或群聊)的聊天窗口,需要管理会话列表、未读计数、更新已读序号以及会话的创建。
- 离线消息拉取:客户端获取会话列表 → 计算未读消息数 → 点击某个会话拉取最新消息 → 标记已读。
本文基于 go-zero 框架,结合 WebSocket 和 Kafka,实现上述能力。
项目代码结构
项目基于 go-zero 微服务划分,核心服务 message 提供 RPC 接口供 API 层调用,内部操作 MongoDB 存储消息记录和会话信息。关键目录如下:
dir
.
├── message
│ ├── message.proto # RPC 定义
│ ├── internal
│ │ ├── logic # 业务逻辑层
│ │ │ ├── getmessagerecord
│ │ │ ├── getconversations
│ │ │ ├── updateconversation
│ │ │ └── createconversation
│ │ ├── model # 数据模型(MongoDB)
│ │ └── server # 启动入口
│ └── go.mod
├── ws # WebSocket 服务
│ └── ...
└── api # API 网关服务
└── ...
所有代码基于 go-zero@latest 生成,并使用了最新版的依赖。
消息记录查询实现
RPC 接口定义(message.proto 片段):
proto
syntax = "proto3";
package message;
service Message {
rpc GetMessageRecord(GetMessageRecordReq) returns (GetMessageRecordResp);
}
message GetMessageRecordReq {
string conversationId = 1; // 会话 ID
int64 maxId = 2; // 若不为空,按单条消息 ID 查询
int64 startTime = 3; // 起始时间戳
int64 endTime = 4; // 结束时间戳
int32 pageSize = 5;
}
message Message {
int64 msgId = 1;
string conversationId = 2;
string senderId = 3;
string receiverId = 4;
int32 msgType = 5;
string content = 6;
int64 sendTime = 7;
}
message GetMessageRecordResp {
repeated Message messages = 1;
}
在 logic 层中,GetMessageRecord 根据 maxId 是否传递来决定查询方式。
go
package getmessagerecord
import (
"context"
"errors"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"message/internal/svc"
"message/pb"
)
func (l *GetMessageRecordLogic) GetMessageRecord(in *pb.GetMessageRecordReq) (*pb.GetMessageRecordResp, error) {
if in.MaxId != 0 {
return l.queryByID(in)
}
return l.queryByTimeRange(in)
}
func (l *GetMessageRecordLogic) queryByID(in *pb.GetMessageRecordReq) (*pb.GetMessageRecordResp, error) {
var msg pb.Message
err := l.svcCtx.MongoCol.FindOne(l.ctx, bson.M{
"conversationId": in.ConversationId,
"msgId": in.MaxId,
}).Decode(&msg)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return &pb.GetMessageRecordResp{}, nil
}
return nil, err
}
return &pb.GetMessageRecordResp{Messages: []*pb.Message{&msg}}, nil
}
func (l *GetMessageRecordLogic) queryByTimeRange(in *pb.GetMessageRecordReq) (*pb.GetMessageRecordResp, error) {
filter := bson.M{
"conversationId": in.ConversationId,
"sendTime": bson.M{
"$gte": in.StartTime,
"$lte": in.EndTime,
},
}
opts := options.Find().SetLimit(int64(in.PageSize)).SetSort(bson.M{"sendTime": -1})
cursor, err := l.svcCtx.MongoCol.Find(l.ctx, filter, opts)
if err != nil {
return nil, err
}
defer cursor.Close(l.ctx)
var msgs []*pb.Message
if err := cursor.All(l.ctx, &msgs); err != nil {
return nil, err
}
return &pb.GetMessageRecordResp{Messages: msgs}, nil
}
这样便支持了两种查询方式:精确 ID 查询和时间窗口分页查询。
会话列表获取与未读消息计算
获取会话列表分为三个步骤:
- 查询用户的会话列表(存储了用户参与的所有会话 ID)。
- 根据会话 ID 集合批量获取会话详情。
- 迭代每个会话,计算是否包含未读消息,并修正显示状态。
接口定义(message.proto 新增):
proto
rpc GetConversations(GetConversationsReq) returns (GetConversationsResp);
message GetConversationsReq {
string userId = 1;
}
message Conversation {
string conversationId = 1;
int32 chatType = 2; // 1:私聊 2:群聊
int64 totalMsgCount = 3;
int64 readMsgCount = 4;
int64 unreadCount = 5;
bool isShow = 6;
}
message GetConversationsResp {
repeated Conversation conversations = 1;
}
实现代码:
go
package getconversations
import (
"context"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"message/internal/svc"
"message/pb"
)
func (l *GetConversationsLogic) GetConversations(in *pb.GetConversationsReq) (*pb.GetConversationsResp, error) {
// 1. 获取用户会话列表记录
userConvDoc, err := l.svcCtx.UserConvModel.FindOne(l.ctx, in.UserId)
if err != nil && err != mongo.ErrNoDocuments {
return nil, err
}
// 如果没有会话记录,返回空列表(正常情况)
if userConvDoc == nil || len(userConvDoc.ConversationIds) == 0 {
return &pb.GetConversationsResp{}, nil
}
// 2. 批量获取会话详情
convs, err := l.svcCtx.ConversationModel.FindByIds(l.ctx, userConvDoc.ConversationIds)
if err != nil {
return nil, err
}
// 3. 计算未读数量并调整显示状态
resp := make([]*pb.Conversation, 0, len(convs))
for _, conv := range convs {
// 如果 readMsgCount < totalMsgCount,说明有未读
unread := int64(0)
if conv.ReadMsgCount < conv.TotalMsgCount {
unread = conv.TotalMsgCount - conv.ReadMsgCount
// 如果会话被用户隐藏,有新消息时重新置为显示
if !conv.IsShow {
conv.IsShow = true
// 注意:这里仅对返回结果修改,不持久化,实际更新在 UpdateConversation 中处理
}
}
resp = append(resp, &pb.Conversation{
ConversationId: conv.ConversationId,
ChatType: conv.ChatType,
TotalMsgCount: conv.TotalMsgCount,
ReadMsgCount: conv.ReadMsgCount,
UnreadCount: unread,
IsShow: conv.IsShow,
})
}
return &pb.GetConversationsResp{Conversations: resp}, nil
}
此处关键点:会话列表可能为空,应视为正常,不报错;未读数量通过差值计算,且当有新消息时自动恢复隐藏的会话。
会话更新逻辑
更新会话通常由客户端定时触发,或用户阅读消息后主动提交。接口允许客户端传递一个 map[string]int64(会话 ID -> 本次已读数量),服务端累加到历史已读数上。
接口定义:
proto
rpc UpdateConversation(UpdateConversationReq) returns (UpdateConversationResp);
message UpdateConversationReq {
string userId = 1;
map<string, int64> readMap = 2; // key: conversationId, value: 客户端最新已读消息序号增量
}
message UpdateConversationResp {}
实现:
go
package updateconversation
import (
"context"
"go.mongodb.org/mongo-driver/mongo"
"message/internal/svc"
"message/pb"
)
func (l *UpdateConversationLogic) UpdateConversation(in *pb.UpdateConversationReq) (*pb.UpdateConversationResp, error) {
// 用户会话列表必然存在,因为建立会话时已经创建
for convId, readIncrement := range in.ReadMap {
conv, err := l.svcCtx.ConversationModel.FindOne(l.ctx, convId)
if err != nil {
// 不存在则初始化
conv = &model.Conversation{
ConversationId: convId,
TotalMsgCount: 0,
ReadMsgCount: 0,
}
if err != mongo.ErrNoDocuments {
return nil, err
}
}
// 记录历史已读数
oldRead := conv.ReadMsgCount
// 更新已读数:历史上已经读的 + 本次客户端上报增量
newRead := oldRead + readIncrement
// 安全保护:已读数量不能超过总消息数
if newRead > conv.TotalMsgCount {
newRead = conv.TotalMsgCount
}
conv.ReadMsgCount = newRead
// 持久化
if err := l.svcCtx.ConversationModel.Update(l.ctx, conv); err != nil {
return nil, err
}
}
return &pb.UpdateConversationResp{}, nil
}
该实现支持批量更新,并通过历史增量累加保证数据一致。
会话建立流程
建立单聊会话时,需要:
- 根据双方用户 ID 按同一规则生成唯一会话 ID(例如
combine(userA, userB))。 - 查询该会话是否已存在,存在则直接返回(幂等)。
- 若不存在,创建会话记录,并为双方用户分别添加会话列表记录。
接口定义:
proto
rpc CreateConversation(CreateConversationReq) returns (CreateConversationResp);
message CreateConversationReq {
string senderId = 1;
string receiverId = 2;
int32 chatType = 3; // 1:私聊
bool showForSender = 4; // 发送方是否在列表显示
}
message CreateConversationResp {
string conversationId = 1;
}
实现:
go
package createconversation
import (
"context"
"sort"
"strings"
"go.mongodb.org/mongo-driver/mongo"
"message/internal/svc"
"message/internal/model"
"message/pb"
)
// genConversationId 生成唯一会话 ID:私聊即两个用户 ID 排序后拼接
func genConversationId(uid1, uid2 string) string {
ids := []string{uid1, uid2}
sort.Strings(ids)
return strings.Join(ids, "_")
}
func (l *CreateConversationLogic) CreateConversation(in *pb.CreateConversationReq) (*pb.CreateConversationResp, error) {
convId := genConversationId(in.SenderId, in.ReceiverId)
// 检查会话是否已存在
conv, err := l.svcCtx.ConversationModel.FindOne(l.ctx, convId)
if err != nil && err != mongo.ErrNoDocuments {
return nil, err
}
if conv != nil {
// 已经建立,直接返回
return &pb.CreateConversationResp{ConversationId: convId}, nil
}
// 创建会话记录
newConv := &model.Conversation{
ConversationId: convId,
ChatType: in.ChatType,
TotalMsgCount: 0,
ReadMsgCount: 0,
IsShow: true,
}
if err := l.svcCtx.ConversationModel.Insert(l.ctx, newConv); err != nil {
return nil, err
}
// 为双方添加会话列表记录(发送方显示,接收方默认不显示)
if err := l.addUserConversation(in.SenderId, convId, true); err != nil {
return nil, err
}
if err := l.addUserConversation(in.ReceiverId, convId, false); err != nil {
return nil, err
}
return &pb.CreateConversationResp{ConversationId: convId}, nil
}
// addUserConversation 向用户会话列表中添加一条会话记录,若不存在则创建,存在则追加(幂等)
func (l *CreateConversationLogic) addUserConversation(userId, convId string, isShow bool) error {
userConv, err := l.svcCtx.UserConvModel.FindOne(l.ctx, userId)
if err != nil && err != mongo.ErrNoDocuments {
return err
}
if userConv == nil {
// 初始化用户会话列表
userConv = &model.UserConversation{
UserId: userId,
ConversationIds: []string{convId},
}
return l.svcCtx.UserConvModel.Insert(l.ctx, userConv)
}
// 已存在,判断是否已包含该会话 ID
if !contains(userConv.ConversationIds, convId) {
userConv.ConversationIds = append(userConv.ConversationIds, convId)
return l.svcCtx.UserConvModel.Update(l.ctx, userConv)
}
return nil
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
这里使用排序合并的方式生成唯一 ID,并确保了接口的幂等性。发起方调用时,发送方往往需要立刻看到该会话,因此 showForSender=true,而被添加方默认不显示,直到对方主动发送消息。
WebSocket 与消息收发模型回顾
在完整的 IM 系统中,离线消息的拉取依赖于消息能够可靠存储,而实时推送则依赖 WebSocket 连接和 Kafka 异步模型。
- WebSocket 鉴权 :客户端携带用户服务的 token 连接 WebSocket 服务,服务端调用
user服务Auth接口验证身份,并获取userId后建立长连接。 - 心跳检测:利用 gRPC 长连接的空闲检测器,定时发送/检测心跳,及时释放死连接。
- 私聊消息推送 :发送方将消息发送至 WebSocket 服务,服务根据
receiverId查找到对应连接进行推送。 - 消息收发模型(Kafka 异步):WebSocket 接收到消息后,不直接处理存储和推送,而是将消息写入 Kafka;后台消费者读取消息,完成数据库写入和推送。收与发解耦,提高吞吐和可靠性。
- ACK 确认机制:参考 TCP 握手设计,服务端推送消息后等待客户端确认,超时重推,确保消息可靠性。
离线消息拉取完整链路
下面用 Mermaid 时序图展示用户上线后拉取离线消息的完整流程:
WebSocket MongoDB message RPC API 网关 客户端 WebSocket MongoDB message RPC API 网关 客户端 #mermaid-svg-3OgZjU6vSW0c7bxA{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-3OgZjU6vSW0c7bxA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3OgZjU6vSW0c7bxA .error-icon{fill:#552222;}#mermaid-svg-3OgZjU6vSW0c7bxA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3OgZjU6vSW0c7bxA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3OgZjU6vSW0c7bxA .marker.cross{stroke:#333333;}#mermaid-svg-3OgZjU6vSW0c7bxA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3OgZjU6vSW0c7bxA p{margin:0;}#mermaid-svg-3OgZjU6vSW0c7bxA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3OgZjU6vSW0c7bxA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3OgZjU6vSW0c7bxA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA .sequenceNumber{fill:white;}#mermaid-svg-3OgZjU6vSW0c7bxA #sequencenumber{fill:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA .messageText{fill:#333;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3OgZjU6vSW0c7bxA .labelText,#mermaid-svg-3OgZjU6vSW0c7bxA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .loopText,#mermaid-svg-3OgZjU6vSW0c7bxA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .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-3OgZjU6vSW0c7bxA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3OgZjU6vSW0c7bxA .noteText,#mermaid-svg-3OgZjU6vSW0c7bxA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3OgZjU6vSW0c7bxA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3OgZjU6vSW0c7bxA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3OgZjU6vSW0c7bxA .actorPopupMenu{position:absolute;}#mermaid-svg-3OgZjU6vSW0c7bxA .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-3OgZjU6vSW0c7bxA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3OgZjU6vSW0c7bxA .actor-man circle,#mermaid-svg-3OgZjU6vSW0c7bxA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3OgZjU6vSW0c7bxA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 实时消息推送通过WebSocket完成 获取会话列表GetConversations(userId)查询用户会话ID列表批量查询会话详情会话列表(含未读计数)显示未读红点点击某个会话,拉取消息GetMessageRecord(convId, startTime, endTime)查询消息记录消息列表展示消息标记已读UpdateConversation(userId, readMap)更新已读消息序号成功更新本地未读计数
测试与常见问题
在开发过程中,遇到过以下几个典型问题并已修复:
-
字段类型不匹配
调用创建会话接口时,由于 protobuf 生成的字段类型与 MongoDB 存储字段类型不一致导致报错。修正措施:统一使用 string 类型存储用户 ID。
-
消息记录中会话 ID 和发送者缺失
消息消费者在处理 Kafka 任务时,未设置
conversationId和senderId,导致消息虽然写入却无法正确关联会话。修正:在推送消息至 Kafka 前,填充这两个字段;消费时直接使用。 -
发送时间为空
最初由客户端传递发送时间,不安全且可能为空。改为服务端在 WebSocket 收到消息时记录服务端时间
time.Now().Unix(),确保时间准确。 -
会话更新未生效
因为会话 ID 未正确传递到更新逻辑,需在 Kafka 消费者中显式设置
conversationId,之后更新才成功执行。
通过以上调整,离线消息的拉取和已读更新均可正确运行,消息总量和未读计数保持一致。
总结
本文详细介绍了使用 go-zero 实现即时通讯中离线消息拉取和会话管理的全过程。核心内容包括:
- 消息记录的双模式查询设计;
- 会话列表的获取与未读消息实时计算;
- 会话更新与幂等建立;
- 结合 WebSocket、Kafka 的实时推送模型;
- 典型问题的排查与解决。
掌握这些模块后,可以轻松扩展群聊、消息漫游等高级特性。所有实现均基于 go-zero@latest,确保代码的现代化与可维护性。