从MySQL迁移到PostgreSQL:那些没人告诉你的血泪避坑指南
说出来你可能不信,我们团队去年做了一个"疯狂"的决定:把公司的核心数据库从MySQL切换到PostgreSQL。
老板问:为什么要换?
我们答:为了更好的性能、更强大的SQL支持、以及那个听起来很厉害的JSONB。
老板说:行,换吧。
然后我们就踏上了为期三个月的踩坑之旅。今天把这个过程中踩得最惨烈的几个坑分享出来,希望后来者能少流点血——虽然我觉得该踩的坑迟早会踩。
坑一:varchar(255)的诅咒
你知道MySQL里varchar(255)意味着什么吗?在MySQL 5.7及之前,这个255是字符数,不是字节数。所以你存一个emoji进去,数据库愉快地接受了。
切换到PostgreSQL后,我们系统里所有用户的简介、评论、签名档全部炸了。因为PostgreSQL的varchar(n)里的n是字节数,而且它对字符集非常——怎么形容呢——较真。
-- MySQL: 存得下
INSERT INTO users (bio) VALUES ('Hello 🦞');
-- PostgreSQL: 不好意思,超长了
ERROR: value too long for type character varying(255)
你以为这是个例?天真。太长是你的错,不是emoji的错。PostgreSQL的哲学是:明确你的数据要多大,就告诉我多大,别让我猜。
所以我们花了两个通宵,把所有varchar(255)改成了varchar(1024)或text,谁让产品经理当年非要给用户开放"写小作文"的权限呢。
血的教训:迁移前一定要跑一遍数据长度扫描,emoji、emoji+中文、分词符串,这三个东西组合起来能让你对"字符数"产生全新的认识。
坑二:自增ID的"隐形杀手"
MySQL里你写AUTO_INCREMENT,用得飞起。PostgreSQL里对应的叫SERIAL,用法几乎一样。但这里有个坑,坑了我们整整两周才定位到。
看看这个查询:
-- MySQL
SELECT COUNT(*) FROM orders WHERE user_id = 12345;
-- PostgreSQL
SELECT COUNT(*) FROM orders WHERE user_id = 12345;
看起来一样对吧?执行计划完全不一样!
MySQL的AUTO_INCREMENT是一个暴露的计数器,查询优化器能精确知道最大ID是多少。PostgreSQL的SERIAL是一个隐藏的序列对象,优化器只能靠统计信息估算。
结果就是:当你的user_id字段分布不均匀时,MySQL能快速跳过不需要扫描的页面,PostgreSQL可能会老老实实地扫全表。
我们有个报表查询,在MySQL上跑0.3秒,PostgreSQL上跑40秒。explain analyze一看,优化器选了一个你以为很聪明但实际很傻的执行计划。
血的教训:迁移后务必用EXPLAIN ANALYZE重新审查所有核心查询,别相信"语法一样执行计划就一样"这种鬼话。
坑三:大小写敏感性的"文艺复兴"
MySQL在Linux上的默认设置是大小写敏感,Windows上是大小写不敏感。PostgreSQL则是全平台大小写敏感。
如果你在Windows上开发,MySQL的表名可能是这样:
SELECT * FROM User_Orders -- 成功
SELECT * FROM user_orders -- 也成功
到了PostgreSQL,对不起:
SELECT * FROM User_Orders -- 成功
SELECT * FROM user_orders -- ERROR: relation "user_orders" does not exist
我们有个历史遗留项目,表名大小写混用,上线第一天就收获了三十多个"表不存在"错误。
解决方案有两个:要么统一改成小写,要么在PostgreSQL配置文件里把lc_collate设成和MySQL Windows版本一样的规则。但说实话,配置文件改完了又可能引发其他奇怪问题。
血的教训:迁移前先检查所有SQL文件,统一命名规范。最好的办法是把表名全改成小写,这辈子都别再用驼峰命名法给PostgreSQL的表命名。
坑四:那个你不知道的"间隙锁"玩法
MySQL的InnoDB和PostgreSQL的MVCC,看起来都是并发控制机制,实际上是两个世界的生物。
MySQL用锁 + MVCC混合模式,InnoDB的行锁可以锁住不存在的记录(也就是间隙锁)。这个特性让我们实现某些业务逻辑时特别顺手。
PostgreSQL是纯MVCC,写不阻塞读,读不阻塞写,听起来很美好。但当你需要"只有一个人能抢到"这种业务逻辑时,PostgreSQL会给你一个经典的PG特有的错误:
ERROR: could not serialize access due to concurrent update
我们有个"限时抢购"功能,MySQL上跑了三年稳如老狗。PostgreSQL上第一次压测就出现了大量serialization failure。
解决方案是重写锁逻辑,用SELECT ... FOR UPDATE加上重试机制:
CREATE OR REPLACE FUNCTION grab_seat(p_seat_id INT)
RETURNS BOOLEAN AS $$
DECLARE
v_available BOOLEAN;
BEGIN
LOOP
SELECT available INTO v_available
FROM seats
WHERE id = p_seat_id
FOR UPDATE NOWAIT;
IF NOT v_available THEN
RETURN FALSE;
END IF;
UPDATE seats SET available = FALSE WHERE id = p_seat_id;
IF FOUND THEN
RETURN TRUE;
END IF;
-- 如果UPDATE没影响行,说明被抢了,重试
END LOOP;
END;
$$ LANGUAGE plpgsql;
这段代码看起来有点变态,但它是PostgreSQL下正确处理"抢"这个动作的方式。
血的教训:所有依赖特定锁行为的代码,在迁移前都要review一遍。MySQL的某些"特性"其实是"技巧",换个数据库就失效了。
坑五:JSONB不是万能药
我们迁移之前就听说PostgreSQL的JSONB多么多么厉害,可以存灵活的半结构化数据,比MySQL的JSON强十倍。
于是兴冲冲地把一些原本存在text字段里的配置信息迁移到了JSONB。然后查询性能让我们怀疑人生。
-- 我们的配置存储
{"theme": "dark", "lang": "zh-CN", "notifications": {"email": true, "sms": false}}
-- 查询所有开启了email通知的用户
SELECT * FROM users
WHERE config -> 'notifications' ->> 'email' = 'true';
这个查询在PostgreSQL里用不到任何索引,每次都要扫描全表。因为JSONB的查询语法太灵活了,优化器表示"我猜不透你的意图"。
后来我们老老实实地建了表达式索引:
CREATE INDEX idx_users_email_notif
ON users ((config -> 'notifications' ->> 'email'));
但问题是,如果你有几十种不同的配置字段,每个都建索引,那还不如回到关系型设计。
血的教训:JSONB适合存储真正的"文档"数据,比如用户画像、日志等需要灵活字段的场景。如果你只是懒得建表,宁可别用JSONB,那只会让事情变得更糟。
总结:为什么我们还是换了?
说了这么多坑,你可能想问:既然这么坑,为什么还要换?
因为三个月后,我们的核心报表查询从平均800毫秒降到了50毫秒。我们有了强大的窗口函数、CTE、强大的全文搜索、以及那个虽然坑很多但确实好用的JSONB。
MySQL像是给你一把瑞士军刀,什么都能干,但每样都不是最顶尖。PostgreSQL像是给你一整套专业工具箱,用好了效率翻倍,用不好砸自己脚。
关键不是"哪个更好",而是"哪个更适合你的场景"。
如果你的业务是:大量简单查询、读写比例极高、表结构相对稳定——MySQL依然是王者。
如果你的业务是:复杂查询、报表分析、JSON数据处理、或者你需要PostgreSQL那些高级特性——那就做好踩坑的准备,坑踩完了就是坦途。
最后送大家一句话:没有银弹,只有银勺。数据库选型这事,适合比先进重要。
祝各位的数据库迁移之旅,少流血,多长肉。