YAOTU INSIGHTS

MySQL转Oracle脚本转换工具:Java实现与核心转换规则解析

MySQL转Oracle脚本转换工具:Java实现与核心转换规则解析
简介这是一款基于 Java 开发的数据库脚本转换工具主要解决 MySQL 脚本向 Oracle 迁移时的语法兼容问题适用于数据迁移、系统升级或多数据库环境运维场景。压缩包共 34 个文件约 3.01MB内含 11 个 Java 源文件、11 个 class 编译文件、6 个依赖 jar 包含 MySQL 与 Oracle 驱动、properties 配置、README 说明及 Eclipse 项目配置等源码与工程结构完整便于直接导入 IDE 查看。目前已有 182 人学习下载适合作为数据库迁移实践参考。阅读源码可以了解 MySQL 与 Oracle 在 DDL、DML、数据类型及函数映射上的差异掌握 Java 解析 SQL、语法转换与生成目标脚本的实现思路对于视图、触发器、存储过程等特殊对象也能看到针对两种数据库差异的处理方式。压缩包中的依赖库和工程配置文件同时提供了可复用的代码模板与运行环境适合需要做数据库迁移或研究脚本转换逻辑的开发者参考实践。1. 为什么 MySQL 脚本不能直接扔给 Oracle在做数据迁移或系统升级时从 MySQL 导出的 .sql 文件直接放到 Oracle 里执行通常会在前几十行就停下。MySQL 的ENGINEInnoDB、AUTO_INCREMENT、TINYINT这些语法Oracle 要么不认识要么语义完全不同。这个基于 Java 的数据库脚本转换工具mysql-oracle就是为这种情况准备的它读取 MySQL 脚本把 DDL 和 DML 改写成 Oracle 能执行的形式再输出一个新的 .sql 文件。对于做数据库迁移的工程师、同时维护两套数据库的老项目还有想找一个真实 Java 解析案例的人来说这个工程能把“手动改脚本”变成“配置规则批量造”。后面的内容会按工程结构、转换逻辑、存储过程处理、序列与分页、验证方法一条线展开。2. 工程结构用 Java 组织一个可扩展的 SQL 转换器2.1 项目文件与依赖说明先看压缩包里的工程描述文件。.project和.classpath说明这是一个标准 Eclipse Java 工程导入 IDE 后可以直接编译src/com是源码根目录转换逻辑按包组织bin是编译输出目录README.md记录了使用方式。libs 目录下的几个 jar 包基本决定了这个工具的能力边界下面这张表列一下Jar 包作用mysql-connector-java-5.1.33-bin.jar连接 MySQL用于读取源表结构或元数据ojdbc14-10.2.0.1.0.jarOracle 10g 的 JDBC 驱动用于连接目标库执行转换后的脚本commons-dbutils-1.6.jar封装 JDBC 查询减少手工处理 ResultSet 的代码commons-lang3-3.0.1.jar提供 StringUtils 等工具处理 SQL 文本拼接commons-io-2.5.jar文件读写比如一次性把脚本读入内存注意 ojdbc14 对应 Oracle 10g。如果目标环境是 11g 或 12c建议同步替换为 ojdbc6 或 ojdbc8。很多人在“oracle监听服务无法启动”上花了大半天最后发现是驱动和服务名不匹配这类问题在迁移工具里同样会出现。工具里同时出现 mysql 和 oracle 驱动说明它既能读取源库元数据也能把转换结果直接送进目标库验证。我一般会把这样一个转换器拆成三层。文件层用 commons-io 负责读取/写出解析层按语句类型拆分脚本识别 CREATE TABLE、INSERT、CREATE PROCEDURE 等生成层做语法映射和关键字处理。这样新增一条转换规则时只需要改生成层而不动主流程。2.2 读取脚本先把整个文件变成语句列表转换的第一件事是把 .sql 文件变成可以逐条处理的语句列表。下面这段代码用 commons-io 读文件再按分号粗拆import org.apache.commons.io.FileUtils; import java.io.File; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; public class SqlScriptReader { public static ListString splitStatements(String sqlText) { ListString statements new ArrayList(); String[] parts sqlText.split(;); StringBuilder current new StringBuilder(); for (String part : parts) { current.append(part).append(;); String candidate current.toString().trim(); if (isComplete(candidate)) { statements.add(candidate); current.setLength(0); } } return statements; } private static boolean isComplete(String sql) { return sql.endsWith(;) !sql.toLowerCase().startsWith(delimiter); } public static void main(String[] args) throws Exception { String script FileUtils.readFileToString(new File(input.sql), UTF-8); ListString list splitStatements(script); System.out.println(拆分出 list.size() 条语句); } }逻辑说明先用split(;)把文本切成多个片段再把片段依次拼回遇到完整语句就收入结果。isComplete这里排除了以delimiter开头的控制命令避免把 MySQL 的客户端指令误当 SQL。参数说明readFileToString的第二个参数是字符集脚本里有中文注释时用 UTF-8 最安全如果源文件是 GBK要改为Charset.forName(GBK)否则转出来的脚本在 Oracle 里会乱码。这段代码只是一个基础分句器。真正的难点在于存储过程体内也有分号第 4 章会讲如何用 DELIMITER 把过程体整体保留下来。工具里还会在这个阶段记录每条语句的行号方便后面报错时回查。2.3 数据库连接用 ojdbc 和 DbUtils 验证目标库转换器除了生成文本还经常需要把结果直接执行到目标库用来验证语法是否正确。这时就用到了 ojdbc 和 commons-dbutils。下面是连接 Oracle 并执行一个简单查询的代码import org.apache.commons.dbutils.QueryRunner; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class TargetDbConnector { public static Connection openOracle(String url, String user, String password) throws SQLException { try { // 注册 Oracle JDBC 驱动 Class.forName(oracle.jdbc.driver.OracleDriver); } catch (ClassNotFoundException e) { throw new SQLException(缺少 ojdbc 驱动, e); } return DriverManager.getConnection(url, user, password); } public static void main(String[] args) throws SQLException { Connection conn openOracle(jdbc:oracle:thin:localhost:1521:orcl, scott, tiger); QueryRunner runner new QueryRunner(); int one runner.query(conn, SELECT 1 FROM dual, rs - { rs.next(); return rs.getInt(1); }); System.out.println(连接成功: one); conn.close(); } }说明jdbc:oracle:thin:localhost:1521:orcl中thin是 Java 纯驱动1521是监听端口orcl是数据库服务名。QueryRunner.query的第三个参数是ResultSetHandler这里用 Lambda 取出查询结果。参数说明如果目标是 Oracle 10gojdbc14 可以正常工作连 Oracle 11g 时若报ORA-28040说明驱动版本太旧需要换成 ojdbc6。这个连接类也可以复用转换器把生成的脚本逐条执行后遇到异常会把行号记录到日志里。3. 核心转换逻辑DDL 与 DML 的映射规则与实现3.1 数据类型映射TINYINT 不能直接翻成 NUMBERMySQL 和 Oracle 的数据类型看起来相似实际上各自有一套完整的存储体系。直接做文本翻译会踩很多坑比如TINYINT在 Oracle 里没有DATETIME在 Oracle 里是TIMESTAMPTEXT在 Oracle 里要考虑用CLOB。下面是一份常用的映射表MySQL 类型Oracle 类型注意事项TINYINTNUMBER(3)TINYINT(1) 常表示布尔迁移前要确认业务语义SMALLINTNUMBER(5)INT / INTEGERNUMBER(10)BIGINTNUMBER(19)防止超范围溢出FLOATBINARY_FLOATDOUBLEBINARY_DOUBLEVARCHAR(n)VARCHAR2(n)Oracle 中空字符串等于 NULL语义不同DATETIMETIMESTAMP保留时间部分TEXT / LONGTEXTCLOB如果长度小于 4000也可以用 VARCHAR2ENUMVARCHAR2(长度) CHECK需要拆分成列定义和约束这张表不是一一映射就完事还要处理默认值。MySQL 里常见DEFAULT CURRENT_TIMESTAMPOracle 12c 之后才支持如果目标是 10g需要转成触发器或者在插入时由应用层赋值。所以转换器在设计映射时一定要带目标版本参数。这个项目 libs 里的 ojdbc14 对应 10g意味着很多 12c 新特性不能用需要走老方案。3.2 DDL 转换CREATE TABLE 的改写过程CREATE TABLE 是迁移中最容易出错的语句。MySQL 建表时会在末尾追加ENGINEInnoDB DEFAULT CHARSETutf8而 Oracle 没有这些概念必须删掉。AUTO_INCREMENT也要替换。下面是一个简化版的转换代码import java.util.regex.Pattern; public class CreateTableTransformer { private static final Pattern ENGINE Pattern.compile(ENGINE\\s*\\s*\\w, Pattern.CASE_INSENSITIVE); private static final Pattern CHARSET Pattern.compile(DEFAULT\\sCHARSET\\s*\\s*\\w, Pattern.CASE_INSENSITIVE); private static final Pattern AUTO_INCREMENT Pattern.compile(AUTO_INCREMENT, Pattern.CASE_INSENSITIVE); public static String transform(String sql) { // 先删 MySQL 表选项再处理自增 String s ENGINE.matcher(sql).replaceAll(); s CHARSET.matcher(s).replaceAll(); s AUTO_INCREMENT.matcher(s).replaceAll(GENERATED BY DEFAULT AS IDENTITY); return s; } }逻辑说明ENGINE.matcher(sql).replaceAll()会把所有ENGINExxx文本删掉CHARSET同理。AUTO_INCREMENT被替换成 Oracle 12c 的 IDENTITY 语法。参数说明如果目标库是 10g这一步不能直接替换而应该生成一个序列和一个触发器具体的生成方法在第 5 章展开。替换的顺序也有讲究先删表选项再改自增列因为表选项后面可能跟着分号提前处理会影响自增列的匹配。这个版本没有处理UNSIGNED。MySQL 的INT UNSIGNED在 Oracle 中建议映射为NUMBER(10)并加CHECK (col 0)如果只是简单替换成NUMBER(10)负数数据进来后 Oracle 不会拒绝但应用层读出来可能不符合预期。我在实际项目中会把类型映射做成一个外部配置而不是硬编码到正则里这样遇到特殊列可以在配置里临时加规则。3.3 DML 转换函数、NULL 和分页的替换DML 主要是 INSERT、UPDATE、SELECT 语句。MySQL 的NOW()对应 Oracle 的SYSTIMESTAMPCURDATE()对应TRUNC(SYSDATE)IFNULL(a,b)对应NVL(a,b)。分页的LIMIT处理要特别当心不同 Oracle 版本写法不同。先看一个普通的函数替换实现import java.util.LinkedHashMap; import java.util.Map; public class FunctionMapper { private static final MapString, String RULES new LinkedHashMap(); static { RULES.put((?i)\\bNOW\\s*\\(\\), SYSTIMESTAMP); RULES.put((?i)\\bCURDATE\\s*\\(\\), TRUNC(SYSDATE)); RULES.put((?i)\\bIFNULL\\s*\\(\\s*, NVL(); RULES.put((?i)\\bLIMIT\\s(\\d)\\s*,\\s*(\\d), OFFSET $1 ROWS FETCH NEXT $2 ROWS ONLY); } public static String transformDml(String sql) { String s sql; // 按顺序替换先函数后分页 for (Map.EntryString, String rule : RULES.entrySet()) { s s.replaceAll(rule.getKey(), rule.getValue()); } return s; } }说明这里用 LinkedHashMap 保证替换顺序。(?i)忽略大小写\\b是单词边界防止误替换NOWHERE。分页正则把LIMIT a, b转成 Oracle 12c 的OFFSET a ROWS FETCH NEXT b ROWS ONLY。参数说明IFNULL的替换只是把函数名改成NVL参数顺序和个数恰好一致但NVL要求两个参数的类型兼容否则 Oracle 报错比如一个参数是字符串一个是数字。纯正则对这种嵌套函数处理起来很脆比如IFNULL(IFNULL(a,b),c)转成NVL(NVL(a,b),c)可能没问题但遇到带子查询的复杂表达式正则就扛不住了。生产环境我会先用词法分析把函数调用树拆出来再按类型递归改写。注意分页改写依赖绑定变量。如果源脚本里LIMIT后面是常量直接替换成数字也能执行但会让不同页生成不同 SQL导致 Oracle 硬解析变多。建议在转换时把数字抽成绑定变量占位符。4. 存储过程与触发器MySQL 的 DELIMITER 与 Oracle 的 PL/SQL 块4.1 为什么必须单独处理 DELIMITERMySQL 存储过程是多行文本内部大量使用分号。客户端为了把整个过程作为一个对象提交引入了DELIMITER命令改变语句结束符。Oracle 的 PL/SQL 虽然没有 DELIMITER但在 SQL*Plus 和 JDBC 中习惯用一个单独的/来结束整个块。转换器必须识别 MySQL 的DELIMITER $$把CREATE PROCEDURE ... END$$整体提取出来再转换成 Oracle 能识别的格式。下面这段代码实现了一个粗略的 DELIMITER 处理public class DelimiterProcessor { public static String convertDelimiter(String script) { String[] lines script.split(\\r?\\n); StringBuilder result new StringBuilder(); String currentDelimiter ;; StringBuilder block new StringBuilder(); boolean inBlock false; for (String line : lines) { String trimmed line.trim(); String lower trimmed.toLowerCase(); if (lower.startsWith(delimiter)) { if (inBlock) { result.append(block).append(\n/\n); block.setLength(0); inBlock false; } String[] parts trimmed.split(\\s); currentDelimiter parts.length 1 ? parts[1] : ;; } else { if (inBlock trimmed.endsWith(currentDelimiter) !;.equals(currentDelimiter)) { block.append(line).append(\n); result.append(block).append(\n/\n); block.setLength(0); inBlock false; } else if (inBlock) { block.append(line).append(\n); } else if (trimmed.endsWith(;) ;.equals(currentDelimiter)) { result.append(line).append(\n); } else { block.append(line).append(\n); if (!;.equals(currentDelimiter)) { inBlock true; } } } } if (block.length() 0) { result.append(block); } return result.toString(); } }逻辑说明这段脚本逐行扫描。遇到delimiter行时如果之前已经在收集块就先把旧块闭合并追加/然后更新currentDelimiter。后续行先判断是否以新分隔符结尾如果是就把整个 block 追加/输出。参数说明inBlock标记是否进入了过程体避免普通分号语句也被错误合并。这个实现没有处理注释里的delimiter和字符串中的分隔符所以只能算预处理完整版本需要在扫描时维护一个“是否在注释/字符串”的状态机。4.2 存储过程内部的 PL/SQL 改写进入过程体后类型和函数的转换规则和 DDL 类似但还要额外处理游标、变量和异常。MySQL 的DECLARE cur CURSOR FOR SELECT ...必须写成CURSOR cur IS SELECT ...并放在 Oracle 的声明区赋值语句SET a 1;要改成a : 1;。下面列一个常见差异表MySQL 过程语法Oracle PL/SQL 语法DECLARE v_id INT;v_id NUMBER;DECLARE v_name VARCHAR(50);v_name VARCHAR2(50);DECLARE cur CURSOR FOR SELECT ...CURSOR cur IS SELECT ...;SET v_id 1;v_id : 1;IF a THEN ... END IF;IF a THEN ... END IF;LAST_INSERT_ID()通过序列取CURRVALINSERT ... ON DUPLICATE KEY UPDATEMERGE INTO ...表里最后两行不是简单替换。LAST_INSERT_ID()在 Oracle 中通常用SELECT seq.CURRVAL INTO :v FROM dual;实现但前提是该插入语句已经使用了同一个序列。ON DUPLICATE KEY UPDATE需要重写成MERGE这要同时处理 SELECT 和 INSERT 两个分支转换器里一般单独留一个MergeRewriter。这里给出一个过程体文本的简单改写器public class PlSqlRewriter { public static String rewrite(String procedureBody) { String s procedureBody; // 类型与函数名改写 s s.replaceAll((?i)\\bVARCHAR\\b, VARCHAR2); s s.replaceAll((?i)\\bDATETIME\\b, TIMESTAMP); s s.replaceAll((?i)\\bIFNULL\\s*\\(, NVL(); s s.replaceAll((?i)\\bNOW\\s*\\(\\), SYSTIMESTAMP); // 变量赋值改成 PL/SQL 的 : s s.replaceAll((?i)\\bSET\\s(\\w)\\s*, $1 :); return s; } }说明SET a 1被重写成a : 1但前提是变量名去掉后仍然合法。Oracle 的标识符以字母开头长度不超过 30如果 MySQL 变量名带且后面跟特殊字符这里就要做标识符规范化。参数说明NOW\\s*\\(\\)匹配NOW()并替换成SYSTIMESTAMP不带括号因为 Oracle 中SYSTIMESTAMP更常见的写法是直接作为伪列使用加括号反而会报错。这个过程体改写仍然依赖上下文比如游标循环里的FETCH cur INTO v1, v2;在 Oracle 中也能用只是声明方式不同所以转换器要保留 FETCH 相关行。4.3 触发器中 NEW 和 OLD 的冒号差异MySQL 触发器内用NEW.字段和OLD.字段引用新行和旧行Oracle 必须写成:NEW.字段和:OLD.字段。另外 MySQL 的CREATE TRIGGER在 Oracle 里更推荐CREATE OR REPLACE TRIGGER方便重复执行。一个简单的转换方法如下public class TriggerTransformer { public static String transform(String sql) { String s sql.replaceAll((?i)CREATE\\sTRIGGER, CREATE OR REPLACE TRIGGER); // NEW 改 :NEW注意词边界 s s.replaceAll((?i)\\bNEW\\., :NEW.); s s.replaceAll((?i)\\bOLD\\., :OLD.); return s; } }说明\\bNEW\\.中的\\b是单词边界保证NEW.前面不是字母数字下划线这样不会把NEWS.误转换。Oracle 触发器内部不允许COMMIT如果原 MySQL 触发器里写了COMMIT这个工具不会自动删除而是应该在读取阶段把警告输出。参数说明替换成:NEW.时要注意原 SQL 中如果已经有冒号不能重复替换否则会变成::NEW.。实际处理时可以先检查前缀。5. 序列与分页10g 和 12c 下的两套处理方案5.1 AUTO_INCREMENT 迁移的两条路MySQL 的AUTO_INCREMENT在 Oracle 里没有直接对应物。Oracle 12c 起可以用GENERATED BY DEFAULT AS IDENTITY但 10g/11g 必须用序列加触发器。这个项目带的是 ojdbc14大概率面向 10g所以转换器内置序列生成方案更可靠。常见的做法是为每个有自增列的表生成一个序列命名为SEQ_表名再生成一个BEFORE INSERT触发器。生成触发器的代码可以这样写public class IdentityGenerator { public static String buildTrigger(String table, String column, String seqName) { return String.join(\n, CREATE OR REPLACE TRIGGER trg_ table _before_insert, BEFORE INSERT ON table FOR EACH ROW, DECLARE, BEGIN, IF :NEW. column IS NULL THEN, SELECT seqName .NEXTVAL INTO :NEW. column FROM dual;, END IF;, END;, / ); } }说明触发器的名字trg_表名_before_insert一般不会和现有对象冲突但 Oracle 对象名不能超过 30 个字符如果表名已经接近上限触发器名要截断否则执行时报ORA-00972。IF :NEW.id IS NULL表示应用层没有显式传主键时才去序列取值这和 MySQL 自增行为一致。参数说明seqName也要遵守 30 字符限制长表名可以缩写成SEQ_T1或者使用表名的 hash 后缀。要生成这些对象转换器需要先知道哪一列是自增列。最简单的方式是从 CREATE TABLE 文本里抓AUTO_INCREMENT特征import java.util.regex.Matcher; import java.util.regex.Pattern; public class AutoIncrementExtractor { private static final Pattern PATTERN Pattern.compile( ?([^\\s])?\\s[^,]*?AUTO_INCREMENT, Pattern.CASE_INSENSITIVE); public static String findColumn(String createTableSql) { Matcher matcher PATTERN.matcher(createTableSql); return matcher.find() ? matcher.group(1) : null; } }说明这个正则在列定义中寻找列名 类型 [其他属性] AUTO_INCREMENT的模式。([^\s])捕获列名后面用\s[^,]?跳过类型和注释最后落到AUTO_INCREMENT。参数说明如果列的属性里包含括号内的逗号比如DECIMAL(10,2)[^,]?会在第一个逗号处停下导致匹配失败。更稳的做法是连接 MySQL 用DatabaseMetaData.getPrimaryKeys 读取主键列但这样就要求工具运行时源库在线。这个项目同时引入了 mysql 驱动目的之一就在这里。5.2 LIMIT 分页的三种改写方式MySQL 的LIMIT offset, count在 Oracle 不同版本下有不同写法。我自己在迁移时按版本选择目标版本推荐改写12c 及以上OFFSET ? ROWS FETCH NEXT ? ROWS ONLY11gROW_NUMBER() OVER (ORDER BY ...)子查询10g双层ROWNUM子查询10g 的双层写法模板如下SELECT * FROM ( SELECT t.*, ROWNUM rn FROM (SELECT * FROM your_table ORDER BY id) t WHERE ROWNUM :offset :count ) WHERE rn :offset;说明最内层先执行排序中间层用ROWNUM offset count截断到需要的最大行外层再用rn offset去掉开头。ROWNUM在 Oracle 中是查询结果生成后才赋值所以必须先排序再截断否则分页结果不稳定。参数说明:offset和:count是绑定变量实际转换时可以从 MySQL 的LIMIT ? , ?里提取数字也可以保留成绑定变量形式让目标库执行计划重用。如果原 SELECT 没有 ORDER BY分页语义本身就不确定转换器会给出警告。6. 用工具把 MySQL 迁移到 Oracle验证脚本与常见坑6.1 先跑通再跑全量用 sqlplus 定位错误行转换完成后不要直接在生产库执行全量脚本。我一般会在开发环境建一个独立的 Oracle schema用 sqlplus 执行转换产物把输出重定向到日志文件再根据ORA-错误行号回查。命令很简单sqlplus user/passlocalhost:1521/orcl converted.sql exec.log 21 grep -n -i ORA- exec.log | head -40说明sqlplus 会按顺序执行脚本错误信息里通常带着当前语句的行号或 PL/SQL 块的行号。grep -n显示日志中的行号head -40只取前 40 条错误避免刷屏。参数说明如果脚本很大建议把 DDL 和 DML 分开执行DDL 前加一行WHENEVER SQLERROR EXIT;这样遇到第一个建表错误就退出不会再往下执行 DML节省排查时间。6.2 高频坑空字符串、TINYINT(1) 和关键字冲突三个高频问题要先确认。第一MySQL 的在 Oracle 中会被当成 NULL如果表里要区分空串和 NULL转换后查询条件会变化。第二TINYINT(1)在 MySQL 里常用来表示布尔迁移时最好转成CHAR(1)并配合CHECK IN (0,1)而不是简单转成NUMBER(1)否则应用层的语义会错。第三Oracle 保留字很多比如LEVEL、UID、ROWNUM如果 MySQL 表里用了这些词做列名转换后必须加双引号或 rename 列。这个工具里可以内置一个保留字表在生成列名时自动加双引号。6.3 核对列数的实用技巧转换后的表结构和源表完全一致是迁移成功的最低要求。先在 MySQL 里查询源表的列信息SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema your_db ORDER BY table_name, ordinal_position;再到 Oracle 里查询目标表的信息SELECT table_name, column_name, data_type FROM user_tab_columns WHERE table_name YOUR_TABLE ORDER BY column_id;把两份结果导出到文件后 diff立刻能发现列缺失或顺序错位。Oracle 的column_id和 MySQL 的ordinal_position是一一对应的排序字段选它们就对了。实际操作时我会写一个小脚本循环执行这两条 SQL然后直接输出差异表名一旦有差异再去翻对应表的 DDL 转换日志重点看ENGINE删除和AUTO_INCREMENT替换那两行大部分列丢失都能从这里找出来。本文还有配套的精品资源点击获取