事故:升级到 MySQL 8.0 之后,所有页面都变成未登录
去年冬天我把一台跑了六年的小站从 PHP 5.6 搬到 8 系列,顺手把数据库从 5.7 升到了 8.0。升级过程很顺利,业务 SQL 一条没改,访问也正常,只有一个问题:用户登录之后再点任意一个链接,就变回未登录。
我先怀疑 session 目录权限,查了没问题;又怀疑 Cookie 的 SameSite,改了也没用;最后在数据库里翻 typecho_options 表才发现线索:每次请求,表里都多一行 session_xxxxx,而且旧的那行从来没被删掉。也就是说,会话数据确实写进去了,但下一次读不到。
真正的原因很隐蔽:MySQL 8.0 默认的 character_set_server 是 utf8mb4、collation_server 是 utf8mb4_0900_ai_ci,而我新建的会话表的列沿用了升级前的 utf8mb4_general_ci。两边排序规则不同,用字符串做主键关联时比较结果不合预期,PHP 侧的键查不回来,于是每次都是新会话。
这件事教会我一件事:字符集和业务逻辑不沾边,但它出错的时候,症状往往出现在业务层——乱码、排序不对、唯一键冲突、登录失效、JSON 解析失败。而这些问题没有一个会直接告诉你「是字符集的问题」。
这篇文章把我这些年碰过的字符集问题按「症状」重新整理了一遍,每个都给出定位方法和确定性的修复路径。
先建立一张正确的认知表
字符集相关概念有五层,很多人卡在分不清「字符集」和「排序规则」上,直接导致查错方向:
- 字符集(charset):一套「字符 → 数字编号」的映射表。UTF-8 是万能表,GBK 是中文表,latin1 是西欧表。它决定「一个字符存成什么字节」。
- 排序规则(collation):在同一字符集内部,定义「怎么比较、怎么排序」。
utf8mb4_general_ci、utf8mb4_unicode_ci、utf8mb4_0900_ai_ci都是 utf8mb4 的排序规则,差别在于比较规则和性能。它决定「a 和 A 算不算相同」「中文按什么顺序排」。 - 连接字符集:客户端和服务器之间传输时使用的编码,由
SET NAMES或驱动的charset参数控制。 - 列字符集:表定义时定下来的,是数据真正存储时的编码。
- 表默认字符集:只影响「新建列」时的默认值,不改已有列。
关键概念是:这五层可以完全不同。这就是乱码和比较异常的全部根源。一个列是 utf8mb4,但连接是 latin1,你写进去的中文就会变成一堆问号或乱码,而且这种损坏是不可逆的——字节被错误解释后再转换,原信息已经丢了。
顺带说一下历史遗留问题:MySQL 里的 utf8 不是真正的 UTF-8,它最多只能存三个字节,存不了 emoji 和很多生僻字(比如「𠈷」这种四字节字符)。正确做法永远是 utf8mb4,utf8 在任何新项目里都不应该出现。这个坑在 2016 年前后的 WordPress 站点上非常普遍,至今还有大量遗留。
症状一:中文变成问号、乱码、或者「?????」
这是最常见的一类,但其实是三种完全不同的故障,判断错了修不好。
症状 A:页面上的中文显示成「?????」。 这几乎一定是客户端在「存」的时候就已经丢了信息——连接字符集设成了 latin1 或 ascii,中文被替换成问号写进库。这种数据已经坏了,改连接字符集也救不回来,只能从备份恢复或人工重录。判断依据:在数据库里直接 SELECT 出来就是问号,跟页面编码无关。
症状 B:页面上看到「䏿–‡」这种怪字符。 这是 UTF-8 字节被当成 latin1 显示的结果。数据本身是对的,只是「显示时解释错了」。这种情况通常修复成本很低:把页面的 Content-Type 或 <meta charset> 改成 UTF-8 就好了。判断依据:在数据库里 SELECT 出来是正常中文,但浏览器显示是怪字符。
症状 C:页面上一部分中文正常、一部分是乱码。 这是最麻烦的:说明库里同时存在两种编码的数据。常见于「从旧站导入一批文章,导入脚本用的编码和原来不一致」。这种必须逐个检查,用 CONVERT() 或导出重导入的方式修复,没有一键方案。
定位的第一步永远是「在哪一层出错」。我的固定做法是查三次:
-- 1. 看库里真实存的是什么
SELECT id, HEX(title) FROM posts WHERE id = 1;
-- 2. 看连接层的设置
SHOW VARIABLES LIKE 'character_set%';
-- 3. 看列定义
SHOW FULL COLUMNS FROM posts LIKE 'title';第一步的 HEX() 是最关键的:它绕过一切客户端显示逻辑,直接给你字节。中文「测试」在 UTF-8 下是 E6B58BE8AF95,如果在库里看到的是 3F3F(两个问号),那就是真丢了;如果看到的是 C3A6C2B5... 这种,那说明是「UTF-8 被当成 latin1 存了两遍」,属于可修复的「双重编码」。
症状二:明明有中文,LIKE 却搜不到
这个症状特别能骗人,因为它不报错、不显示乱码,只是「搜不到」。原因通常是排序规则不一致导致比较失败,或者连接字符集让 LIKE 的模式串被错误解释。
最典型的例子:写入时用 PDO 的 charset=utf8mb4,读取时用的是另一个没设 charset 的连接,读出来的字节被当成 latin1 解释,于是 WHERE title LIKE '%关键词%' 里那个关键词和库里的字节比较,永远不相等。
另一个常见原因是表级的 collation 是 utf8mb4_general_ci,而查询里拼接的参数在不同连接下被隐式转换。MySQL 在比较两个不同类型/不同排序规则的字符串时,会做「隐式转换」,而转换规则很容易让人踩坑。
稳妥的做法有两条:
- 连接建立后立刻执行
SET NAMES utf8mb4(或驱动参数charset=utf8mb4),并且确认它真的生效了。PHP 的 PDO 有个著名的坑:new PDO($dsn, $user, $pass)之后再SET NAMES是可以的,但如果 DSN 里已经写了charset=latin1,后来的SET NAMES会被覆盖,顺序很重要。 - 全库统一排序规则。
utf8mb4_unicode_ci和utf8mb4_0900_ai_ci不要混用;同一张表里如果有列是utf8mb4_general_ci、有的是0900_ai_ci,联合查询时 MySQL 会报「Illegal mix of collations」——这个错误信息其实很清晰,看到就知道该改哪一层的 collation。
值得强调的是:「Illegal mix of collations」是好事,因为它会明确报错。真正可怕的是不报错但结果不对的情况——那才是真的难查。
症状三:唯一索引不唯一,同一篇文章入库两次
这是排序规则带来的最反直觉的坑,我在一个论坛站上实实在在遇到过。
那篇文章的标题叫「如何备份数据库」,入库时创建的 slug 是 how-to-backup-database。过了一段时间,用户又发了一篇几乎一样的,slug 变成了 How-To-Backup-Database——大小写不同。本来 slug 上有 UNIQUE 索引,应该挡住。但它没挡住,因为排序规则是 utf8mb4_general_ci,结尾的 ci 意思是 case-insensitive,不区分大小写。
等等,如果是 ci,那它应该认为两个 slug 相同,UNIQUE 应该报错才对。结果却相反——两篇都进去了。真正的原因是:两边的会话连接使用了不同的排序规则,一个连接把 slug 当成 ci 比较,另一个把 slug 当成 cs 比较,索引在写入时按连接的 collation 判断,读取时又按另一个,于是索引的「唯一性」在不同连接下表现不一致。
这个坑的预防比修复重要得多:
- 所有业务连接使用同一套 charset/collation,这一点在连接池/ORM 配置里统一设定,绝不各写各的。
- 对于
slug、邮箱这类需要精确匹配的字段,除了索引之外,在应用层做「小写化 + 去空白」的规范化,把不一致提前收敛掉。 - 定期用
SELECT LOWER(slug), COUNT(*) FROM posts GROUP BY 1 HAVING COUNT(*) > 1检查是否有重复,这比相信索引的保证更可靠。
顺带说一句 utf8mb4_0900_ai_ci 和 utf8mb4_general_ci 的差异:前者按 Unicode 9.0 标准做重音和大小写的折叠,后者是一个更老的、更粗糙的实现。混用时不仅仅是「报错」或「不报错」的差别,而是同一个字符串在两种规则下的比较结果可能完全不同。
症状四:emoji 存不进去,或者被截断成半截
emoji 是四字节字符,只有 utf8mb4 才能存。utf8 会直接报「Incorrect string value: '\xF0\x9F\x98\x80'」。
但更常见的现象是「悄悄截断」:用户评论里带 emoji,前端提交成功,后端存进去之后 emoji 后面的文字全没了。这通常不是字符集问题,而是「字段长度按字节算」的后果——VARCHAR(255) 在 utf8mb4 下最多存 255 个字符,但 MySQL 5.6 之前的一些版本或部分驱动会按字节计算,一个 emoji 就占 4 字节,255 字符的字段如果只按 255 字节算,存 63 个 emoji 就满了。
检查方法很简单:SHOW FULL COLUMNS 里 Type 列显示 varchar(255),配合 SCHEMA 的 CHARACTER SET utf8mb4,就是按字符算的,正常。如果显示的是 char(255) 且字符集是别的,就要小心。
还有一个隐藏的相关问题:代理层可能截断多字节字符。比如某些 WAF 或者老版本的 Nginx 在处理 client_body_buffer_size 不足时,会把请求体从中间切断,而切断点恰好落在一个 emoji 的四个字节之间,于是后端收到一个「非法的 UTF-8 序列」,PHP 的 json_decode 直接返回 null,表现为「提交了没反应」。这类问题排查要用「字节数」而不是「字符数」去想。
修复的三种技术路径,各有适用场景
真出问题时,「怎么修」比「为什么会这样」更重要。三种做法按代价从小到大排:
路径一:只修连接层(零风险,改配置,重启服务)。 适用于症状 B 那种「数据本身是对的,只是显示错了」,或者「原来用 latin1 连接,现在改成 utf8mb4 就正常」的情况。这是第一选择,几乎所有「看起来是乱码」的问题都先试这个。
路径二:改表/列的字符集(中等风险,需要锁表)。 ALTER TABLE posts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci。这会把表里所有文本列转成目标字符集,并且会重建表,大表上可能锁很久。正确做法是:先备份,然后在低峰期执行,用 pt-online-schema-change 或 gh-ost 避免长时间锁表。注意:如果原来的数据是「双重编码」的,这条语句会把它再转一次,变成三重编码。所以转换前必须先确认数据本身的状态,不能盲目转。
路径三:数据修复(高风险,逐条处理)。 用于「双重编码」这类已经存坏的数据。做法通常是「以 latin1 读出 → 以 utf8 写回」,或者用 CONVERT(CAST(CONVERT(col USING latin1) AS BINARY) USING utf8mb4) 这种三段式。这个操作必须先在测试库上跑,并在修复前后各算一次 md5 备份,否则一失足就是永久数据损坏。
特别提一句:不要用「在 phpMyAdmin 里点一下转换」这种方式修生产库。这个操作没有事务保护,中途失败会留下一半转一半没转的状态,比不修还糟。
上线之前的三项核对
字符集这类问题一旦上了生产,修复成本会翻十倍。所以我在每个项目上线前都固定跑一遍这三项:
- 核对一:库、表、列、连接四层一致。
SHOW VARIABLES LIKE 'character_set%'、SHOW CREATE TABLE、应用的 DSN 配置,三处对比,不能有任何utf8(非 mb4)或latin1的残留。 - 核对二:真实存一条带 emoji、带中文、带生僻字的测试数据。 不要相信「配置看起来对」,写一条
'中文与 emoji 混排'进去再读出来,HEX()对比字节。这一步能一次性发现 80% 的配置问题。 - 核对三:导出一份数据再用导入脚本导回测试库。 很多编码问题只在「导出/导入」这条链路上出现——因为 mysqldump 默认的
--default-character-set可能跟库不一致。加上--default-character-set=utf8mb4是必须的。
还有一个容易忘的点:备份文件本身也要用 utf8mb4 导出。我见过一次事故,是「恢复演练时一切都正常,因为导入脚本加了 SET NAMES utf8mb4,但生产恢复时用的是另一个脚本没加」,导致恢复出来的站全是问号。备份的恢复脚本,必须和导入脚本同源。
几条总结
把上面这些收成几句能记住的话:
- utf8 一律不要用,只认 utf8mb4。 这条没有例外。
- 乱码先看 HEX,不要看页面。 看页面会被浏览器/客户端的显示逻辑骗。
- 「?????」是不可逆的损坏,「䏿–‡」是可修的显示错误。 这两者的处理方式完全不同,一定要先分清。
- 排序规则不同,比较和唯一索引的行为都会变。 这不是理论问题,是会真实导致数据重复的。
- 转换生产库前,先备份、先在测试库跑一遍、并且确认数据本身没有双重编码。 三个条件缺一个都不要动手。
- 字符集问题从不单独出现。 它总是伴随升级、迁移、导入这些动作。所以在这三个动作的执行清单里,字符集核对必须是单独一项,不能夹在别的步骤里。
回到我那个换到 MySQL 8.0 的站。最后修复的方式是把会话表的列统一成 utf8mb4_0900_ai_ci,然后清掉所有旧会话,重新登录一次。整个过程不到五分钟,但从「怀疑 session 权限」到「发现是 collation」花了我三个小时。
这三个小时里最大的教训不是「要检查 collation」,而是:当症状出现在业务层,而业务逻辑完全没改过的时候,问题往往在迁移过程中被动改变的那些「默认值」上。 升级数据库、换 PHP 版本、换操作系统、换 CDN,每一次都会有一批默认值改变,而它们从来不在你的变更清单里。