消失的大小写:一次 MySQL 迁移 PostgreSQL 的排障实录
一条 TooManyResultsException,牵出 MySQL 与 PostgreSQL 对大小写截然不同的判断,也揭开了藏在 Collation 里的迁移暗雷。
数据库迁移完成后,一切看起来都很平静。
数据已经搬完,服务顺利启动,监控也重新接上。那种感觉很像一艘刚换过发动机的船终于驶离港口:仪表正常,水面无波,似乎最危险的阶段已经过去。
直到监控里出现了这条异常:
org.apache.ibatis.exceptions.TooManyResultsException:
Expected one result (or null) to be returned by selectOne(), but found: 2
selectOne() 期待零条或一条记录,数据库却不多不少,偏偏返回了两条。
迁移前从未出现,迁移后突然发生。直觉告诉我,这两件事很难只是巧合。
多出来的那条数据
调查先从异常涉及的数据开始。
在 MySQL 里,对应业务标识只有一条记录,其中包含大写字母;而在 PostgreSQL 里,同一个标识却出现了两条:一条保留着大写,另一条则是全小写。
ExampleKey
examplekey
它们看起来不同,又显然属于同一份业务数据。
我继续比对插入时间。带大写的记录来自迁移前,全小写的记录则是在迁移完成后新插入的。至此,问题的轮廓开始浮现:迁移工具并没有简单地把同一条数据复制两次,而是新环境中的服务把一条“本应存在”的数据判断成了“不存在”,随后又插入了一遍。
为什么同一段业务逻辑,换了数据库之后,就不认识自己的旧数据了?
那段看似无辜的写入逻辑
顺着写入链路往回找,我很快定位到一段常见的逻辑:写入前先查询记录数,为零则插入,为一则更新。
SELECT COUNT(*)
FROM business_record
WHERE business_key = #{businessKey};
count = 0 -> INSERT
count = 1 -> UPDATE
MySQL 版本如此,PostgreSQL 版本也几乎如此。SQL 没有复杂的关联,没有数据库方言特有的函数,参数也没有异常。
代码看起来干净得近乎无辜。
我一度怀疑过迁移遗漏、事务时序,甚至缓存中的旧值。但数据的大小写像一根没有被剪断的线,始终把问题拉回到同一个方向:
在这两个数据库眼里,ExampleKey 和 examplekey 真的是同一个值吗?
真相藏在 Collation 里
我回头检查了 MySQL 字段的字符集与排序规则,答案终于出现:
latin1_swedish_ci
这个名字看起来有些古老,却已经把规则写得明明白白。
latin1是字符集,决定数据可以使用哪些字符以及如何编码。在 MySQL 中,它是一个单字节的西欧字符集。swedish表示这套 Collation 采用瑞典语的排序与比较规则。它的出现带有 MySQL 的历史默认配置背景,并不意味着业务数据来自瑞典。ci是case-insensitive的缩写,表示比较时不区分字母大小写。
因此,在使用 latin1_swedish_ci 的 MySQL 字段上执行等值查询时,ExampleKey 与 examplekey 会被视为相等。
这也解释了为什么旧逻辑在 MySQL 中一直相安无事。两年前,系统已经改成在写入前将标识统一转为小写;但更早的那条大写历史数据依然留在表中。新请求带着小写标识来查询时,MySQL 的 _ci 规则仍能找到那条大写记录,于是程序进入更新分支。
迁移到 PostgreSQL 后,情况变了。在当前 PostgreSQL 配置下,普通 text 或 varchar 的等值比较区分大小写。小写参数再去查询大写旧记录,结果自然是零。程序随即执行插入,于是数据库中第一次同时出现了:
ExampleKey != examplekey
对 PostgreSQL 来说,这是两个不同的值;对业务来说,它们却仍然是同一个标识。
直到后续查询把这两条逻辑上重复的数据同时取出,MyBatis 的 selectOne() 才替整个系统拉响警报。
修复:让比较规则回到业务语义
明确原因之后,修复并不复杂。我在存在性检查中显式统一两侧的大小写,不再依赖数据库默认的比较行为:
SELECT COUNT(*)
FROM business_record
WHERE LOWER(business_key) = LOWER(#{businessKey});
随后删除迁移后错误插入的全小写重复记录,并重新检查相关数据。异常消失,写入逻辑也重新回到了预期路径。
至于为什么旧数据里会有大写字母,答案反而是整件事中最朴素的一部分:那是一段历史遗留数据。两年前,写入逻辑就已经统一改成了小写,只是 MySQL 长期以来用大小写不敏感的比较替我们遮住了这处不一致。
遮住,不等于消失。
迁移的从来不只是数据
这次问题最值得记录的地方,并不是给 SQL 加上了一个 LOWER(),而是一个很容易在迁移中被忽略的事实:
数据库迁移,迁移的不只是表结构和数据,还包括数据的比较语义。
字符集、Collation、大小写规则、空值行为、时区处理、隐式类型转换,这些平日藏在数据库默认配置里的细节,往往不会出现在业务代码中。一旦换了数据库,它们就会从幕后走到台前。
当然,LOWER() 解决了眼前的问题,却还不是唯一性的最终防线。只靠“先 COUNT、再 INSERT”仍可能在并发请求下产生竞态;如果业务上确实要求标识不区分大小写且全局唯一,更稳妥的做法是统一存储格式,并让数据库通过函数唯一索引或规范化字段上的唯一约束来守住边界。
但那是下一步的工程治理了。
这一次,故事始于两条本不该同时存在的记录,结束于一个沉睡多年的 _ci。而监控里那句 found: 2,最终指向的不是 MyBatis,也不是迁移工具,而是一条被默认规则悄悄掩盖了两年的历史数据。
有些迁移问题并不会在迁移时发生。
它们只是换了一个环境,终于有机会被看见。