基于 Node.js + mysql2 的实用同步助手,适合开发/测试环境下快速对齐表数据

作为前端开发者,我们经常面临开发环境与测试/预发布环境数据不一致的问题。手动同步数据不仅耗时,更易因操作失误导致数据丢失。本文将分享一个数据库同步工具实现方案。

目录

为什么需要自动化同步工具?

在日常开发中,我们常遇到以下场景:

  • 测试环境需要与开发环境数据完全一致
  • 预发布环境需要复现线上问题
  • 修复数据问题后需快速同步到测试环境
  • 手动操作风险高:误删数据、覆盖关键配置、同步不完整

工具设计核心思路

本工具设计聚焦安全、可控、可追溯三大原则:

特性 实现方式 价值
安全第一 操作前自动备份目标表(带时间戳) 避免误操作导致数据丢失
精准同步 数据差异对比 + 仅同步差异数据(支持双向) 避免覆盖目标环境已修改的关键数据
操作可审计 每次操作生成带时间戳的备份文件 + 详细日志输出 问题溯源有据可查
操作可视化 交互式选择(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环境同步到目标环境 - 覆盖)

执行流程:

  1. 导出 dev 环境数据 → t_menu_dev_20260121140000.sql
  2. 备份 目标环境数据 → t_menu_test_menu_20260121140000.sql
  3. 清空目标表 TRUNCATE TABLE t_menu
  4. 导入导出的SQL文件 → 完成覆盖

💡 适用场景:测试环境需要与开发环境完全一致(如新功能测试前)


3. 反向同步(差异新增)

plaintext 复制代码
2. 反向同步 (从目标环境新增到dev环境 - 仅新增差异数据)

执行流程:

  1. 识别目标表独有数据(如测试环境新增的菜单项)
  2. 生成 INSERT 语句
  3. 仅新增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 仅同步新增问题修复,不覆盖其他配置
误操作覆盖了测试环境数据 用备份文件恢复 备份文件已自动保存

最后提醒

本文代码仅作技术示例生产环境必须

  1. 将密码移至环境变量
  2. 限制脚本执行权限
  3. 添加操作日志审计
  4. 通过 dockerk8s 隔离运行环境

完整代码

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();
相关推荐
一路向北he2 小时前
ac791 wifi连接成功流程
网络·智能路由器
很㗊2 小时前
Linux --- tar命令常见用法
linux·运维·服务器
RisunJan2 小时前
Linux命令-ld(将目标文件连接为可执行程序)
linux·运维·服务器
Mr Aokey2 小时前
RabbitMQ进阶实战:三种典型消息路由模式详解(订阅/路由/主题)
java·网络·rabbitmq
huohaiyu2 小时前
UDP协议
网络·网络协议·udp
一轮弯弯的明月2 小时前
TCP连接管理(三次握手与四次挥手)
网络·经验分享·笔记·网络协议·tcp/ip·学习心得
无心水2 小时前
4、Go语言程序实体详解:变量声明与常量应用【初学者指南】
java·服务器·开发语言·人工智能·python·golang·go
init_23612 小时前
【HCIE-08】NAT64
linux·服务器·网络
贾修行2 小时前
Kestrel:.NET 的高性能 Web 服务器探秘
服务器·前端·kestrel·.net·net core·web-server·asp.net-core