Go操作MongoDB更新字段时将旧值$push到历史数组实现版本控制
实现方案
普通MongoDB更新语法无法直接在单条语句中读取文档原有字段值作为更新参数,要实现「更新字段时将旧值存入历史数组」的需求,有两种成熟方案,优先选择第一种原子实现。
方案1:聚合管道更新(MongoDB 4.2+ 支持,推荐)
MongoDB 4.2及以上版本支持将聚合管道作为更新参数,管道内可以直接通过$字段名引用文档更新前的原始值,单条语句原子完成所有操作,无并发一致性问题,性能最优。
注意:聚合管道更新阶段使用聚合操作符,不能直接使用普通更新的
$push,可以用$concatArrays实现完全一致的数组追加效果。
示例代码:
func UpdateDocument(ctx context.Context, id uuid.UUID, updateFields bson.M) error { filter := bson.M{"_id": id} // 构建聚合管道更新语句 update := mongo.Pipeline{ {{ Key: "$set", Value: bson.D{ // 先拼接所有待更新的字段,若updateFields是动态传入的,可以遍历键值对自动拼接 {"original_name", updateFields["original_name"]}, {"user_name", updateFields["user_name"]}, // 追加original_name旧值到历史数组:兼容历史数组不存在的场景,初始化为空数组 {"old_original_names", bson.D{ {"$concatArrays", bson.A{ bson.D{{"$ifNull", bson.A{"$old_original_names", bson.A{}}}}, bson.A{"$original_name"}, // 这里$original_name就是更新前的原字段值 }}, }}, // 版本号+1,兼容首次无version字段的场景,默认从1开始计数 {"version", bson.D{ {"$add", bson.A{ bson.D{{"$ifNull", bson.A{"$version", 0}}}, 1, }}, }}, }, }}, } _, err := r.db.FindOneAndUpdate(ctx, filter, update).DecodeBytes() return err }
如果需要追踪多个字段的历史值,只需要在$set块中追加对应字段的数组拼接逻辑即可,比如要追踪user_name的变更历史,就新增如下配置:
{"old_user_names", bson.D{ {"$concatArrays", bson.A{ bson.D{{"$ifNull", bson.A{"$old_user_names", bson.A{}}}}, bson.A{"$user_name"}, }}, }},
方案2:读取旧文档后追加(兼容MongoDB 4.2以下版本)
如果使用的MongoDB版本低于4.2不支持聚合管道更新,可以通过FindOneAndUpdate配置返回更新前的文档,取出旧值后再追加到历史数组。该方案需要两次数据库交互,非原子,必须配合乐观锁避免并发问题。
示例代码:
func UpdateDocument(ctx context.Context, id uuid.UUID, obj interface{}, currentVersion int) error { // 把version加入过滤条件做乐观锁,避免并发更新冲突 filter := bson.M{"_id": id, "version": currentVersion} // 配置返回更新前的文档 opts := options.FindOneAndUpdate().SetReturnDocument(options.Before) var oldDoc bson.M // 第一步:更新字段、递增版本,拿到更新前的旧文档 err := r.db.FindOneAndUpdate(ctx, filter, bson.M{ "$set": obj, "$inc": bson.M{"version": 1}, }, opts).Decode(&oldDoc) if err != nil { return err } // 第二步:从旧文档取出原值,追加到历史数组 if oldVal, exists := oldDoc["original_name"]; exists { _, err = r.db.UpdateOne(ctx, bson.M{"_id": id}, bson.M{ "$push": bson.M{"old_original_names": oldVal}, }) if err != nil { return err } } return nil }
该方案注意事项:
- 两次写操作非原子,如果第一步更新成功、第二步追加历史失败会导致数据不一致,建议添加失败重试或补偿逻辑
- 必须给
version字段加索引,更新时携带当前版本号做乐观锁校验,否则并发场景下会出现旧值和版本对应错乱的问题 - 性能比方案1差30%以上,仅在版本不支持时使用
内容的提问来源于stack exchange,提问作者dante0x75
相关产品推荐
相关产品推荐

