emoji 存进去变成问号:从库表到连接层的 utf8mb4 全链路改造

一、先看症状:内容本身进不去数据库

中文站站长早晚会碰到「emoji 存进去变成问号」这件事。用户在一个评论框或者文章标题里打了一个表情,提交之后数据库里躺着的是 ???,或者更糟——整条 INSERT 直接报错,用户那边显示「提交失败,请稍后再试」,你在后台看不出任何异常。这类问题的排查成本很高,因为表面上一切正常:网页是 UTF-8、响应头也写了 charset=utf-8、SHOW VARIABLES LIKE 'character_set%' 看着全是 utf8,但表情就是存不进去。

根因其实只有一句话:MySQL 的 utf8 不是真正的 UTF-8。它是 utf8mb3 的别名,最多只能用三个字节表示一个字符,覆盖到 U+FFFF 为止。而 emoji 大多落在 U+1F300 到 U+1FAFF 这个区间,需要四个字节。也就是说,在 utf8 的列里,四字节字符「根本不存在」这个可能性,MySQL 只能选择报错或者静默截断。

这套东西牵涉的面比想象中多:库的默认字符集、表的默认字符集、列的字符集、连接的字符集(三个:client、connection、results)、mysqldump 导出的字符集、程序里驱动的 DSN 参数、还有 PHP 侧字符串处理函数。任何一层还是 utf8mb3,四字节内容在那一层就会被截断或替换成问号。这篇文章按这条链路从下往上走一遍,最后给一个可以照着做的迁移顺序。

二、服务器层:character_set_server 与 collation 的四个变量

先看全局状态。执行下面这几条,重点不是「有没有 utf8」,而是逐项确认都带 mb4:

SHOW VARIABLES LIKE 'character_set%';
SHOW VARIABLES LIKE 'collation%';

输出里会看到七八行。需要改为 utf8mb4 的关键三项是 character_set_server、character_set_database,以及连接层的 character_set_client / character_set_connection / character_set_results。前两个决定新建库和新建表的默认值;后三个决定「你发过去的字节怎么解释」和「回来的字节怎么解释」。

在 my.cnf 里对应的是:

[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_ci

[client]
default-character-set = utf8mb4

[mysql]
default-character-set = utf8mb4

这里有一个必须说清楚的坑:只改 [mysqld] 不够,命令行客户端 mysql 自己的默认字符集来自 [mysql] 段;如果你只用 mysql 命令跑 SQL,而 [client] 段没设,那么你手工执行的中文 UPDATE 可能已经在客户端被转成问号,再写进数据库——这时候你打开网页看,会觉得「数据库坏了」,其实是命令行工具把它弄坏的。这种情况在中文站的历史事故里占比不低,判断方法是看客户端连上来之后的 character_set_client,如果是 utf8 或 latin1,先把它改对再谈其他。

排序规则怎么选?utf8mb4_unicode_ci 是最保守的选择,兼容性最好;MySQL 5.7 之后有 utf8mb4_0900_ai_ci,排序更快也更符合现代 Unicode 规则,但它和 unicode_ci 的比较结果在某些边缘字符上不一样。如果你的站里有唯一索引建在文本列上(比如文章 slug、标签名),切换排序规则会让「原来认为相同的两个值」变成不同,或者反过来,从而可能引发唯一索引冲突。这一点在迁移前必须预估:SELECT slug, COUNT(*) FROM posts GROUP BY slug HAVING COUNT(*)>1 跑一遍,排序规则变化后可能新增的重复要人工处理。

三、表与列:真正决定能不能存四个字节的地方

库和表的默认值只影响「以后新建的列」,已有的列字符集不会跟着变。所以第四层检查是列级:

SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
  AND CHARACTER_SET_NAME IS NOT NULL
  AND CHARACTER_SET_NAME <> 'utf8mb4';

这条 SQL 的返回就是你需要处理的全部清单。如果返回空,说明列级已经全是 mb4,问题不在这一层;如果返回一堆,那么每个 varchar/text 列都要转换。转换语句的写法:

ALTER TABLE posts
  CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CONVERT TO 会一次性把表里所有字符列都转掉,包括 varchar、text、mediumtext、longtext,以及 enum 里的取值。它是一次表重建,会锁表(MySQL 5.6+ 的部分场景可以 online,但 text 列多的表通常不行)。所以要在大表的低峰期做,并且一条一条来,不要用一个脚本把所有表串起来跑——中间某张表失败的时候你会不知道进度到哪里。

转换还有一个非常现实的问题:如果表里已有的内容本身就是坏的(比如早年被 latin1 连接写进去的乱码),CONVERT TO 不会帮你修,它只是按「当前字符集 → 目标字符集」做转码,坏数据会变成另一种坏法。所以建议的顺序是:先把连接层弄对,验证新写入的中文和 emoji 都正确,再考虑历史数据是否要单独修。不要在连接层还有问题的时候去动表结构,否则你会分不清哪一层出的错。

四、索引长度:那个 767 字节的时代遗留

迁移到 utf8mb4 时最常见的技术拦路虎不是数据,是索引长度。老版本 MySQL(5.5/5.6 及之前)的 InnoDB 单列索引前缀默认上限是 767 字节,而 utf8mb4 每个字符最多占 4 字节,所以一个 varchar(255) 的列在 mb4 下需要 1020 字节,直接超限,报错 Specified key was too long; max key length is 767 bytes。

MySQL 5.7.7 之后 innodb_large_prefix 默认开启(并且在 8.0 里被移除,即永远是开启状态),上限提升到 3072 字节,这个问题基本消失。但如果你的站还跑在 5.6 或者被别人装的旧版本上,处理方式有两条:一是把索引列改成 varchar(191)(191×4=764,刚好不超 767),这是当年 WordPress 生态最流行的做法,代价是 slug 长度受限;二是升级 MySQL,或者显式打开 innodb_large_prefix 并把表的行格式设为 DYNAMIC。

-- 检查现有索引字节长度(近似:字符长度 × 4)
SELECT table_name, index_name, column_name,
       SUB_PART, CHARACTER_SET_NAME
FROM information_schema.STATISTICS s
JOIN information_schema.COLUMNS c USING (TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME)
WHERE s.TABLE_SCHEMA = 'your_db' AND s.SUB_PART IS NULL;

这条查询能帮你把「没有前缀长度限制」的索引列列出来,再按字符集估算字节数,就能提前发现哪些会在转换时炸掉。我的经验是:这类报错在转换前预估十分钟就能全部找出来,等转换时一条条撞上去,至少要花两小时,而且可能已经有一半表转换完成,处于「一半 mb4 一半 mb3」的中间状态,非常难受。

五、连接层:DSN、SET NAMES 与那个不能用的初始化命令

源站、表、列都对了之后,还剩最后一层:连接。PHP 里 PDO 的 DSN 必须显式写字符集:

new PDO('mysql:host=127.0.0.1;dbname=site;charset=utf8mb4', $user, $pass,
    [PDO::ATTR_EMULATE_PREPARES => false]);

少了 charset=utf8mb4 这一段,PDO 会用服务器的默认值,而在很多发行版上那仍然是 latin1 或 utf8。表现出来就是「网页显示正常、写进去变问号」或者「读出来的中文全是问号」。这个问题在 Typecho、WordPress 这类基于 PDO/mysqli 的程序里都出现过,排查时先看 DSN。

另一个千万要避免的写法是在连接之后执行 SET NAMES utf8。很多人(包括不少老教程)把这个当成万能药,但它有两个问题:一是 utf8 仍然是 mb3,emoji 照样存不进去;二是 SET NAMES 只改 client/connection/results 三个变量,不改变驱动层的字符串处理,与 mysql_set_charset() 或 PDO 的 charset 参数不等价,在转义函数上会有不一致——极端情况下会造成注入风险。正确做法是在建立连接时就指定字符集,不要事后用 SET NAMES 补救。

mysqli 的话对应 $mysqli->set_charset('utf8mb4'),注意它在 PHP 5.6+ 之前对 mb4 支持有 bug,需要确认 PHP 版本。如果你的站还跑在 PHP 5.6(比如一些老的 Typecho 部署),升级 PHP 往往比改字符集更值得做。

六、历史数据:怎么识别坏掉的字节,以及不要用错工具

连接层修好之后,新写入的数据是干净的,但老数据里可能还躺着被截断的内容。识别方法有两种。一种是按长度和可疑字符扫:

-- 找出含替换字符 U+FFFD 的标题
SELECT id, title FROM posts WHERE title LIKE CONCAT('%', CHAR(0xEFBFBD USING utf8mb4), '%') LIMIT 20;

-- 找出以问号结尾且长度可疑的短标题
SELECT id, title, CHAR_LENGTH(title) FROM posts
WHERE title LIKE '%?' AND CHAR_LENGTH(title) < 12 LIMIT 20;

另一种是拿备份对比:如果你有历史备份,把当时的文本导出来和现在的比,差异就是被损坏的部分。这个办法最准,但前提是备份没有同样被损坏——注意 mysqldump 默认用的字符集也要检查,--default-character-set=utf8mb4 是必须显式加的,否则导出的 SQL 里可能已经丢了四字节字符,那你保留的备份本身就是残缺的。

修复坏数据没有通用方案,只能人工或半人工:能对照原文的(比如从其他渠道有同一篇文章)就重新导入;不能的只能删掉重发。这里要提醒一个坏习惯:不要用 CONVERT(CAST(CONVERT(col USING latin1) AS BINARY) USING utf8mb4) 这类「双重转换」去批量修乱码,它对某一种特定的错误编码有效,但对其他情况会把好数据也弄坏。批量执行之前必须先在一条数据上验证,并保留完整备份。

七、迁移顺序:一个可以照着走的清单

把上面的内容收成可执行顺序,一共七步。任何一步不通过就不要往下走。

第一步,全量备份,并且用 --default-character-set=utf8mb4 导出,导出之后用 iconv -f utf-8 -t utf-8 检查一遍文件是否合法 UTF-8,不合法就说明导出环节已经丢字符,先解决这个问题。

第二步,改 my.cnf 的 [mysqld]、[client]、[mysql] 三段,重启 mysqld,确认 SHOW VARIABLES LIKE 'character_set_server' 返回 utf8mb4。

第三步,改应用侧连接(PDO DSN、mysqli set_charset、或者框架的 charset 配置),重启 PHP-FPM。这一步做完之后,先用一个测试脚本插入一条带 emoji 的记录,直接 SELECT 出来核对字节,确认连接层通了。

第四步,逐表 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4。小表先跑,验证一台表没问题之后再动大表;每张表跑完 CHECK TABLE 一下,确认没有损坏。

第五步,修改 information_schema 那条查询,确认返回为空——也就是全库不再有非 mb4 的字符列。

第六步,检查索引长度与唯一索引:跑一次 SHOW INDEX 对比迁移前后的 key_name 列表,确认没有索引因为转换失败而丢失(CONVERT TO 在部分版本上会因为索引前缀超限而报错,表会回滚,但如果用了 ALTER IGNORE 之类的写法可能留下半成品)。

第七步,回归测试:注册、发帖、评论、搜索各走一遍,每处都输入中文 + emoji + 一个生僻字(比如 U+20BB7 这个码位上的生僻字,同样是四字节),确认显示、存储、搜索、RSS 输出全对。搜索这一项经常被漏掉——如果站内搜索用了 LIKE 并且连接层字符集不一致,匹配可能悄悄失效。

整条链路里最容易漏的是 RSS 和 sitemap 的输出字符集声明,以及 JSON 接口的 Content-Type。它们不属于数据库层,但用户看到乱码时,往往分不清是库的问题还是输出层的问题。迁移完成之后顺手检查一遍这几处,能省掉后面一轮误报。

Last modification:November 13th, 2026 at 09:26 pm

Leave a Comment