事件增量同步机制解析
1. 背景与设计理念
1.1 问题背景
在IM系统中,用户设备经常会遇到网络断连、应用重启等情况。传统的数据同步方式是在设备重连时进行全量数据拉取,这种方式虽然简单有效,但存在严重的性能问题:
- 数据量庞大:用户可能加入几十甚至上百个群组,每个群组可能有数百名成员
- 加载缓慢:全量同步导致应用启动缓慢,用户体验差
- 网络浪费:大量重复数据传输,浪费带宽资源
- 服务器压力:大量全量请求造成服务器负载过高
1.2 增量同步的核心价值
增量同步机制通过版本控制技术,只传输自上次同步以来发生变更的数据:
erlang
传统全量同步:每次传输100%数据
增量同步:只传输1-5%的变更数据
性能提升:20-50倍的传输效率提升
2. 系统架构:四大增量同步模块
OpenIM系统在四个核心模块中实现了增量同步机制:
2.1 模块概览
模块 | 接口方法 | 版本表 | 控制维度 | 数据范围 |
---|---|---|---|---|
好友模块 | GetIncrementalFriends |
friend_version |
用户维度 | 用户的好友关系变更 |
群组模块 | GetIncrementalJoinGroup |
group_join_version |
用户维度 | 用户加入的群组变更 |
群成员模块 | GetIncrementalGroupMember |
group_member_version |
群组维度 | 群组内成员变更 |
会话模块 | GetIncrementalConversation |
conversation_version |
用户维度 | 用户的会话列表变更 |
2.2 版本控制维度详解
2.2.1 用户维度控制(好友、群组、会话)
yaml
控制维度: user_id
d_id: "user_12345" # 用户ID作为版本标识
作用: 追踪单个用户相关数据的变更历史
应用场景:
- 用户的好友列表变更
- 用户加入/退出的群组变更
- 用户的会话列表变更
2.2.2 群组维度控制(群成员)
yaml
控制维度: group_id
d_id: "group_67890" # 群组ID作为版本标识
作用: 追踪单个群组内成员的变更历史
应用场景:
- 群成员的加入/退出
- 群成员角色变更
- 群成员信息修改
3. 核心版本控制机制
3.1 版本日志数据结构
所有版本表共享相同的数据结构设计:
go
// 版本日志表结构 - MongoDB文档
type VersionLogTable struct {
ID primitive.ObjectID `bson:"_id"` // MongoDB文档ID
DID string `bson:"d_id"` // 维度标识(用户ID或群组ID)
Logs []VersionLogElem `bson:"logs"` // 变更日志数组
Version uint `bson:"version"` // 当前最大版本号
Deleted uint `bson:"deleted"` // 软删除版本号
LastUpdate time.Time `bson:"last_update"` // 最后更新时间
}
// 版本日志元素 - 单个变更记录
type VersionLogElem struct {
EID string `bson:"e_id"` // 元素ID(成员ID、好友ID等)
State int32 `bson:"state"` // 变更状态(1:新增 2:删除 3:更新)
Version uint `bson:"version"` // 变更发生的版本号
LastUpdate time.Time `bson:"last_update"` // 变更发生时间
}
3.2 版本状态定义
go
const (
VersionStateInsert = 1 // 新增操作
VersionStateDelete = 2 // 删除操作
VersionStateUpdate = 3 // 更新操作
)
3.3 核心方法:IncrVersion - 版本递增处理
IncrVersion
方法是版本控制的核心,负责在数据变更时更新版本信息。该方法采用多层调用链设计,确保版本递增的原子性和可靠性。
3.3.1 调用链路架构
scss
IncrVersion()
↓
IncrVersionResult() // 包装层,处理版本上下文
↓
incrVersionResult() // 核心逻辑层,错误处理和重试
↓
writeLogBatch2() // 更新现有文档
↓
initDoc() // 初始化新文档(文档不存在时)
3.3.2 入口方法:IncrVersion
go
// 版本递增入口方法 - 简化接口,只返回错误
func (l *VersionLogMgo) IncrVersion(ctx context.Context, dId string, eIds []string, state int32) error {
_, err := l.IncrVersionResult(ctx, dId, eIds, state)
return err
}
设计理念:
- 接口简化:对外只暴露错误信息,隐藏版本日志细节
- 职责分离:专注于版本递增操作,不涉及结果处理
- 向后兼容:保持API的稳定性和简洁性
3.3.3 结果包装方法:IncrVersionResult
go
// 版本递增结果包装方法 - 返回版本日志和错误
func (l *VersionLogMgo) IncrVersionResult(ctx context.Context, dId string, eIds []string, state int32) (*model.VersionLog, error) {
// 1. 执行核心版本递增逻辑
vl, err := l.incrVersionResult(ctx, dId, eIds, state)
if err != nil {
return nil, err
}
// 2. 将版本信息添加到上下文中(用于事务管理和调试)
versionctx.GetVersionLog(ctx).Append(versionctx.Collection{
Name: l.coll.Name(), // 集合名称
Doc: vl, // 版本日志文档
})
return vl, nil
}
功能特性:
- 上下文管理:将版本信息存储到请求上下文中
- 事务支持:为事务回滚提供版本信息追踪
- 调试支持:便于调试时追踪版本变更历史
- 监控集成:支持版本变更的监控和统计
3.3.4 核心逻辑方法:incrVersionResult
go
// 版本递增核心逻辑 - 处理并发和错误重试
func (l *VersionLogMgo) incrVersionResult(ctx context.Context, dId string, eIds []string, state int32) (*model.VersionLog, error) {
// 1. 参数验证
if len(eIds) == 0 {
return nil, errs.ErrArgs.WrapMsg("elem id is empty", "dId", dId)
}
now := time.Now() // 统一时间戳,确保版本一致性
// 2. 【第一次尝试】更新现有版本文档
if res, err := l.writeLogBatch2(ctx, dId, eIds, state, now); err == nil {
return res, nil // 成功更新,直接返回
} else if !errors.Is(err, mongo.ErrNoDocuments) {
return nil, err // 非"文档不存在"错误,直接返回
}
// 3. 【文档不存在】尝试初始化新版本文档
if res, err := l.initDoc(ctx, dId, eIds, state, now); err == nil {
return res, nil // 成功初始化,直接返回
} else if !mongo.IsDuplicateKeyError(err) {
return nil, err // 非"重复键"错误,直接返回
}
// 4. 【并发冲突】其他协程已创建文档,重试更新操作
return l.writeLogBatch2(ctx, dId, eIds, state, now)
}
错误处理策略:
- 乐观锁机制:先尝试更新,失败后再初始化
- 并发控制:处理多协程同时初始化的竞态条件
- 原子性保证:整个操作过程确保数据一致性
- 重试机制:并发冲突时自动重试操作
3.4 核心方法:FindChangeLog - 增量查询
FindChangeLog
方法根据客户端版本号查询增量变更,同样采用多层调用链设计。
3.4.1 调用链路架构
scss
FindChangeLog()
↓
findChangeLog() // 核心查询逻辑
↓
findDoc() // 简单文档查询(无版本过滤)
↓
initDoc() // 文档不存在时初始化
3.4.2 入口方法:FindChangeLog
go
// 增量查询入口方法 - 自动处理文档不存在情况
func (l *VersionLogMgo) FindChangeLog(ctx context.Context, dId string, version uint, limit int) (*model.VersionLog, error) {
// 1. 【第一次尝试】查询现有变更日志
if wl, err := l.findChangeLog(ctx, dId, version, limit); err == nil {
return wl, nil // 查询成功,直接返回
} else if !errors.Is(err, mongo.ErrNoDocuments) {
return nil, err // 非"文档不存在"错误,直接返回
}
// 2. 【文档不存在】记录调试日志并初始化
log.ZDebug(ctx, "init doc", "dId", dId)
if res, err := l.initDoc(ctx, dId, nil, 0, time.Now()); err == nil {
log.ZDebug(ctx, "init doc success", "dId", dId)
return res, nil // 初始化成功,返回空版本日志
} else if mongo.IsDuplicateKeyError(err) {
// 3. 【并发冲突】其他协程已创建文档,重新查询
return l.findChangeLog(ctx, dId, version, limit)
} else {
return nil, err // 其他错误,直接返回
}
}
自动初始化机制:
- 懒加载:首次访问时自动创建版本文档
- 并发安全:处理多协程同时初始化的情况
- 调试友好:提供详细的日志记录
- 状态一致:确保系统状态的一致性
3.4.3 文档初始化方法:initDoc
go
// 版本文档初始化方法 - 创建新的版本日志文档
func (l *VersionLogMgo) initDoc(ctx context.Context, dId string, eIds []string, state int32, now time.Time) (*model.VersionLog, error) {
// 1. 构建初始版本文档结构
wl := model.VersionLogTable{
ID: primitive.NewObjectID(), // 生成新的MongoDB ObjectID
DID: dId, // 维度标识(用户ID或群组ID)
Logs: make([]model.VersionLogElem, 0, len(eIds)), // 预分配日志容量
Version: database.FirstVersion, // 初始版本号(通常为1)
Deleted: database.DefaultDeleteVersion, // 默认删除版本号(通常为0)
LastUpdate: now, // 创建时间
}
// 2. 添加初始变更日志(如果有元素ID)
for _, eId := range eIds {
wl.Logs = append(wl.Logs, model.VersionLogElem{
EID: eId, // 元素ID
State: state, // 变更状态
Version: database.FirstVersion, // 版本号
LastUpdate: now, // 变更时间
})
}
// 3. 原子性插入文档到MongoDB
if _, err := l.coll.InsertOne(ctx, &wl); err != nil {
return nil, err
}
// 4. 转换为标准版本日志格式并返回
return wl.VersionLog(), nil
}
初始化策略:
- 预分配内存:根据元素数量预分配日志切片容量
- 默认值设置:使用系统定义的默认版本号
- 原子性插入:确保文档创建的原子性
- 格式转换:将存储格式转换为业务逻辑格式
3.5 批量写入方法:writeLogBatch2
go
// 版本日志批量写入方法 - MongoDB聚合管道实现的原子性更新
func (l *VersionLogMgo) writeLogBatch2(ctx context.Context, dId string, eIds []string, state int32, now time.Time) (*model.VersionLog, error) {
// 1. 空数组处理
if eIds == nil {
eIds = []string{}
}
// 2. 构建查询过滤器 - 根据维度ID定位文档
filter := bson.M{"d_id": dId}
// 3. 构建新增日志元素数组
elems := make([]bson.M, 0, len(eIds))
for _, eId := range eIds {
elems = append(elems, bson.M{
"e_id": eId,
"version": "$version", // 引用当前文档的版本号
"state": state,
"last_update": now,
})
}
// 4. MongoDB聚合管道 - 五步原子性更新操作
pipeline := []bson.M{
// 【阶段1】添加临时字段 - 待删除的元素ID列表
{
"$addFields": bson.M{
"delete_e_ids": eIds, // 将要更新的元素ID暂存
},
},
// 【阶段2】版本递增和时间更新
{
"$set": bson.M{
"version": bson.M{"$add": []any{"$version", 1}}, // 版本号+1
"last_update": now, // 更新时间戳
},
},
// 【阶段3】去重处理 - 从现有日志中删除重复的元素ID
{
"$set": bson.M{
"logs": bson.M{
"$filter": bson.M{
"input": "$logs", // 输入:现有日志数组
"as": "log", // 遍历变量名
"cond": bson.M{ // 过滤条件:保留不在删除列表中的日志
"$not": bson.M{
"$in": []any{"$$log.e_id", "$delete_e_ids"},
},
},
},
},
},
},
// 【阶段4】合并新日志 - 将新日志追加到过滤后的日志数组
{
"$set": bson.M{
"logs": bson.M{
"$concatArrays": []any{
"$logs", // 过滤后的现有日志
elems, // 新增的日志元素
},
},
},
},
// 【阶段5】清理临时字段
{
"$unset": "delete_e_ids", // 删除临时字段
},
}
// 5. 配置更新选项
opt := options.FindOneAndUpdate().
SetUpsert(false). // 不允许upsert,文档必须已存在
SetReturnDocument(options.After). // 返回更新后的文档
SetProjection(bson.M{"logs": 0}) // 不返回日志数组,减少网络传输
// 6. 执行原子性更新操作
res, err := mongoutil.FindOneAndUpdate[*model.VersionLog](ctx, l.coll, filter, pipeline, opt)
if err != nil {
return nil, err
}
// 7. 手动构造返回的日志数组(因为投影中排除了logs字段)
res.Logs = make([]model.VersionLogElem, 0, len(eIds))
for _, id := range eIds {
res.Logs = append(res.Logs, model.VersionLogElem{
EID: id, // 元素ID
State: state, // 变更状态
Version: res.Version, // 使用更新后的版本号
LastUpdate: res.LastUpdate, // 使用更新后的时间戳
})
}
return res, nil
}
流程图
dId, eIds, state, now] B --> C[构建新日志元素数组
elems] C --> D[创建MongoDB聚合管道] D --> E[阶段1: $addFields
添加临时字段delete_e_ids] E --> F[阶段2: $set
版本号+1, 更新时间] F --> G[阶段3: $set + $filter
过滤删除旧日志] G --> H[阶段4: $set + $concatArrays
合并新日志到logs数组] H --> I[阶段5: $unset
删除临时字段delete_e_ids] I --> J[配置更新选项
SetUpsert=false
SetReturnDocument=After
SetProjection排除logs] J --> K[执行FindOneAndUpdate
原子操作] K --> L{操作成功?} L -->|失败| M[返回错误] L -->|成功| N[手动构建返回的Logs数组
使用新版本号和时间] N --> O[返回VersionLog结果
包含新增的日志条目] O --> P[结束] M --> P style A fill:#e1f5fe style P fill:#f3e5f5 style L fill:#fff3e0 style M fill:#ffebee
3.5.1 函数签名与功能
go
func (l *VersionLogMgo) writeLogBatch2(ctx context.Context, dId string, eIds []string, state int32, now time.Time) (*model.VersionLog, error)
功能概述:
- 更新指定设备(dId)的版本日志
- 删除旧日志条目,添加新日志
- 版本号原子性递增+1
- 返回新增的日志条目信息
核心参数:
dId
:设备ID,用于定位文档eIds
:需要更新的日志条目ID列表state
:新日志状态now
:当前时间戳
3.5.2 MongoDB 聚合管道解析
假设集合中存在如下初始文档:
json
{
"d_id": "device_123",
"version": 5,
"last_update": ISODate("2023-01-01T00:00:00Z"),
"logs": [
{"e_id": "old_1", "version": 4, "state": 0, "last_update": ISODate("...")},
{"e_id": "old_2", "version": 5, "state": 1, "last_update": ISODate("...")}
]
}
阶段1: $addFields - 添加临时字段
go
{
"$addFields": bson.M{
"delete_e_ids": eIds, // 假设 eIds = ["new_1", "new_2"]
}
}
效果: 添加临时字段 delete_e_ids
存储待处理的ID
模拟数据变化:
diff
{
"d_id": "device_123",
"version": 5,
+ "delete_e_ids": ["new_1", "new_2"], // 新增字段
...
}
阶段2: $set - 更新版本和时间
go
{
"$set": bson.M{
"version": bson.M{"$add": []any{"$version", 1}}, // 版本+1
"last_update": now, // 更新时间
}
}
MongoDB特性: $add
操作符实现数学运算
模拟数据变化:
diff
{
"d_id": "device_123",
- "version": 5,
+ "version": 6, // 版本递增
- "last_update": ISODate("..."),
+ "last_update": ISODate("2024-01-01T00:00:00Z"), // 新时间
...
}
阶段3: <math xmlns="http://www.w3.org/1998/Math/MathML"> s e t − 过滤旧日志 ( set - 过滤旧日志 ( </math>set−过滤旧日志(filter)
go
{
"$set": bson.M{
"logs": bson.M{
"$filter": {
"input": "$logs",
"as": "log",
"cond": bson.M{
"$not": bson.M{
"$in": []any{"$$log.e_id", "$delete_e_ids"}, // 保留不在删除列表的日志
}
}
}
}
}
}
MongoDB特性:
$filter
:过滤数组元素$$log
:引用管道中的临时变量$in
:集合成员检查
模拟数据变化(若 old_2 在 delete_e_ids 中):
diff
"logs": [
- {"e_id": "old_1", ...},
- {"e_id": "old_2", ...} // 被删除
+ {"e_id": "old_1", ...} // 保留
]
阶段4: <math xmlns="http://www.w3.org/1998/Math/MathML"> s e t − 追加新日志 ( set - 追加新日志 ( </math>set−追加新日志(concatArrays)
go
{
"$set": bson.M{
"logs": bson.M{
"$concatArrays": []any{
"$logs", // 过滤后的旧日志
elems // 新日志数组
}
}
}
}
MongoDB特性: $concatArrays
合并数组
输入 elems:
json
[
{"e_id": "new_1", "version": 6, "state": 1, "last_update": "2024-01-01T00:00:00Z"},
{"e_id": "new_2", "version": 6, "state": 1, "last_update": "2024-01-01T00:00:00Z"}
]
模拟数据变化:
json
"logs": [
{"e_id": "old_1", ...},
{"e_id": "new_1", ...}, // 新增
{"e_id": "new_2", ...} // 新增
]
阶段5: $unset - 删除临时字段
go
{"$unset": "delete_e_ids"}
最终文档:
json
{
"d_id": "device_123",
"version": 6,
"last_update": ISODate("2024-01-01T00:00:00Z"),
"logs": [ ... ] // 更新后的日志
}
3.5.3 更新操作关键配置
go
opt := options.FindOneAndUpdate().
SetUpsert(false). // 禁止不存在时创建
SetReturnDocument(options.After). // 返回更新后的文档
SetProjection(bson.M{"logs": 0}) // 排除logs字段(节省网络传输)
res, err := mongoutil.FindOneAndUpdate(...) // 原子操作
为何手动构建返回的 Logs:
- 投影排除了
logs
字段(可能很大) - 只返回新增的日志条目(非全量)
go
res.Logs = make([]model.VersionLogElem, 0, len(eIds))
for _, id := range eIds {
res.Logs = append(res.Logs, model.VersionLogElem{
EID: id,
State: state,
Version: res.Version, // 使用递增后的版本(6)
LastUpdate: res.LastUpdate, // 使用新时间
})
}
3.5.4 完整数据流模拟
输入:
go
dId = "device_123"
eIds = ["new_1", "new_2"]
state = 1
now = 2024-01-01T00:00:00Z
数据库操作顺序:
- 匹配文档:
{d_id: "device_123"}
- 执行管道更新(版本5→6,日志替换)
- 返回更新后的文档(不含logs)
函数构建新增的日志条目:
json
[
{e_id: "new_1", version: 6, state: 1, last_update: "2024-01-01T00:00:00Z"},
{e_id: "new_2", version: 6, state: 1, last_update: "2024-01-01T00:00:00Z"}
]
聚合管道优势解析:
- 原子性保证:整个更新过程在单个MongoDB操作中完成,确保数据一致性
- 去重机制:自动处理重复元素,避免日志冗余
- 版本递增:原子性地递增版本号,防止并发问题
- 性能优化:使用聚合管道减少网络往返,提高更新效率
- 内存友好:通过投影减少返回数据量,降低内存使用
3.6 增量查询方法:findChangeLog
go
// 增量查询核心方法 - 复杂的MongoDB聚合查询实现
func (l *VersionLogMgo) findChangeLog(ctx context.Context, dId string, version uint, limit int) (*model.VersionLog, error) {
// 1. 快速路径:无版本限制且无数量限制的完整查询
if version == 0 && limit == 0 {
return l.findDoc(ctx, dId) // 直接返回文档概要信息
}
// 2. 复杂增量查询聚合管道 - 五阶段过滤和处理
pipeline := []bson.M{
// 【阶段1】文档匹配 - 根据维度ID定位目标文档
{
"$match": bson.M{
"d_id": dId, // 精确匹配维度标识
},
},
// 【阶段2】版本有效性检查 - 判断是否需要返回日志
{
"$addFields": bson.M{
"logs": bson.M{
"$cond": bson.M{
"if": bson.M{
"$or": []bson.M{
// 条件1:当前版本 < 请求版本(数据过旧)
{"$lt": []any{"$version", version}},
// 条件2:删除版本 >= 请求版本(数据已删除)
{"$gte": []any{"$deleted", version}},
},
},
"then": []any{}, // 条件成立:返回空日志数组
"else": "$logs", // 条件不成立:保留原始日志数组
},
},
},
},
// 【阶段3】版本过滤 - 只保留版本号大于请求版本的日志
{
"$addFields": bson.M{
"logs": bson.M{
"$filter": bson.M{
"input": "$logs", // 输入:经过有效性检查的日志数组
"as": "l", // 遍历变量名
"cond": bson.M{ // 过滤条件:日志版本 > 请求版本
"$gt": []any{"$$l.version", version},
},
},
},
},
},
// 【阶段4】日志计数 - 统计过滤后的日志数量
{
"$addFields": bson.M{
"log_len": bson.M{"$size": "$logs"}, // 计算日志数组长度
},
},
// 【阶段5】数量限制检查 - 根据限制决定是否返回日志
{
"$addFields": bson.M{
"logs": bson.M{
"$cond": bson.M{
"if": bson.M{
"$gt": []any{"$log_len", limit}, // 日志数量 > 限制数量
},
"then": []any{}, // 超出限制:返回空数组(触发全量同步)
"else": "$logs", // 未超出限制:返回过滤后的日志
},
},
},
},
}
// 3. 无限制查询优化 - 移除最后一个限制检查阶段
if limit <= 0 {
pipeline = pipeline[:len(pipeline)-1] // 移除第5阶段
}
// 4. 执行聚合查询
vl, err := mongoutil.Aggregate[*model.VersionLog](ctx, l.coll, pipeline)
if err != nil {
return nil, err
}
// 5. 结果验证
if len(vl) == 0 {
return nil, mongo.ErrNoDocuments // 文档不存在
}
return vl[0], nil
}
流程图
dId, version, limit] B --> C{边界检查
version==0 && limit==0?} C -->|是| D[直接调用findDoc
返回完整文档] C -->|否| E[构建MongoDB聚合管道] E --> F[阶段1: $match
匹配设备ID] F --> G[阶段2: $addFields + $cond
版本检查
服务端版本<客户端版本或
deleted>=客户端版本] G --> H[阶段3: $addFields + $filter
过滤版本>客户端版本的日志] H --> I[阶段4: $addFields + $size
计算过滤后日志数量log_len] I --> J{limit > 0?} J -->|是| K[阶段5: $addFields + $cond
检查log_len > limit
超过则返回空数组] J -->|否| L[跳过数量限制检查] K --> M[执行聚合查询] L --> M M --> N{查询结果为空?} N -->|是| O[返回ErrNoDocuments] N -->|否| P[返回VersionLog结果
包含过滤后的日志] P --> Q[结束] D --> Q O --> Q style A fill:#e1f5fe style Q fill:#f3e5f5 style C fill:#fff3e0 style J fill:#fff3e0 style N fill:#fff3e0 style O fill:#ffebee
3.6.1 函数签名与功能
go
func (l *VersionLogMgo) findChangeLog(ctx context.Context, dId string, version uint, limit int) (*model.VersionLog, error)
功能概述:
- 查询设备变更日志,支持版本过滤和数量限制
- 过滤出大于指定版本的日志条目
- 检查日志数量是否超过限制
- 处理特殊边界情况
关键参数:
version
:客户端当前版本,只返回更高版本的日志limit
:最大返回日志数量限制
3.6.2 MongoDB 聚合管道解析(5阶段)
假设集合中有如下初始文档:
json
{
"_id": "device_001",
"d_id": "device_001",
"version": 10,
"deleted": 0,
"logs": [
{"e_id": "e1", "version": 6},
{"e_id": "e2", "version": 7},
{"e_id": "e3", "version": 8},
{"e_id": "e4", "version": 9},
{"e_id": "e5", "version": 10}
]
}
阶段1: 文档匹配 ($match)
go
{
"$match": bson.M{"d_id": dId}
}
功能: 筛选指定设备ID的文档
等效SQL: WHERE d_id = 'device_001'
阶段2: 版本检查 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> a d d F i e l d s + addFields + </math>addFields+cond)
go
{
"$addFields": bson.M{
"logs": bson.M{
"$cond": bson.M{
"if": bson.M{
"$or": []bson.M{
{"$lt": []any{"$version", version}},
{"$gte": []any{"$deleted", version}}
}
},
"then": []any{},
"else": "$logs"
}
}
}
}
功能: 检查是否需要返回空日志数组
条件判断:
- 服务端版本 < 客户端版本(数据过时)
- 删除标记 >= 客户端版本(数据已删除)
示例场景(version=7):
$version=10 > 7
→ 条件1 false$deleted=0 < 7
→ 条件2 false- 结果: 保留原始日志
阶段3: 版本过滤 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> a d d F i e l d s + addFields + </math>addFields+filter)
go
{
"$addFields": bson.M{
"logs": bson.M{
"$filter": bson.M{
"input": "$logs",
"as": "l",
"cond": bson.M{"$gt": []any{"$$l.version", version}}
}
}
}
}
功能: 过滤出大于客户端版本的日志
操作: $filter
+ $gt
(大于)比较
模拟数据变化(version=7):
diff
"logs": [
- {"e_id": "e1", "version": 6}, // 小于7,被过滤
- {"e_id": "e2", "version": 7}, // 等于7,被过滤
{"e_id": "e3", "version": 8},
{"e_id": "e4", "version": 9},
{"e_id": "e5", "version": 10}
]
阶段4: 数量计算 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> a d d F i e l d s + addFields + </math>addFields+size)
go
{
"$addFields": bson.M{
"log_len": bson.M{"$size": "$logs"}
}
}
功能: 计算过滤后的日志数量
结果: 添加 log_len
字段(本例中值为3)
阶段5: 数量限制检查 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> a d d F i e l d s + addFields + </math>addFields+cond)
go
{
"$addFields": bson.M{
"logs": bson.M{
"$cond": bson.M{
"if": bson.M{"$gt": []any{"$log_len", limit}},
"then": []any{},
"else": "$logs"
}
}
}
}
功能: 检查日志数量是否超过限制
逻辑: 超过限制时返回空数组
场景分析:
limit | log_len | 结果 | 含义 |
---|---|---|---|
2 | 3 | 返回 [] (空数组) | 需要客户端全量同步 |
5 | 3 | 返回过滤后的日志 | 可进行增量同步 |
0 | 3 | 不执行此阶段 | 返回所有符合条件的日志 |
3.6.3 特殊处理与优化
1. 边界情况处理
go
if version == 0 && limit == 0 {
return l.findDoc(ctx, dId)
}
含义: 当客户端版本为0且无数量限制时,直接返回完整文档(全量数据)
2. 动态管道优化
go
if limit <= 0 {
pipeline = pipeline[:len(pipeline)-1] // 跳过阶段5
}
优化目的: 当无数量限制时,避免不必要的计算
3. 结果处理
go
if len(vl) == 0 {
return nil, mongo.ErrNoDocuments
}
return vl[0], nil
逻辑: 确保查询到有效结果,否则返回错误
3.6.4 数据流示例(version=7, limit=2)
makefile
初始文档 (5条日志)
↓
阶段1: 匹配设备ID
↓
阶段2: 版本检查 (通过,保留日志)
↓
阶段3: 过滤版本>7 (剩余3条)
↓
阶段4: 计算数量 (log_len=3)
↓
阶段5: 限制检查 (3>2,返回空数组)
↓
最终结果: logs=[] (触发全量同步)
4. 深入解析:群成员增量同步完整流程
4.1 群成员数据变更的版本管理
群成员模块采用双重版本控制机制,每次数据变更都会同时更新两个维度的版本信息:
go
type GroupMemberMgo struct {
coll *mongo.Collection // 群成员主数据集合
member database.VersionLog // 群组维度版本控制(追踪群组内成员变化)
join database.VersionLog // 用户维度版本控制(追踪用户加入群组变化)
}
双重版本控制的核心原理:
- 群组维度 (member):记录群组内成员的增删改变化,支持群组成员列表的增量同步
- 用户维度 (join):记录用户加入/退出群组的变化,支持用户群组列表的增量同步
- 原子性保证:每次操作同时更新两个维度,确保数据一致性
- 业务解耦:不同客户端可以根据业务需求选择不同维度进行同步
下面详细分析群成员的三种核心操作及其版本管理策略:
4.1.1 群成员创建操作的版本管理(Create)
go
func (g *GroupMemberMgo) Create(ctx context.Context, groupMembers []*model.GroupMember) error {
return mongoutil.IncrVersion(
// 第一步:批量插入群成员数据
func() error {
return mongoutil.InsertMany(ctx, g.coll, groupMembers)
},
// 第二步:更新群组成员版本(群组维度)
func() error {
// 按群组分组统计新增成员
gms := make(map[string][]string)
for _, member := range groupMembers {
gms[member.GroupID] = append(gms[member.GroupID], member.UserID)
}
// 为每个群组更新成员版本
for groupID, userIDs := range gms {
if err := g.member.IncrVersion(ctx, groupID, userIDs, model.VersionStateInsert); err != nil {
return err
}
}
return nil
},
// 第三步:更新用户加群版本(用户维度)
func() error {
// 按用户分组统计加入的群组
gms := make(map[string][]string)
for _, member := range groupMembers {
gms[member.UserID] = append(gms[member.UserID], member.GroupID)
}
// 为每个用户更新加群版本
for userID, groupIDs := range gms {
if err := g.join.IncrVersion(ctx, userID, groupIDs, model.VersionStateInsert); err != nil {
return err
}
}
return nil
})
}
版本管理策略说明:
- 群组视角:群组A新增了用户B和C,群组A的版本+1,记录B、C的加入
- 用户视角:用户B加入了群组A,用户B的加群版本+1,记录A的加入
4.1.2 群成员删除操作的版本管理(Delete)
go
func (g *GroupMemberMgo) Delete(ctx context.Context, groupID string, userIDs []string) error {
filter := bson.M{"group_id": groupID}
if len(userIDs) > 0 {
filter["user_id"] = bson.M{"$in": userIDs}
}
return mongoutil.IncrVersion(
// 第一步:删除群成员数据
func() error {
return mongoutil.DeleteMany(ctx, g.coll, filter)
},
// 第二步:处理群组成员版本
func() error {
if len(userIDs) == 0 {
// 全群删除:直接删除群组版本记录
return g.member.Delete(ctx, groupID)
} else {
// 部分删除:更新群组版本,标记用户删除
return g.member.IncrVersion(ctx, groupID, userIDs, model.VersionStateDelete)
}
},
// 第三步:更新用户加群版本
func() error {
for _, userID := range userIDs {
if err := g.join.IncrVersion(ctx, userID, []string{groupID}, model.VersionStateDelete); err != nil {
return err
}
}
return nil
})
}
4.1.3 群成员更新操作的版本管理(Update)
群成员更新操作是最复杂的版本管理场景,因为它需要区分不同类型的更新,采用不同的版本控制策略。更新操作主要分为两类:角色等级更新 和普通信息更新。
go
func (g *GroupMemberMgo) Update(ctx context.Context, groupID string, userID string, data map[string]any) error {
// 1. 空数据检查 - 避免无效更新
if len(data) == 0 {
return nil
}
return mongoutil.IncrVersion(
// 第一步:更新成员信息
func() error {
return mongoutil.UpdateOne(ctx, g.coll,
bson.M{"group_id": groupID, "user_id": userID},
bson.M{"$set": data},
true)
},
// 第二步:智能版本更新策略
func() error {
var userIDs []string
// 判断更新类型,采用不同的版本控制策略
if g.IsUpdateRoleLevel(data) {
// 【策略A】角色等级更新 - 影响成员列表排序
userIDs = []string{model.VersionSortChangeID, userID}
} else {
// 【策略B】普通信息更新 - 只更新成员信息
userIDs = []string{userID}
}
// 只更新群组维度版本(用户维度不受影响)
return g.member.IncrVersion(ctx, groupID, userIDs, model.VersionStateUpdate)
})
}
4.2 群成员增量同步查询流程与Option.Build调用链解析
群成员增量同步查询是整个同步机制的核心执行引擎,通过incrversion.Option.Build()
方法实现完整的同步逻辑。本节将详细解析从客户端请求到数据响应的完整调用链路。
4.2.1 增量同步请求处理与Option配置
客户端请求参数:
protobuf
message GetIncrementalGroupMemberReq {
string groupID = 1; // 目标群组ID
string versionID = 2; // 客户端当前版本ID(MongoDB ObjectID的Hex字符串)
uint64 version = 3; // 客户端当前版本号(递增序列号)
}
服务端完整处理流程:
go
func (s *groupServer) GetIncrementalGroupMember(ctx context.Context, req *pbgroup.GetIncrementalGroupMemberReq) (*pbgroup.GetIncrementalGroupMemberResp, error) {
// 1. 权限验证与群组状态检查
if err := s.checkAdminOrInGroup(ctx, req.GroupID); err != nil {
return nil, err
}
group, err := s.db.TakeGroup(ctx, req.GroupID)
if err != nil {
return nil, err
}
if group.Status == constant.GroupStatusDismissed {
return nil, servererrs.ErrDismissedAlready.Wrap()
}
// 2. 特殊版本处理变量
var (
hasGroupUpdate bool // 是否有群组信息更新
sortVersion uint64 // 排序版本号
)
// 3. 构造增量同步选项配置
opt := incrversion.Option[*sdkws.GroupMemberFullInfo, pbgroup.GetIncrementalGroupMemberResp]{
Ctx: ctx,
VersionKey: req.GroupID, // 群组ID作为版本键
VersionID: req.VersionID, // 客户端版本ID
VersionNumber: req.Version, // 客户端版本号
// 版本日志查询回调:处理特殊标识符
Version: func(ctx context.Context, groupID string, version uint, limit int) (*model.VersionLog, error) {
vl, err := s.db.FindMemberIncrVersion(ctx, groupID, version, limit)
if err != nil {
return nil, err
}
// 过滤并处理特殊变更标识
logs := make([]model.VersionLogElem, 0, len(vl.Logs))
for i, log := range vl.Logs {
switch log.EID {
case model.VersionGroupChangeID:
vl.LogLen--
hasGroupUpdate = true
case model.VersionSortChangeID:
vl.LogLen--
sortVersion = uint64(log.Version)
default:
logs = append(logs, vl.Logs[i])
}
}
vl.Logs = logs
if vl.LogLen > 0 {
hasGroupUpdate = true
}
return vl, nil
},
// 缓存版本查询回调:性能优化
CacheMaxVersion: s.db.FindMaxGroupMemberVersionCache,
// 数据查询回调:获取具体成员信息
Find: func(ctx context.Context, ids []string) ([]*sdkws.GroupMemberFullInfo, error) {
return s.getGroupMembersInfo(ctx, req.GroupID, ids)
},
// 响应构造回调:组装最终响应
Resp: func(version *model.VersionLog, delIDs []string, insertList, updateList []*sdkws.GroupMemberFullInfo, full bool) *pbgroup.GetIncrementalGroupMemberResp {
return &pbgroup.GetIncrementalGroupMemberResp{
VersionID: version.ID.Hex(),
Version: uint64(version.Version),
Full: full,
Delete: delIDs,
Insert: insertList,
Update: updateList,
SortVersion: sortVersion,
}
},
}
// 4. 执行增量同步构建
resp, err := opt.Build()
if err != nil {
return nil, err
}
// 5. 补充群组信息(全量同步或有群组更新时)
if resp.Full || hasGroupUpdate {
count, err := s.db.FindGroupMemberNum(ctx, group.GroupID)
if err != nil {
return nil, err
}
owner, err := s.db.TakeGroupOwner(ctx, group.GroupID)
if err != nil {
return nil, err
}
resp.Group = s.groupDB2PB(group, owner.UserID, count)
}
return resp, nil
}
4.2.2 Option.Build()核心逻辑与完整调用链解析
Option.Build()方法是增量同步的核心执行引擎,负责整个同步流程的决策和执行:
go
func (o *Option[A, B]) Build() (*B, error) {
// 【阶段1】参数完整性验证
if err := o.check(); err != nil {
return nil, err
}
// 【阶段2】版本策略智能决策
var tag int
version, err := o.getVersion(&tag)
if err != nil {
return nil, err
}
// 【阶段3】全量同步策略判断
var full bool
switch tag {
case tagQuery:
// 增量查询:检查版本日志完整性,决定是否需要全量同步
full = version.ID.Hex() != o.VersionID ||
uint64(version.Version) < o.VersionNumber ||
len(version.Logs) != version.LogLen
case tagFull:
// 全量同步
full = true
case tagEqual:
// 版本相等,无需同步
full = false
}
// 【阶段4】版本日志数据解析
var (
insertIds []string // 新增数据ID列表
deleteIds []string // 删除数据ID列表
updateIds []string // 更新数据ID列表
)
// 只有增量同步时才需要解析版本日志
if !full {
insertIds, deleteIds, updateIds = version.DeleteAndChangeIDs()
}
// 【阶段5】业务数据批量查询
var (
insertList []A // 新增数据内容列表
updateList []A // 更新数据内容列表
)
if len(insertIds) > 0 {
insertList, err = o.Find(o.Ctx, insertIds)
if err != nil {
return nil, err
}
}
if len(updateIds) > 0 {
updateList, err = o.Find(o.Ctx, updateIds)
if err != nil {
return nil, err
}
}
// 【阶段6】响应结果标准化构造
return o.Resp(version, deleteIds, insertList, updateList, full), nil
}
4.2.3 getVersion()方法详细解析与决策流程图
getVersion()是同步策略的核心决策引擎,负责确定采用哪种同步策略:
go
func (o *Option[A, B]) getVersion(tag *int) (*model.VersionLog, error) {
// 【路径A】无缓存模式:直接基于版本有效性查询
if o.CacheMaxVersion == nil {
if o.validVersion() {
*tag = tagQuery // 增量查询
return o.Version(o.Ctx, o.VersionKey, uint(o.VersionNumber), syncLimit)
}
*tag = tagFull // 全量同步
return o.Version(o.Ctx, o.VersionKey, 0, 0)
} else {
// 【路径B】缓存优化模式:先查缓存再决策
cache, err := o.CacheMaxVersion(o.Ctx, o.VersionKey)
if err != nil {
return nil, err
}
// 版本有效性检查
if !o.validVersion() {
*tag = tagFull
return cache, nil
}
// 版本ID匹配检查
if !o.equalID(cache.ID) {
*tag = tagFull
return cache, nil
}
// 版本号比较
if o.VersionNumber == uint64(cache.Version) {
*tag = tagEqual // 版本相等
return cache, nil
}
// 执行增量查询
*tag = tagQuery
return o.Version(o.Ctx, o.VersionKey, uint(o.VersionNumber), syncLimit)
}
}
同步策略决策流程图:
VersionGroupChangeID
VersionSortChangeID] BB --> CC[阶段3: 全量同步判断] CC --> DD{需要全量同步?} DD -->|是| EE[full = true] DD -->|否| FF[阶段4: 解析版本日志
version.DeleteAndChangeIDs] FF --> GG[提取 insertIds, deleteIds, updateIds] GG --> HH[阶段5: 批量数据查询] HH --> II[opt.Find 查询新增数据] II --> JJ[getGroupMembersInfo] JJ --> KK[opt.Find 查询更新数据] KK --> LL[getGroupMembersInfo] EE --> MM[阶段6: 构造响应] LL --> MM MM --> NN[opt.Resp 构造最终响应] NN --> OO[补充群组信息
如果需要] OO --> PP[返回 GetIncrementalGroupMemberResp] style A fill:#e1f5fe style E fill:#f3e5f5 style L fill:#ffcdd2 style O fill:#fff3e0 style P fill:#c8e6c9 style Y fill:#e8f5e8 style PP fill:#e1f5fe
4.2.4 完整调用链路流程图
从客户端请求到数据响应的完整调用链路:
4.2.5 错误处理与重试机制时序图
并发场景下的错误处理和自动重试机制:
4.3 同步策略与全量同步触发条件
系统在以下情况下会触发全量同步,而非增量同步:
4.3.1 全量同步触发场景流程图
5. 总结
OpenIM的群增量同步机制通过精心设计的版本控制系统,实现了高效的数据同步:
5.1 架构设计亮点
四大增量同步模块:
- 好友模块:用户维度的好友关系变更追踪
- 群组模块:用户维度的群组加入/退出追踪
- 群成员模块:群组维度的成员变更追踪(双重版本控制)
- 会话模块:用户维度的会话列表变更追踪
5.2 核心方法总结
版本控制核心方法:
方法 | 功能 | 调用链 | 关键特性 |
---|---|---|---|
IncrVersion |
版本递增更新 | IncrVersion → IncrVersionResult → incrVersionResult → writeLogBatch2/initDoc | 乐观锁、并发控制、自动重试 |
FindChangeLog |
增量查询 | FindChangeLog → findChangeLog → MongoDB聚合管道 | 五阶段过滤、智能降级、性能优化 |
writeLogBatch2 |
批量写入 | MongoDB五阶段聚合管道 | 原子性、去重、版本递增 |
群成员操作与版本管理:
操作 | 版本更新策略 | 特殊处理 |
---|---|---|
Create | 群组+用户双维度版本同时+1 | 按群组/用户分组批量更新 |
Delete | 全删除:删除版本记录 部分删除:标记删除状态 | 区分全量删除和部分删除 |
Update | 角色更新:添加排序变更标记 普通更新:只更新成员信息 | 智能识别更新类型 |
这套增量同步机制为构建高性能、高可用的企业级IM系统提供了坚实的技术基础,是OpenIM在海量用户场景下保持卓越性能的关键技术之一。