OpenIM 源码深度解析系列(十二):群聊读扩散机制场景解析

群聊读扩散机制场景解析

📋 概述

本文档深入解析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

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

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个核心机制实现了复杂场景下的精确控制:

关键技术要点

  1. 双重删除机制:逻辑删除(del_list)与物理删除并存,支持个人删除和全员删除
  2. 管理员硬删除:通过DestructMsgs实现物理删除和conversationMinSeq边界控制,释放存储空间
  3. 五层序号控制:conversationMinSeq、conversationMaxSeq、userMinSeq、userMaxSeq、userReadSeq精确权限管理
  4. 权限隔离设计:退群用户通过userMaxSeq锁定,新成员通过userMinSeq隔离,硬删除通过conversationMinSeq全局控制
  5. 多设备同步:基于通知机制的删除、撤回状态实时同步
  6. 读扩散架构:消息统一存储,会话记录分离,权限个性化管理
  7. 状态标记系统:del_list、revoke记录、序号边界的组合式状态管理

这套机制为大规模IM系统提供了完整的群聊读扩散解决方案,在保证功能完整性的同时,实现了高性能和高可用性。

相关推荐
bobz96519 分钟前
用于服务器测试的 MCP 开发工具
后端
SimonKing22 分钟前
流式数据服务端怎么传给前端,前端怎么接收?
java·后端·程序员
Laplaces Demon24 分钟前
Spring 源码学习(十)—— DispatcherServlet
java·后端·学习·spring
BigYe程普30 分钟前
出海技术栈集成教程(一):域名解析与配置
前端·后端·全栈
这里有鱼汤32 分钟前
如何用‘资金视角’理解短线交易?这篇讲透了!
后端
扶风呀1 小时前
负载均衡详解
运维·后端·微服务·面试·负载均衡
写bug写bug1 小时前
彻底搞懂Spring Boot的系统监控机制
java·后端·spring
邦杠1 小时前
最新SpringBoot上传Maven中央仓库,在其他项目直接引入依赖(github开源项目打包上传,不需要私服)
spring boot·后端·开源·github·maven
BigYe程普1 小时前
出海技术栈集成教程(四):Resend邮件服务
前端·后端·全栈
风象南2 小时前
SpringBoot 实现在线查看内存对象拓扑图 —— 给 JVM 装上“透视眼”
后端