作为前端开发者,我们经常面临开发环境与测试/预发布环境数据不一致的问题。手动同步数据不仅耗时,更易因操作失误导致数据丢失。本文将分享一个数据库同步工具实现方案。
目录
-
- 为什么需要自动化同步工具?
- 工具设计核心思路
- 核心功能详解
-
- [1. 数据差异智能对比](#1. 数据差异智能对比)
- [2. 正向同步(覆盖式)------ 适用于**测试环境全量同步**](#2. 正向同步(覆盖式)—— 适用于测试环境全量同步)
- [3. 反向同步(差异新增)](#3. 反向同步(差异新增))
- 关键安全设计解析
-
- [1. 备份机制](#1. 备份机制)
- [2. 操作二次确认](#2. 操作二次确认)
- [3. 密码安全处理](#3. 密码安全处理)
- 实际使用场景
- 完整代码
为什么需要自动化同步工具?
在日常开发中,我们常遇到以下场景:
- 测试环境需要与开发环境数据完全一致
- 预发布环境需要复现线上问题
- 修复数据问题后需快速同步到测试环境
- 手动操作风险高:误删数据、覆盖关键配置、同步不完整
工具设计核心思路
本工具设计聚焦安全、可控、可追溯三大原则:
| 特性 | 实现方式 | 价值 |
|---|---|---|
| 安全第一 | 操作前自动备份目标表(带时间戳) | 避免误操作导致数据丢失 |
| 精准同步 | 数据差异对比 + 仅同步差异数据(支持双向) | 避免覆盖目标环境已修改的关键数据 |
| 操作可审计 | 每次操作生成带时间戳的备份文件 + 详细日志输出 | 问题溯源有据可查 |
| 操作可视化 | 交互式选择(1/2/3) + 二次确认机制 | 降低误操作概率 |
核心功能详解
1. 数据差异智能对比
javascript
// 对比两表数据差异
const comparisonResult = await compareTableData(config.dev, config[target], tableName);
输出示例:
plaintext
数据对比结果:
源表 (dev): 85 条记录
目标表 (test_menu): 82 条记录
相同记录: 82 条
仅在源表存在 (正向同步会增加): 3 条
仅在目标表存在 (反向同步会增加): 0 条
两表数据基本一致,无需同步
为什么用
JSON.stringify比较?避免对象引用导致的比较失效,确保100%数据一致性判断
2. 正向同步(覆盖式)------ 适用于测试环境全量同步
plaintext
1. 正向同步 (从dev环境同步到目标环境 - 覆盖)
执行流程:
- 导出
dev环境数据 →t_menu_dev_20260121140000.sql - 备份 目标环境数据 →
t_menu_test_menu_20260121140000.sql - 清空目标表
TRUNCATE TABLE t_menu - 导入导出的SQL文件 → 完成覆盖
💡 适用场景:测试环境需要与开发环境完全一致(如新功能测试前)
3. 反向同步(差异新增)
plaintext
2. 反向同步 (从目标环境新增到dev环境 - 仅新增差异数据)
执行流程:
- 识别目标表独有数据(如测试环境新增的菜单项)
- 生成
INSERT语句 - 仅新增 到
dev环境(不覆盖已有数据!)
为什么推荐这个?
保留
dev环境的自定义配置避免覆盖测试环境发现的线上问题修复
100%安全(仅新增,不修改已有数据)
输出示例:
plaintext
开始执行反向数据新增...
发现 2 条目标表独有记录,正在准备添加到源表...
成功将 2 条数据从目标表添加到源表!
关键安全设计解析
1. 备份机制
javascript
const backupFile = path.join(
tempDir,
`${tableName}_${target}_${dayjs().format("YYYYMMDDhhmmss")}.sql`
);
- 自动创建
./backups目录 - 文件名包含 环境名+时间戳 (
t_menu_test_menu_20260121140000.sql) - 每次操作前自动备份,即使失败也能快速恢复
2. 操作二次确认
javascript
const confirmReverse = await askQuestion("\n⚠️ 警告: 反向同步将把目标表独有数据新增到dev环境,确认继续? (y/n): ");
- 防止误触执行反向同步
- 重要操作必须手动确认
3. 密码安全处理
注意 :示例代码中密码硬编码仅用于演示
生产环境必须:
- 使用环境变量(如
process.env.DB_PASSWORD)- 通过
dotenv加载密钥- 禁止提交到 Git
实际使用场景
| 场景 | 操作选择 | 为什么? |
|---|---|---|
| 测试环境需要复现开发环境数据 | 1 | 全量覆盖,确保一致性 |
| 测试环境修复了关键问题并验证 | 2 | 仅同步新增问题修复,不覆盖其他配置 |
| 误操作覆盖了测试环境数据 | 用备份文件恢复 | 备份文件已自动保存 |
最后提醒 :
本文代码仅作技术示例 ,生产环境必须:
- 将密码移至环境变量
- 限制脚本执行权限
- 添加操作日志审计
- 通过
docker或k8s隔离运行环境
完整代码
javascript
// 解析命令行参数以确定模式
const args = process.argv.slice(2);
let mode = "default"; // 默认模式
// 检查是否有 --mode 参数
for (let i = 0; i < args.length; i++) {
if (args[i] === "--mode" && args[i + 1]) {
mode = args[i + 1];
break;
}
}
// 根据模式加载相应的环境变量文件
if (mode !== "default") {
require("dotenv").config({ path: `.env.${mode}` });
} else {
require("dotenv").config();
}
const mysql = require("mysql2/promise");
const fs = require("fs").promises;
const readline = require("readline");
const path = require("path");
const dayjs = require("dayjs");
// 创建交互式接口
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// 询问用户输入的辅助函数
function askQuestion(question) {
return new Promise(resolve => {
rl.question(question, answer => {
resolve(answer.toLowerCase());
});
});
}
// 检查特定环境所需的环境变量是否设置
function checkRequiredEnvVariables(targetEnv) {
let requiredEnvVars = [];
switch (targetEnv) {
case "dev":
requiredEnvVars = [
"DEV_DB_HOST",
"DEV_DB_USER",
"DEV_DB_PASSWORD",
"DEV_DB_NAME"
];
break;
case "test_menu":
case "test_dictionary":
requiredEnvVars = [
"TEST_DB_HOST",
"TEST_DB_USER",
"TEST_DB_PASSWORD",
"TEST_DB_NAME"
];
break;
case "uat_menu":
case "uat_dictionary":
requiredEnvVars = [
"UAT_DB_HOST",
"UAT_DB_USER",
"UAT_DB_PASSWORD",
"UAT_DB_NAME"
];
break;
default:
console.error(`❌ 不支持的目标环境: ${targetEnv}`);
process.exit(1);
}
const missingEnvVars = [];
for (const envVar of requiredEnvVars) {
// 检查环境变量是否存在且不是默认值
if (!process.env[envVar] || process.env[envVar].startsWith("default_")) {
missingEnvVars.push(envVar);
}
}
if (missingEnvVars.length > 0) {
console.error("\n❌ 错误: 检测到以下必需的环境变量未设置:");
for (const envVar of missingEnvVars) {
console.error(` - ${envVar}`);
}
console.error(
"\n💡 提示: 请确保已创建 .env 文件并填写正确的数据库配置信息"
);
console.error(" 参考 .env.example 文件创建 .env 文件");
console.error(" 然后运行: node index.js " + targetEnv);
process.exit(1);
}
}
// 备份的输出路径
const tempDir = "./backups";
const target = args.find(arg => !arg.startsWith("--")) || "";
if (!target) {
console.error("❌ 请提供目标环境参数,例如: node script.js test_menu");
process.exit(1);
}
// 检查当前目标环境的环境变量
checkRequiredEnvVariables(target);
// 从环境变量获取数据库配置
const config = {
dev: {
host: process.env.DEV_DB_HOST,
user: process.env.DEV_DB_USER,
password: process.env.DEV_DB_PASSWORD,
database: process.env.DEV_DB_NAME
},
test_menu: {
host: process.env.TEST_DB_HOST,
user: process.env.TEST_DB_USER,
password: process.env.TEST_DB_PASSWORD,
database: process.env.TEST_DB_NAME,
table: "t_menu"
},
uat_menu: {
host: process.env.UAT_DB_HOST,
user: process.env.UAT_DB_USER,
password: process.env.UAT_DB_PASSWORD,
database: process.env.UAT_DB_NAME,
table: "t_menu"
},
test_dictionary: {
host: process.env.TEST_DB_HOST,
user: process.env.TEST_DB_USER,
password: process.env.TEST_DB_PASSWORD,
database: process.env.TEST_DICTIONARY_DB_NAME || process.env.TEST_DB_NAME,
table: "t_dictionary"
},
uat_dictionary: {
host: process.env.UAT_DB_HOST,
user: process.env.UAT_DB_USER,
password: process.env.UAT_DB_PASSWORD,
database: process.env.UAT_DICTIONARY_DB_NAME || process.env.UAT_DB_NAME,
table: "t_dictionary"
}
};
if (!config[target]) {
console.error(`❌ 不支持的目标环境: ${target}`);
console.log(`📋 支持的环境: ${Object.keys(config).join(", ")}`);
process.exit(1);
}
// 配置参数
const tableName = config[target].table;
config.dev.database = config[target].database; // 保持数据库一致
// 删除createConnection不需要的参数
Object.keys(config).forEach(item => {
delete config[item].table;
});
const exportFile = path.join(
tempDir,
`${tableName}_dev_${dayjs().format("YYYYMMDDhhmmss")}.sql`
);
const backupFile = path.join(
tempDir,
`${tableName}_${target}_${dayjs().format("YYYYMMDDhhmmss")}.sql`
);
// const reverseExportFile = path.join(
// tempDir,
// `${tableName}_${target}_${dayjs().format("YYYYMMDDhhmmss")}_reverse.sql`
// );
/**
* 确保备份目录存在
*/
async function prepareDirectory() {
await fs.mkdir(tempDir, { recursive: true });
}
/**
* 导出表数据到SQL文件
* @param {Object} dbConfig - 数据库配置信息
* @param {string} tableName - 表名
* @param {string} outputFile - 输出文件路径
*/
async function exportTableToSQL(dbConfig, tableName, outputFile) {
const connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.query(`SELECT * FROM ${tableName}`);
await connection.end();
const insertStatements = rows
.map(row => {
const columns = Object.keys(row).join(", ");
const values = Object.values(row)
.map(val => connection.escape(val))
.join(", ");
return `INSERT INTO ${tableName} (${columns}) VALUES (${values});`;
})
.join("\n\n");
await fs.writeFile(outputFile, insertStatements);
console.info(`✅ 导出完成: ${outputFile}`);
}
/**
* 备份表数据到本地
* @param {Object} dbConfig - 数据库配置信息
* @param {string} tableName - 表名
* @param {string} backupFile - 备份文件路径
*/
async function backupTable(dbConfig, tableName, backupFile) {
const connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.query(`SELECT * FROM ${tableName}`);
await connection.end();
const backupStatements = rows
.map(row => {
const columns = Object.keys(row).join(", ");
const values = Object.values(row)
.map(val => connection.escape(val))
.join(", ");
return `INSERT INTO ${tableName} (${columns}) VALUES (${values});`;
})
.join("\n\n");
await fs.writeFile(backupFile, backupStatements);
console.info(`✅ 备份完成: ${backupFile}`);
}
/**
* 截断数据库表
* @param {Object} dbConfig - 数据库配置信息
* @param {string} tableName - 表名
*/
async function truncateTable(dbConfig, tableName) {
const connection = await mysql.createConnection(dbConfig);
await connection.query(`TRUNCATE TABLE ${tableName}`);
await connection.end();
console.info(`✅ 已截断表: ${tableName}`);
}
/**
* 执行SQL文件
* @param {Object} dbConfig - 数据库配置信息
* @param {string} sqlFile - SQL文件路径
*/
async function executeSQLFile(dbConfig, sqlFile) {
const connection = await mysql.createConnection(dbConfig);
const sql = await fs.readFile(sqlFile, "utf8");
const queries = sql.split(";").filter(q => q.trim() !== "");
for (const query of queries) {
if (query.trim()) {
await connection.query(query);
}
}
await connection.end();
console.info(`✅ SQL文件执行完成: ${sqlFile}`);
}
/**
* 比较两个表的数据差异
* @param {Object} sourceDbConfig - 源数据库配置
* @param {Object} targetDbConfig - 目标数据库配置
* @param {string} tableName - 表名
*/
async function compareTableData(sourceDbConfig, targetDbConfig, tableName) {
console.log(`🔍 正在比较表 ${tableName} 的数据差异...`);
// 获取源表数据
const sourceConnection = await mysql.createConnection(sourceDbConfig);
const [sourceRows] = await sourceConnection.query(
`SELECT * FROM ${tableName}`
);
await sourceConnection.end();
// 获取目标表数据
const targetConnection = await mysql.createConnection(targetDbConfig);
const [targetRows] = await targetConnection.query(
`SELECT * FROM ${tableName}`
);
await targetConnection.end();
// 将行数据转换为字符串,便于比较
const sourceRowStrings = sourceRows.map(row => JSON.stringify(row));
const targetRowStrings = targetRows.map(row => JSON.stringify(row));
// 找出在源表中有但在目标表中没有的行(需要插入/更新)
const rowsOnlyInSource = sourceRows.filter(
row => !targetRowStrings.includes(JSON.stringify(row))
);
// 找出在目标表中有但在源表中没有的行(需要插入)
const rowsOnlyInTarget = targetRows.filter(
row => !sourceRowStrings.includes(JSON.stringify(row))
);
// 计算统计信息
const addedOrModified = rowsOnlyInSource.length;
const deletedOrModified = rowsOnlyInTarget.length;
const totalSource = sourceRows.length;
const totalTarget = targetRows.length;
const common =
Math.max(totalSource, totalTarget) -
Math.max(addedOrModified, deletedOrModified);
console.log("\n📊 数据对比结果:");
console.log(`源表 (${config.dev.host}): ${totalSource} 条记录`);
console.log(`目标表 (${config[target].host}): ${totalTarget} 条记录`);
console.log(`相同记录: ${common} 条`);
console.log(`仅在源表存在 (正向同步会增加): ${addedOrModified} 条`);
console.log(`仅在目标表存在 (反向同步会增加): ${deletedOrModified} 条`);
if (rowsOnlyInSource.length > 0) {
console.log("\n➕ 以下是源表中独有的记录 (正向同步会添加到目标表):");
rowsOnlyInSource.forEach((row, idx) => {
console.log(`${idx + 1}.`, row);
});
}
if (rowsOnlyInTarget.length > 0) {
console.log("\n➖ 以下是目标表中独有的记录 (反向同步会添加到源表):");
rowsOnlyInTarget.forEach((row, idx) => {
console.log(`${idx + 1}.`, row);
});
}
if (rowsOnlyInSource.length === 0 && rowsOnlyInTarget.length === 0) {
console.log("\n✅ 两表数据完全一致,无需同步");
}
return {
sourceCount: totalSource,
targetCount: totalTarget,
addedOrModified: addedOrModified,
deletedOrModified: deletedOrModified,
rowsOnlyInSource: rowsOnlyInSource,
rowsOnlyInTarget: rowsOnlyInTarget
};
}
/**
* 反向同步数据(从目标表新增到源表,仅添加目标表独有数据)
* @param {Object} sourceDbConfig - 源数据库配置 (这里是目标环境)
* @param {Object} targetDbConfig - 目标数据库配置 (这里是dev环境)
* @param {string} tableName - 表名
* @param {Array} rowsToAdd - 需要添加的行数据
*/
async function reverseSync(
sourceDbConfig,
targetDbConfig,
tableName,
rowsToAdd
) {
console.log("\n🔄 开始执行反向数据新增...");
// 检查是否有需要添加的数据
if (!rowsToAdd || rowsToAdd.length === 0) {
console.log("✅ 目标表中没有额外的数据需要添加到源表,跳过反向同步");
return;
}
console.log(
`✅ 发现 ${rowsToAdd.length} 条目标表独有记录,正在准备添加到源表...`
);
// 创建一个临时连接到目标数据库
const connection = await mysql.createConnection(targetDbConfig);
try {
// 逐条插入目标表独有的数据
for (const row of rowsToAdd) {
const columns = Object.keys(row).join(", ");
const values = Object.values(row)
.map(val => connection.escape(val))
.join(", ");
const insertQuery = `INSERT INTO ${tableName} (${columns}) VALUES (${values});`;
await connection.query(insertQuery);
}
console.info(`✅ 成功将 ${rowsToAdd.length} 条数据从目标表添加到源表!`);
} catch (error) {
console.error("❌ 反向同步过程中发生错误:", error);
throw error;
} finally {
await connection.end();
}
}
/**
* 主流程 - 同步数据库表数据
* 从dev环境导出数据并同步到目标环境
*/
async function syncTables() {
try {
await prepareDirectory();
// 首先比较数据差异
const comparisonResult = await compareTableData(
config.dev,
config[target],
tableName
);
// 询问用户选择操作类型
console.log("\n📋 请选择操作:");
console.log("1. 正向同步 (从dev环境同步到目标环境 - 覆盖)");
console.log("2. 反向同步 (从目标环境新增到dev环境 - 仅新增差异数据)");
console.log("3. 不执行任何同步");
const choice = await askQuestion("\n请输入选项 (1/2/3): ");
if (choice === "1") {
console.log("\n🚀 开始执行正向数据同步...");
// 步骤1:导出A库数据
await exportTableToSQL(config.dev, tableName, exportFile);
// 步骤2:备份B库数据
await backupTable(config[target], tableName, backupFile);
// 步骤3:清空B库表
await truncateTable(config[target], tableName);
// 步骤4:导入A库数据到B库
await executeSQLFile(config[target], exportFile);
console.info("✅ 正向数据同步完成!");
} else if (choice === "2") {
// 反向同步 - 仅新增差异数据
const confirmReverse = await askQuestion(
"\n⚠️ 警告: 反向同步将把目标表独有数据新增到dev环境,确认继续? (y/n): "
);
if (confirmReverse === "y" || confirmReverse === "yes") {
await reverseSync(
config[target],
config.dev,
tableName,
comparisonResult.rowsOnlyInTarget
);
} else {
console.log("ℹ️ 已取消反向同步操作");
}
} else if (choice === "3") {
console.log("ℹ️ 已选择不执行任何同步操作");
} else {
console.log("⚠️ 无效的选项,已取消操作");
}
rl.close();
} catch (error) {
console.error("❌ 同步过程中发生错误:", error);
rl.close();
process.exit(1);
}
}
// 执行同步
syncTables();