如何丝滑迁移 Mongodb 数据库

背景

公司业务支线 app 日活逐渐上涨,核心功能逻辑复杂,高峰期占用太多数据库性能,影响其他业务,决定迁移到独立 Mongodb 实例

目标

  1. 数据完整性:原始数据和增量数据都能迁移。

  2. 迁移连续性:迁移过程中不影响业务服务。

  3. 顺序一致性:特别是增量操作(insert/update/delete)必须按照 oplog 顺序执行。

核心流程

数据同步机制:Mongodb Change Stream(基于副本集 oplog)

全量备份 > 实时增量同步 > 增量补偿同步

markdown 复制代码
┌─────────────────────┐
│ 1. 记录全量迁移开始时间 │ backupStartTime
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ 2. 执行 mongodump    │ 导出全量数据
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ 3. 执行 mongorestore │ 导入目标数据库
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ 4. 实时增量同步      │ 持续监听最新变更,保证迁移后数据同步
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ 5. 补偿增量同步      │ change stream + 时间范围(备份开始-备份恢复完成) + 正序处理
└─────────────────────┘

全量迁移

  1. 记录开始时间 T1

  2. 执行 mongodump

    css 复制代码
    mongodump 
        --host localhost \
        --port 27017 \
        --db dbName \    --archive=/mnt/backup/dbName.gz \
        --gzip
  3. Scp 将数据库备份文件拷贝到目标数据库主机上

  4. 执行 mongorestore

    css 复制代码
    mongorestore \
      --host localhost \
      --port 27017 \
      --archive=/path/to/dbName.gz \  --gzip
  5. 记录结束时间 T2

实时增量同步

为了确保丝滑迁移,备份恢复完成后,通过 Change Stream 实时监听, 持续同步新增的 写入/修改/删除 等变更

操作类型

  • insertupdateOne + $setOnInsert(幂等写入)

  • updatereplaceOne + upsert

  • deletedeleteOne

javascript 复制代码
const { MongoClient, Timestamp } = require('mongodb');
const { argv } = require('node:process');

// 源 MongoDB 副本集
const sourceUri = 'mongodb://host:27017/?replicaSet=rs0';
const sourceDbName = 'dnName';

// 目标 MongoDB 实例
const targetUri = 'mongodb://targetHost:27017/?replicaSet=rs0';
const targetDbName = 'dbName';

const sourceClient = new MongoClient(sourceUri);
const targetClient = new MongoClient(targetUri);

async function main() {
    let insertCount = 0;
    let updateCount = 0;
    let deleteCount = 0;

    try {
        await sourceClient.connect();
        await targetClient.connect();

        const sourceDb = sourceClient.db(sourceDbName);
        const targetDb = targetClient.db(targetDbName);

        console.error(`✅ 已连接源数据库: ${sourceDbName}`);
        console.error(`✅ 已连接目标数据库: ${targetDbName}`);
        console.error('📅 开始监听实时变更 \n');

        // 打开 change stream(监听整个数据库)
        const changeStream = sourceDb.watch([], {
            fullDocument: 'updateLookup',
        });

        changeStream.on('change', async (change) => {
            const {
                operationType, ns, fullDocument, documentKey,
            } = change;

            const targetCollection = targetDb.collection(ns.coll);

            try {
                // eslint-disable-next-line default-case
                switch (operationType) {
                case 'insert':
                    await targetCollection.updateOne(
                        { _id: fullDocument._id },
                        { $setOnInsert: fullDocument },
                        { upsert: true },
                    );
                    insertCount += 1;
                    console.error('>>>', operationType, ns.coll, documentKey, `; total inserted: ${insertCount}`);
                    break;
                case 'update':
                case 'replace':
                    await targetCollection.replaceOne({ _id: documentKey._id }, fullDocument, { upsert: true });
                    updateCount += 1;
                    console.error('>>>', operationType, ns.coll, documentKey, `; total updated: ${updateCount}`);
                    break;
                case 'delete':
                    await targetCollection.deleteOne({ _id: documentKey._id });
                    deleteCount += 1;
                    console.error('>>>', operationType, ns.coll, documentKey, `; total deleted: ${deleteCount}`);
                    break;
                }
            } catch (err) {
                console.error(`❌ 同步 ${operationType} 失败: ${err.message}`);
            }
        });

        changeStream.on('error', (err) => {
            console.error('🚨 ChangeStream 错误:', err);
        });

        console.error('🚀 Change Stream 已启动,正在监听中...\n');

        // 防止程序退出
        await new Promise(() => {});
    } catch (err) {
        console.error(`💥 初始化失败: ${err.message}`);
    }
}

main();

补偿增量同步

  • 覆盖全量备份恢复过程中遗漏的增量数据
  • 指定时间范围,即「全量迁移」流程中记录的 T1、T2 时间点作为起止时间
  • 读取所有 change stream 事件数据并缓存
  • clusterTime 正序排序后再处理(mongoClient 读取监听旧 change 事件时无法保证顺序一致性):
javascript 复制代码
/**
 * change stream 增量补偿模式
 * 将指定时间范围内的变更事件,按序同步到目标数据库
 */

const { MongoClient, Timestamp } = require('mongodb');
const { argv } = require('node:process');

// 源 MongoDB 副本集
const sourceUri = 'mongodb://host:27017/?replicaSet=rs0';
const sourceDbName = 'dnName';

// 目标 MongoDB 实例
const targetUri = 'mongodb://targetHost:27017/?replicaSet=rs0';
const targetDbName = 'dbName';

const sourceClient = new MongoClient(sourceUri);
const targetClient = new MongoClient(targetUri);

async function main() {
    const startAtTime = Number(argv[2]);
    const endTime = Number(argv[3]);

    if (!startAtTime || !endTime) {
        console.error('❌ 补偿模式需要传入 startAtTime 和 endTime 参数');
        process.exit(1);
    }

    console.error(`🟠 补偿模式: 同步时间范围 [${new Date(startAtTime * 1000).toISOString()}, ${new Date(endTime * 1000).toISOString()}]`);

    try {
        await sourceClient.connect();
        await targetClient.connect();

        const sourceDb = sourceClient.db(sourceDbName);
        const targetDb = targetClient.db(targetDbName);

        console.error(`✅ 已连接源数据库: ${sourceDbName}`);
        console.error(`✅ 已连接目标数据库: ${targetDbName}`);

        // 打开 change stream
        const changeStream = sourceDb.watch([], {
            fullDocument: 'updateLookup',
            startAtOperationTime: new Timestamp({ t: startAtTime, i: 0 }),
        });

        // 缓存所有事件
        const changes = [];
        changeStream.on('change', (change) => {
            const tsSec = change.clusterTime.getHighBits();
            if (tsSec <= endTime) changes.push(change); // 只收集 endTime 之前的事件
        });

        // 等待 change stream 结束(比如手动 ctrl+c 后结束)
        await new Promise((resolve) => {
            changeStream.on('close', resolve);
        });

        console.error(`🟢 收集到 ${changes.length} 条事件,开始排序处理`);

        // 按 clusterTime 正序
        changes.sort((a, b) => a.clusterTime.getHighBits() - b.clusterTime.getHighBits());

        // 遍历处理
        let insertCount = 0; let updateCount = 0; let
            deleteCount = 0;
        for (const change of changes) {
            const {
                operationType, ns, fullDocument, documentKey,
            } = change;
            const targetCollection = targetDb.collection(ns.coll);

            try {
                switch (operationType) {
                case 'insert':
                    await targetCollection.updateOne(
                        { _id: fullDocument._id },
                        { $setOnInsert: fullDocument },
                        { upsert: true },
                    );
                    insertCount++;
                    break;
                case 'update':
                case 'replace':
                    await targetCollection.replaceOne({ _id: documentKey._id }, fullDocument, { upsert: true });
                    updateCount++;
                    break;
                case 'delete':
                    await targetCollection.deleteOne({ _id: documentKey._id });
                    deleteCount++;
                    break;
                }
            } catch (err) {
                console.error(`❌ 同步 ${operationType} 失败: ${err.message}`);
            }
        }

        console.error(`✅ 同步完成: insert=${insertCount}, update=${updateCount}, delete=${deleteCount}`);

        await sourceClient.close();
        await targetClient.close();
    } catch (err) {
        console.error(`💥 初始化失败: ${err.message}`);
    }
}

main();
node sync.js T1 T2

更换 db 连接

  • 检查各个集合数据变更同步正常,切换 db 连接 uri,部署上线

  • 上线后再针对源 db 读取 change stream,确保服务滚动更新时新增的数据变更

关于 change stream 乱序

现象:

  • 源数据库:先插入,后删除
  • Change Stream 监听执行:先删除,再插入

原因:

1️⃣ Oplog 事件传播延迟

  • MongoDB 副本集内部,写操作先写入 primary 的 oplog,然后复制到 secondary

  • Change Stream 的监听节点可能是 primary 或 secondary,不同节点接收事件的时间略有差异。

  • 导致同一事件在监听端到达顺序与实际写入顺序不完全一致。

2️⃣ Change Stream 并发批量返回

  • Change Stream 会按 批次(batch) 返回事件。

  • 批次内部事件顺序通常正确,但不同批次之间可能存在时间交叉。

  • 如果事件产生速度很快,处理端接收顺序可能出现先 delete 再 insert 的情况。

3️⃣ 时间戳判断与网络延迟

  • clusterTime 标识事件发生顺序,但网络传输、队列积压等因素可能导致回调顺序与 clusterTime 不一致。
  • 脚本中直接处理 change 事件而不做缓存排序,可能出现乱序执行。
相关推荐
qincloudshaw3 小时前
Linux系统下安装JDK并设置环境变量
后端
程序定小飞3 小时前
基于springboot的民宿在线预定平台开发与设计
java·开发语言·spring boot·后端·spring
代码扳手4 小时前
Golang 实战:用 Watermill 构建订单事件流系统,一文掌握概念与应用
后端·go
麻木森林4 小时前
利用Apipost 的AI能力轻松破解接口测试的效率与质量困局
后端·api
紫荆鱼4 小时前
设计模式-代理模式(Proxy)
c++·后端·设计模式·代理模式
IT技术分享社区5 小时前
架构入门系列:在线二手交易平台技术选型指南
程序员·架构
调试人生的显微镜5 小时前
Web 开发的工具全攻略 一套高效、实用、可落地的前端工作体系
后端
非凡ghost5 小时前
Affinity Photo(图像编辑软件) 多语便携版
前端·javascript·后端
非凡ghost5 小时前
VideoProc Converter AI(视频转换软件) 多语便携版
前端·javascript·后端