群聊读扩散机制场景解析
📋 概述
本文档深入解析OpenIM群聊读扩散机制中的7个关键复杂场景,包括消息删除、管理员硬删除、会话清理、群组序号控制、新成员历史权限、消息拉取逻辑以及消息撤回处理等。这些机制确保了群聊系统的数据一致性、权限隔离和多设备同步。
🔢 序号控制体系说明
OpenIM采用五个关键序号字段实现精确的消息权限控制和状态管理:
会话级序号控制(seq表)
- conversationMinSeq(min_seq):会话全局最小可读序号,主要用于管理员硬删除后的边界控制
- conversationMaxSeq(max_seq):会话全局最大序号,群内每发送一条消息都会使其递增
用户级序号控制(seq_user表)
- userMinSeq(min_seq):控制用户可读的最小序号值,主要用于新成员历史消息隐藏
- userMaxSeq(max_seq):控制用户可读的最大序号值,主要用于退群用户的权限隔离
- userReadSeq(req_seq):用户已读的序号,用于控制未读红点和已读状态
消息级状态控制
- msgSeq(seq):每个消息的唯一序号,全局递增,用于消息排序和检索
- msgIsRead(is_read):消息是否已读标识,在单聊场景中用于已读回执
数据表关系说明
- seq表:存储会话级的序号边界信息
- seq_user表:存储每个用户在特定会话中的个性化序号控制
- conversation表:虽然也包含minSeq和maxSeq字段,但主要为兼容老版本,实际权限控制以seq和seq_user表为准
核心技术点
- 五层序号管理:conversation级2个 + user级3个 + 消息级状态控制
- 权限隔离:用户级别的消息可见性精确控制
- 删除策略:逻辑删除vs物理删除的双重机制,支持管理员硬删除释放存储
- 同步机制:基于del_list和序号的多设备同步
- 读扩散实现:会话记录分离,消息统一存储
🚀 第一部分:消息删除逻辑解析
1.1 双重删除机制设计
OpenIM采用逻辑删除 和物理删除并存的双重删除机制。其中物理删除一般是管理员操作,用于清理无用的群消息,释放存储空间,提升系统性能。
逻辑删除(用户级隐藏)
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:577
func (db *commonMsgDatabase) DeleteUserMsgsBySeqs(ctx context.Context, userID string, conversationID string, seqs []int64) error {
for docID, seqs := range db.msgTable.GetDocIDSeqsMap(conversationID, seqs) {
for _, seq := range seqs {
// 关键:将用户ID添加到del_list数组,实现逻辑删除
if _, err := db.msgDocDatabase.PushUnique(ctx, docID, db.msgTable.GetMsgIndex(seq), "del_list", []string{userID}); err != nil {
return err
}
}
}
return db.msgCache.DelMessageBySeqs(ctx, conversationID, seqs)
}
物理删除(全局删除)
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:566
func (db *commonMsgDatabase) DeleteMsgsPhysicalBySeqs(ctx context.Context, conversationID string, allSeqs []int64) error {
for docID, seqs := range db.msgTable.GetDocIDSeqsMap(conversationID, allSeqs) {
var indexes []int
for _, seq := range seqs {
indexes = append(indexes, int(db.msgTable.GetMsgIndex(seq)))
}
// 关键:彻底删除消息内容,对所有用户生效
if err := db.msgDocDatabase.DeleteMsgsInOneDocByIndex(ctx, docID, indexes); err != nil {
return err
}
}
return db.msgCache.DelMessageBySeqs(ctx, conversationID, allSeqs)
}
1.2 删除模式控制机制
go
// 文件:open-im-server/internal/rpc/msg/delete.go:98
func (m *msgServer) DeleteMsgs(ctx context.Context, req *msg.DeleteMsgsReq) (*msg.DeleteMsgsResp, error) {
// 解析同步选项,决定删除模式
isSyncSelf, isSyncOther := m.validateDeleteSyncOpt(req.DeleteSyncOpt)
if isSyncOther {
// 物理删除模式:从数据库中彻底删除消息
if err := m.MsgDatabase.DeleteMsgsPhysicalBySeqs(ctx, req.ConversationID, req.Seqs); err != nil {
return nil, err
}
// 发送删除通知给会话中的其他用户
tips := &sdkws.DeleteMsgsTips{
UserID: req.UserID,
ConversationID: req.ConversationID,
Seqs: req.Seqs,
}
m.notificationSender.NotificationWithSessionType(ctx, req.UserID,
m.conversationAndGetRecvID(conv, req.UserID),
constant.DeleteMsgsNotification, conv.ConversationType, tips)
} else {
// 逻辑删除模式:只对指定用户隐藏消息
if err := m.MsgDatabase.DeleteUserMsgsBySeqs(ctx, req.UserID, req.ConversationID, req.Seqs); err != nil {
return nil, err
}
}
}
1.3 删除标记检测与过滤
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:771
func (db *commonMsgDatabase) handlerDeleteAndRevoked(ctx context.Context, userID string, msgs []*model.MsgInfoModel) {
for i := range msgs {
msg := msgs[i]
if msg == nil || msg.Msg == nil {
continue
}
// 检查用户是否在删除列表中
if datautil.Contain(userID, msg.DelList...) {
msg.Msg.Content = "" // 清空消息内容
msg.Msg.Status = constant.MsgDeleted // 标记为已删除
}
}
}
1.4 消息删除架构图
graph TB
subgraph "消息删除决策"
A[删除请求] --> B{同步选项判断}
B --> |isSyncOther=true| C[物理删除模式]
B --> |isSyncOther=false| D[逻辑删除模式]
end
subgraph "物理删除流程"
C --> E[DeleteMsgsPhysicalBySeqs]
E --> F[MongoDB文档删除]
F --> G[Redis缓存清理]
G --> H[全用户删除通知]
end
subgraph "逻辑删除流程"
D --> I[DeleteUserMsgsBySeqs]
I --> J[del_list数组添加用户ID]
J --> K[消息内容保留]
K --> L[单用户同步通知]
end
subgraph "消息读取过滤"
M[GetMessageBySeqs] --> N[handlerDeleteAndRevoked]
N --> O{用户在del_list中?}
O --> |是| P[返回空内容+已删除状态]
O --> |否| Q[返回完整消息内容]
end
style C fill:#ff9999
style D fill:#99ccff
🗑️ 第二部分:管理员硬删除机制
2.1 DestructMsgs硬删除概述
管理员硬删除(DestructMsgs)是OpenIM提供的物理删除功能,主要用于:
- 存储优化:删除过期或无用消息,释放MongoDB存储空间
- 数据清理:满足数据保留策略和合规性要求(如GDPR)
- 性能提升:减少索引大小,提高查询性能
- 系统维护:清理测试数据或异常数据
2.2 硬删除的核心实现
go
// 文件:open-im-server/internal/rpc/msg/clear.go:17
func (m *msgServer) DestructMsgs(ctx context.Context, req *msg.DestructMsgsReq) (*msg.DestructMsgsResp, error) {
// ========== 第一步:权限验证 ==========
// 只有系统管理员才能执行硬删除操作,这是重要的安全检查
if err := authverify.CheckAdmin(ctx, m.config.Share.IMAdminUserID); err != nil {
return nil, err
}
// ========== 第二步:随机查找待删除文档 ==========
// 查找指定时间戳之前的消息文档,使用随机查找避免每次删除相同文档
// Limit参数控制每次删除的文档数量,避免一次性删除过多数据影响性能
docs, err := m.MsgDatabase.GetRandBeforeMsg(ctx, req.Timestamp, int(req.Limit))
if err != nil {
return nil, err
}
// ========== 第三步:逐个处理每个文档 ==========
for i, doc := range docs {
// 从MongoDB中物理删除文档,这个操作是不可逆的
if err := m.MsgDatabase.DeleteDoc(ctx, doc.DocID); err != nil {
return nil, err
}
log.ZDebug(ctx, "DestructMsgs delete doc", "index", i, "docID", doc.DocID)
// ========== 第四步:解析文档ID提取会话ID ==========
// 文档ID格式通常为 "conversationID:xxx"
index := strings.LastIndex(doc.DocID, ":")
if index < 0 {
continue // 文档ID格式不正确,跳过
}
// ========== 第五步:计算最大序列号 ==========
var maxSeqInDoc int64
for _, model := range doc.Msg {
if model.Msg == nil {
continue
}
// 记录文档中最大的序列号
if model.Msg.Seq > maxSeqInDoc {
maxSeqInDoc = model.Msg.Seq
}
}
if maxSeqInDoc <= 0 {
continue
}
// 提取会话ID
conversationID := doc.DocID[:index]
if conversationID == "" {
continue
}
// ========== 第六步:关键操作 - 设置会话最小序号 ==========
// 设置conversationMinSeq = maxSeqInDoc + 1
// 这样可以确保所有小于等于maxSeqInDoc的消息都不会被查询到
// 即使它们在其他文档中仍然存在,也会被逻辑上"删除"
newMinSeq := maxSeqInDoc + 1
if err := m.MsgDatabase.SetMinSeq(ctx, conversationID, newMinSeq); err != nil {
return nil, err
}
log.ZDebug(ctx, "DestructMsgs delete doc set min seq", "index", i, "docID", doc.DocID,
"conversationID", conversationID, "setMinSeq", newMinSeq)
}
// 返回实际删除的文档数量
return &msg.DestructMsgsResp{Count: int32(len(docs))}, nil
}
2.3 SetMinSeq的边界控制机制
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:820
func (db *commonMsgDatabase) SetMinSeq(ctx context.Context, conversationID string, seq int64) error {
// 先获取当前的conversationMinSeq
dbSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID)
if err != nil {
if errors.Is(errs.Unwrap(err), redis.Nil) {
return nil // 会话不存在,忽略
}
return err
}
// 关键:只有新的序号更大时才更新,确保minSeq单调递增
if dbSeq >= seq {
return nil // 不允许回退minSeq
}
// 更新会话的最小序号
return db.seqConversation.SetMinSeq(ctx, conversationID, seq)
}
2.4 硬删除的序号影响分析
删除前状态
ini
会话: group_123
├── conversationMinSeq: 1
├── conversationMaxSeq: 10000
├── 文档范围: [1-100], [101-200], ..., [9901-10000]
└── 用户可见范围: 1-10000
执行硬删除:删除时间戳1000之前的文档
go
// 假设删除了包含序号1-500的5个文档
// 文档ID: group_123:0, group_123:1, group_123:2, group_123:3, group_123:4
// 最大被删除序号: 500
newMinSeq := 500 + 1 = 501
SetMinSeq(ctx, "group_123", 501)
删除后状态
ini
会话: group_123
├── conversationMinSeq: 501 (关键变化)
├── conversationMaxSeq: 10000
├── 物理存在文档: [501-600], [601-700], ..., [9901-10000]
├── 逻辑边界: 501-10000
└── 用户可见范围: 501-10000 (序号1-500的消息不可见)
2.5 消息查询时的边界检查
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:429
func (db *commonMsgDatabase) GetMsgBySeqsRange(ctx context.Context, userID string, conversationID string, begin, end, num, userMaxSeq int64) (int64, int64, []*sdkws.MsgData, error) {
// 获取用户最小序号
userMinSeq, err := db.seqUser.GetUserMinSeq(ctx, conversationID, userID)
if err != nil && !errors.Is(err, redis.Nil) {
return 0, 0, nil, err
}
// 获取会话最小序号(硬删除后会更新)
conversationMinSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID)
if err != nil {
return 0, 0, nil, err
}
// 取更严格的最小序号限制
effectiveMinSeq := conversationMinSeq
if userMinSeq > conversationMinSeq {
effectiveMinSeq = userMinSeq
}
// 关键:被硬删除的消息即使用户权限允许,也无法查询到
if effectiveMinSeq > end {
log.ZWarn(ctx, "minSeq > end after hard delete", errs.New("minSeq>end"),
"effectiveMinSeq", effectiveMinSeq, "end", end)
return 0, 0, nil, nil // 查询范围在被删除区域内
}
// 继续后续查询逻辑...
}
2.6 硬删除架构流程图
graph TB
subgraph "管理员硬删除触发"
A[管理员权限验证] --> B[DestructMsgs请求]
B --> C[时间戳+数量限制]
end
subgraph "文档查找与删除"
C --> D[GetRandBeforeMsg
随机查找待删除文档] D --> E[逐个删除文档
MongoDB物理删除] E --> F[解析docID
提取conversationID] end subgraph "序号边界更新" F --> G[计算文档最大序号
maxSeqInDoc] G --> H[SetMinSeq
conversationMinSeq = maxSeqInDoc + 1] H --> I[Redis缓存清理] end subgraph "查询边界生效" J[用户消息查询] --> K[GetMinSeq获取边界] K --> L{seq < conversationMinSeq?} L --> |是| M[消息不可见
已被硬删除] L --> |否| N[正常查询流程] end I --> J style A fill:#ff9999 style E fill:#ffcccc style H fill:#ffffcc style M fill:#ccccff
随机查找待删除文档] D --> E[逐个删除文档
MongoDB物理删除] E --> F[解析docID
提取conversationID] end subgraph "序号边界更新" F --> G[计算文档最大序号
maxSeqInDoc] G --> H[SetMinSeq
conversationMinSeq = maxSeqInDoc + 1] H --> I[Redis缓存清理] end subgraph "查询边界生效" J[用户消息查询] --> K[GetMinSeq获取边界] K --> L{seq < conversationMinSeq?} L --> |是| M[消息不可见
已被硬删除] L --> |否| N[正常查询流程] end I --> J style A fill:#ff9999 style E fill:#ffcccc style H fill:#ffffcc style M fill:#ccccff
2.7 硬删除使用场景
场景 | 删除策略 | 时间窗口 | 影响范围 | 目的 |
---|---|---|---|---|
定期清理 | 删除6个月前消息 | 固定时间戳 | 全系统 | 存储优化 |
合规要求 | 删除用户注销数据 | 指定时间段 | 特定用户 | 法规遵从 |
测试清理 | 删除测试群消息 | 测试期间 | 测试环境 | 环境清理 |
异常处理 | 删除异常数据 | 故障时间段 | 受影响会话 | 数据修复 |
🧹 第三部分:会话消息清理机制
3.1 序号控制清理策略
会话消息清理通过设置conversationMinSeq实现,而非真正删除消息:
go
// 文件:open-im-server/internal/rpc/msg/delete.go:200
func (m *msgServer) clearConversation(ctx context.Context, conversationIDs []string, userID string, deleteSyncOpt *msg.DeleteSyncOpt) error {
// 获取每个会话的最大序列号
maxSeqs, err := m.MsgDatabase.GetMaxSeqs(ctx, existConversationIDs)
if err != nil {
return err
}
isSyncSelf, isSyncOther := m.validateDeleteSyncOpt(deleteSyncOpt)
if !isSyncOther {
// 逻辑清理:设置用户的最小序列号
setSeqs := m.getMinSeqs(maxSeqs) // maxSeq + 1
// 为用户设置会话的最小序列号,隐藏历史消息
if err := m.MsgDatabase.SetUserConversationsMinSeqs(ctx, userID, setSeqs); err != nil {
return err
}
} else {
// 物理清理:全局设置最小序列号
if err := m.MsgDatabase.SetMinSeqs(ctx, m.getMinSeqs(maxSeqs)); err != nil {
return err
}
}
}
3.2 conversationMinSeq计算逻辑
go
// 文件:open-im-server/internal/rpc/msg/delete.go:37
func (m *msgServer) getMinSeqs(maxSeqs map[string]int64) map[string]int64 {
minSeqs := make(map[string]int64)
for k, v := range maxSeqs {
minSeqs[k] = v + 1 // 关键:最小序列号设置为最大序列号+1
}
return minSeqs
}
3.3 会话清理类型对比
清理类型 | 作用范围 | 实现方式 | 可恢复性 | 同步范围 |
---|---|---|---|---|
逻辑清理 | 单用户 | 设置UserMinSeq | 可恢复 | 自己的其他设备 |
物理清理 | 全用户 | 设置conversationMinSeq | 不可恢复 | 会话所有参与者 |
本地清理 | 当前设备 | 本地数据库删除 | 不可恢复 | 无 |
3.4 清理操作的序号影响
graph LR
subgraph "清理前状态"
A[MinSeq: 1] --> B[MaxSeq: 1000]
B --> C[可见消息: 1-1000]
end
subgraph "执行清理"
D[getMinSeqs] --> E[newMinSeq = MaxSeq + 1]
E --> F[newMinSeq = 1001]
end
subgraph "清理后状态"
G[MinSeq: 1001] --> H[MaxSeq: 1000]
H --> I[可见消息: 无]
I --> J[新消息从1001开始]
end
A --> D
F --> G
🚪 第四部分:群组退出序号控制
4.1 退群序号设置机制
用户退群时,系统通过SetConversationMaxSeq
设置其userMaxSeq为当前群最大序号,确保重新加入时看不到离开期间的消息。
4.1.1 deleteMemberAndSetConversationSeq 核心方法
go
// 文件:open-im-server/internal/rpc/group/group.go:1663
func (g *groupServer) deleteMemberAndSetConversationSeq(ctx context.Context, groupID string, userIDs []string) error {
// 第一步:构建群组会话ID
conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, groupID)
// 第二步:获取当前群组会话的最大序列号
maxSeq, err := g.msgClient.GetConversationMaxSeq(ctx, conversationID)
if err != nil {
return err
}
// 第三步:关键操作 - 为指定用户设置会话序列号为当前最大值
// 这样用户重新加入时不会看到离开期间的历史消息
return g.conversationClient.SetConversationMaxSeq(ctx, conversationID, userIDs, maxSeq)
}
4.1.2 SetConversationMaxSeq 完整调用链路
链路分析说明:整个调用链路会同时更新seq_user表和conversation表,两个表的逻辑完全一样。根据代码分析,conversation表的更新主要是为了兼容老版本逻辑,实际的权限控制以seq_user表为准。这种设计确保了系统升级的平滑过渡。
go
// 第一层:Conversation服务接口层
// 文件:open-im-server/internal/rpc/conversation/conversation.go:762
func (c *conversationServer) SetConversationMaxSeq(ctx context.Context, req *pbconversation.SetConversationMaxSeqReq) (*pbconversation.SetConversationMaxSeqResp, error) {
// 步骤1:更新用户序号缓存和数据库
if err := c.msgClient.SetUserConversationMaxSeq(ctx, req.ConversationID, req.OwnerUserID, req.MaxSeq); err != nil {
return nil, err
}
// 步骤2:更新会话表中的max_seq字段
if err := c.conversationDatabase.UpdateUsersConversationField(ctx, req.OwnerUserID, req.ConversationID,
map[string]any{"max_seq": req.MaxSeq}); err != nil {
return nil, err
}
// 步骤3:发送变更通知,触发多端同步
for _, userID := range req.OwnerUserID {
c.conversationNotificationSender.ConversationChangeNotification(ctx, userID, []string{req.ConversationID})
}
return &pbconversation.SetConversationMaxSeqResp{}, nil
}
go
// 第二层:消息服务序号更新
// 文件:open-im-server/internal/rpc/msg/seq.go:82
func (m *msgServer) SetUserConversationMaxSeq(ctx context.Context, req *pbmsg.SetUserConversationMaxSeqReq) (*pbmsg.SetUserConversationMaxSeqResp, error) {
for _, userID := range req.OwnerUserID {
// 为每个用户更新该会话的最大序列号
if err := m.MsgDatabase.SetUserConversationsMaxSeq(ctx, req.ConversationID, userID, req.MaxSeq); err != nil {
return nil, err
}
}
return &pbmsg.SetUserConversationMaxSeqResp{}, nil
}
go
// 第三层:数据存储控制器
// 文件:open-im-server/pkg/common/storage/controller/msg.go:607
func (db *commonMsgDatabase) SetUserConversationsMaxSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
return db.seqUser.SetUserMaxSeq(ctx, conversationID, userID, seq)
}
go
// 第四层:Redis缓存层
// 文件:open-im-server/pkg/common/storage/cache/redis/seq_user.go:51
func (s *seqUserCacheRedis) SetUserMaxSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
// 先更新MongoDB数据库
if err := s.mgo.SetUserMaxSeq(ctx, conversationID, userID, seq); err != nil {
return err
}
// 然后清理Redis缓存,确保下次读取最新数据
return s.rocks.TagAsDeleted2(ctx, s.getSeqUserMaxSeqKey(conversationID, userID))
}
go
// 第五层:MongoDB数据层
// 文件:open-im-server/pkg/common/storage/database/mgo/seq_user.go:71
func (s *seqUserMongo) SetUserMaxSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
return s.setSeq(ctx, conversationID, userID, seq, "max_seq")
}
func (s *seqUserMongo) setSeq(ctx context.Context, conversationID string, userID string, seq int64, field string) error {
filter := map[string]any{
"user_id": userID,
"conversation_id": conversationID,
}
insert := bson.M{
"user_id": userID,
"conversation_id": conversationID,
"min_seq": 0,
"max_seq": 0,
"read_seq": 0,
}
delete(insert, field)
update := map[string]any{
"$set": bson.M{
field: seq, // 更新max_seq字段
},
"$setOnInsert": insert,
}
opt := options.Update().SetUpsert(true)
return mongoutil.UpdateOne(ctx, s.coll, filter, update, false, opt)
}
4.1.3 UpdateUsersConversationField 会话表更新机制
go
// 文件:open-im-server/pkg/common/storage/controller/conversation.go:126
func (c *conversationDatabase) UpdateUsersConversationField(ctx context.Context, userIDs []string, conversationID string, args map[string]any) error {
// 第一步:批量更新会话字段
_, err := c.conversationDB.UpdateByMap(ctx, userIDs, conversationID, args)
if err != nil {
return err
}
// 第二步:清理相关缓存
cache := c.cache.CloneConversationCache()
cache = cache.DelUsersConversation(conversationID, userIDs...).DelConversationVersionUserIDs(userIDs...)
// 第三步:根据更新的字段类型清理对应缓存
if _, ok := args["recv_msg_opt"]; ok {
cache = cache.DelConversationNotReceiveMessageUserIDs(conversationID)
cache = cache.DelConversationNotNotifyMessageUserIDs(userIDs...)
}
if _, ok := args["is_pinned"]; ok {
cache = cache.DelConversationPinnedMessageUserIDs(userIDs...)
}
return cache.ChainExecDel(ctx)
}
go
// 文件:open-im-server/pkg/common/storage/database/mgo/conversation.go:77
func (c *ConversationMgo) UpdateByMap(ctx context.Context, userIDs []string, conversationID string, args map[string]any) (int64, error) {
if len(args) == 0 || len(userIDs) == 0 {
return 0, nil
}
filter := bson.M{
"conversation_id": conversationID,
"owner_user_id": bson.M{"$in": userIDs},
}
var rows int64
err := mongoutil.IncrVersion(func() error {
// 批量更新多个用户的会话记录
res, err := mongoutil.UpdateMany(ctx, c.coll, filter, bson.M{"$set": args})
if err != nil {
return err
}
rows = res.ModifiedCount
return nil
}, func() error {
// 更新版本信息,用于增量同步
for _, userID := range userIDs {
if err := c.version.IncrVersion(ctx, userID, []string{conversationID}, model.VersionStateUpdate); err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, err
}
return rows, nil
}
4.2 退群处理完整流程
go
// 文件:open-im-server/internal/rpc/group/group.go:1593
func (g *groupServer) QuitGroup(ctx context.Context, req *pbgroup.QuitGroupReq) (*pbgroup.QuitGroupResp, error) {
// 1. 权限验证:检查群主不能退出群组
if member.RoleLevel == constant.GroupOwner {
return nil, errs.ErrNoPermission.WrapMsg("group owner can't quit")
}
// 2. 从群组中删除该成员
err = g.db.DeleteGroupMember(ctx, req.GroupID, []string{req.UserID})
if err != nil {
return nil, err
}
// 3. 发送成员退出通知
g.notification.MemberQuitNotification(ctx, g.groupMemberDB2PB(member, 0))
// 4. 关键:设置用户的会话序列号,确保退出后不能看到后续消息
if err := g.deleteMemberAndSetConversationSeq(ctx, req.GroupID, []string{req.UserID}); err != nil {
return nil, err
}
return &pbgroup.QuitGroupResp{}, nil
}
4.3 踢人场景的序号处理
go
// 文件:open-im-server/internal/push/push_handler.go:351
func (c *ConsumerHandler) groupMessagesHandler(ctx context.Context, groupID string, pushToUserIDs *[]string, msg *sdkws.MsgData) (err error) {
switch msg.ContentType {
case constant.MemberQuitNotification:
var tips sdkws.MemberQuitTips
if unmarshalNotificationElem(msg.Content, &tips) != nil {
return err
}
// 退群时设置序号
if err = c.DeleteMemberAndSetConversationSeq(ctx, groupID, []string{tips.QuitUser.UserID}); err != nil {
log.ZError(ctx, "MemberQuitNotification DeleteMemberAndSetConversationSeq", err)
}
case constant.MemberKickedNotification:
var tips sdkws.MemberKickedTips
if unmarshalNotificationElem(msg.Content, &tips) != nil {
return err
}
kickedUsers := datautil.Slice(tips.KickedUserList, func(e *sdkws.GroupMemberFullInfo) string { return e.UserID })
// 踢人时批量设置序号
if err = c.DeleteMemberAndSetConversationSeq(ctx, groupID, kickedUsers); err != nil {
log.ZError(ctx, "MemberKickedNotification DeleteMemberAndSetConversationSeq", err)
}
}
4.4 退群序号控制架构
架构图说明:下图展示了退群时序号设置的完整流程,从触发操作到最终的数据库更新和重新加入的效果控制。
flowchart TB
subgraph "退群触发层"
A[QuitGroup请求] --> C[MemberQuitNotification]
B[KickGroupMember请求] --> C
end
subgraph "序号设置层"
C --> D[deleteMemberAndSetConversationSeq]
D --> E[GetConversationMaxSeq
获取群组当前最大序号] E --> F[SetConversationMaxSeq
设置用户最大序号] end subgraph "数据层处理" F --> G[SetUserConversationMaxSeq
更新seq_user表] F --> H[UpdateUsersConversationField
更新conversation表兼容] G --> I[Redis缓存层] H --> J[MongoDB存储层] end subgraph "版本控制层" J --> K[IncrVersion版本递增] K --> L[ConversationChangeNotification
多端同步通知] end subgraph "效果控制层" M[用户重新加入群组] --> N[SetUserConversationsMinSeq
设置userMinSeq = userMaxSeq + 1] N --> O[形成序号隔离
无法看到退群前消息] end L --> M style D fill:#ffcccc style F fill:#ffffcc style N fill:#ccffcc style O fill:#ccccff
获取群组当前最大序号] E --> F[SetConversationMaxSeq
设置用户最大序号] end subgraph "数据层处理" F --> G[SetUserConversationMaxSeq
更新seq_user表] F --> H[UpdateUsersConversationField
更新conversation表兼容] G --> I[Redis缓存层] H --> J[MongoDB存储层] end subgraph "版本控制层" J --> K[IncrVersion版本递增] K --> L[ConversationChangeNotification
多端同步通知] end subgraph "效果控制层" M[用户重新加入群组] --> N[SetUserConversationsMinSeq
设置userMinSeq = userMaxSeq + 1] N --> O[形成序号隔离
无法看到退群前消息] end L --> M style D fill:#ffcccc style F fill:#ffffcc style N fill:#ccffcc style O fill:#ccccff
4.5 SetConversationMaxSeq方法详解
4.5.1 方法签名和参数
go
func (c *conversationServer) SetConversationMaxSeq(ctx context.Context, req *pbconversation.SetConversationMaxSeqReq) (*pbconversation.SetConversationMaxSeqResp, error)
// 请求参数结构
type SetConversationMaxSeqReq struct {
ConversationID string // 会话ID
OwnerUserID []string // 用户ID列表(支持批量操作)
MaxSeq int64 // 要设置的最大序列号
}
4.5.2 核心处理逻辑
go
func (c *conversationServer) SetConversationMaxSeq(ctx context.Context, req *pbconversation.SetConversationMaxSeqReq) (*pbconversation.SetConversationMaxSeqResp, error) {
// ========== 第一步:更新用户序号系统 ==========
// 调用消息服务,更新seq_user表中的max_seq字段
if err := c.msgClient.SetUserConversationMaxSeq(ctx, req.ConversationID, req.OwnerUserID, req.MaxSeq); err != nil {
return nil, err
}
// ========== 第二步:更新会话表(兼容性) ==========
// 更新conversation表中的max_seq字段,主要为兼容老版本,实际权限控制以seq_user表为准
if err := c.conversationDatabase.UpdateUsersConversationField(ctx, req.OwnerUserID, req.ConversationID,
map[string]any{"max_seq": req.MaxSeq}); err != nil {
return nil, err
}
// ========== 第三步:多端同步通知 ==========
// 发送会话变更通知,确保用户的所有设备都能及时同步
for _, userID := range req.OwnerUserID {
c.conversationNotificationSender.ConversationChangeNotification(ctx, userID, []string{req.ConversationID})
}
return &pbconversation.SetConversationMaxSeqResp{}, nil
}
4.5.3 UpdateUsersConversationField详解
go
// 文件:open-im-server/pkg/common/storage/controller/conversation.go:126
func (c *conversationDatabase) UpdateUsersConversationField(ctx context.Context, userIDs []string, conversationID string, args map[string]any) error {
// ========== 第一步:执行数据库更新 ==========
_, err := c.conversationDB.UpdateByMap(ctx, userIDs, conversationID, args)
if err != nil {
return err
}
// ========== 第二步:缓存管理 ==========
cache := c.cache.CloneConversationCache()
cache = cache.DelUsersConversation(conversationID, userIDs...).DelConversationVersionUserIDs(userIDs...)
// ========== 第三步:智能缓存清理 ==========
// 根据更新的字段类型,选择性清理相关缓存
if _, ok := args["recv_msg_opt"]; ok {
// 消息接收选项变更,清理消息推送相关缓存
cache = cache.DelConversationNotReceiveMessageUserIDs(conversationID)
cache = cache.DelConversationNotNotifyMessageUserIDs(userIDs...)
}
if _, ok := args["is_pinned"]; ok {
// 置顶状态变更,清理置顶列表缓存
cache = cache.DelConversationPinnedMessageUserIDs(userIDs...)
}
return cache.ChainExecDel(ctx)
}
4.5.4 MongoDB UpdateByMap实现
go
// 文件:open-im-server/pkg/common/storage/database/mgo/conversation.go:77
func (c *ConversationMgo) UpdateByMap(ctx context.Context, userIDs []string, conversationID string, args map[string]any) (int64, error) {
if len(args) == 0 || len(userIDs) == 0 {
return 0, nil
}
// ========== 构建查询条件 ==========
filter := bson.M{
"conversation_id": conversationID,
"owner_user_id": bson.M{"$in": userIDs}, // 批量更新指定用户列表
}
var rows int64
// ========== 使用版本控制的事务更新 ==========
err := mongoutil.IncrVersion(
// 数据更新函数
func() error {
res, err := mongoutil.UpdateMany(ctx, c.coll, filter, bson.M{"$set": args})
if err != nil {
return err
}
rows = res.ModifiedCount
return nil
},
// 版本更新函数
func() error {
for _, userID := range userIDs {
if err := c.version.IncrVersion(ctx, userID, []string{conversationID}, model.VersionStateUpdate); err != nil {
return err
}
}
return nil
},
)
return rows, err
}
4.6 序号设置的关键数据变化
阶段 | seq_user表 | seq表 | 效果 |
---|---|---|---|
退群前 | max_seq: 1200 min_seq: 1 read_seq: 1200 | max_seq: 1200 min_seq: 1 | 可见消息1-1200 |
退群时 | max_seq: 1500 min_seq: 1 read_seq: 1200 | max_seq: 1500 min_seq: 1 | 锁定在1500 |
重新加入 | max_seq: 1500 min_seq: 1501 read_seq: 1500 | max_seq: 1500 min_seq: 1501 | 看不到1500前的消息 |
4.7 退群机制的技术亮点
4.7.1 双重序号锁定机制
- MaxSeq锁定:退群时锁定用户的最大可见序号
- MinSeq控制:重新加入时设置最小序号,形成权限隔离
4.7.2 多层存储一致性
- seq_user表:用户级别的序号控制
- seq表:会话级别的状态管理
- Redis缓存:高性能数据访问
- 版本控制:支持增量同步
👥 第五部分:新成员历史消息控制
5.1 EnableHistoryForNewMembers配置
新成员是否能看到历史消息由EnableHistoryForNewMembers
配置控制:
go
// 文件:open-im-server/internal/rpc/group/notification.go:1568
func (g *NotificationSender) groupApplicationAgreeMemberEnterNotification(ctx context.Context, groupID string, SendMessage *bool, invitedOpUserID string, entrantUserID ...string) error {
if !g.config.RpcConfig.EnableHistoryForNewMembers {
// 新成员不能看历史消息:设置MinSeq为当前MaxSeq+1
conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, groupID)
maxSeq, err := g.msgClient.GetConversationMaxSeq(ctx, conversationID)
if err != nil {
return err
}
// 关键:设置新成员的userMinSeq为conversationMaxSeq+1,隐藏历史消息
if err := g.msgClient.SetUserConversationsMinSeq(ctx, conversationID, entrantUserID, maxSeq+1); err != nil {
return err
}
}
// 为新成员创建群组会话
if err := g.conversationClient.CreateGroupChatConversations(ctx, groupID, entrantUserID); err != nil {
return err
}
}
5.2 会话创建时间控制
go
// 文件:open-im-server/pkg/common/storage/controller/conversation.go:446
func (c *conversationDatabase) CreateGroupChatConversation(ctx context.Context, groupID string, userIDs []string) error {
conversations := make([]*relationtb.Conversation, 0, len(userIDs))
now := time.Now() // 关键:使用当前时间,而非群创建时间
for _, userID := range userIDs {
conversations = append(conversations, &relationtb.Conversation{
OwnerUserID: userID,
ConversationID: conversationID,
ConversationType: constant.GroupChatType,
GroupID: groupID,
CreateTime: now, // 会话创建时间为当前时间
// ... 其他字段
})
}
return c.conversationDB.Create(ctx, conversations)
}
5.3 历史消息权限控制对比
配置 | EnableHistoryForNewMembers | 新成员userMinSeq设置 | 历史消息可见性 | 适用场景 |
---|---|---|---|---|
允许 | true | 不设置(默认为1) | 可见所有历史消息 | 开放性社群、学习群 |
禁止 | false | conversationMaxSeq + 1 | 只能看加入后消息 | 私密群、工作群 |
5.4 新成员权限设置流程
graph TB
subgraph "成员加入触发"
A[申请入群] --> B[管理员同意]
C[成员邀请] --> B
B --> D[groupApplicationAgreeMemberEnterNotification]
end
subgraph "历史权限判断"
D --> E{EnableHistoryForNewMembers?}
E --> |true| F[允许查看历史消息]
E --> |false| G[禁止查看历史消息]
end
subgraph "禁止历史的实现"
G --> H[GetConversationMaxSeq]
H --> I[当前群最大序号: maxSeq]
I --> J[SetUserConversationsMinSeq]
J --> K[新成员userMinSeq = conversationMaxSeq + 1]
end
subgraph "会话创建"
F --> L[CreateGroupChatConversations]
K --> L
L --> M[创建个人会话记录]
M --> N[CreateTime = 当前时间]
end
style G fill:#ff9999
style K fill:#ffcccc
📨 第六部分:消息拉取逻辑详解
6.1 消息拉取范围计算
6.1.1 四层边界检查机制
OpenIM采用四层边界检查机制来精确控制用户的消息可见范围:
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:632
func (db *commonMsgDatabase) getMsgBySeqsRange(ctx context.Context, userID, conversationID string, begin, end int64, maxNum int64, maxSeq int64) (minSeq, maxSeq int64, seqMsg []*sdkws.MsgData, err error) {
// ========== 第一层:获取会话级边界 ==========
conversation, err := db.conversationCache.GetConversationByID(ctx, conversationID)
if err != nil {
return 0, 0, nil, err
}
// 会话最小序号(全局下边界)
conversationMinSeq := conversation.MinSeq
if conversationMinSeq < 1 {
conversationMinSeq = 1
}
// 会话最大序号(全局上边界)
conversationMaxSeq := conversation.MaxSeq
// ========== 第二层:获取用户级边界 ==========
// 用户最小序号(个人下边界,主要用于新成员控制)
userMinSeq, err := db.seqUserCache.GetUserMinSeq(ctx, conversationID, userID)
if err != nil {
userMinSeq = conversationMinSeq // 默认使用会话最小序号
}
// 用户最大序号(个人上边界,主要用于退群用户控制)
userMaxSeq, err := db.seqUserCache.GetUserMaxSeq(ctx, conversationID, userID)
if err != nil {
userMaxSeq = conversationMaxSeq // 默认使用会话最大序号
}
// ========== 第三层:计算有效范围 ==========
// 取更严格的边界
effectiveMinSeq := max(conversationMinSeq, userMinSeq)
effectiveMaxSeq := min(conversationMaxSeq, userMaxSeq, maxSeq) // maxSeq是请求参数中的限制
// ========== 第四层:序号过滤 ==========
validSeqs := make([]int64, 0)
for seq := begin; seq <= end && len(validSeqs) < int(maxNum); seq++ {
if seq >= effectiveMinSeq && seq <= effectiveMaxSeq {
validSeqs = append(validSeqs, seq)
}
}
return effectiveMinSeq, effectiveMaxSeq, db.getMsgsBySeqs(ctx, conversationID, validSeqs)
}
6.1.2 范围计算流程图
graph TD
A[GetUserMinSeq] --> E[边界计算]
B[GetConversationMinSeq] --> E
C[GetConversationMaxSeq] --> E
D[GetUserMaxSeq] --> E
E --> F[计算effectiveMinSeq]
F --> G[计算effectiveMaxSeq]
G --> H[序号范围过滤]
H --> I{在范围内?}
I -->|是| J[添加到列表]
I -->|否| K[跳过该序号]
J --> L[返回消息列表]
K --> L
四层边界检查逻辑:
层级 | 操作 | 输入 | 输出 | 说明 |
---|---|---|---|---|
第一层 | 获取会话级边界 | conversationID | conversationMinSeq, conversationMaxSeq | 全局消息范围 |
第二层 | 获取用户级边界 | userID, conversationID | userMinSeq, userMaxSeq | 个人权限范围 |
第三层 | 计算有效范围 | 上述四个值 | effectiveMinSeq, effectiveMaxSeq | 取更严格的边界 |
第四层 | 序号过滤 | begin, end, effectiveRange | validSeqs[] | 只返回有效范围内的序号 |
计算公式:
effectiveMinSeq = max(conversationMinSeq, userMinSeq)
effectiveMaxSeq = min(conversationMaxSeq, userMaxSeq)
validSeq = seq >= effectiveMinSeq && seq <= effectiveMaxSeq
6.1.3 边界控制场景示例
场景 | conversationMinSeq | conversationMaxSeq | userMinSeq | userMaxSeq | 有效范围 | 说明 |
---|---|---|---|---|---|---|
普通成员 | 1 | 2000 | - | - | 1-2000 | 可见所有历史消息 |
新成员 | 1 | 2000 | 1500 | - | 1500-2000 | 只能看加入后的消息 |
退群用户 | 1 | 2000 | - | 1200 | 1-1200 | 只能看退群前的消息 |
消息清理后 | 500 | 2000 | - | - | 500-2000 | 早期消息已清理 |
复杂情况 | 500 | 2000 | 800 | 1500 | 800-1500 | 多重限制叠加 |
6.1.4 分页控制与边界处理
go
// 文件:open-im-server/internal/rpc/msg/sync_msg.go:89
func (m *msgServer) handleSeqRange(ctx context.Context, userID, conversationID string, begin, end, num int64, order sdkws.PullOrder) (*sdkws.PullMessageBySeqsResp_Msgs, error) {
// 获取用户权限信息
conversation, err := m.ConversationLocalCache.GetConversation(ctx, userID, conversationID)
if err != nil {
return nil, err
}
// 应用用户最大序号限制
userMaxSeq := conversation.MaxSeq
if end > userMaxSeq {
end = userMaxSeq
// 设置边界到达标志
isEnd = true
}
// 应用用户最小序号限制
userMinSeq := conversation.MinSeq
if userMinSeq == 0 {
userMinSeq = 1
}
if begin < userMinSeq {
begin = userMinSeq
isEnd = true
}
// 拉取消息
_, _, msgs, err := m.MsgDatabase.GetMsgBySeqsRange(ctx, userID, conversationID, begin, end, num, userMaxSeq)
return &sdkws.PullMessageBySeqsResp_Msgs{
Msgs: msgs,
IsEnd: isEnd,
MinSeq: begin,
MaxSeq: end,
}, nil
}
6.2 分页拉取的序号控制
go
// 文件:open-im-server/internal/rpc/msg/sync_msg.go:40
func (m *msgServer) PullMessageBySeqs(ctx context.Context, req *sdkws.PullMessageBySeqsReq) (*sdkws.PullMessageBySeqsResp, error) {
for _, seq := range req.SeqRanges {
if !msgprocessor.IsNotification(seq.ConversationID) {
// 获取用户在该会话中的权限信息
conversation, err := m.ConversationLocalCache.GetConversation(ctx, req.UserID, seq.ConversationID)
if err != nil {
continue
}
// 按序列号范围拉取消息,使用conversation.MaxSeq进行权限控制
minSeq, maxSeq, msgs, err := m.MsgDatabase.GetMsgBySeqsRange(ctx, req.UserID, seq.ConversationID,
seq.Begin, seq.End, seq.Num, conversation.MaxSeq)
// 判断是否到达边界
var isEnd bool
if req.Order == sdkws.PullOrder_PullOrderDesc {
isEnd = minSeq <= seq.Begin
} else {
isEnd = maxSeq >= seq.End
}
}
}
}
🚫 第七部分:消息撤回处理机制
7.1 消息撤回权限验证
go
// 文件:open-im-server/internal/rpc/msg/revoke.go:43
func (m *msgServer) RevokeMsg(ctx context.Context, req *msg.RevokeMsgReq) (*msg.RevokeMsgResp, error) {
// 获取要撤回的消息
_, _, msgs, err := m.MsgDatabase.GetMsgBySeqs(ctx, req.UserID, req.ConversationID, []int64{req.Seq})
// 根据会话类型进行权限验证
var role int32
switch sessionType := msgs[0].SessionType; sessionType {
case constant.SingleChatType:
// 单聊:只有发送者可以撤回
if msgs[0].SendID != req.UserID {
return nil, errs.ErrNoPermission.WrapMsg("you can only revoke your own messages")
}
role = 0
case constant.ReadGroupChatType:
if msgs[0].SendID == req.UserID {
// 群聊:发送者可以撤回自己的消息
member, err := m.GroupLocalCache.GetGroupMember(ctx, msgs[0].GroupID, req.UserID)
if err != nil {
return nil, err
}
role = member.RoleLevel
} else {
// 群聊:群主和管理员可以撤回他人消息
member, err := m.GroupLocalCache.GetGroupMember(ctx, msgs[0].GroupID, req.UserID)
if err != nil {
return nil, err
}
if member.RoleLevel <= constant.GroupOrdinaryUsers {
return nil, errs.ErrNoPermission.WrapMsg("only group admin can revoke other's message")
}
role = member.RoleLevel
}
}
}
7.2 撤回信息记录机制
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:271
func (db *commonMsgDatabase) RevokeMsg(ctx context.Context, conversationID string, seq int64, revoke *model.RevokeModel) error {
// 将撤回信息插入到消息文档中
if err := db.batchInsertBlock(ctx, conversationID, []any{revoke}, updateKeyRevoke, seq); err != nil {
return err
}
// 清理缓存,确保下次拉取时获取最新状态
return db.msgCache.DelMessageBySeqs(ctx, conversationID, []int64{seq})
}
// RevokeModel结构
type RevokeModel struct {
Role int32 `bson:"role"` // 撤回者角色
UserID string `bson:"user_id"` // 撤回者用户ID
Nickname string `bson:"nickname"` // 撤回者昵称
Time int64 `bson:"time"` // 撤回时间
}
7.3 撤回消息过滤处理
go
// 文件:open-im-server/pkg/common/storage/controller/msg.go:771
func (db *commonMsgDatabase) handlerDeleteAndRevoked(ctx context.Context, userID string, msgs []*model.MsgInfoModel) {
for i := range msgs {
msg := msgs[i]
if msg.Revoke == nil {
continue
}
// 将消息类型设置为撤回通知
msg.Msg.ContentType = constant.MsgRevokeNotification
// 构造撤回内容
revokeContent := sdkws.MessageRevokedContent{
RevokerID: msg.Revoke.UserID,
RevokerRole: msg.Revoke.Role,
ClientMsgID: msg.Msg.ClientMsgID,
RevokerNickname: msg.Revoke.Nickname,
RevokeTime: msg.Revoke.Time,
SourceMessageSendTime: msg.Msg.SendTime,
SourceMessageSendID: msg.Msg.SendID,
SourceMessageSenderNickname: msg.Msg.SenderNickname,
SessionType: msg.Msg.SessionType,
Seq: msg.Msg.Seq,
Ex: msg.Msg.Ex,
}
// 将撤回信息序列化为消息内容
data, _ := jsonutil.JsonMarshal(&revokeContent)
elem := sdkws.NotificationElem{Detail: string(data)}
content, _ := jsonutil.JsonMarshal(&elem)
msg.Msg.Content = string(content)
}
}
7.4 引用消息的撤回处理
go
// 文件:openim-sdk-core/internal/conversation_msg/revoke.go:123
func (c *Conversation) quoteMsgRevokeHandle(ctx context.Context, conversationID string, v *model_struct.LocalChatLog, revokedMsg sdk_struct.MessageRevoked) error {
s := sdk_struct.QuoteElem{}
if err := utils.JsonStringToStruct(v.Content, &s); err != nil {
return errs.New("ChatLog content transfer failed.")
}
if s.QuoteMessage == nil {
return errs.New("QuoteMessage is nil").Wrap()
}
// 检查引用消息是否为被撤回的消息
if s.QuoteMessage.ClientMsgID != revokedMsg.ClientMsgID {
return nil
}
// 更新引用消息的内容为撤回信息
s.QuoteMessage.Content = utils.StructToJsonString(revokedMsg)
s.QuoteMessage.ContentType = constant.RevokeNotification
v.Content = utils.StructToJsonString(s)
// 更新数据库中的引用消息
if err := c.db.UpdateMessageBySeq(ctx, conversationID, v); err != nil {
return errs.Wrap(err)
}
return nil
}
7.5 消息撤回完整流程
graph TB
subgraph "撤回请求处理"
A[RevokeMsg请求] --> B[参数验证]
B --> C[消息查找]
C --> D[权限验证]
end
subgraph "权限验证分支"
D --> E{会话类型}
E --> |单聊| F[发送者验证]
E --> |群聊| G{撤回者身份}
G --> |发送者本人| H[允许撤回]
G --> |群管理员| I[管理员权限验证]
F --> J[权限通过]
H --> J
I --> J
end
subgraph "撤回执行"
J --> K[RevokeMsg数据库操作]
K --> L[插入撤回记录]
L --> M[清理消息缓存]
M --> N[发送撤回通知]
end
subgraph "消息过滤处理"
O[GetMessageBySeqs] --> P[handlerDeleteAndRevoked]
P --> Q{存在Revoke记录?}
Q --> |是| R[转换为撤回通知]
Q --> |否| S[返回原始消息]
R --> T[更新引用消息]
end
style D fill:#ffffcc
style K fill:#ffcccc
style P fill:#ccffcc
🔗 第八部分:序号关系图谱与场景分析
8.1 序号关系矩阵
序号类型 | 作用域 | 设置时机 | 影响范围 | 主要用途 |
---|---|---|---|---|
ConversationMaxSeq | 全群 | 消息发送时递增 | 所有成员 | 消息排序、同步边界 |
ConversationMinSeq | 全群 | 数据清理时设置 | 所有成员 | 历史消息隐藏 |
UserMaxSeq | 单用户 | 退群时设置 | 特定用户 | 退群权限隔离 |
UserMinSeq | 单用户 | 入群时设置 | 特定用户 | 历史权限控制 |
ReadSeq | 单用户 | 阅读消息时更新 | 特定用户 | 未读数计算 |
8.2 复杂场景的序号状态变化
场景1:新成员加入群聊
sequenceDiagram
participant User as 新用户
participant Group as 群聊(MaxSeq: 1000)
participant System as 系统
User->>Group: 申请加入群聊
Group->>System: 同意申请
System->>System: 检查EnableHistoryForNewMembers
alt 禁止查看历史
System->>User: SetUserMinSeq(1001)
Note over User: 只能看到1001之后的消息
else 允许查看历史
System->>User: UserMinSeq = 1
Note over User: 可以看到所有历史消息
end
System->>User: CreateConversation
Note over User: 会话创建时间为当前时间
场景2:用户退群重新加入
sequenceDiagram
participant User as 用户
participant Group as 群聊
participant System as 系统
Note over Group: 当前MaxSeq: 1000
User->>Group: 退出群聊
System->>User: SetUserMaxSeq(1000)
Note over User: 最大可见序号锁定为1000
Note over Group: 群聊继续,MaxSeq增长到1200
User->>Group: 重新加入群聊
System->>User: SetUserMinSeq(1201)
Note over User: 最小可见序号为1201
Note over User: 1001-1200期间消息不可见
场景3:消息删除的多设备同步
sequenceDiagram
participant Device1 as 设备1
participant Device2 as 设备2
participant Server as 服务器
participant DB as 数据库
Device1->>Server: DeleteMsgs(逻辑删除)
Server->>DB: PushUnique(del_list, userID)
Server->>Device1: 删除成功
Server->>Device2: DeleteMsgsTips通知
Device2->>Device2: 本地标记消息已删除
Note over Device1,Device2: 两设备消息状态同步
Device2->>Server: GetMessageBySeqs
Server->>DB: 检查del_list
DB->>Server: 返回空内容+删除状态
Server->>Device2: MsgDeleted状态消息
8.3 未读数计算的序号逻辑
go
// 未读数计算公式
unreadCount = MaxSeq - ReadSeq
// 但需要考虑用户权限边界
if UserMaxSeq > 0 && UserMaxSeq < MaxSeq {
effectiveMaxSeq = UserMaxSeq
} else {
effectiveMaxSeq = MaxSeq
}
if UserMinSeq > ReadSeq {
effectiveReadSeq = UserMinSeq - 1 // 历史消息视为已读
} else {
effectiveReadSeq = ReadSeq
}
unreadCount = effectiveMaxSeq - effectiveReadSeq
8.4 读扩散机制总结图
flowchart TB
subgraph "消息存储层MongoDB"
A[单条消息存储] --> B[分配全局唯一seq]
B --> C[消息内容统一管理]
end
subgraph "用户权限层Redis"
D[UserMinSeq控制] --> F[个性化可见范围]
E[UserMaxSeq控制] --> F
F --> G[ReadSeq管理]
end
subgraph "会话记录层MySQL"
H[每用户独立会话] --> I[个性化设置]
I --> J[置顶免打扰等]
I --> K[未读数独立计算]
end
subgraph "多设备同步层"
L[del_list数组] --> M[删除状态同步]
N[revoke记录] --> O[撤回状态同步]
P[序号边界] --> Q[权限状态同步]
end
A --> D
C --> H
F --> L
G --> N
style A fill:#e1f5fe
style F fill:#f3e5f5
style I fill:#e8f5e8
style M fill:#fff3e0
📝 总结
OpenIM的群聊读扩散机制通过以下7个核心机制实现了复杂场景下的精确控制:
关键技术要点
- 双重删除机制:逻辑删除(del_list)与物理删除并存,支持个人删除和全员删除
- 管理员硬删除:通过DestructMsgs实现物理删除和conversationMinSeq边界控制,释放存储空间
- 五层序号控制:conversationMinSeq、conversationMaxSeq、userMinSeq、userMaxSeq、userReadSeq精确权限管理
- 权限隔离设计:退群用户通过userMaxSeq锁定,新成员通过userMinSeq隔离,硬删除通过conversationMinSeq全局控制
- 多设备同步:基于通知机制的删除、撤回状态实时同步
- 读扩散架构:消息统一存储,会话记录分离,权限个性化管理
- 状态标记系统:del_list、revoke记录、序号边界的组合式状态管理
这套机制为大规模IM系统提供了完整的群聊读扩散解决方案,在保证功能完整性的同时,实现了高性能和高可用性。