纲要
- 需求场景与设计思路
- 数据模型与
Bitmap的已读记录字段 WebSocket消息体扩展:已读请求结构- 消费者架构重构:抽象
BaseTransfer,避免代码重复 - 新增已读消费者,配置与注册
- 已读业务逻辑:更新
Bitmap、查询消息、构建已读回执 WebSocket层接入已读消息处理与路由API层增加已读记录查询接口- 测试验证:群聊消息已读推送与查询
背景与需求
在即时通讯系统中,消息的已读/未读状态对用户体验至关重要。本次技术实践将基于go-zero框架,结合Bitmap实现群聊消息的已读未读功能。Bitmap以高效的空间利用率记录大量用户对单条消息的阅读状态,特别适合群聊中成员众多的场景。需要前后端协同,前端上报已读消息ID,服务端更新对应消息的Bitmap,并通过WebSocket将已读状态变更推送给相关用户。
数据结构设计
消息模型
首先为聊天消息模型增加一个字段,用于存储该消息的已读记录(使用Bitmap)。以go-zero的模型文件为例,在ChatLog结构体中新增ReadBitmap字段,类型为[]byte,映射数据库的read_bitmap列。
go
// model/chatlog.go
package model
import "time"
type ChatLog struct {
Id int64 `db:"id"`
SenderId int64 `db:"sender_id"`
SessionId string `db:"session_id"`
Content string `db:"content"`
ReadBitmap []byte `db:"read_bitmap"` // 记录已读用户位图
CreateTime time.Time `db:"create_time"`
}
WebSocket 消息体扩展
客户端需要告知服务端哪些消息已读,因此定义一个新的消息结构ReadMsgReq,包含接收者ID、会话ID以及消息ID列表。
go
// types/ws.go
package types
type ReadMsgReq struct {
ReceiverId int64 `json:"receiver_id"` // 已读结果的接收者(通常为发送者本人)
SessionId string `json:"session_id"` // 会话ID
MsgIds []int64 `json:"msg_ids"` // 已读的消息ID列表
}
对应的,服务端推送已读记录回执时,消息体内增加内容类型和已读数据字段。定义内容类型常量,区分普通聊天消息和已读回执。
go
// types/ws.go (续)
const (
ContentTypeChat = 1 // 普通聊天消息
ContentTypeRead = 2 // 已读回执消息
)
type PushMsg struct {
ContentType int `json:"content_type"` // 内容类型
MsgId int64 `json:"msg_id"` // 消息ID
SenderId int64 `json:"sender_id"`
ReceiverId int64 `json:"receiver_id"`
SessionId string `json:"session_id"`
Content string `json:"content"` // 普通消息内容
ReadData string `json:"read_data"` // 已读bitmap的base64编码,仅在content_type=2时有效
SendTime int64 `json:"send_time"`
}
消费者架构重构
原有系统中已经存在一个用于消息转发的消费者(MessageTransfer),其核心流程是:接收消息 -> 业务处理 -> 结果转发。新增的已读消费者逻辑类似,也会接收已读指令 -> 更新bitmap -> 推送变更。为避免重复,将共同部分抽象到BaseTransfer。
类层次结构
dir
├─ internal/
│ ├─ consumer/
│ │ ├─ base_transfer.go (基础转发逻辑)
│ │ ├─ msg_transfer.go (普通消息处理,继承BaseTransfer)
│ │ └─ read_transfer.go (已读消息处理,继承BaseTransfer)
│ └─ config/
│ └─ config.go (配置)
BaseTransfer 实现
BaseTransfer包含通用的推送方法,根据会话类型(私聊/群聊)将结果发送到对应的WebSocket连接。
go
// internal/consumer/base_transfer.go
package consumer
import (
"context"
"im/internal/svc"
"im/internal/ws"
"im/types"
"github.com/zeromicro/go-zero/core/logx"
)
type BaseTransfer struct {
svcCtx *svc.ServiceContext
logx.Logger
}
func NewBaseTransfer(svcCtx *svc.ServiceContext) *BaseTransfer {
return &BaseTransfer{
svcCtx: svcCtx,
Logger: logx.WithContext(context.Background()),
}
}
// Transfer 根据会话类型转发消息
func (b *BaseTransfer) Transfer(ctx context.Context, sessionId string, chatType int64, sendTime int64, senderId int64, receiverIds []int64, content string, contentTyp int, readData string, msgId int64) error {
pushMsg := &types.PushMsg{
ContentType: contentTyp,
MsgId: msgId,
SenderId: senderId,
SessionId: sessionId,
Content: content,
ReadData: readData,
SendTime: sendTime,
}
switch chatType {
case types.ChatTypePrivate: // 私聊
return b.transferPrivate(ctx, pushMsg, receiverIds)
case types.ChatTypeGroup: // 群聊
return b.transferGroup(ctx, pushMsg, receiverIds)
default:
return nil
}
}
func (b *BaseTransfer) transferPrivate(ctx context.Context, msg *types.PushMsg, receiverIds []int64) error {
// 私聊逻辑:推送给接收者
for _, uid := range receiverIds {
if conn, ok := ws.GetConnection(uid); ok {
conn.WriteJSON(msg)
}
}
return nil
}
func (b *BaseTransfer) transferGroup(ctx context.Context, msg *types.PushMsg, receiverIds []int64) error {
// 群聊逻辑:推送给所有在线成员(需根据群组获取成员列表)
// 示例:通过群组服务获取成员ID
members, err := b.svcCtx.GroupModel.FindMembers(ctx, msg.SessionId)
if err != nil {
return err
}
for _, uid := range members {
if conn, ok := ws.GetConnection(uid); ok {
conn.WriteJSON(msg)
}
}
return nil
}
普通消息消费者
原先的消息消费者改写为继承BaseTransfer,去除重复的转发代码。
go
// internal/consumer/msg_transfer.go
package consumer
import (
"context"
"im/internal/svc"
"im/model"
"github.com/zeromicro/go-zero/core/logx"
)
type MsgTransfer struct {
*BaseTransfer
}
func NewMsgTransfer(svcCtx *svc.ServiceContext) *MsgTransfer {
return &MsgTransfer{
BaseTransfer: NewBaseTransfer(svcCtx),
}
}
func (m *MsgTransfer) Consume(ctx context.Context, key, value string) error {
// 1. 解析消息
var msg model.ChatLog
// ... 反序列化逻辑
// 2. 存储消息并设置发送者已读
msg.ReadBitmap = SetBit(nil, int(msg.SenderId), 1) // 发送者自己默认已读
// insert to db
// 3. 推送
return m.Transfer(ctx, msg.SessionId, chatType, sendTime, msg.SenderId, receiverIds, content, types.ContentTypeChat, "", msg.Id)
}
已读消费者实现
配置文件
在go-zero的配置文件(如etc/im.yaml)中添加已读消费者的Kafka配置。此处假设使用Kafka作为消息队列。
yaml
Kafka:
ReadMsgGroup: read-msg-group
ReadMsgTopic: read-msg-topic
Brokers:
- 127.0.0.1:9092
消费者代码
已读消费者ReadTransfer同样继承BaseTransfer,核心业务是:根据消息ID列表更新每条消息的已读bitmap,然后构造已读记录回执推送给相关用户。
go
// internal/consumer/read_transfer.go
package consumer
import (
"context"
"encoding/base64"
"fmt"
"im/internal/svc"
"im/model"
"im/types"
"strconv"
"strings"
"github.com/zeromicro/go-zero/core/logx"
)
type ReadTransfer struct {
*BaseTransfer
}
func NewReadTransfer(svcCtx *svc.ServiceContext) *ReadTransfer {
return &ReadTransfer{
BaseTransfer: NewBaseTransfer(svcCtx),
}
}
// Consume 处理已读消息
func (r *ReadTransfer) Consume(ctx context.Context, key, value string) error {
// 解析已读请求
var req types.ReadMsgReq
// ... 反序列化
// 1. 根据msgIds批量查询聊天记录
chatLogs, err := r.svcCtx.ChatLogModel.FindByIds(ctx, req.MsgIds)
if err != nil {
return err
}
// 2. 逐个更新已读bitmap,并收集变更结果
readRecords := make(map[int64]string) // key: msgId, value: base64编码的bitmap
for _, log := range chatLogs {
bitmap := log.ReadBitmap
// 将当前请求用户标记为已读(假设已从上下文获取请求用户ID userId)
bitmap = SetBit(bitmap, int(userId), 1) // userId需从消息上下文获取
// 更新数据库
err = r.svcCtx.ChatLogModel.UpdateReadBitmap(ctx, log.Id, bitmap)
if err != nil {
r.Errorf("update read bitmap error: %v", err)
continue
}
// 将bitmap转为base64以便网络传输
readRecords[log.Id] = base64.StdEncoding.EncodeToString(bitmap)
}
// 3. 推送已读回执给接收者(一般是消息发送者)
for msgId, bitmapBase64 := range readRecords {
// 此处需要获取该消息的发送者作为接收者
msg, err := r.svcCtx.ChatLogModel.FindOne(ctx, msgId)
if err != nil {
continue
}
// 发送已读回执
r.Transfer(ctx, msg.SessionId, chatType, sendTime, msg.SenderId, []int64{req.ReceiverId}, "", types.ContentTypeRead, bitmapBase64, msgId)
}
return nil
}
// SetBit 位图操作辅助函数:设置指定位置为1
func SetBit(bitmap []byte, index int, value int) []byte {
// 实现细节...
return bitmap
}
在ServiceContext中注册消费者:
go
// internal/svc/service_context.go
package svc
import (
"im/internal/consumer"
"github.com/zeromicro/go-zero/core/service"
)
type ServiceContext struct {
Config config.Config
ReadConsumer *consumer.ReadTransfer
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
ReadConsumer: consumer.NewReadTransfer(c),
}
}
WebSocket 层接入
客户端注册
WebSocket模块需要新增一个方法处理客户端发来的已读消息,将请求推送到Kafka中供ReadConsumer消费。
go
// internal/ws/handler.go
package ws
import (
"context"
"im/types"
"encoding/json"
"github.com/zeromicro/go-zero/core/logx"
)
type Handler struct {
svcCtx *svc.ServiceContext
}
func (h *Handler) ReadMsgHandler(userId int64, conn *websocket.Conn, data []byte) error {
var req types.ReadMsgReq
if err := json.Unmarshal(data, &req); err != nil {
return err
}
// 将已读请求写入Kafka
msgKey := fmt.Sprintf("read:%d:%s", userId, req.SessionId)
msgValue, _ := json.Marshal(req)
return h.svcCtx.KafkaPusher.Push("read-msg-topic", msgKey, string(msgValue))
}
同时在路由中添加对read_msg消息类型的支持。
go
// internal/ws/router.go
func Route(h *Handler, conn *websocket.Conn, msgType string, data []byte) {
switch msgType {
case "send_msg":
h.SendMsgHandler(conn, data)
case "read_msg":
userId := conn.GetUserId() // 从连接获取用户ID
h.ReadMsgHandler(userId, conn, data)
default:
logx.Error("unknown message type")
}
}
API层:已读记录查询
为了方便前端展示某个消息的已读/未读用户列表,提供HTTP API接口。根据消息ID返回已读用户列表和未读用户列表(群聊需知道所有成员)。
go
// internal/handler/read/read_record_handler.go
package read
import (
"net/http"
"im/internal/logic/read"
"im/internal/svc"
"im/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ReadRecordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ReadRecordReq
if err := httpx.Parse(r, &req); err != nil {
httpx.Error(w, err)
return
}
l := read.NewReadRecordLogic(r.Context(), svcCtx)
resp, err := l.ReadRecord(&req)
if err != nil {
httpx.Error(w, err)
return
}
httpx.OkJson(w, resp)
}
}
业务逻辑:查询消息,解析bitmap,与群组成员列表比对,区分已读和未读。
go
// internal/logic/read/read_record_logic.go
package read
import (
"context"
"im/internal/svc"
"im/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ReadRecordLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewReadRecordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReadRecordLogic {
return &ReadRecordLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ReadRecordLogic) ReadRecord(req *types.ReadRecordReq) (*types.ReadRecordResp, error) {
// 1. 获取消息记录
msg, err := l.svcCtx.ChatLogModel.FindOne(l.ctx, req.MsgId)
if err != nil {
return nil, err
}
// 2. 获取群组所有成员(假设群聊)
members, err := l.svcCtx.GroupModel.FindMembers(l.ctx, msg.SessionId)
if err != nil {
return nil, err
}
// 3. 解析bitmap
readUids := make([]int64, 0)
unreadUids := make([]int64, 0)
for _, uid := range members {
if GetBit(msg.ReadBitmap, int(uid)) == 1 {
readUids = append(readUids, uid)
} else {
unreadUids = append(unreadUids, uid)
}
}
return &types.ReadRecordResp{
ReadUids: readUids,
UnreadUids: unreadUids,
}, nil
}
func GetBit(bitmap []byte, index int) int {
// 实现位图读取...
return 0
}
流程示意
将整个已读流程按服务间交互展示如下:
数据库 ReadConsumer Kafka WebSocket服务 客户端 数据库 ReadConsumer Kafka WebSocket服务 客户端 #mermaid-svg-5MhbCfCI4EkelaVa{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-5MhbCfCI4EkelaVa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5MhbCfCI4EkelaVa .error-icon{fill:#552222;}#mermaid-svg-5MhbCfCI4EkelaVa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5MhbCfCI4EkelaVa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5MhbCfCI4EkelaVa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5MhbCfCI4EkelaVa .marker.cross{stroke:#333333;}#mermaid-svg-5MhbCfCI4EkelaVa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5MhbCfCI4EkelaVa p{margin:0;}#mermaid-svg-5MhbCfCI4EkelaVa .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5MhbCfCI4EkelaVa text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5MhbCfCI4EkelaVa .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5MhbCfCI4EkelaVa .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa .sequenceNumber{fill:white;}#mermaid-svg-5MhbCfCI4EkelaVa #sequencenumber{fill:#333;}#mermaid-svg-5MhbCfCI4EkelaVa #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5MhbCfCI4EkelaVa .messageText{fill:#333;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5MhbCfCI4EkelaVa .labelText,#mermaid-svg-5MhbCfCI4EkelaVa .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .loopText,#mermaid-svg-5MhbCfCI4EkelaVa .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .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-5MhbCfCI4EkelaVa .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5MhbCfCI4EkelaVa .noteText,#mermaid-svg-5MhbCfCI4EkelaVa .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5MhbCfCI4EkelaVa .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5MhbCfCI4EkelaVa .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5MhbCfCI4EkelaVa .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5MhbCfCI4EkelaVa .actorPopupMenu{position:absolute;}#mermaid-svg-5MhbCfCI4EkelaVa .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-5MhbCfCI4EkelaVa .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5MhbCfCI4EkelaVa .actor-man circle,#mermaid-svg-5MhbCfCI4EkelaVa line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5MhbCfCI4EkelaVa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop每条消息 发送 read_msg {msgIds}产生消息到 read-msg-topic消费消息根据msgIds查询聊天记录更新bitmap (设置当前用户已读)写回新的bitmap构造已读回执 (bitmap base64)推送已读回执给发送者回执推送
测试与验证
启动所有服务后,使用多个WebSocket客户端连接,模拟群聊消息。
- 客户端A发送一条群消息,客户端B、C均收到该消息,消息体内包含
msg_id。 - 客户端B发送
read_msg,携带刚才的msg_id。 - 客户端B自身可在UI上看到已读状态变化,同时A和C会收到一条
content_type=2的推送,包含read_data(base64编码的bitmap)。 - 调用
API /read_record?msg_id=xxx查询,返回已读用户列表(含A作为发送者默认已读、B)和未读用户列表(C)。 - 再次由客户端C发送已读后,查询接口返回结果更新,所有成员均已读。
通过以上测试,功能符合预期。需要注意的是,bitmap的长度需根据可能的最大用户ID动态扩容,使用SetBit和GetBit时做好边界检查。
总结
本文基于go-zero框架,利用Bitmap数据结构高效实现了群聊消息的已读未读功能。关键技术点包括:
- 位图在消息模型中的存储与应用;
- 消费者抽象重构,避免重复代码;
Kafka消息驱动,实现异步更新与推送;WebSocket实时推送已读状态;API查询接口方便前端数据渲染。
在实际项目中,还需考虑大量群聊成员时的bitmap长度优化、并发更新的原子性等问题,后续可进一步结合Redis缓存bitmap提升性能。