一个月搞定100+表迁移:我的“偷师”Navicat实战复盘

个人声明:本文所有代码示例均已脱敏处理,仅保留核心技术逻辑,不涉及任何敏感业务信息。


前情提要:一个堪称"社死"的工期

还记得那天,老板把我叫到办公室,递过来一份需求文档:"下个月要把项目迁移到新平台,数据这块你来搞定。"

我打开文档,扫了一眼,差点当场石化:

需求清单

  • 100+张数据表要迁移(还要支持后续动态新增)
  • 双链路同步:MySQL到MySQL、MongoDB到PostgreSQL
  • 不能写死配置,要能灵活扩展
  • 工期不到1个月

技术约束

  • 源环境(塔外)和目标环境(塔内)网络完全隔离
  • 塔外只能读源库,无法访问目标库
  • 塔内只能写目标库,无法访问源库
  • 两端唯一的桥梁:阿里云OSS(塔外只能写,塔内可以读写)
  • 塔内不支持MongoDB,必须用PostgreSQL替代

数据规模

  • 单表最大1000万+行数据
  • 单店铺单表50万+行(涉及1000+个店铺)
  • 总计100+张表

那一刻,我脑海里浮现的画面是:在公司地下室疯狂写MyBatis <select><insert>语句直到猝死...

但最终,我不仅提前5天完成迁移 ,还搞出了一套能让后续表秒级上线的"全自动化流水线"。怎么做到的?

答案就藏在Navicat的"导入/导出"功能里------直接构造SQL文件上传OSS,塔内执行,复杂逻辑全都在塔外处理!


一眼望去的七大技术难点

在开始动手前,我先梳理了一下面临的挑战:

难点1:表结构千差万别

100+张表,每张表的字段、类型、主键都不一样。传统MyBatis方式意味着要写100+个Mapper、100+个实体类。后续新增表还得继续写,代码复用度≈0

难点2:同步策略多样化

100+张表需要支持四种同步策略,条件各不相同:

  • 全表同步 :基础配置表,数据量小,TRUNCATE后一次性插入全部数据
  • 公司级条件同步 :按company_id维度同步,支持条件过滤
  • 店铺级增量同步 :有is_deletedupdate_time的表,按shop_id+时间条件增量同步
  • 店铺级全量同步 :物理删除的表,按shop_id维度全量同步单店铺数据

每张表的策略和条件都不同,需要支持灵活配置

难点3:数据内容包含特殊字符

某些字段的内容包含分号、单引号等SQL特殊字符,如果不处理,生成的SQL文件会在执行时语法报错。

难点4:超大数据量
单表1000万+数据 ,一次性加载到内存必然OOM。而且生成的SQL文件可能几百MB,网络传输和存储都是问题。

难点5:MongoDB到PostgreSQL的类型鸿沟

MongoDB的ObjectId、BSON对象、数组类型,PostgreSQL都不支持。需要做复杂的类型映射和转换。

难点6:网络隔离架构

塔外和塔内网络完全隔离,传统的ETL工具(DataX)根本用不了。它们都是"读→处理→写"的单机模式,需要同时访问源库和目标库。

解决方案:自己搭建一个类似navicat的导入/导出,能动态执行SQL的功能。

难点7:表间依赖关系导致的顺序问题

部分表之间存在外键依赖关系(如order_items依赖orders),如果并发同步:

  • order_items先执行插入,但orders还未同步 → 外键约束失败
  • 需要识别依赖关系,先同步父表,再同步子表,保证数据完整性

解决方案:塔内扫描SQL文件时,优先处理父表,再并发处理其他表


灵感来源:Navicat是怎么做的?

某天深夜,我打开Navicat准备手动导出第一批测试数据。盯着"导出向导"发呆的时候,突然脑子里闪过一个念头:

Navicat是怎么做到导出任意表的?

我点开导出的.sql文件:

sql 复制代码
-- 删除旧表
DROP TABLE IF EXISTS `demo_table`;

-- 重建表结构
CREATE TABLE `demo_table` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

-- 插入数据
INSERT INTO `demo_table` VALUES (1, 'test');

豁然开朗!Navicat的核心逻辑就是:

  1. SHOW CREATE TABLE获取表结构
  2. SELECT *查询数据
  3. 生成标准SQL文件
  4. 用户手动在目标库执行

如果我把这套逻辑自动化呢?

  • 塔外:自动查表结构、自动查数据、自动生成SQL、自动上传OSS
  • 塔内:自动扫描OSS、自动读取SQL文件、自动执行

这不就完美契合了"塔外-塔内"的架构约束吗!


核心方案设计

整体架构流程

技术选型说明

塔外系统技术栈

组件 选型 使用场景 选型理由
消息队列 RocketMQ 触发同步,异步解耦进行SQL文件构造 支持TAG过滤 (MySQLToMySQL/MongodbToPgSQL) 顺序消费 保证数据一致性,支持可后续扩展同步类型例如RedisToMySQL
流式处理 JDBC Stream MongoTemplate 读取超大表数据 避免OOMsetFetchSize(Integer.MIN_VALUE)启用MySQL服务器端游标,Mongo使用流式读取的api,内存占用恒定
配置管理 MySQL配置表 管理同步规则 配置驱动 ,新增表无需改代码,支持占位符动态替换{shopId}/{companyId}
文件上传 阿里云OSS SDK SQL文件上传 唯一能打通塔外塔内的桥梁,可用性99.995%,支持大文件

塔内系统技术栈

组件 选型 使用场景 选型理由
并发控制 CompletableFuture 并发处理多个SQL文件 JDK8原生 ,无需引入第三方库,轻量级异步编程
文件下载 阿里云OSS SDK SQL文件下载和删除 流式下载,支持逐行读取 ,执行成功后立即删除防止重复
批量执行 JDBC Batch SQL批量执行 1000条/批 平衡性能和内存,setAutoCommit(true)防止事务过大

第一难:100+张表结构各异,怎么动态生成SQL?

传统方案的绝望之路

如果用传统MyBatis写法,画面会是这样:

xml 复制代码
<!-- 表1的Mapper -->
<select id="queryTable1">
    SELECT id, name, create_time FROM table_1 WHERE shop_id = #{shopId}
</select>

<!-- 表2的Mapper -->
<select id="queryTable2">
    SELECT id, title, status FROM table_2 WHERE company_id = #{companyId}
</select>

<!-- ...重复100次... -->

手写100个Mapper?别说一个月,一年都写不完 !而且后续新增表还得继续写,代码复用度约等于0。

灵感来源:SHOW CREATE TABLE

MySQL提供了一个神器:SHOW CREATE TABLE

sql 复制代码
SHOW CREATE TABLE `user_info`;

输出:

sql 复制代码
CREATE TABLE `user_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

拿到建表语句 = 拿到了一切表信息(字段名、类型、主键...)

核心实现:动态解析表结构

java 复制代码
public TableStructure getTableStructure(DataSource ds, String tableName) {
    String sql = "SHOW CREATE TABLE `" + tableName + "`";
    
    try (Connection conn = ds.getConnection();
         Statement stmt = conn.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        
        if (rs.next()) {
            String ddl = rs.getString(2);  // 第2列是DDL语句
            
            // 核心:正则解析DDL语句
            List<String> columns = parseColumns(ddl);      // 提取字段名
            String primaryKey = parsePrimaryKey(ddl);      // 提取主键
            
            return new TableStructure(columns, primaryKey);
        }
    }
    return null;
}

关键亮点

  1. 表名转义 :防止关键字冲突(如表名叫orderuser
  2. 正则解析DDL:一次性获取字段、主键、类型信息
  3. 零硬编码:任何表都能自动处理,后续新增表只需加配置

你问怎么知道哪张表要同步?表名从哪来?请继续往下看...(第三难中有解决方案,通过配置表实现)

这里用到JDBC编程,适合当前业务需求(古法编程,不得已而为之)

生成完整SQL文件

拿到表结构后,生成标准SQL文件:

java 复制代码
// 1. 先删除目标环境的旧数据(保证幂等性)
String deleteStatement = "DELETE FROM `user_info` WHERE shop_id = 12345;\n";

// 2. 批量插入新数据(每批1000条)
String insertStatement = 
    "INSERT INTO `user_info` (`id`, `username`, `create_time`) VALUES\n" +
    "(1, 'Alice', '2025-01-01 12:00:00'),\n" +
    "(2, 'Bob', '2025-01-02 13:00:00');\n";

上传到OSS后,塔内直接逐行读取执行,完美!


第二难:数据里有分号,SQL会被切割炸掉!

问题现场

默认SQL语句以;结尾,但数据内容可能包含各种特殊情况:

sql 复制代码
-- 情况1: 数据中包含分号
INSERT INTO `content` VALUES (1, '教程:Java;Spring;MyBatis');

-- 情况2: 数据以分号结尾
INSERT INTO `config` VALUES (2, 'path=/usr/local/bin;');

-- 情况3: 数据中有换行符,且以;结尾
INSERT INTO `article` VALUES (3, '第一行
第二行;
第三行');

塔内如果用;判断SQL结束:

java 复制代码
String line = reader.readLine();  
// 只读到: INSERT INTO `content` VALUES (1, '教程:Java
// 数据被截断了!

导致SQL切割错位、语法报错。

解决方案:特殊符号标记 + 逐行读取

核心思路 :每条SQL独占一行,用特殊符号;#END#标记结束

塔外生成SQL时

java 复制代码
// 关键:使用特殊符号作为SQL结束标记
String SPECIAL_DELIMITER = ";#END#";

// 构造SQL(数据内容里的分号、换行符都不处理)
String sql = "INSERT INTO `content` VALUES (1, 'Java;Spring')";  

// 写入文件:每条SQL独占一行,以特殊符号结尾
writer.write(sql + SPECIAL_DELIMITER);
writer.write(System.lineSeparator());  // 系统换行符

上传到OSS的文件内容:

sql 复制代码
INSERT INTO `content` VALUES (1, 'Java;Spring');#END#
INSERT INTO `config` VALUES (2, 'path=/usr/bin;');#END#
INSERT INTO `article` VALUES (3, '第一行\n第二行');#END#

说明

  • 每条SQL独占一行(以System.lineSeparator()换行)
  • 每条SQL以;#END#结尾(完整的SQL结束标记)
  • 数据内容里的分号;、换行符\n等都保持原样

塔内执行前还原

java 复制代码
try (BufferedReader reader = new BufferedReader(
         new InputStreamReader(ossStream))) {
    
    List<String> sqlBatch = new ArrayList<>();
    StringBuilder currentSql = new StringBuilder();
    String line;
    
    while ((line = reader.readLine()) != null) {
        // 拼接当前行
        currentSql.append(line);
        
        // 检查是否是完整的SQL(以;#END#结尾)
        if (currentSql.toString().endsWith(";#END#")) {
            // 还原:特殊符号 → 正常分号
            String realSql = currentSql.toString().replace(";#END#", ";");
            
            // 添加到批次
            sqlBatch.add(realSql);
            currentSql.setLength(0);  // 清空,准备下一条SQL
            
            // 批量执行(每500条一批)
            if (sqlBatch.size() >= 100) {
                executeBatch(stmt, sqlBatch);
                sqlBatch.clear();
            }
        }
    }
    
    // 执行剩余SQL
    if (!sqlBatch.isEmpty()) {
        executeBatch(stmt, sqlBatch);
    }
}

为什么选;#END#

  • 足够长,不会和数据内容冲突(实测几千万条数据从未冲突)
  • 标记明确,易于理解
  • 塔内处理简单,一行代码搞定

关键点:为什么塔内要逐行读取?

原因一:SQL文件可能很大

单个SQL文件可能达到几百MB(如50万行数据),如果一次性读取:

  • 内存占用过高:100MB文件加载需要几百MB+内存,而且多线程处理更容易造成OOM
  • GC压力大:大对象频繁创建和回收

原因二:无法按普通分号切割

如果用;切割会出错:

java 复制代码
// ❌ 错误做法
String[] sqls = allContent.split(";");  // 会误切数据里的分号!

正确做法:逐行拼接,遇到;#END#才算完整

java 复制代码
// ✅ 正确做法
StringBuilder currentSql = new StringBuilder();
while ((line = reader.readLine()) != null) {
    currentSql.append(line);
    
    if (currentSql.toString().endsWith(";#END#")) {
        String sql = currentSql.toString().replace(";#END#", ";");
        executeBatch(sql);
        currentSql.setLength(0);  // 清空,准备下一条
    }
}

SQL文件格式示例

sql 复制代码
DELETE FROM `table` WHERE id = 1;#END#
INSERT INTO `table` VALUES (1, 'data;with;semicolons');#END#
INSERT INTO `table` VALUES (2, 'line1\nline2');#END#

第三难:同步策略多样化,怎么灵活配置?

背景:四种同步策略

同步策略 适用场景 SQL操作 数据范围
全表同步 基础配置表(数据量小,千行级) TRUNCATE + INSERT 整张表的所有数据
公司级条件同步 按公司维度管理的表 DELETE WHERE company_id=? + INSERT 单个公司的所有数据
店铺级增量同步 有软删除标记和更新时间的表 DELETE WHERE shop_id=? AND ... + INSERT 单店铺增量数据
店铺级全量同步 物理删除的表 DELETE WHERE shop_id=? + INSERT 单店铺全部数据

问题 :100+张表里,四种策略混杂 ,查询条件各不相同。需要灵活配置每张表的同步策略和WHERE条件。

解决方案:配置驱动 + 占位符

核心思想:把同步策略、查询条件放到配置表里,每张表单独配置

配置表设计

sql 复制代码
CREATE TABLE `sync_config` (
    `id` int PRIMARY KEY,
    `table_name` varchar(100),
    `table_level` varchar(20),     -- company/shop
    `sync_type` int,                -- 0:全表, 1:条件同步
    `where_condition` text,         -- WHERE条件模板(支持占位符)
    `delete_strategy` varchar(20)   -- TRUNCATE/DELETE
);

配置示例

sql 复制代码
-- 全表同步
INSERT INTO sync_config VALUES (1, 'sys_config', 'company', 0, NULL, 'TRUNCATE');

-- 公司级条件同步
INSERT INTO sync_config VALUES (2, 'company_settings', 'company', 1, 
    'company_id = {companyId} AND status = 1', 'DELETE');

-- 店铺级增量同步
INSERT INTO sync_config VALUES (3, 'user_table', 'shop', 1, 
    'shop_id = {shopId} AND update_time > {lastTime}', 'DELETE');

-- 店铺级全量同步
INSERT INTO sync_config VALUES (4, 'order_table', 'shop', 1, 
    'shop_id = {shopId}', 'DELETE');

占位符替换逻辑

java 复制代码
private String buildWhereCondition(String template, SyncContext ctx) {
    if (template == null) return "";  // 全表同步,无WHERE条件
    
    return template
        .replace("{shopId}", String.valueOf(ctx.getShopId()))
        .replace("{companyId}", String.valueOf(ctx.getCompanyId()))
        .replace("{lastTime}", ctx.getLastSyncTime());
}

SQL生成过程(以店铺级增量同步为例)

步骤1:构造查询SQL

java 复制代码
// 占位符替换后得到WHERE条件
String whereCondition = "shop_id = 123 AND update_time > '2025-01-15 00:00:00'";

// 构造SELECT语句
String selectSql = "SELECT * FROM user_table WHERE " + whereCondition;

步骤2:流式读取并生成SQL文件

关键点:从ResultSet元数据动态获取字段,而非写死字段名

java 复制代码
try (ResultSet rs = stmt.executeQuery(selectSql)) {
    ResultSetMetaData metadata = rs.getMetaData();
    int columnCount = metadata.getColumnCount();
    
    // 从元数据获取列名列表
    List<String> columnNames = new ArrayList<>();
    for (int i = 1; i <= columnCount; i++) {
        columnNames.add(metadata.getColumnName(i));
    }
    
    // 1. 先写DELETE语句
    writer.write("DELETE FROM user_table WHERE " + whereCondition + ";#END#");
    writer.write(System.lineSeparator());
    
    // 2. 构造INSERT语句头部(字段名从元数据获取)
    String insertHeader = "INSERT INTO `user_table` (" + 
        String.join(", ", columnNames) + ") VALUES\n";
    
    StringBuilder values = new StringBuilder();
    int batchCount = 0;
    
    // 3. 流式读取数据并拼接VALUES
    while (rs.next()) {
        values.append("(");
        for (int i = 1; i <= columnCount; i++) {
            if (i > 1) values.append(", ");
            // 根据字段类型格式化值(动态处理)
            values.append(formatValue(rs, i, metadata.getColumnType(i)));
        }
        values.append(")");
        batchCount++;
        
        // 每10行生成一条INSERT
        if (batchCount >= 10) {
            writer.write(insertHeader + values.toString() + ";#END#");
            writer.write(System.lineSeparator());
            values.setLength(0);
            batchCount = 0;
        } else {
            values.append(", ");
        }
    }
    
    // 4. 处理剩余数据
    if (batchCount > 0) {
        writer.write(insertHeader + values.toString() + ";#END#");
    }
}

最终生成的SQL文件

sql 复制代码
DELETE FROM user_table WHERE shop_id = 123 AND update_time > '2025-01-15 00:00:00';#END#
INSERT INTO `user_table` (id, shop_id, username, update_time) VALUES
(1, 123, 'Alice', '2025-01-16 10:00:00'),
(2, 123, 'Bob', '2025-01-16 11:00:00');#END#

优势总结

灵活性 :四种策略自由配置,满足不同表的需求

可扩展 :新增表只需加配置,代码零改动

占位符 :支持{shopId}{companyId}{lastTime}等动态参数

零硬编码:字段名从元数据动态获取,适配任意表结构


第四难:单表50W+数据,如何防止OOM?

问题:传统方式的内存杀手

java 复制代码
// 反面教材:一次性加载全部数据
String sql = "SELECT * FROM huge_table WHERE shop_id = 123";
List<Map<String, Object>> allRows = jdbcTemplate.queryForList(sql);  // 直接OOM

单店铺单表可能50W+行,全部加载到内存会导致OutOfMemoryError。

解决方案:流式读取 + 临时文件

MySQL流式读取

java 复制代码
private void generateSQL(DataSource ds, String sql) throws SQLException {
    try (Connection conn = ds.getConnection();
         Statement stmt = conn.createStatement(
             ResultSet.TYPE_FORWARD_ONLY,    // 只向前遍历
             ResultSet.CONCUR_READ_ONLY)) {  // 只读模式
        
        // 核心:启用MySQL流式读取
        stmt.setFetchSize(Integer.MIN_VALUE);  // MySQL JDBC特殊约定!
        
        try (ResultSet rs = stmt.executeQuery(sql)) {
            int batchCount = 0;
            StringBuilder sqlValues = new StringBuilder();
            
            while (rs.next()) {  // 逐行处理
                sqlValues.append("(");
                for (int i = 1; i <= columnCount; i++) {
                    sqlValues.append(formatValue(rs, i));
                }
                sqlValues.append(")");
                batchCount++;
                
                // 每10行生成一条INSERT
                if (batchCount >= 10) {
                    writeInsert(sqlValues.toString());
                    sqlValues.setLength(0);  // 清空缓冲
                    batchCount = 0;
                }
            }
        }
    }
}

核心技巧

  • stmt.setFetchSize(Integer.MIN_VALUE):MySQL JDBC的特殊约定,启用服务器端游标
  • 每次只拉取1行数据到客户端,内存占用恒定
  • 批量拼接VALUES:多行生成一条INSERT,减少SQL数量

MongoDB流式读取

java 复制代码
CloseableIterator<Document> iterator = 
    mongoTemplate.stream(query, Document.class, collectionName);

try {
    while (iterator.hasNext()) {
        Document doc = iterator.next();  // 逐文档处理
        processDocument(doc);
    }
} finally {
    iterator.close();  // ⚠️ 必须手动关闭,否则连接泄漏!
}

塔内执行:流式读取

java 复制代码
try (BufferedReader reader = new BufferedReader(
         new InputStreamReader(ossStream))) {
    
    List<String> sqlBatch = new ArrayList<>();
    StringBuilder currentSql = new StringBuilder();
    String line;
    
    while ((line = reader.readLine()) != null) {
        // 拼接当前行
        currentSql.append(line);
        
        // 检查是否是完整的SQL(以;#END#结尾)
        if (currentSql.toString().endsWith(";#END#")) {
            // 还原:特殊符号 → 正常分号
            String realSql = currentSql.toString().replace(";#END#", ";");
            
            // 添加到批次
            sqlBatch.add(realSql);
            currentSql.setLength(0);  // 清空,准备下一条SQL
            
            // 批量执行(每100条一批,塔外10条数据构造成1个insert语句)
            if (sqlBatch.size() >= 100) {
                executeBatch(stmt, sqlBatch);
                sqlBatch.clear();
            }
        }
    }
    
    // 执行剩余SQL
    if (!sqlBatch.isEmpty()) {
        executeBatch(stmt, sqlBatch);
    }
    
    // 关键:自动提交,避免事务过大
    conn.setAutoCommit(true);
}

为什么setAutoCommit(true)

单文件可能几千条SQL,如果在一个事务里会导致:

  • 锁表时间过长
  • 回滚日志暴涨
  • 内存占用飙升

自动提交后,每条SQL独立提交,避免以上问题。

效果对比:

方案 内存占用 风险
一次性加载 2GB(50W行) 必然OOM
流式处理 50MB(常量级) 稳定

第五难:MongoDB到PostgreSQL的类型转换

问题

MongoDB和PostgreSQL的数据类型完全不兼容:

MongoDB PostgreSQL 问题
ObjectId 无对应类型 主键转换
BSON对象 JSONB 嵌套结构
数组 Array 类型声明

解决方案

在配置表的扩展字段定义类型映射:

json 复制代码
{
  "mongoCollection": "user_profile",
  "pgTable": "user_profile",
  "fieldMapping": {
    "_id": "id",
    "preferences": "preferences",
    "tags": "tags"
  },
  "typeMapping": {
    "_id": "OBJECTID_TO_VARCHAR",
    "preferences": "JSONB",
    "tags": "INTEGER_ARRAY"
  }
}

类型转换代码:

java 复制代码
private String convertValue(Object value, String typeRule) {
    if (value == null) return "NULL";
    
    switch (typeRule) {
        case "JSONB":
            // {name: "test"} → '{"name":"test"}'::jsonb
            String json = toJsonString(value);
            return "'" + escapeSql(json) + "'::jsonb";
            
        case "INTEGER_ARRAY":
            // [1,2,3] → ARRAY[1,2,3]::INTEGER[]
            List<Integer> list = (List) value;
            return "ARRAY[" + String.join(",", list) + "]::INTEGER[]";
            
        case "OBJECTID_TO_VARCHAR":
            // ObjectId("507f...") → '507f...'
            return "'" + value.toString() + "'";
            
        default:
            return convertDefault(value);
    }
}

复盘:一个月完成迁移的关键

整体架构:塔外-塔内双链路

复制代码
┌──────────── 塔外系统 (Outer) ────────────┐
│                                         │
│  ① API触发同步                          │
│  ② 查询配置表 → 拆分公司级/店铺级配置       │
│  ③ 构建MQ消息 → 投递RocketMQ             │
│  ④ MQ Consumer                         │
│     ├─ SHOW CREATE TABLE 获取表结构       │
│     ├─ 流式读取源数据库                    │
│     ├─ 生成 DELETE + INSERT SQL          │
│     ├─ 分号替换为特殊符号                  │
│     └─ 上传到 OSS                        │
└───────────────────────────────────────────┘
                    │
                    │ OSS中转
                    ↓
┌──────────── 塔内系统 (Inner) ────────────┐
│                                         │
│  ⑤ 定时任务 / 手动触发                    │
│  ⑥ 扫描OSS目录 → 获取待处理SQL文件列表      │
│  ⑦ 流式下载SQL文件 → 逐行读取              │
│     ├─ 特殊符号还原为分号                  │
│     ├─ 批量执行(1000条/批)                │
│     └─ setAutoCommit(true) 防止事务过大   │
│  ⑧ 执行成功 → 立即删除OSS文件              │
└───────────────────────────────────────────┘

核心亮点总结

技术点 传统方案 本方案 效果
表结构获取 手写100个Mapper SHOW CREATE TABLE动态解析 零硬编码,支持任意表
SQL分隔符 ;判断结束 特殊符号;#END# 支持数据含分号、换行符
同步策略 全量同步or硬编码 配置表+占位符 灵活配置,4种策略
大数据量处理 一次性加载(OOM) 流式读取+临时文件 常量级内存,50W+行稳定
扩展性 新增表需改代码 只需加配置 秒级上线新表同步

做对的3件事

1. 从工具中偷师学艺

Navicat的导入/导出功能启发了整体方案,SHOW CREATE TABLE是突破口

2. 把复杂逻辑放在塔外

塔内只负责执行SQL,逻辑简单;塔外可以随意调试、优化

3. 配置驱动,而非代码驱动

新增表只需加配置,不改代码。后续维护成本趋近于0

最终效果

指标 数据
迁移表数量 200+张(含后续新增)
最大单表数据 1000+万行
首次全量同步 10-30分钟
日常增量同步 公司级表约30秒,店铺级表约1分钟
内存占用 稳定在200MB左右
OOM次数 0(连续运行3个月)
工期 25天(提前5天完成)


写在最后

以上便是我这次迁移实战的全部分享。绝非标准答案,但希望能为你带来一丝灵感。

这次迁移让我深刻体会到:
好的架构不是设计出来的,而是从实际问题中"偷"出来的。

当你面对技术难题时,不妨问自己:

  • 有没有现成的工具已经解决了类似问题?不要重复造轮子!!(Navicat)
  • 数据库/框架本身提供了什么能力?(SHOW CREATE TABLE、setFetchSize)
  • 能否用配置代替硬编码?(配置表+占位符)

感谢那些"默默扛下所有"的技术细节

  • SHOW CREATE TABLE ------ 你扛下了表结构解析的苦活
  • stmt.setFetchSize(Integer.MIN_VALUE) ------ 你默默守护了内存安全
  • ;#END# ------ 你可能是全网最诡异但最实用的分隔符
  • RocketMQ的TAG过滤 ------ 你让消息路由变得优雅
  • CompletableFuture ------ 你让塔内并发处理成为可能
  • System.lineSeparator() ------ 你让SQL文件格式清晰明了

最后送大家一段话

写代码的时候,我们都是站在巨人肩膀上的追梦人。

技术本身没有高低贵贱,能解决问题的就是好技术 。不要盲目追求所谓的"最佳实践",在约束下求最优解,才是工程师的智慧。

愿你在技术的道路上,既能仰望星空,也能脚踏实地。


"在技术的世界里,没有完美的方案,只有最合适的选择。

而最合适的选择,往往来自于对问题本质的深刻理解。"

------ 一个在生产环境爬坑的后端开发


文章的最后,想和你多聊两句。

技术之路,常常是热闹与孤独并存。那些深夜的调试、灵光一闪的方案、还有踩坑爬起后的顿悟,如果能有人一起聊聊,该多好。

为此,我建了一个小花园------我的微信公众号「[努力的小郑]」。

这里没有高深莫测的理论堆砌,只有我对后端开发、系统设计和工程实践的持续思考与沉淀。它更像我的数字笔记本,记录着那些值得被记住的解决方案和思维火花。

如果你觉得今天的文章还有一点启发,或者单纯想找一个同行者偶尔聊聊技术、谈谈思考,那么,欢迎你来坐坐。

愿你前行路上,总有代码可写,有梦可追,也有灯火可亲。