异构数据库迁移

Entropy Tree Lv4

以 Oracle 19c 迁移 MySQL 8.0 为例,寻求一种心智负担最小的可行方案。

阅前须知:

  • 本文只考虑 Oracle、MySQL、PostgreSQL 三种比较知名的关系型数据库管理系统。
  • 本文不考虑依托于庞大云服务的云数据库,云服务供应商应该会提供更强大的方案。
  • 对于视图、物化视图、触发器、存储过程以及其他高级特性的异构数据库迁移不在本文能够处理的范围内。
  • 本文的视角以 Oracle 往 MySQL 的迁移为主,涉及到 Oracle 往 PostgreSQL 的迁移,可能有其他更好的方案。
  • 由于授权问题,商业版工具不在本文考虑范围内。
  • 未经过实际生产验证,本文所有方案不适用于线上生产环境。
  • 若无特别说明,本文提到的“表结构”主要包含字段类型、索引、约束等信息。
  • 本文虽然明面考虑的是异构数据库迁移,但实际上更倾向于考虑长期同时支持多个异构数据库运作。
  • 本文提到的大部分框架,本人没有深度使用经验,在实际应用中使用这些框架可能会带来一些挑战。

Oracle 快速搭建

使用 docker 搭建 oracle

1
docker pull registry.cn-hangzhou.aliyuncs.com/zhuyijun/oracle:19c

运行容器

1
2
3
4
5
6
7
8
9
10
11
12
docker run -itd --name oracle19c \
--privileged=true \
--restart=always \
-p 1521:1521 \
-p 5500:5500 \
-e ORACLE_SID=orcl \
-e ORACLE_PDB=orclPDB1 \
-e ORACLE_PWD=123456 \
-e ORACLE_EDITION=standard \
-e ORACLE_CHARACTERSET=UTF8 \
-e TZ=Asia/Shanghai \
registry.cn-hangzhou.aliyuncs.com/zhuyijun/oracle:19c

Oracle 属于重量级数据库,docker 启动可能需要等待一定时间

这个镜像可能存在 initorcl.ora 文件缺失的问题,可以从 pfile 下拷贝 init.ora.xxxx 文件至 dbs 目录下重命名为 initorcl.ora 文件,并设置为 644 权限。

1
2
3
4
docker exec -it oracle19c bash

cp /opt/oracle/admin/ORCL/pfile/init.ora.* /opt/oracle/product/19c/dbhome_1/dbs/initorcl.ora
chmod 644 initorcl.ora

MySQL 快速搭建

使用以下 docker-compose.yaml 快速搭建一个 MySQL 实例

1
2
3
4
5
6
7
8
services:
db:
image: mysql:8.0
ports:
- "3306:3306"
restart: always
environment:
MYSQL_ROOT_PASSWORD: 12345
1
docker compose up -d

客户端连接

Oracle 自带的 sqlplus 工具使用体验较差,为了方便,可以使用 Navicat premium lite 17.3.4 版本(初次使用需要注册登录)或者考虑使用 Oracle SQLcl(Oracle 官方推出的相对于 sqlplus 的现代化命令行工具)。

Oracle 连接配置用户名为SYSTEM、密码为 docker 指定的ORACLE_PWD参数值、服务名称为 orclPDB1。

在 Navicat 中需要依次新建表空间(相当于MySQL中的数据库)、新建用户并关联表空间、授权,然后再以新用户的身份连接,才能使用其关联的表空间。

如果使用 sqlcl,可参考以下命令

1
2
# 连接到 PDB
sql SYSTEM/123456@172.17.0.1:1521/orclPDB1

进入 SQL Shell 内,创建表空间、用户和关联用户表空间、授权连接权限

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLESPACE apps 
DATAFILE '/opt/oracle/product/19c/dbhome_1/dbs/apps01' SIZE 500M AUTOEXTEND ON;

# DROP TABLESPACE apps INCLUDING CONTENTS AND DATAFILES;

CREATE USER appuser IDENTIFIED BY "Secure123"
DEFAULT TABLESPACE apps
TEMPORARY TABLESPACE temp
QUOTA UNLIMITED ON apps;

GRANT CONNECT, RESOURCE TO appuser;

切换连接

1
conn appuser/Secure123@172.17.0.1:1521/orclPDB1

准备工作

本人并不是 Oracle 的深度用户,本着“以核心高频使用的数据类型为主,剔除过时/不推荐的数据类型,保留必要的边界测试”的理念来设计测试表,同时考虑不规范设计导致的隐式行为对迁移工作带来的问题。

表结构定义

以下是基于 LLM 生成的测试用例,不代表真实业务

核心业务数据类型测试表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- 核心业务数据类型测试表 (高频场景)
CREATE TABLE "CORE_DATA_TEST" (
id NUMBER(10) PRIMARY KEY, -- 主键必须显式指定精度

-- === 数值类型 (必须显式精度) ===
price NUMBER(15,4) NOT NULL, -- 金融场景精度要求
rating BINARY_DOUBLE, -- 64位浮点 (保留科学计算场景)

-- === 字符串类型 (必须显式字符集语义) ===
username VARCHAR2(50 CHAR) NOT NULL, -- 字符语义 (避免字节陷阱)
description NVARCHAR2(100) NOT NULL, -- Unicode支持
json_data CLOB CHECK (json_data IS JSON), -- JSON存储 (CLOB存储大JSON)

-- === 日期时间 (必须明确精度和时区) ===
created_at TIMESTAMP(6) DEFAULT SYSTIMESTAMP, -- 创建时间 (微秒精度)
expires_at TIMESTAMP WITH TIME ZONE, -- 带时区时间 (国际化场景)

-- === 二进制数据 (限制使用) ===
avatar BLOB, -- 小头像 (仅示例,生产应存OSS链接)
metadata RAW(200), -- 固定长度二进制 (如加密密钥)
CONSTRAINT core_data_test_username_unq UNIQUE (username) -- 用户名唯一性约束
);
-- 过期时间索引
CREATE INDEX idx_core_expires_at ON "CORE_DATA_TEST" (expires_at);
-- 创建时间索引
CREATE INDEX idx_core_created_at ON "CORE_DATA_TEST" (created_at);
边界与规范陷阱测试表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 边界与规范陷阱测试表 (迁移关键验证点)
CREATE TABLE "EDGE_CASE_TEST" (
id NUMBER(10) PRIMARY KEY,

-- === 数值边界 ===
max_scale NUMBER(38,0), -- 大整数 (如订单ID)
nan_value BINARY_FLOAT, -- 浮点NaN (需特殊处理)
infinity_value BINARY_DOUBLE, -- 浮点Infinity (需转换NULL)

-- === 字符编码陷阱 ===
byte_semantic_str VARCHAR2(10 BYTE), -- 字节语义 (Oracle默认!)
emoji_string NVARCHAR2(20), -- Emoji存储测试 (😊)

-- === 时间边界 ===
min_date DATE DEFAULT DATE'0001-01-01', -- 最小日期
max_timestamp TIMESTAMP(9), -- 纳秒精度 (MySQL最高支持微秒)

-- === 空值/大对象陷阱 ===
empty_clob CLOB DEFAULT EMPTY_CLOB(), -- 空CLOB (非NULL!)
null_blob BLOB -- 显式NULL二进制
);
高频不规范类型测试表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 高频不规范类型测试表 (真实业务常见但危险的写法)
CREATE TABLE "ANTIPATTERN_TEST" (
id NUMBER, -- 致命!未指定精度 (Oracle默认38位)

-- === 数值型不规范 ===
price NUMBER, -- 无精度金融字段 (灾难性存储浪费)
discount FLOAT, -- 模糊的浮点类型 (Oracle会转为BINARY_FLOAT)
quantity NUMBER(*,0), -- 不规范:变长整数 (存储空间不可控)
tax_rate NUMBER(10), -- 不规范:未指定小数位 (实际为NUMBER(10,0))

-- === 字符串型不规范 ===
name VARCHAR2(4000), -- 不规范:滥用最大长度 (索引失效)
address NVARCHAR2(2000), -- 不规范:超长Unicode (性能低下)
remark VARCHAR2(4000 CHAR), -- 滥用最大长度 (影响索引性能)

-- === 日期型不规范 ===
created_date DATE, -- 仅日期但存储了时间 (业务误解)
updated_time TIMESTAMP, -- 未指定精度 (默认6位但业务可能误用)

-- === 隐式转换陷阱 ===
numeric_string VARCHAR2(10), -- 存储数字的字符串 (如'123.45')
date_string VARCHAR2(20) -- 存储日期的字符串 (如'2023-01-01')
);

测试数据生成

具体的测试数据可以使用以下 LLM 提供的 SQL 脚本自动生成

CORE_DATA_TEST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
-- 生成 CORE_DATA_TEST 表测试数据
DECLARE
v_rows NUMBER := 20; -- 生成20行核心业务数据
BEGIN
EXECUTE IMMEDIATE 'TRUNCATE TABLE CORE_DATA_TEST';

INSERT INTO CORE_DATA_TEST (
id, price, rating, username, description, json_data,
created_at, expires_at, avatar, metadata
)
SELECT
ROWNUM AS id,
-- 金融价格:范围0.0001-99999.9999,带边界值
CASE
WHEN ROWNUM = 1 THEN 0.0001
WHEN ROWNUM = 2 THEN 99999.9999
ELSE ROUND(DBMS_RANDOM.VALUE(0.01, 1000), 4)
END AS price,
-- 科学计算评分:包含NaN和Infinity
CASE
WHEN ROWNUM = 3 THEN BINARY_DOUBLE_NAN
WHEN ROWNUM = 4 THEN BINARY_DOUBLE_INFINITY
ELSE DBMS_RANDOM.VALUE(0, 5)
END AS rating,
-- 用户名:不同长度的字符语义
DBMS_RANDOM.STRING('A', TRUNC(DBMS_RANDOM.VALUE(5, 50))) AS username,
-- Unicode描述:包含多语言字符
CASE MOD(ROWNUM, 4)
WHEN 0 THEN '描述-' || CHR(20320) || CHR(22909) -- 中文
WHEN 1 THEN 'Описание-' || CHR(1087) || CHR(1088) -- 俄文
WHEN 2 THEN '説明-' || CHR(12371) || CHR(12428) -- 日文
ELSE 'Description-' || CHR(128522) -- 英文+表情
END AS description,
-- JSON数据:不同结构的JSON
CASE
WHEN MOD(ROWNUM, 3) = 0 THEN '{"id":' || ROWNUM || ', "name":"user' || ROWNUM || '"}'
WHEN MOD(ROWNUM, 3) = 1 THEN '{"product": {"id": ' || ROWNUM || ', "price": ' || ROUND(DBMS_RANDOM.VALUE(10,100),2) || '}}'
ELSE '{"array": [1, 2, 3], "nested": {"level": 2}}'
END AS json_data,
-- 创建时间:带微秒精度
SYSTIMESTAMP - DBMS_RANDOM.VALUE(0, 365) AS created_at,
-- 带时区的过期时间
SYSTIMESTAMP AT TIME ZONE 'UTC' + DBMS_RANDOM.VALUE(1, 30) AS expires_at,
-- 小头像二进制:生成小尺寸二进制数据
UTL_RAW.CAST_TO_RAW(DBMS_RANDOM.STRING('X', 100)) AS avatar,
-- 元数据:固定长度二进制
UTL_RAW.CAST_TO_RAW(DBMS_RANDOM.STRING('X', 200)) AS metadata
FROM DUAL
CONNECT BY LEVEL <= v_rows;

COMMIT;
DBMS_OUTPUT.PUT_LINE('成功生成 ' || v_rows || ' 行 CORE_DATA_TEST 数据');
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('CORE_DATA_TEST 错误: ' || SQLERRM);
ROLLBACK;
END;
/

CORE_DATA_TEST 表数据特点:

  1. 数值类型
    • 金融价格包含边界值(0.0001和99999.9999)
    • 科学评分包含NaN和Infinity特殊值
  2. 字符串类型
    • 用户名使用字符语义(VARCHAR2 CHAR)
    • 描述包含多语言字符(中文、俄文、日文)
    • JSON数据验证JSON约束
  3. 日期时间
    • 创建时间使用微秒精度TIMESTAMP(6)
    • 过期时间带时区信息
  4. 二进制数据
    • 小头像使用小尺寸BLOB
    • 元数据使用固定长度RAW(200)
EDGE_CASE_TEST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
-- 生成 EDGE_CASE_TEST 表测试数据
DECLARE
v_rows NUMBER := 15; -- 生成15行边界测试数据
BEGIN
EXECUTE IMMEDIATE 'TRUNCATE TABLE EDGE_CASE_TEST';

INSERT INTO EDGE_CASE_TEST (
id, max_scale, nan_value, infinity_value,
byte_semantic_str, emoji_string, min_date, max_timestamp,
empty_clob, null_blob
)
SELECT
ROWNUM AS id,
-- 大整数边界
CASE
WHEN ROWNUM = 1 THEN 0
WHEN ROWNUM = 2 THEN POWER(10, 38)-1 -- 最大38位整数
ELSE TRUNC(DBMS_RANDOM.VALUE(0, POWER(10, 18)))
END AS max_scale,
-- 浮点特殊值
CASE
WHEN ROWNUM = 3 THEN BINARY_FLOAT_NAN
ELSE NULL
END AS nan_value,
CASE
WHEN ROWNUM = 4 THEN BINARY_DOUBLE_INFINITY
ELSE NULL
END AS infinity_value,
-- 字节语义字符串:包含多字节字符
CASE
WHEN ROWNUM = 5 THEN '汉字' -- 测试多字节截断
ELSE DBMS_RANDOM.STRING('X', TRUNC(DBMS_RANDOM.VALUE(1, 10)))
END AS byte_semantic_str,
-- Emoji字符串:混合文本和表情
CASE
WHEN ROWNUM = 6 THEN 'Hello' || CHR(128512) || CHR(128525) -- 😀😍
WHEN ROWNUM = 7 THEN CHR(128640) || CHR(128641) || CHR(128642) -- 🚀🚁🚂
ELSE 'Text' || CHR(128522) -- 🙂
END AS emoji_string,
-- 日期边界
CASE
WHEN ROWNUM = 8 THEN DATE '0001-01-01' -- 最小日期
ELSE SYSDATE - DBMS_RANDOM.VALUE(0, 365 * 10)
END AS min_date,
-- 时间戳边界:纳秒精度
CASE
WHEN ROWNUM = 9 THEN TIMESTAMP '9999-12-31 23:59:59.999999999' -- 最大时间戳
ELSE SYSTIMESTAMP + DBMS_RANDOM.VALUE(0, 365)
END AS max_timestamp,
-- 空CLOB(非NULL)
EMPTY_CLOB() AS empty_clob,
-- 显式NULL二进制
NULL AS null_blob
FROM DUAL
CONNECT BY LEVEL <= v_rows;

COMMIT;
DBMS_OUTPUT.PUT_LINE('成功生成 ' || v_rows || ' 行 EDGE_CASE_TEST 数据');
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('EDGE_CASE_TEST 错误: ' || SQLERRM);
ROLLBACK;
END;
/

EDGE_CASE_TEST 表边界测试:

  1. 数值边界

    • 最大38位整数(10³⁸-1)
    • 浮点NaN和Infinity特殊值
  2. 字符编码陷阱

    • 字节语义字符串包含多字节字符(测试截断)

    • Emoji字符串混合文本和表情符号

  3. 时间边界

    • 最小日期(0001-01-01)
    • 最大时间戳(9999-12-31 23:59:59.999999999)
  4. 空值/大对象

    • 空CLOB(EMPTY_CLOB()非NULL)
    • 显式NULL二进制数据
ANTIPATTERN_TEST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
-- 生成 ANTIPATTERN_TEST 表测试数据
DECLARE
-- 生成1000行测试数据
v_rows NUMBER := 1000;
BEGIN
-- 清空表(可选)
EXECUTE IMMEDIATE 'TRUNCATE TABLE ANTIPATTERN_TEST';

-- 插入测试数据
INSERT INTO ANTIPATTERN_TEST (
id, price, discount, quantity, tax_rate,
name, address, remark,
created_date, updated_time,
numeric_string, date_string
)
SELECT
-- id: 序列号
ROWNUM,

-- price: 随机金融数值(无精度控制)
DBMS_RANDOM.VALUE(0.01, 10000),

-- discount: 随机浮点数(精度问题)
DBMS_RANDOM.VALUE(0, 1),

-- quantity: 变长整数(存储不可控)
TRUNC(DBMS_RANDOM.VALUE(1, 1000)),

-- tax_rate: 未指定小数位的税率
TRUNC(DBMS_RANDOM.VALUE(0, 30)),

-- name: 随机长度字符串(滥用最大长度)
DBMS_RANDOM.STRING('A', TRUNC(DBMS_RANDOM.VALUE(1, 4000))),

-- address: 随机Unicode字符串(性能低下)
DBMS_RANDOM.STRING('X', TRUNC(DBMS_RANDOM.VALUE(1, 2000))),

-- remark: 滥用最大长度的备注
CASE
WHEN MOD(ROWNUM, 10) = 0 THEN RPAD('长备注', 4000, 'x') -- 10%为超长备注
ELSE DBMS_RANDOM.STRING('A', TRUNC(DBMS_RANDOM.VALUE(10, 100)))
END,

-- created_date: 包含时间的日期(业务只需日期)
SYSDATE - DBMS_RANDOM.VALUE(0, 365),

-- updated_time: 高精度时间戳(业务可能误用)
SYSTIMESTAMP - DBMS_RANDOM.VALUE(0, 86400),

-- numeric_string: 存储数字的字符串
TO_CHAR(DBMS_RANDOM.VALUE(1, 1000), '9999.99'),

-- date_string: 存储日期的字符串
TO_CHAR(SYSDATE - DBMS_RANDOM.VALUE(0, 365), 'YYYY-MM-DD')
FROM DUAL
CONNECT BY LEVEL <= v_rows;

COMMIT;
DBMS_OUTPUT.PUT_LINE('成功生成 ' || v_rows || ' 行测试数据');
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('错误: ' || SQLERRM);
ROLLBACK;
END;
/

ANTIPATTERN_TEST 表数据说明:

  1. 体现数值型不规范
    • price:生成带小数的随机值,展示无精度控制的问题
    • discount:生成0-1之间的随机浮点数,展示精度问题
    • quantity:生成整数,但使用变长存储
    • tax_rate:生成整数税率,但未指定小数位
  2. 体现字符串型不规范
    • name:生成长度1-4000的随机字符串(滥用最大长度)
    • address:生成长度1-2000的Unicode字符串(性能问题)
    • remark:90%为短字符串,10%为4000字符超长字符串(索引问题)
  3. 体现日期型不规范
    • created_date:生成带时间分量的日期(业务只需日期)
    • updated_time:生成高精度时间戳(默认6位小数)
  4. 体现隐式转换陷阱
    • numeric_string:存储格式化的数字字符串
    • date_string:存储格式化的日期字符串
  5. 数据多样性
    • 使用DBMS_RANDOM包生成随机数据
    • 包含边界值(如超长字符串)
    • 生成1000行代表性数据

迁移方案

迁移步骤分成两个阶段,结构迁移和数据迁移。结构迁移的主要问题是异构数据库数据类型的兼容。由于 SQL 标准并不负责规定各种数据库的具体类型定义,Oracle、MySQL 等的数据类型并不一致,不过 Oracle 中实际高频使用的类型基本在 MySQL 中有等价或近似的类型,但即便有一定程度的兼容性,也仍有可能存在一些细节问题,甚至还得考虑重新设计源数据库数据类型,以满足兼容性。

废案历史

主要讨论结构迁移部分。

第一版废案:liquibase(community) + 脚本处理,得到 MySQL DDL。liquibase(community) 生成的 changelog 无法解决 Oracle 与 MySQL 之间的 DDL 差异性问题。DDL 相差较大,脚本处理可靠性存疑且需要耗费精力验证。

考虑 liquibase(community) 而非 flyway,主要原因是 diff-changelog 的功能,flyway 的类似 diff 功能仅在商业版提供。但在能够获取到 DDL 的情况下,两者都能很好地实现 SQL 版本控制。

此外,另一个轻量工具 sqldef/sqldef 能够导出数据库 DDL,并能将修改过后的 DDL 与当前数据库进行对比生成差异的 DDL,但仅限于同构的数据库,且不支持 Oracle。

第二版废案:AWS SCT。AWS SCT 能够对比异构数据库差异并提供建议,但是还是需要人工干预处理,且 AWS SCT 主要为 AWS 的云服务工作,对本地自建数据库支持较弱。

第三版废案:ora2pg + PostgreSQL,利用 PostgreSQL 作为中间者,间接实现 Oracle 到 MySQL 的迁移。但是实际上围绕 PostgreSQL 的工具基本都是单向迁移工具,且只能往 PostgreSQL 迁移。推测 PostgreSQL 相关的工具之所以只能实现单向迁移,是因为 PostgreSQL 本身有足够大的能力可以“容纳另一个数据库的所有常用功能”,反之则不成立。进行迁移的过程中,底层实现的不同导致“元信息”发生不可逆的转变,因此也不可能再进行反向迁移了。

目前方案

经过漫长的考虑,这里最终决定采用一种特别的方案:利用具备自动建表功能(或者是能够自动生成 DDL 方言)的框架基于实体类、结构体代码来实现对 MySQL 的自动建表。

在该方案的上一版中,其实还有一个步骤就是基于 Oracle 的已有表结构生成实体类、结构体代码。但是综合多种因素考虑,该步骤可能并不是必要的。

基于 Oracle 表结构提取并生成代码这一点,Java 可能是最省力的方案(然而实际上它所生成的实体类缺少必要的注解信息,无法与支持自动建表的框架集成),而不少出现在这之后的新语言例如 Go、Rust 等可能都无法很好地与 Oracle 适配。但这一点也并非是硬性要求,详情见下文说明。

另一方面,这些新语言也大概率不会选择 Oracle 作为开发环境,而是选择更开放的 PostgreSQL 等,因此也就不存在与 Oracle 相关的迁移需求。

自动建表框架选型:

Java

Golang

  • go-gorm/gorm

    原生支持自动表结构维护的 orm 框架。

  • xorm/xorm

    Simple and Powerful ORM for Go, support mysql,postgres,tidb,sqlite3,sqlite,mssql,oracle,cockroach.

    这个似乎实验性支持 Oracle 数据库。

可能的伪需求:基于已有的表结构生成实体类、结构体代码

在项目生命周期里,结构体或实体类总是与数据表同步进行的,通常也不会出现“数据表一万张,实体类或结构体一个没写”的情况(除非是要将项目代码和数据库一并更换,那这也就不在本文的考虑范围内了)。

在实体类、结构体代码都已经齐全的情况下,大部分语言可以利用各自的框架来实现对 MySQL 的自动建表。因此,直接基于当前项目所使用的语言来进行自动建表即可,并不存在从 Oracle 中提取表结构来生成代码的需求。

此外需要注意判断框架能否支持在已有表的基础上通过修改实体类、结构体代码来实现对数据表结构的新增、删除和修改等,以决定框架的使用时机,否则会造成代码与数据表的不一致。

但是出于某种原因考虑,本人还是决定使用 mybatis-generator 尝试生成 Java 实体类代码。

快速新建一个 maven 项目

1
2
3
4
5
mvn archetype:generate \
-DgroupId=com.example \
-DartifactId=entity-generator \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DinteractiveMode=false

pom.xml 补充内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<properties>
<java.version>17</java.version>
<mybatis-generator.version>1.4.2</mybatis-generator.version>
<mysql.version>8.0.33</mysql.version>
<ojdbc.version>19.3.0.0</ojdbc.version>
</properties>

<dependencies>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
</dependency>

<!-- Oracle 驱动 -->
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc8</artifactId>
<version>${ojdbc.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<!-- MyBatis Generator Plugin -->
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>${mybatis-generator.version}</version>
<configuration>
<configurationFile>src/main/resources/generatorConfig.xml</configurationFile>
<includeCompileDependencies>true</includeCompileDependencies>
<overwrite>true</overwrite>
<verbose>true</verbose>
</configuration>
</plugin>
</plugins>
</build>

src/main/resources/generatorConfig.xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration PUBLIC
"-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

<generatorConfiguration>
<context id="OracleGenerator" targetRuntime="MyBatis3Simple">
<!-- 生成内容配置 -->
<commentGenerator>
<!-- 是否去除自动生成的注释 -->
<property name="suppressAllComments" value="true"/>
</commentGenerator>

<!-- 配置数据库连接 -->
<jdbcConnection driverClass="oracle.jdbc.OracleDriver"
connectionURL="jdbc:oracle:thin:@//localhost:1521/ORCLPDB1"
userId="APPUSER"
password="Secure123">
</jdbcConnection>

<!-- Java模型生成器 -->
<javaModelGenerator targetPackage="com.example.entity"
targetProject="src/main/java">
<property name="enableSubPackages" value="true"/>
<property name="trimStrings" value="true"/>
</javaModelGenerator>

<!-- 表配置 -->
<table tableName="%" schema="APPUSER"/>
</context>
</generatorConfiguration>

生成代码

1
mvn mybatis-generator:generate

运行过程中发现了以下日志

1
2
3
4
[WARNING] Unsupported Data Type 101 in table APPUSER.CORE_DATA_TEST, column: RATING, property defaults to Object type.
[WARNING] Unsupported Data Type -101 in table APPUSER.CORE_DATA_TEST, column: EXPIRES_AT, property defaults to Object type.
[WARNING] Unsupported Data Type 100 in table APPUSER.EDGE_CASE_TEST, column: NAN_VALUE, property defaults to Object type.
[WARNING] Unsupported Data Type 101 in table APPUSER.EDGE_CASE_TEST, column: INFINITY_VALUE, property defaults to Object type.

Oracle 中的 BINARY_FLOAT、BINARY_DOUBLE、TIMESTAMP WITH TIME ZONE 属于非标准类型,没有在 mybatis-generator 中包含,所以被默认处理为了 Object 类型,但实际上这些类型可以对应到 Java 中更具体的类型。通过拓展 mybatis-generator 的 resolver 可以自定义实现对这些类型的处理。

另外新建一个项目

1
2
3
4
5
mvn archetype:generate \
-DgroupId=com.example.extension \
-DartifactId=mybatis-generator-extension \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DinteractiveMode=false

修改 pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-core</artifactId>
<version>1.4.2</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<release>${java.version}</release>
</configuration>
</plugin>
</plugins>
</build>

自定义拓展 mybatis-generator 的 JavaTypeResolverDefaultImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.extension;

import org.mybatis.generator.api.dom.java.FullyQualifiedJavaType;
import org.mybatis.generator.internal.types.JavaTypeResolverDefaultImpl;

public class OracleTypeResolver extends JavaTypeResolverDefaultImpl {
public OracleTypeResolver() {
addReplacement(100, "BINARY_FLOAT", Double.class);
addReplacement(101, "BINARY_DOUBLE", Double.class);
addReplacement(-101, "TIMESTAMP WITH TIME ZONE", "java.time.ZonedDateTime");
}

private void addReplacement(int code, String dbType, String javaType) {
typeMap.put(code, new JdbcTypeInformation(dbType, new FullyQualifiedJavaType(javaType)));
}

private void addReplacement(int code, String dbType, Class<?> javaType) {
typeMap.put(code, new JdbcTypeInformation(dbType, new FullyQualifiedJavaType(javaType.getName())));
}
}

安装到本地仓库

1
mvn clean install -DskipTests

在原来的项目 pom.xml 中补充依赖

1
2
3
4
5
<dependency>
<groupId>com.example.extension</groupId>
<artifactId>mybatis-generator-extension</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

在 src/main/resources/generatorConfig.xml 中加入自定义映射配置。注意配置有顺序要求,需要放在 jdbcConnectionjavaModelGenerator 之间

1
2
3
4
<!-- 自定义映射配置 -->
<javaTypeResolver type="com.example.extension.OracleTypeResolver">
<property name="useJSR310Types" value="true"/>
</javaTypeResolver>

重新生成代码

1
mvn mybatis-generator:generate

有一点值得考虑的是,当代码生成器无法准确地判断数据字段类型到底对应哪种代码内置类型时,是否能够认为该数据字段类型是一个数据库专有类型或者是一个不被推荐使用的类型?

结构迁移

本文采用 mybatis-plus-ext(auto-table 扩展) 的方案来实现 Oracle 往 MySQL 的表结构迁移。

初始化项目

1
spring init -d=mysql,lombok --build=maven demo

补充依赖

1
2
3
4
5
<dependency>
<groupId>org.dromara.mpe</groupId>
<artifactId>mybatis-plus-ext-spring-boot3-starter</artifactId>
<version>3.5.14-EXT881</version>
</dependency>

application.yaml

1
2
3
4
5
spring:
application:
name: demo
profiles:
active: dev

application-dev.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test
username: root
password: 12345

# 开发环境 application-dev.yml
auto-table:
# 开启自动建表
enable: true
record-sql:
# 开启SQL记录
enable: true
# 当前版本(此处可保持与计划上线的版本号一致,方便管理SQL文件)
version: 1.0.0
# 记录到文件
record-type: file
# 文件夹位置
folder-path: ./sqlLogs

启动类上增加 @EnableAutoTable 注解

1
2
3
4
5
6
7
@EnableAutoTable
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

CoreDataTest 实体类示例,更多用法参考 Dromara AutoTable | Auto Table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Table
public class CoreDataTest {
@ColumnId
private Long id;

@Column(notNull = true, length = 15, decimalLength = 4)
private BigDecimal price;

@Column(type = "double")
private Double rating;

@Column(notNull = true, length = 50)
@UniqueIndex
private String username;

@Column(notNull = true, length = 100)
private String description;

@Column(length = 6, defaultValue = "CURRENT_TIMESTAMP(6)")
@Index(name = "idx_core_created_at")
private LocalDateTime createdAt;

@Column(type = "TIMESTAMP(6)")
@Index(name = "idx_core_expires_at")
private ZonedDateTime expiresAt;

@Column(type = "longtext")
private String jsonData;

@Column(type = "longblob")
private byte[] avatar;

@Column(type = "varbinary", length = 200)
private byte[] metadata;
}

CoreDataTest、EdgeCaseTest 根据前面的 DDL 可以提取出较多的数据表元信息,而 AntipatternTest 只能依靠框架的隐式行为或者人工手动规范。实际上 AntipatternTest 在前面的代码生成器中就已经生成了错误的类型,基本可以认定为无法直接用于迁移,是不能在数据表设计中出现的反规范设计。

数据迁移

解决了前面的类型兼容问题,数据迁移相对就没有那么麻烦。数据可以通过直接导出平台无关的通用数据格式文件,例如 csv 文件,再导入到异构数据库中。这对于小规模数据迁移很方便,对于大规模数据这里采用的是 apache/seatunnel

顺便一提,apache/seatunnel 也具备自动建表的功能,但是需要依赖已存在的、规范的数据源,并且无法完整迁移索引等结构。

拉取 seatunnel 镜像,seatunnel 的镜像比较大,拉取需要等待一定时间

1
docker pull apache/seatunnel

编写配置文件 o2m.config,更多配置可查询 官方文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
env {
job.mode = "BATCH"
parallelism = 4
}
source {
Jdbc {
plugin_output = "oracle"

url = "jdbc:oracle:thin:@172.17.0.1:1521/orclPDB1"
driver = "oracle.jdbc.OracleDriver"
connection_check_timeout_sec = 100
username = "APPUSER"
password = "Secure123"
"table_list"=[
{
"table_path"="APPUSER.CORE_DATA_TEST"
},
{
"table_path"="APPUSER.EDGE_CASE_TEST"
},
{
"table_path"="APPUSER.ANTIPATTERN_TEST"
}
]
split.size = 10000
}
}

transform {
TableRename {
plugin_input = "oracle"
plugin_output = "table_rename"

convert_case = "LOWER"
}
FieldRename {
plugin_input = "table_rename"
plugin_output = "trans_result"

convert_case = "LOWER"
}
}

sink {
Jdbc {
plugin_output = "trans_result"

url = "jdbc:mysql://172.17.0.1:3306/test?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true"
driver = "com.mysql.cj.jdbc.Driver"
username = "root"
password = "12345"
# Automatically generate sql statements based on database table names
generate_sink_sql = true
database = test
}
}

运行服务

1
docker run --rm -it -v ./:/config apache/seatunnel ./bin/seatunnel.sh -m local -c /config/o2m.config

不出所料,ANTIPATTERN_TEST 因为元信息的规范性问题,迁移的数据出现了因类型错误导致数据存储格式不正确的问题,比较明显的就是 NUMBER 类型没有明确指定精度,导致主键 ID 的类型变成了 DECIMAL(38, 18)。

而 CORE_DATA_TEST 和 EDGE_CASE_TEST 则出现了部分数据无法迁移的问题。以这里的测试数据为例,经过排查发现是因为 TIMESTAMP 类型的一个极端值超过了 MySQL 中 timestamp 类型的上限,以及 BINARY_FLOAT 和 BINARY_DOUBLE 类型的测试数据中包含了 nan 和 inf 这两个特殊值。

这两个特殊值在 MySQL 中没有相应的支持,实际应用中出于兼容目的需要考虑使用其他方案替代。

在除去了极端值以及 nan 和 inf 这两个特殊值后,CORE_DATA_TEST 和 EDGE_CASE_TEST 也顺利地完成了迁移。

一些思考

个人观点

一个开发哲学问题:先写代码还是先写 DDL ?

传统的项目开发中,DDL 总是必不可少的存在,项目起步通常是先在数据库执行 DDL,有了基础的数据库环境后,再着手于项目代码的编写,然后随着新功能的开发就是反复地执行 DDL、编写代码。然而这可能会存在以下问题:

  • 如果要同时支持多个异构数据库,设计成本巨大,且难以保证兼容性。
  • 不利于处于需求频繁变化时期的项目的快速迭代。
  • 需要人为去把控数据表设计的规范性,或者需要借助专门的审查平台。
  • 后期改动数据表成本巨大。
  • 容易将一部分本该由代码控制的业务逻辑泄露到数据库中 (定义了一系列与业务关联的触发器、存储过程等,会为开发带来巨大的心智负担,而且不利于异构数据库的兼容)。

不过 DDL 优先设计仍有其存在的必要性,大部分时候可能还是最符合习惯与直觉的开发流程,只是需要基于一些影响因素来考虑使用。

  • 明确项目未来只会支持单一数据库类型,如果未来必须要支持多个异构数据库,则需要考虑牺牲一定的性能来换取兼容性。

    只是为了某一个数据库特有的性能优化,而放弃与其他数据库的兼容性,是否是一个战略上正确的决定?

    如果有足够强大的资源能够同时支持多个异构数据库,并能为每个异构数据库做专门优化的,可以忽略这一点。

  • 明确是不需要快速迭代的大型项目,或者拥有一套强大的多 DDL 方言版本管理方案。

  • 团队整体应该指定并遵循严格的数据表设计规范,不能出现不清晰的设计。可以借助 SQL 审查平台基于规则自动检查设计的规范性,同时保存改动记录,便于随时回滚。

    有的团队中可能会存在 DBA 或者其他由 DBA 转变而来的现代化新角色来处理数据库相关的工作,这与“每个团队成员需要具有一定数据表设计规范意识”并不冲突。

  • 小规模或个人项目,数据库变动不复杂、规模小、且成本完全可控。

    这种情况下,其实也更推荐去尝试新的开发方式。


现代项目的开发中,已经出现打破了这种 DDL 优先设计的技术。比较知名的就是以 golang 语言主导的项目中的 gorm 框架,能够通过定义结构体来实现对数据表结构的创建。

不过打破 DDL 优先设计并不是说要脱离 DDL。出于审计、可追溯或者版本控制的目的,DDL 仍会在项目开发中出现,只是说不再需要人为去设计编写,而是由框架自动生成。

其实在更早的时候就已经出现了通过代码来自动生成数据表结构的技术(这里简称为 code first),典型的例子就是 hibernate,具有这种思维的各种技术后来被统一为 JPA 规范。

JPA 规范的核心目标:提供一套统一、标准化的API,将开发者从繁琐的 JDBC 和 SQL 细节中解放出来,实现用面向对象的方式操作数据库。

JPA 的设计理念是没问题的,并且契合了领域驱动设计的思想,但是可能过于理想化 (没有银弹)。JPA 规范的实现者们可能并没有真正理解 JPA 的目标,JPA 的使用者们可能也并没有学会如何正确地驾驭这套体系。

实际上后来的 mybatis-plus 也在通过插件增强,以获得与 JPA 类似的便利性。历史上有不少个人或社区孵化项目尝试将 JPA 的 code first 理念应用到 mybatis-plus 的框架上

也有将 JPA 与 Mybtais/Mybtais 配合使用的方式,集两者之长

从现在的视角来看,不妨可以尝试混合持久化:采用“80/20 原则”。即80%简单、标准的 CRUD 操作使用 JPA,提升开发效率;而对 20% 性能关键的复杂查询、报表或批量操作,使用原生 SQL 或更灵活的轻量级工具。或者说 mybatis-plus 本身就在实现这样一种思想。

JPA 规范的实现与实际应用也许存在诸多问题(阻抗失衡、N+1 问题),但其 code first 的设计理念始终影响着后继者,并且与现在的 IaC 理念有着异曲同工之妙。

未来的范式:代码定义结构?

这里需要考虑两种情况:

A:在原有项目的基础上引入

B:在全新的项目里引入

前面提到的 JPA、gorm 其实还是绑定于各自的生态,如果已有项目并没有使用这些框架,就需要花费额外的成本去引入,侵入性较大。

有一个值得关注的项目 prisma/prisma,号称是适用于 Node.js 和 TypeScript 的下一代 orm 框架,统一通过 prisma schema 来进行数据建模,不需要分别在关系型数据库层面和应用程序层面进行数据建模。配合社区客户端实现,能支持生成 go、rust、python 等语言的代码。

但是在 2025 年初,官网打算将未来的 prisma v7 进行重写,这导致一大片客户端决定停止维护。

虽然理论上可以通过开发新的客户端来适配 prisma v7,但这对社区无偿贡献的开发者来说无疑是一个很大的负担。并且正如 prisma 官方博客中所说的:“支持多个客户端的能力,不再是我们的重点”。

原文摘录:

Additionally, the core benefit of the query engine—the ability to support multiple clients—is no longer our focus. Prisma ORM is a TypeScript project and while we support our community clients, we won’t be developing them in house.

prisma 官方的博客文章地址:From Rust to TypeScript: A New Chapter for Prisma ORM

基于前面的思考,一种理想化的代码定义结构的基础设施可能是:

  • 输入唯一性:仅允许实体类/结构体代码作为来源。

    实体类或结构体代码总是能与数据表结构相对应,这能从框架层面上反映项目代码实际需要什么样的表结构来支持运行。而人工手动维护的 DDL,尤其还需要维护多份异构数据库的方言,这个过程本身就很耗费精力,很容易产生人为失误。因此选择代码作为来源是最可靠的依据。(当然代码也会存在失误,但相比数据表层面,代码上的结构性失误可能更容易被发现)

    同时这也变相地要求数据表的唯一来源是代码,以及要求代码具备能够控制一些数据库高级特性的能力。

    前面提到的 prisma schema 可能也有潜力同时作为实体类/结构体代码和数据表的唯一来源,但是过于依赖社区实现。

  • 零侵入性(?):不需要改变已有代码。

    主要是针对情况 A 考虑。

    这点其实存在悖论,因为现有的代码定义结构的方案中,都是通过修改代码以包含索引、约束等元信息。如果不对代码做修改,那么就无法实现代码定义结构,如果要实现代码定义结构,那就必须得修改已有代码。

    按照前面的说法,数据表的唯一来源是代码,那么一开始数据表是不存在的,元信息自然也就不存在(不考虑隐式行为),代码不包含元信息且不能改动,那就必然无法实现元信息的创建,因为这个过程中元信息根本就不存在,是一个不可能三角。

  • 零学习成本或低使用成本(?):无需学习新的 DSL 语法或者 DSL 能够同时处理编程语言代码和数据表两个层面,提高开发效率。

    针对情况 A,新的 DSL 如果还是以人工手动编写的方式运作,那么它和人工维护 SQL 方言没有本质区别。

    针对情况 B,考虑到 DSL 并不仅限于对数据表的控制,如果也能控制代码层面,避免手动编写大量的异构编程语言后端代码 (需要同时进行后端、数据库迁移的,并需要长期支持原系统和新系统),也许是另一种可能。

  • 全生命周期支持:空库创建→增量变更→表管理

    数据表结构不仅能通过代码自动创建,还能基于代码的变动来对比差异并自动调整表结构以适应代码。以此实现代码全面管理数据表。更进一步还能支持分布式集群的同构、异构数据库管理。

  • 多方言自动化:消除 SQL 方言差异

    最核心的功能,基于目标数据库的类型自动处理表结构,解决人工处理异构数据库方言差异的问题。

不过上面的想法还是过于理想化。它们在实际应用中能否得以实现以及在实现过程中会有什么困难,还是无法确定的事情。

参考资料

Docker 安装 Oracle在Docker中快速使用各个版本的Oracle数据库(10g、11g、12c、18c、1 - 掘金

工欲善其事,必先利其器,用上AWS SCT迁移工具,其它的都是将就 - 墨天轮

Oracle迁移PostgreSQL方案 - Cody& - 博客园

Springboot+mybatis-plus+ACTable实现自动创建表结构 - oldwu’s Blog

Introduction to MyBatis Generator – MyBatis Generator Core

第三节 O/R阻抗失配 · 使用JPA全面实现DDD持久化

为什么你的MyBatis总产生N+1问题?原理与根治方案 - 今日头条

01 发展历程:IaC过去、现在与未来展望 - 极客时间文档

Prisma 的全部命令和 schema 语法本文详细介绍了 Prisma 的全部命令,包括初始化项目、配置数据库、生成 - 掘金

大家在工作中创建服务端项目的数据库表都是怎么设计怎么建的?手写 SQL,用 GUI 工具还是 ORM 框架? - V2EX

  • 标题: 异构数据库迁移
  • 作者: Entropy Tree
  • 创建于 : 2025-10-19 10:23:53
  • 更新于 : 2025-10-19 10:59:08
  • 链接: https://www.entropy-tree.top/2025/10/19/heterogeneous-database-migration/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论