资讯动态

PHP脚本实现Oracle到MySQL数据迁移的完整实践与避坑指南

发布时间:2026/10/6 19:13:20 来源:尧图企业网站定制
前阵子接了个数据库搬迁的活儿要把一套Oracle 11g里的业务表搬到MySQL 8.0。甲方内部运维平台是PHP技术栈我也不想为了一个一次性迁移任务专门部署Kettle或者DataX客户端最后决定直接用PHP脚本两头连库把表结构转换、数据批量搬运、结果校验一整套流程全部自动化跑完。整个过程说难不算难但坑是真不少从Oracle的ROWNUM分页陷阱到NUMBER精度丢失再到CLOB字段在PHP里的读取方式不对导致内存溢出每一类都踩过一遍。这篇就当复盘记录把当时的完整操作过程、核心代码和避坑清单全部摊开给同样要处理Oracle到MySQL数据迁移的朋友一个可以直接照抄的示范。1. 场景与选型为什么用PHP做Oracle到MySQL的迁移1.1 这个需求是怎么冒出来的这类需求最常见的来源是系统重构或平台整合。我这次遇到的情况是甲方核心业务跑在Oracle上但新上的报表分析和内部管理系统用的是MySQL两边数据不打通报表库只能靠人工导出再导入天天有人抱怨数据对不上。领导拍板说做一次存量数据迁移把Oracle里的业务主数据、订单流水、工单记录这些同步到新库后续再做增量。如果你也接到类似任务先别急着写代码建议先花半天时间把下面几件事问清楚迁移的数据范围是全部表还是部分表、源端业务在迁移窗口内是否停写、目标MySQL版本是多少、迁移完成后有没有数据校验要求。尤其是“源端是否停写”这点直接决定你能否用“导出-导入”这种离线方案还是要走在线同步。我这次业务窗口是周六晚上8点到周日早上6点源端报表库可以整体停写所以方案就简单很多离线全量搬一次搬完后锁库校准。1.2 PHP脚本相比ETL工具和Navicat的取舍很多人第一反应是用Navicat Premium图形界面点几下就能把Oracle表结构和数据迁到MySQL。这话对一半。Navicat做小表、少表迁移确实快我平时搭测试环境也这么干。但一旦涉及几十张表、表里有CLOB大字段、有Oracle特有的NUMBER精度、有需要按业务逻辑清洗的数据图形工具就露馅了它不让你在搬的过程中插入自定义转换逻辑遇到报错只能整表重来而且整个过程对开发者不透明日志也不够细。那为什么不用Kettle、DataX这些专业ETL工具呢我当时的判断基于三点。第一团队现有的运维调度平台就是PHP 7.4写的脚本可以直接挂上去跑不需要额外部署Java环境或ETL服务第二这次迁移不是全库盲迁而是有选择的业务表并且要做字段清洗和字典映射第三单表数据量在百万到千万行级别PHP在合理批处理策略下性能完全够用。当然我也得说实话如果哪天要迁移几十TB的仓库数据、或者要长期做实时增量同步那肯定还是得老老实实上DataX、Flink CDC这类专业组件PHP不是万能的。1.3 三阶段思路建表、搬数、校验整个迁移脚本我把它拆成三个阶段拆开之后思路会非常清晰后续排查问题也不用在整个脚本里大海捞针。第一阶段是结构迁移。读取Oracle的元数据把每张表的字段名、类型、长度、精度、可空性、默认值都摸清楚然后按照映射规则生成MySQL的建表语句。第二阶段是数据搬运。从Oracle逐批读取数据做类型转换和清洗再批量写入MySQL。第三阶段是校验。不能搬完就算完事必须做行数比对、主键范围比对、抽样字段比对确保两边数据一致后才能交付。这里的核心原则是结构、数据、校验三个环节尽量解耦。结构迁移失败不影响数据脚本数据脚本可以断点续传校验脚本独立跑。后面我会把每一阶段的实现细节详细展开。2. 环境准备与数据摸底先把两头连起来2.1 Oracle连接配置与字符集问题PHP连Oracle需要oci8扩展这玩意儿比PDO连MySQL要麻烦不少但装好之后非常稳定。Linux环境下一般用pecl安装pecl install oci8如果是PHP 8.3编译参数里要指定Instant Client的路径比如pecl install oci8 --with-oci8shared,instantclient,/usr/lib/oracle/21/client64/lib装完记得看扩展有没有加载成功php -m | grep oci8连接Oracle的时候我建议不依赖tnsnames.ora直接用EZ Connect或完整描述串拼连接地址这样脚本换台机器也能跑。代码示例如下$oraConfig [ host 192.168.1.101, port 1521, service ORCL, user mig_user, pass mig_pass, ]; $dsn sprintf( (DESCRIPTION(ADDRESS(PROTOCOLTCP)(HOST%s)(PORT%d))(CONNECT_DATA(SERVICE_NAME%s))), $oraConfig[host], $oraConfig[port], $oraConfig[service] ); $ora oci_connect($oraConfig[user], $oraConfig[pass], $dsn, AL32UTF8); if (!$ora) { $e oci_error(); exit(Oracle连接失败: . $e[message]); }这里有个最容易踩坑的细节oci_connect的第四个参数是客户端字符集。我建议先执行下面这条SQL确认服务端字符集再决定传什么值。SELECT USERENV(LANGUAGE) FROM dual;如果返回的是AL32UTF8那客户端也传AL32UTF8PHP里拿到的就是UTF-8字符串直接进MySQL没问题。如果返回的是ZHS16GBK而你在oci_connect里传了AL32UTF8Oracle虽然会尝试做字符集转换但某些生僻字、特殊符号在转换路径缺失的情况下会变成问号。我在项目中遇到过一次最后老老实实客户端也用ZHS16GBKPHP拿到GBK字符串后统一用mb_convert_encoding转成UTF-8再写入MySQL问题才彻底解决。2.2 MySQL连接和迁移用的辅助表MySQL这边用PDO连接配置相对简单$pdo new PDO( mysql:host192.168.1.102;port3306;dbnamemigration_target;charsetutf8mb4, mig_user, mig_pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, ] );需要注意连接串里的charset一定要用utf8mb4不要用utf8因为MySQL的utf8是utf8mb3存不了emoji和部分四字节字符。Oracle数据里如果混有特殊符号用utf8mb3大概率写入时报错或者变问号。在做数据搬迁之前我建议先在目标库建一张迁移进度表用来支持断点续传和状态跟踪CREATE TABLE migrate_progress ( table_name VARCHAR(64) PRIMARY KEY, last_key_value VARCHAR(64) DEFAULT NULL COMMENT 已搬完的最大主键值, total_rows BIGINT DEFAULT 0, status ENUM(running, done, failed) DEFAULT running, updated_at DATETIME );这张表虽然不起眼但关键时刻能救命。后面第3.4节我会专门讲怎么用它接续中断的迁移任务。2.3 通过Oracle元数据自动生成MySQL建表语句结构迁移阶段的核心是把Oracle的数据字典读出来翻译成MySQL的DDL。先说查询表清单SELECT table_name FROM all_tables WHERE owner MIG_USER AND table_name NOT LIKE BIN$% ORDER BY table_name;WHERE里过滤掉BIN$开头的表很重要。Oracle在drop table之后表会进回收站实际还能查到名字变成BIN$一串乱码。如果不过滤你会在迁移清单里看到一堆莫名其妙的历史垃圾表。另外如果账号权限不够查all_tables可以改用user_tables但那样就只能看到当前账号拥有的表需要根据实际情况调整。再看单表的字段信息SELECT column_name, data_type, data_length, char_length, data_precision, data_scale, nullable, data_default FROM all_tab_columns WHERE owner MIG_USER AND table_name ORDER_HEADER ORDER BY column_id;这里有一个极其关键的细节Oracle的VARCHAR2(n)里的n默认是字节数还是字符数取决于参数NLS_LENGTH_SEMANTICS。而all_tab_columns里有data_length和char_length两个字段data_length是字节长度char_length是字符长度。映射到MySQL的VARCHAR(n)单位是字符所以必须用char_length否则中文场景下会白白浪费一倍的字段空间极端情况还会因为超长导致插入失败。拿到字段元数据之后按照映射规则生成建表语句映射表我放在第3.2节详细讲。生成DDL时还有一些细节要注意MySQL的标识符如果和保留字冲突要用反引号包起来排序规则建议用utf8mb4_bin而不是默认的utf8mb4_0900_ai_ci因为Oracle的字符串比较默认是二进制排序如果你在Oracle里建了大小写敏感的唯一索引迁到MySQL后如果用ai_ci排序唯一约束可能不生效或者行为不一致。3. 数据搬运的核心逻辑游标分批、类型转换与批量写入3.1 游标流式读取避免分页陷阱数据搬运最忌讳的做法是把整张表一次性SELECT到PHP内存里再循环处理。千万行的表就算每行只有几百字节PHP进程内存也扛不住。更合理的方案是游标流式读取配合oci_set_prefetch控制每次从Oracle抓取的行数。$stmt oci_parse($ora, SELECT * FROM {$table}); oci_set_prefetch($stmt, 500); oci_execute($stmt); while ($row oci_fetch_array($stmt, OCI_ASSOC | OCI_RETURN_NULLS)) { // 处理单行 }这里有两个点需要说明。第一oci_set_prefetch的作用是让Oracle每次网络往返多返回一些行减少PHP和Oracle之间的交互次数默认值是100调到500在普通宽表上是比较舒服的。但如果表里有大的CLOB字段prefetch值不能调太大否则每个缓冲区都要预留大对象空间内存反而会涨。第二很多人习惯用ROWNUM做分页查询像这样$sql SELECT * FROM ( SELECT a.*, ROWNUM rn FROM ( SELECT * FROM {$table} ORDER BY {$orderCol} ) a WHERE ROWNUM . ($offset $limit) . ) WHERE rn {$offset};这个小表、少表的时候没问题但大表用这种方式分页有两个隐患。一是ORDER BY的列如果没有索引Oracle要全表排序越到后面越慢二是如果排序键不唯一分页查询的结果可能不稳定出现重复或漏行。所以我的建议是能游标流式读取就坚决不用ROWNUM分页。但流式读取又带来另一个问题如果脚本中途断了怎么知道上次读到哪了答案是用主键做续传标记这在第3.4节展开。3.2 字段类型映射与清洗规则Oracle和MySQL的数据类型不是一一对等的映射错了轻则报错重则数据静默损坏。下面这张表是我实际项目中用的映射规则可以直接抄Oracle类型MySQL类型迁移说明NUMBER(1)TINYINT通常用来表示布尔或标志位NUMBER(5)SMALLINTNUMBER(10)INTNUMBER(18)BIGINTNUMBER(19)及以上DECIMAL(p,0)超过BIGINT范围必须用DECIMALNUMBER(p,s)DECIMAL(p,s)保留精度金额字段尤其重要VARCHAR2(n)VARCHAR(n)用char_length做长度映射CHAR(n)CHAR(n)保留原语义DATEDATETIMEOracle的DATE包含时分秒MySQL的DATE只到天TIMESTAMPDATETIME(6)保留微秒精度CLOBLONGTEXT注意MySQL单行上限是64MBBLOBLONGBLOBRAW(n)BINARY(n)/VARBINARY(n)这里我重点说三个坑。第一个坑是NUMBER精度丢失。Oracle的NUMBER最高能表示38位十进制数字而MySQL的BIGINT只有19位。更阴险的是PHP的oci8扩展在读取NUMBER列时超过一定位数的值会直接转成float而float只有53位二进制精度换算下来大约15到16位十进制数字。也就是说NUMBER(20,0)的值如果超过2^53PHP端读到的就是精度丢失过的近似值写进MySQL就晚了。我的处理方案是元数据阶段扫描所有NUMBER列凡是data_precision大于等于16的在查询SQL里直接用TO_CHAR包一层让PHP收到的是字符串再原样写入MySQL的DECIMAL列。具体生成SELECT语句时动态拼一下就行$selectParts []; foreach ($columns as $col) { if ($col[data_type] NUMBER $col[data_precision] 16) { $selectParts[] TO_CHAR({$col[column_name]}) AS {$col[column_name]}; } else { $selectParts[] $col[column_name]; } } $sql SELECT . implode(, , $selectParts) . FROM {$table};第二个坑是空字符串。Oracle 11g里空字符串和NULL在存储上是等价的你用WHERE col 查不到任何东西因为空字符串会被自动转成NULL。而MySQL里空字符串和NULL是两个完全不同的值。所以迁移时你要先问清楚业务语义这个字段到底是“没有填”还是“填了空值”。我的做法是在清洗阶段统一处理把Oracle传来的NULL和空串都保持原样但在文档里明确标注让业务方确认。第三个坑是DATE类型。Oracle的DATE是包含时分秒的MySQL的DATE只到天。如果映射成DATE等于把所有记录的时间部分全部丢掉而且这个错误是静默的查COUNT不会报错但业务跑起来才发现时间对不上。所以Oracle DATE必须映射成MySQL DATETIME这个是硬规则。CLOB字段也单独说一下。oci8扩展里CLOB列fetch出来之后不是一个普通的PHP字符串而是一个OCI-Lob对象不能直接当字符串用。我在脚本里统一判断如果是对象就调用load()方法取内容case CLOB: $row[$col] is_object($val) ? $val-load() : $val; break;如果你的CLOB值超过几十MBload()一次性把整个对象读进内存也可能爆那就得改用oci_lob_read分段读但那种超大CLOB在实际业务表里非常少见遇到一个处理一个就好。3.3 批量写入MySQL与事务粒度控制从Oracle读出来的数据逐行处理完不能逐行insert到MySQL那样不仅慢而且会给InnoDB造成巨大的提交压力。我的做法是攒一批拼成一条多VALUES的INSERT语句一次性执行。function batchInsert(PDO $pdo, string $table, array $rows): void { $cols array_keys($rows[0]); $colStr implode(,, array_map(function ($c) { return {$c}; }, $cols)); $placeholders ( . implode(,, array_fill(0, count($cols), ?)) . ); $sql INSERT INTO {$table} ({$colStr}) VALUES . implode(,, array_fill(0, count($rows), $placeholders)); $stmt $pdo-prepare($sql); $params []; foreach ($rows as $row) { foreach ($cols as $c) { $params[] $row[$c]; } } $stmt-execute($params); }批量大小的选择我实测下来500行左右比较合适。如果表有50列500行就是25000个参数PDO和MySQL处理起来都很轻松。如果列数更多比如100列那建议降到200到300行因为还要考虑max_allowed_packet的约束。行宽特别大、有多个大文本字段的表我用的是100行一批稳妥优先。关于事务粒度我自己做过对比测试。一开始我图省事整个表只开一个事务搬完再commit结果跑到一半Oracle那边网络抖了一下脚本中断MySQL回滚了快二十分钟才缓过来。后来改成每500行一个事务500行数据量很小回滚代价完全可以接受而且InnoDB的锁释放更平滑总体耗时反而比大事务还少一些。所以我的建议是不要迷信大事务更快分批提交在这个场景下既安全又高效。写入之前还有两个MySQL侧的开关建议在迁移会话里设置SET foreign_key_checks 0; SET unique_checks 0;外键和唯一约束在校验阶段做一次全量扫描就够了迁移过程中关掉能省下大量索引维护开销。跑完记得恢复不然业务上线会出大问题。3.4 断点续传与运行日志迁移脚本跑几个小时中间不中断的概率其实很低。可能是网络抖动可能是源库有Query被杀掉也可能就是PHP脚本自身触发了内存上限。如果没有断点续传能力每次都要从头跑十万行的表还好千万行的表会让人崩溃。我的方案是前面建的那张migrate_progress表。每张表开始迁移前先检查一下状态和目标端当前的最大主键值如果已经跑过一部分就从这个主键值接着拉跳过已经搬完的数据。具体实现思路不复杂。假设表的主键是ID那么每搬完一批就取这一批里最大的ID更新到progress表function updateProgress(PDO $pdo, string $table, array $rows): void { $lastKey max(array_column($rows, ID)); $stmt $pdo-prepare( INSERT INTO migrate_progress (table_name, last_key_value, updated_at, status) VALUES (?, ?, NOW(), running) ON DUPLICATE KEY UPDATE last_key_value VALUES(last_key_value), updated_at NOW() ); $stmt-execute([$table, $lastKey]); }然后Oracle侧读取数据时加上主键过滤条件$lastKey getLastKey($pdo, $table); if ($lastKey ! null) { $sql SELECT * FROM {$table} WHERE ID . intval($lastKey); } else { $sql SELECT * FROM {$table}; }这里有一个隐藏前提主键列的数据在迁移窗口内必须是单调递增的或者说源端在迁移期间不再修改已存在行的主键。我这次迁移窗口内源表是停写的所以这个方案完全够用。如果源库还在持续写入那要处理的就是增量同步问题不是简单的主键续传能覆盖的。运行日志方面我强烈建议每张表处理完都打一条汇总日志包含表名、行数、耗时、最后主键值。格式不用花哨CLI里echo一行就行[2025-01-18 22:31:05] ORDER_HEADER | rows1843720 | elapsed17m32s | max_id1843720这些日志一方面是给人看的另一方面后续写校验脚本也要用到。3.5 迁移后的三层校验数据搬完不等于活干完校验不过关上线之后出了事那才是真的灾难。我在这个项目里做了三层校验每一层对应不同粒度的风险。第一层是行数比对也是最基本的。Oracle执行SELECT COUNT(*) FROM 表MySQL执行同样的SQL两边结果必须一致。如果对不上说明这一张表就有问题后面两层不用做了优先排查断点续传逻辑和主键边界。第二层是主键范围比对。分别查出两边的MIN(ID)和MAX(ID)如果源端是连续的1到N目标端也是连续的1到N勉强算OK。不连续不能说明丢了数据但至少要能解释得通。第三层是抽样比对。随机抽20到50个主键分别从两边查出完整记录逐字段比较。我通常选主键模10等于某个余数的策略伪随机但可复现比程序里的mt_rand更利于排查。比对脚本会输出差异字段、两边各自的值以及主键ID这样业务方拿到差异清单可以直接去对。这三层校验跑完我才会把migrate_progress里的状态改成done并通知业务方验收。4. 常见问题与性能优化速查4.1 字符集和空字符串两个最容易埋雷的地方字符集问题在外观上表现得很隐蔽不是上来就报错而是数据里偶尔出现问号、乱码、甚至字段截断。排查思路是从源端开始一层层看Oracle服务端字符集是什么、oci_connect客户端字符集传的什么、PHP字符串当前是什么编码、MySQL连接charset是什么、MySQL表定义charset是什么。五个环节只要有一环不一致结果就不可控。我这次踩过的具体问题是Oracle服务端是ZHS16GBK客户端也用了ZHS16GBKPHP得到GBK字符串但写进MySQL时连接参数是utf8mb4导致MySQL把GBK字节流当成UTF-8校验遇到某些字节组合直接报错。后来的处理方案是在PHP里统一转码只在读出来的那一刻做一次mb_convert_encoding不要在写入前到处转否则会漏。空字符串的坑前面已经说过根源这里补充一个我在排查时用过的SQL可以快速找出“看起来是空但实际不是”的数据SELECT * FROM target_table WHERE col IS NULL OR col ;Oracle源端基本查不到col 的数据因为都被存成NULL了所以目标端出现空串反而说明数据被动过或者类型转换逻辑有问题要查清楚。4.2 大字段与特殊类型处理CLOB、BLOB这类大对象字段普通字符串处理方式在这里行不通。我自己写迁移脚本时对CLOB的处理是先判断取出来的值是不是对象再决定是否调用load()。这里要特别注意load()是一次性把整个CLOB装进PHP内存如果一个CLOB值有几百MBPHP的memory_limit再高也会被吃穿。实际项目中我遇到过一个极端表里面有CLOB字段存了PDF的Base64文本最长的一条超过120MB。对这种表我把批量大小降到了20行一批并且给PHP脚本单独设置了memory_limit1G。即便如此那个120MB的字符串还是让脚本进程占用接近300MB内存跑了很久才搬完。事后复盘更好的方案是用oci_lob_read按块读再分段写入MySQL但那样代码复杂度会明显上升对于一次性迁移来说性价比不高。TIMESTAMP类型如果是TIMESTAMP(6)微秒精度也不能丢。PHP的date函数格式化后最多到秒超出部分的微秒会被截掉。如果业务对时间精度敏感需要保留微秒那在读取时就别用date格式化直接把Oracle返回的字符串原样写入MySQL的DATETIME(6)列让MySQL自己解析。4.3 提速手段关校验、调包大小、合理提交迁移速度的瓶颈通常不在PHP代码本身而在于数据库的写入路径。MySQL侧我实测有效的手段按效果排序是关外键和唯一校验、先搬数据后建索引、适当调大max_allowed_packet、批量语句复用。“先搬数据后建索引”这条迁移铁律值得单独强调一下。如果目标表在搬之前就已经建好了好几个二级索引每次INSERT都要同步更新所有索引页速度会肉眼可见地下降。我的做法是数据全部搬完并校验通过后再一次性执行建索引的DDL。MySQL 8.0重建索引的速度比边插边建快很多而且不会造成碎片。关于max_allowed_packet默认配置下批量INSERT语句如果特别长比如几百个字段、几千行可能直接报Packet too large。我的建议是把它设为64MB同时把一批的行数控制在合理范围内不要让一条SQL真的逼近这个上限。毕竟网络传输、SQL解析、参数绑定都是有成本的。PHP侧还有一个容易被忽略的点如果你在读取循环里做大量的字符串替换或正则清洗一定要保证这些操作是必要的而不是“顺手加上的”。我见过不少同事写迁移脚本时喜欢给每个字段都过一遍htmlspecialchars这种多余的处理在大表场景下会显著拖慢速度。4.4 常见问题速查表症状根本原因处理方式中文乱码、问号占位Oracle服务端与客户端字符集不一致先查USERENV(LANGUAGE)统一转码后写入UTF-8目标库空字符串查不到/存不进去Oracle空字符串NULLMySQL两者区分迁移前确认业务语义清洗阶段统一处理大数变成科学计数法或精度丢失NUMBER(20)超过BIGINT范围PHP float精度不够SELECT时用TO_CHAR包列目标用DECIMALCLOB取出来是Resource对象oci8返回OCI-Lob对象判断is_object后调用load()目标端插入报主键冲突/唯一键冲突源端数据本身有重复或唯一约束语义不一致先查源端重复数据确认后再迁移迁移中途断网从头再来没有断点续传建migrate_progress表按主键续传PHP内存溢出prefetch过大大字段批量过大调小prefetch和批量行数提高memory_limitMySQL报Packet too large单条INSERT太长超过max_allowed_packet调大max_allowed_packet或减小单批行数迁移很慢目标表索引太多、外键/唯一校验开启先搬数据后建索引迁移会话关掉外键唯一校验这个表是我这次迁移过程中实际遇到过的所有问题对应的处理方式都验证过不是理论推演。如果朋友的场景里还遇到过其他奇怪现象大概率也逃不开“类型映射、字符集、批量策略、约束冲突”这四个大方向沿着这个思路去查基本都能定位。回头复盘这次迁移真正花时间的其实不是写PHP脚本本身而是摸清源库的字段语义、边界值和类型边界。第一次拿Navicat导小表时看着挺顺遇到NUMBER(20)和CLOB就原形毕露。脚本只能保证把数据原样搬过去搬完之后找业务方一起抽查关键表、核对金额和时间字段才是这个活儿真正收尾的标志。这些坑踩过一遍之后后面再听到“把Oracle迁到MySQL”这种需求心里就有数了。如果需要在此基础上继续做增量同步那就要换一套思路去接数据库日志或者CDC组件不过那就是另一个故事了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑