
最近把一套跑了两年的MySQL系统迁到了瀚高数据库前后折腾了接近三周。这个项目不算大但正好覆盖了一整套最常见的切换场景数据迁移、SQL语法兼容改造、应用服务启动与Tomcat部署问题。整个过程下来最大的感受是MySQL和瀚高数据库虽然都是关系型数据库但差的地方全在细节里稍不注意就会在生产环境炸给你看。这篇文章我按实际操作顺序来写所有内容都来自这次项目的真实记录希望能给准备做类似切换的人一些参考。1. 迁移前先摸清家底从MySQL到瀚高数据库的整体思路1.1 这个项目要迁什么这次迁移的是一套企业内部的管理系统核心业务表有三十多张其中有几张是典型的大表比如物料主数据表大概200多万行还有几张表关联了历史流水数据量在500万级别。整体数据量不算特别大但这套系统在业务上已经跑了两年SQL写得也比较“随性”有大量MySQL特有用法的痕迹比如GROUP_CONCAT、DATE_FORMAT、INSERT IGNORE、ON DUPLICATE KEY UPDATE、多表关联更新等。这也是这次迁移最大的工作量来源不只是把数据搬过去就完事应用里的SQL也得跟着改。在动手之前我先梳理了三个核心问题第一业务对停机窗口的容忍度这决定了能不能用最简单的方式做全量迁移第二数据库对象清单除了表还有没有存储过程、视图、触发器、函数、定时任务这些“隐藏资产”第三应用侧的连接方式哪些系统直接连库、哪些走了Tomcat的数据源这些都会影响后续的部署改造。1.2 迁移路线怎么定我的整体路线是先摸清对象再测试环境和工具然后按“小表先行、大表重点”的顺序做全量数据迁移接着进入SQL语法兼容改造最后才是应用部署和Tomcat问题排查。之所以不先动应用是因为如果SQL没改好就部署Tomcat起来以后会抛海量异常日志里全是语法错误根本分不清是连接问题还是驱动问题。一个很实用的经验是先挑一张小表把全链路跑通包括建表、导数据、应用查询、更新确认这条路没问题再放大到全部表。我这次是先挑了一张只有几百行数据的配置表做“探路”结果果然发现了不少问题比如驱动连接串写法、表名大小写、时间字段的处理这些问题如果直接在大表上碰排查起来会非常痛苦。1.3 环境准备与数据库服务启动检查瀚高数据库底层兼容PostgreSQL体系这意味着很多运维技能可以直接迁移过来。服务启动方面不同安装版本的管理命令不太一样有的用系统服务有的用安装目录下的启停脚本但核心检查逻辑是一样的先确认进程是否存在再确认端口能否连通最后用客户端工具实际连一下。我当时的检查顺序大致是这样的# 1. 确认服务进程 ps -ef | grep highgo # 2. 确认默认端口是否在监听不同版本默认端口可能不同 ss -lntp | grep 5866 # 如果是5432就用 5432 检查 # 3. 用客户端工具试连接 psql -h 127.0.0.1 -U test_user -d test_db这里有一个容易忽略的地方数据库服务起来不代表连接没问题很多Tomcat启动报错其实在数据库这一层就能发现。比如用户权限不对、客户端认证配置没放开、监听地址绑定了127.0.0.1但应用服务器不在本机。我的建议是在做应用部署之前先在数据库服务器上把上述三件事全部确认通过再往后走。2. 数据迁移实操表结构转换与全量数据搬运2.1 表结构转换字段类型、自增、注释、索引数据迁移的第一步不是导数据而是先建表。MySQL导出的建表语句是不能直接扔到瀚高里执行的里面有大量的MySQL特有语法比如ENGINEInnoDB、DEFAULT CHARSETutf8mb4、AUTO_INCREMENT、列注释写在字段后面、反引号包裹标识符等。这些都和瀚高不兼容。以物料主数据表为例MySQL原始结构大致长这样CREATE TABLE material ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, material_code varchar(50) NOT NULL COMMENT 物料编码, material_name varchar(200) DEFAULT NULL COMMENT 物料名称, status tinyint(4) DEFAULT 1 COMMENT 状态 1启用 0停用, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_material_code (material_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT物料主数据;转换到瀚高以后至少要调整成下面这个样子CREATE TABLE material ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, material_code varchar(50) NOT NULL, material_name varchar(200), status smallint DEFAULT 1, create_time timestamp DEFAULT CURRENT_TIMESTAMP, update_time timestamp DEFAULT CURRENT_TIMESTAMP ); COMMENT ON TABLE material IS 物料主数据; COMMENT ON COLUMN material.id IS 主键ID; COMMENT ON COLUMN material.material_code IS 物料编码; CREATE INDEX idx_material_code ON material(material_code);这中间有几个细节值得单独说一下MySQL的AUTO_INCREMENT在PG系数据库里通常用SERIAL或者GENERATED AS IDENTITY我更推荐后者因为SERIAL只是一个整型加默认序列而IDENTITY在逻辑上更接近MySQL的自增列。列注释不能写在字段定义里必须用COMMENT ON COLUMN单独声明。索引也尽量从建表语句中拆出来统一在表建好以后再创建尤其大表先导数据再建索引的速度远比边建边导快。tinyint(1)要具体看业务语义有的表确实是布尔值可以转成boolean但如果历史代码里写死1/0判断我建议保守一点转成smallint避免驱动层的布尔类型映射出幺蛾子。2.2 全量数据搬运的三种方式和我的选择表结构搞定之后就是数据搬运。两天前的方案讨论里大家提了三种方式我分别验证了一下第一种是直接用数据库客户端把MySQL查询结果导出成INSERT语句。优点是不用写代码缺点是200万行的表导出成SQL文本文件巨大执行的时候还经常因为单条语句太长或者特殊字符转义问题中断只适合极小的配置表。第二种是用CSV做中转从MySQL导出CSV再通过\copy导入瀚高。速度非常快语法也简单# 在瀚高客户端里执行 \copy material FROM /tmp/material.csv WITH (FORMAT csv, HEADER true, ENCODING UTF8);第三种是自己写脚本分批读取适合数据需要清洗或者需要做双写校验的场景。我这次对物料主数据这种大表就是写了一个Java批处理程序按主键范围分页查询MySQL每批1万行写入瀚高中间做了字段映射。实测下来速度也能接受而且能在导入过程中实时打印错误。我最后采用的组合方案是几十张中小表全部走CSV中转三张大表走批处理脚本。没有用那种“一条SQL直接插几十万行”的做法因为一旦中间报错回滚和定位的成本太高。2.3 数据校验与序列重置数据导完之后不能直接投入使用校验这一步不能省。我的校验方法分两层。第一层是行数对比这个最直观。每张表在MySQL和瀚高各执行一次SELECT count(*)不一致的直接暴露出来。第二层是选用一两个关键字段做聚合校验比如对主键求和、对数量字段求和或者算一算某个时间字段的最大值最小值确认数据没有偏移。还有一个特别容易被忽略的坑序列没有重置。如果表用了自增主键数据导过去以后序列默认从1开始而表里已经有几百万行数据了这时候应用一插入立刻报主键冲突。解决方法是导完数据后执行一次序列重置SELECT setval(pg_get_serial_sequence(material, id), (SELECT max(id) FROM material));这个操作看起来不起眼但没做的话上线第一分钟就会遇到“duplicate key value violates unique constraint”的报错而且如果不知道是序列问题排查起来会觉得非常莫名其妙。3. SQL语法改造手册MySQL写法到瀚高写法的关键差异3.1 函数替换对照表SQL改造是整个迁移中最耗时、也最考验细心程度的环节。MySQL和瀚高在函数上的差异非常明显很多在MySQL里用起来很顺手的函数在瀚高里要么不存在要么行为完全不同。我整理了一张常用函数对照表项目里基本够用MySQL写法瀚高/PG写法说明IFNULL(a, b)COALESCE(a, b)都返回第一个非NULL值IF(cond, a, b)CASE WHEN cond THEN a ELSE b ENDMySQL的IF在PG里没有DATE_FORMAT(now(), %Y-%m-%d)TO_CHAR(now(), YYYY-MM-DD)格式符不同GROUP_CONCAT(name)STRING_AGG(name, ,)排序写法也不同CURDATE()CURRENT_DATE返回当前日期UNIX_TIMESTAMP()EXTRACT(EPOCH FROM now())返回秒级时间戳FROM_UNIXTIME(ts)TO_TIMESTAMP(ts)时间戳转时间SUBSTRING_INDEX(str, ., 2)SPLIT_PART(str, ., n)注意语义差异CONCAT(a, b)CONCAT(a, b)函数名一样但NULL处理不同这里要说一个最容易踩的暗坑CONCAT。在MySQL里CONCAT(a, NULL)返回a而PG系数据库里CONCAT(a, NULL)返回NULL。如果你的业务逻辑里用CONCAT拼过字符串迁移后极有可能拼出来全是NULL。我当时排查一个报表数据为空的问题查了半天最后发现是字段里有NULL导致整个拼接结果变成了NULL替换成COALESCE包一层才解决。3.2 分页、多表更新删除、插入冲突语法差异主要集中在几个高频场景。分页是最早遇到的问题MySQL写LIMIT 10, 20表示跳过10条取20条但PG系数据库的语法是LIMIT 20 OFFSET 10顺序一颠倒取出来的数据就完全不对了。这个只要在代码里全局搜索一下LIMIT就能发现但要是藏在动态SQL里就得靠测试用例去查。多表更新和删除也要特别注意。MySQL允许直接写UPDATE t1 JOIN t2 ...但瀚高需要改成FROM子句的写法-- MySQL习惯写法 UPDATE material m JOIN category c ON m.category_id c.id SET m.category_name c.name WHERE c.status 1; -- 瀚高/PG写法 UPDATE material m SET category_name c.name FROM category c WHERE m.category_id c.id AND c.status 1;删除也一样MySQL的DELETE t1 FROM t1 JOIN t2 ...在PG里是DELETE FROM t1 USING t2 WHERE ...。这些语法本身不复杂但老代码里通常散落着很多类似的写法建议做一个全量代码扫描把JOIN更新的模式全部揪出来。插入冲突处理这块MySQL的INSERT IGNORE和ON DUPLICATE KEY UPDATE在瀚高里对应的是INSERT ... ON CONFLICT。区别在于ON CONFLICT需要明确指定冲突的列或约束INSERT INTO material (id, material_code, material_name) VALUES (1001, M001, 测试物料) ON CONFLICT (material_code) DO UPDATE SET material_name EXCLUDED.material_name;这个语法比MySQL更严谨但也更挑刺如果material_code上没有唯一约束或唯一索引ON CONFLICT (material_code)就会直接报错。所以迁移前一定要检查相关字段的约束是否已经同步建好。3.3 自增列、序列与UUID的坑瀚高数据库不直接沿用MySQL的AUTO_INCREMENT而是通过序列或者IDENTITY来实现自增。数据库层面自己管理序列倒还好真正麻烦的是应用代码里对自增ID的处理方式。MySQL的JDBC驱动支持通过getGeneratedKeys()拿到插入后的自增IDPG系的驱动其实也支持但前提是表定义和驱动版本匹配。如果是用SERIAL实现的驱动可以拿到如果用了GENERATED ALWAYS AS IDENTITY部分旧版本驱动可能会处理得不太一样。这个需要在应用联调阶段重点验证。UUID的情况更麻烦。MySQL里常用的UUID()函数生成的是带横杠的字符串PG系需要依赖内置扩展或者gen_random_uuid()函数。如果原来MySQL表里的UUID列存的是32位不带横杠的十六进制字符串迁移到瀚高以后建议仍然用字符串类型存储不要轻易转成原生的uuid类型否则查询条件里一个带横杠一个不带横杠永远查不出来。我这次有一张流水表就是这种场景最终的方案是保持varchar(32)不动应用层插入时去掉横杠。3.4 存储过程、视图与触发器的改写这套系统里存储过程不多但有那么两个定时跑批用的MySQL的写法在瀚高里几乎不能直接用。MySQL的存储过程习惯用DELIMITER定义结束符用CALL调用瀚高里函数用plpgsql语言调用方式是SELECT 函数名()。举一个简单例子MySQL的存储过程可能是这样DELIMITER $$ CREATE PROCEDURE sync_material() BEGIN DECLARE v_id BIGINT; DECLARE done INT DEFAULT 0; DECLARE cur CURSOR FOR SELECT id FROM tmp_material WHERE status 0; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; OPEN cur; read_loop: LOOP FETCH cur INTO v_id; IF done THEN LEAVE read_loop; END IF; UPDATE material SET sync_flag 1 WHERE id v_id; END LOOP; CLOSE cur; END$$ DELIMITER ;改写为瀚高函数CREATE OR REPLACE FUNCTION sync_material() RETURNS void AS $$ DECLARE v_id bigint; BEGIN FOR v_id IN SELECT id FROM tmp_material WHERE status 0 LOOP UPDATE material SET sync_flag 1 WHERE id v_id; END LOOP; END; $$ LANGUAGE plpgsql; -- 调用方式 SELECT sync_material();视图整体上差异小一些但视图内部如果用了MySQL特有函数还是要改。触发器是另一个潜在的坑MySQL的触发器可以直接在触发器主体里写SET NEW.col ...PG里必须写一个RETURNS TRIGGER的函数再在创建触发器时引用这个函数。迁移的时候如果不想背这个包袱我的建议是把原来靠触发器更新的逻辑改成应用层处理能少一类兼容性问题。4. 应用服务改造与启动驱动、连接串、数据源4.1 JDBC驱动和连接串怎么改应用侧第一件事就是改驱动。MySQL的驱动类名是com.mysql.cj.jdbc.Driver连接串一般是jdbc:mysql://ip:port/dbname。瀚高数据库基于PostgreSQL生态驱动类名通常沿用了PG的风格厂商一般会提供自己的JDBC驱动包有的直接兼容org.postgresql.Driver有的提供了专用驱动类名和连接串前缀。我这次的配置是放在Spring Boot的application.properties里的改完大致长这样spring.datasource.driver-class-nameorg.postgresql.Driver spring.datasource.urljdbc:postgresql://127.0.0.1:5432/mydb spring.datasource.usernameapp_user spring.datasource.passwordxxxx如果你的项目不是Spring Boot而是传统SSH架构数据库连接配置通常在Spring的applicationContext.xml或者Tomcat的context.xml里只需要替换driverClassName、url、username、password四个关键项。连接串里的参数建议做减法MySQL驱动里常见的serverTimezone、useSSL、characterEncoding这些在PG系驱动里基本不需要留着反而可能报警告。4.2 连接池、事务和时区配置连接池方面如果原来用的是Druid、HikariCP这些迁移时不需要换连接池只需要改底层的driverClassName和url。但有几个参数要重新确认。一个是validationQueryMySQL下经常配SELECT 1PG系也支持可以用另一个是connectionTestQuery有些连接池配置会指定MySQL的特有写法需要同步调整。时区是我这次处理得比较吃力的一环。MySQL的连接串里我可以显示指定serverTimezoneAsia/Shanghai到了瀚高这边连接串的时区参数写法变了。如果应用的服务器、数据库服务器、JVM默认时区不一致查出来的时间字段就会偏移8小时。我在这次迁移里统一在数据库连接串上加了TimeZoneAsia/Shanghai同时在应用启动参数里也固定了-Duser.timezoneAsia/Shanghai两边对齐以后时间问题才消失。4.3 服务启动顺序与报错排查应用启动这件事顺序很重要。我踩过几次坑之后形成了一套固定的启动检查流程先确认数据库服务进程在跑、端口通、客户端能登录再启动应用。应用启动之后不要急着访问页面先盯着日志看有没有ERROR级别的异常尤其要看数据源初始化、MyBatis映射加载、SQL语句解析这几个环节。典型的报错展开来说比如ClassNotFoundException: org.postgresql.Driver说明驱动jar没有正确打入应用或者没有放到Tomcat的lib目录Connection refused则是网络或端口问题和数据库驱动无关。这些报错如果按照“数据库层→驱动层→连接池层→SQL层”的顺序排查定位会很快。5. Tomcat部署问题整理war包、启动失败与访问4045.1 部署war包的几种方法这套系统里有一部分是传统Web应用用war包部署在Tomcat下这部分也是问题最多的环节。war包部署一般有三种方式。第一种是直接把war包丢到Tomcat的webapps目录下Tomcat启动时会自动解压部署最简单的也是我用得最多的方式。第二种是修改Tomcat的server.xml在Host下配置Context指向外部目录适合不想把war包放进webapps的场景但配置错了很容易导致应用启动一半就失败。第三种是通过Tomcat的管理页面在线部署适合测试环境生产环境一般不建议开管理器。war包的命名会直接决定访问路径比如material.war部署后访问地址是/material/。很多404问题的根源就是用了ROOT.war改名成别的或者明明访问的是/material但应用实际上下文路径是/material/少了一个斜杠都会出问题。5.2 Tomcat启动一闪而过、端口占用与驱动冲突Tomcat启动一闪而过这个问题特别经典本质上是启动脚本执行过程中抛了异常但窗口闪掉没来得及显示。我处理这个问题的标准动作是打开命令行窗口手动执行catalina.bat runWindows或者catalina.sh runLinux这样日志会直接打在控制台错误原因一目了然。最常见的一闪而过原因有三个一是JAVA_HOME或CATALINA_HOME环境变量没配好脚本找不到Java环境二是8080端口被占用Tomcat启动时报端口冲突退出三是catalina.bat文件编码问题尤其是Windows环境下脚本里有中文注释导致乱码。前两个好排查第三个我实际碰到过把脚本里的中文注释删掉就正常了。驱动冲突也在这个阶段集中出现。如果Tomcat的lib目录下放了旧版本的MySQL驱动而应用WEB-INF/lib下又放了一份新驱动两个驱动类名相同Tomcat类加载顺序一乱就可能出现奇怪的ClassCastException。迁移到瀚高后最好把Tomcat lib目录下MySQL相关驱动清理掉统一使用应用自带的驱动包避免全局加载和局部加载混在一起。5.3 数据源配置错误导致应用无法初始化传统Web应用经常把数据源配置在Tomcat的context.xml里通过JNDI方式提供给应用。这个配置在迁移数据库后要同步修改而且一旦写错Tomcat本身可能启动正常但应用一加载数据源就失败。我当时遇到的一个典型问题是context.xml里的driverClassName改成了PG驱动但url还残留着MySQL的useSSLfalse参数应用启动时报参数不识别。这种问题光看日志还不太明显因为Tomcat只会在访问JNDI数据源时才抛出异常。后来我直接在应用服务器上用Java写了一个几行的测试类通过JNDI拿一次数据源并执行getConnection()很快就把问题定位到了。5.4 访问404的排查路径Tomcat能启动、应用日志也正常但访问页面一直404这种问题也让人头疼。我的排查路径一般是从外到内先确认访问地址的端口对不对Tomcat默认8080如果改过端口要确认用的是新端口再确认应用是否真的部署成功看webapps目录下war包有没有被解压成同名目录然后看Tomcat的localhost日志判断是应用没有部署还是部署过程中失败回滚了。还有一个隐藏很深的坑是应用里的Servlet路径。原系统在MySQL环境下用了一段带版本号的接口路径部署到新环境后前端请求的还是旧路径而后端接口路径已经调整过结果就是404。这个和数据库迁移本身没关系但很容易在联调阶段被误认为是部署问题。我的建议是迁移期间前后端联调时先抓一个真实请求的完整URL对比后端实际映射路径别在404上瞎猜。6. 这次迁移踩过的坑我帮你列成了排查清单6.1 最容易忽略的五个细节回过头来看整个迁移过程有五个细节最容易忽略。第一个是自增列的序列重置这个前面反复提到了但凡是用了自增主键的表建议在数据导入后统一执行一次序列重置脚本不要漏。第二个是表名和字段名的大小写敏感问题。MySQL在Windows环境下表名可以不区分大小写但PG系数据库对未加引号的标识符会统一折叠成小写如果应用SQL里用的是大写表名迁移后很可能直接报relation XXX does not exist。这个在代码层面做一个全局搜索替换成本最低。第三个是空字符串和NULL的差异。MySQL里有些字段因为历史原因存的是空字符串而PG系数据库对于字符串字段的NULL判断会更严格如果SQL里有where col 这种写法迁移后可能查不出来原本应该查到的数据。第四个是GROUP_CONCAT迁移到STRING_AGG后原来用ORDER BY保证拼接顺序的逻辑要同步改写。MySQL的GROUP_CONCAT(name ORDER BY id)在PG里对应STRING_AGG(name, , ORDER BY id)但老代码里不一定写了排序条件如果业务依赖拼接顺序迁移前后结果不一致很难发现。第五个是应用代码里通过getGeneratedKeys()获取自增ID的逻辑放到PG体系下必须用新版本的JDBC驱动并且确认表的主键定义方式。我见过用老驱动导致获取不到ID进而整个插入事务回滚的情况害人匪浅。6.2 一张问题定位速查表最后把这次项目中所有遇到过的典型问题汇总成一张速查表遇到类似情况可以按图索骥。现象可能原因处理方式应用连接数据库报Connection refused数据库服务未启动、端口号错误、防火墙拦截用ps -ef和ss -lntp检查进程和端口再在应用服务器上用telnet测试连通性启动报ClassNotFoundExceptionJDBC驱动jar缺失或放错位置把驱动jar放到应用的WEB-INF/lib下清理Tomcatlib下的旧版本驱动数据源初始化失败连接串参数不兼容、驱动类名错误、用户名密码错误先用数据库客户端工具实测连接再修改连接串插入数据报主键冲突自增序列没有重置执行SELECT setval(pg_get_serial_sequence(表名, id), (SELECT max(id) FROM 表名))查询报relation does not exist表名大小写不一致检查SQL中的表名大小写统一使用小写或加双引号匹配查询报function xxx does not existMySQL函数未替换成PG函数按函数对照表逐个替换SQL报syntax error at or near LIMIT分页语法写错改成LIMIT count OFFSET offsetTomcat启动一闪而过JAVA_HOME未配置、端口被占用、脚本编码问题用catalina.sh run前台启动看日志访问页面404war包未解压失败、上下文路径不对、接口路径不匹配查看localhost日志确认应用部署状态时间字段差8小时时区配置不一致数据库连接串、JVM启动参数、操作系统时区统一设置数据库迁移这件事技术上不存在一个人搞不定的难点真正考验人的是能否把所有零散细节提前想到。我自己的习惯是先做一张几十行配置表的全链路验证再做小表迁移再做大表最后才切应用。这样每一步的问题范围都是可控的。如果你也在做类似的迁移建议给自己留足测试时间尤其是SQL语法改造那一层宁可慢一点也别在最后一刻才暴露兼容性问题。