背景
公司业务支线 app 日活逐渐上涨,核心功能逻辑复杂,高峰期占用太多数据库性能,影响其他业务,决定迁移到独立 Mongodb 实例
目标
-
数据完整性:原始数据和增量数据都能迁移。
-
迁移连续性:迁移过程中不影响业务服务。
-
顺序一致性:特别是增量操作(insert/update/delete)必须按照 oplog 顺序执行。
核心流程
数据同步机制:Mongodb Change Stream(基于副本集 oplog)
全量备份 > 实时增量同步 > 增量补偿同步
markdown
┌─────────────────────┐
│ 1. 记录全量迁移开始时间 │ backupStartTime
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 2. 执行 mongodump │ 导出全量数据
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 3. 执行 mongorestore │ 导入目标数据库
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 4. 实时增量同步 │ 持续监听最新变更,保证迁移后数据同步
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 5. 补偿增量同步 │ change stream + 时间范围(备份开始-备份恢复完成) + 正序处理
└─────────────────────┘
全量迁移
-
记录开始时间 T1
-
执行 mongodump
cssmongodump --host localhost \ --port 27017 \ --db dbName \ --archive=/mnt/backup/dbName.gz \ --gzip -
Scp 将数据库备份文件拷贝到目标数据库主机上
-
执行 mongorestore
cssmongorestore \ --host localhost \ --port 27017 \ --archive=/path/to/dbName.gz \ --gzip -
记录结束时间 T2
实时增量同步
为了确保丝滑迁移,备份恢复完成后,通过 Change Stream 实时监听, 持续同步新增的 写入/修改/删除 等变更
操作类型:
-
insert :
updateOne+$setOnInsert(幂等写入) -
update :
replaceOne+upsert -
delete :
deleteOne
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事件而不做缓存排序,可能出现乱序执行。