第一次见这问题是在一个用户中心项目。产品说"有人反馈昵称里的笑脸存进去变成了一串方块和问号"。我打开库一看,本来应该是 😀 的字段,躺在那儿的是 ðŸ. 当时我第一反应是"前端转义没做好",让前端改,前端说"我传的就是 emoji 啊"。推来推去,最后发现是库表的字符集是 utf8,而 MySQL 的 utf8 是个被阉割过的东西——它最多只占 3 个字节,emoji 和一大票生僻汉字是 4 字节,根本塞不进去,被截断或替换成了乱码。
后来第二次、第三次踩,分别是一次 GBK 到 UTF-8 的迁移把中文备注弄成了 ???,和一个 PHP 老项目连上新库之后所有中文都变成了 å°å° 这种"拉丁化"乱码。三次下来我发现,这类问题本质从来不是"数据丢了",而是"字符集在某一层对不上,字节被错误解释或错误截断"。把 MySQL 字符集的五层模型和连接链路搞明白,乱码这事儿基本就不该再出现第二次。
MySQL 的 utf8 是假的,记住这一条
这是所有乱码的总根子。MySQL 里的 utf8 字符集,历史上实现的是"最多 3 字节的 UTF-8",学名 utf8mb3,官方现在都标它为 utf8mb3 的别名并建议弃用。标准的 UTF-8 是变长 1~4 字节,4 字节部分承载了 emoji(U+1F300 之后)、不少生僻字、还有一些少数民族文字。
所以:
- 字段是
utf8(即 utf8mb3),插入一个 4 字节字符 → MySQL 要么报错Incorrect string value,要么在宽松模式下把它截断成?(这就是???的一种来源)。 - 字段是
utf8mb4(真正的 4 字节 UTF-8),才能完整存下 emoji 和全 Unicode。
现在新建库表,默认字符集在 MySQL 8.0 已经是 utf8mb4、默认排序规则 utf8mb4_0900_ai_ci。但大量存量系统、云上老实例、还有那些"我照着五年前的博客建的库"仍然是 utf8(utf8mb3)。查一下你的库到底是多少字节的:
SHOW VARIABLES LIKE 'character_set_server';
SHOW VARIABLES LIKE 'collation_server';
-- character_set_server utf8 ← 这就埋了雷
-- collation_server utf8_general_ci
字符集和排序规则是两回事
新手常把 charset 和 collation 混为一谈。charset(字符集)决定"一个字符用哪几个字节存",collation(排序规则)决定"这些字节怎么比较大小、是否区分大小写"。一个 charset 可以配多种 collation:
utf8mb4_general_ci:老牌、快、但排序准确性差一点(某些语言排序不标准)。utf8mb4_unicode_ci:基于 Unicode 排序算法,更准确,但比 general 慢一丢丢。utf8mb4_0900_ai_ci:MySQL 8.0 默认,基于 Unicode 9.0,准确且快,ai=accent insensitive(不区分重音)、ci=case insensitive(不区分大小写)。utf8mb4_bin:按字节比较,区分大小写和重音,适合要精确匹配的场景(比如存 token)。utf8mb4_0900_as_cs:as=accent sensitive、cs=case sensitive,要区分大小写就用它。
我一般新项目无脑 utf8mb4_0900_ai_ci,要区分大小写就上 _bin 或 _as_cs,基本不碰 general_ci 了——它在某些语系下排序结果会反直觉,出了问题查起来更费劲。
五层字符集,漏一层就乱
MySQL 里字符集是分层级的,从外到内:服务器(server) → 数据库(database) → 表(table) → 列(column),再加上一条最容易忘的——连接层(connection)。前四层管"数据落到磁盘上用什么编码",连接层管"客户端和服务器之间传的字节按什么编码解释"。乱码九成出在连接层和列层对不上。
查全貌:
SHOW VARIABLES LIKE 'character_set%';
-- character_set_client utf8mb4 ← 客户端发来的 SQL 按这个解码
-- character_set_connection utf8mb4 ← 执行前转成这个
-- character_set_results utf8mb4 ← 结果集按这个编码返回
-- character_set_database utf8mb4
-- character_set_server utf8mb4
SHOW VARIABLES LIKE 'collation%';
最经典的"拉丁化乱码" å°å°(本该是"中国"之类的中文)是怎么来的?客户端用 GBK 或 latin1 发了字节,但连接层 character_set_client 设成了 utf8mb4,服务器以为你发来的是 UTF-8 字节,按 UTF-8 解码——可那些字节明明是别的编码,于是被错误解释成一串"看起来像北欧字母"的字符,再原样存进 utf8mb4 字段。存进去的字节本身就是错的,显示出来就是 å°å°。
正确的连接层设置是:让 client / connection / results 三者都和客户端实际发出的编码一致。绝大多数现代应用客户端发 UTF-8,那就:
SET NAMES utf8mb4;
-- 等价于同时设置 client、connection、results 为 utf8mb4
应用代码里也要对应上,别只改库:
// JDBC:注意 MySQL Connector/J 8.0 里 characterEncoding=utf8 实际映射为 utf8mb4
jdbc:mysql://host/db?useUnicode=true&characterEncoding=utf8&connectionCollation=utf8mb4_0900_ai_ci
# Python mysqlclient / PyMySQL:显式写 utf8mb4
conn = pymysql.connect(host='h', user='u', password='p', db='d', charset='utf8mb4')
// PHP PDO
$pdo = new PDO("mysql:host=h;dbname=d;charset=utf8mb4", $u, $p);
// 或 mysqli
$mysqli->set_charset('utf8mb4');
一个常见坑:连接池(HikariCP、Druid)初始化 SQL 里得带上 SET NAMES utf8mb4,否则从池里拿到的连接可能还带着旧字符集。我就遇到过"同样的代码,本地好端端的,上了用连接池的生产就乱码"——根因就是连接池没设初始化字符集,连的是库默认 utf8 的老实例。
已经乱了,怎么救
分两种烂法,治法完全不同,搞反了会把数据弄得更糟。
第一种:存储层字符集太小,字符被截断成 ?
比如列是 utf8(utf8mb3),插 emoji 被截成 ?。这种是真丢了——那 4 字节信息在写入时就没存进去,变成了不可逆的 ?,救不回来,只能从源数据重导。所以这种场景只能"防",不能"修"。把列改成 utf8mb4 是必须的,但已经变 ? 的行没救。
第二种:连接层错配,导致字节被错误解释(典型的 å°å° 或 䏿–‡)
这种数据其实字节层面没丢,只是被"用错了编码去读"。比如中文本来以 UTF-8 存进了 utf8mb4 列,但某次读取时连接层是 latin1,于是 UTF-8 的多字节被当成 Latin-1 单字节逐个解释,出来就是 䏿–‡ 这种拉丁乱码。修复方法是"反向转码":把错读的字节重新按正确编码解回来。
-- 假设某列 col 里存的是"被 latin1 误读的 UTF-8"
-- 先转成二进制(去掉旧 charset 的解释),再按 utf8mb4 重新解释
UPDATE t SET col = CONVERT(CAST(col AS BINARY) USING utf8mb4) WHERE id = ?;
这个 CAST(... AS BINARY) 再 CONVERT ... USING utf8mb4 的组合,本质上就是"抹掉当前错误的字符集标签,把裸字节重新用对的编码解释一遍"。前提是你得先搞清楚"它现在被当成什么、它本来该是什么"——SELECT HEX(col) 看裸字节是关键证据。
SELECT HEX(col) FROM t WHERE id = 1;
-- 中文"中"正确的 UTF-8 字节是 E4 B8 AD
-- 如果 HEX 显示 E4B8AD,说明字节是对的,只是显示/连接层在捣乱
-- 如果显示 3F(即 ASCII 的 ?),说明已经被截断,没救了
HEX() 是诊断乱码的照妖镜:能看到字段里到底躺的是真 UTF-8 字节,还是已经被污染。在任何"修复"之前,先 HEX 确认字节状态,否则盲修会把好数据也搞坏。
一个更隐蔽的雷:CONVERT TO 会重写数据
想把整个表从 utf8 升到 utf8mb4,新手会写:
ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
这条语句不只是改表的默认字符集,它会把每一列的数据按"当前字符集→目标字符集"重新转码。如果数据本来就是以正确的 UTF-8 字节存的(只是表默认字符集标注是 utf8),CONVERT TO 会把它当成 latin1/utf8 再编码一次,结果产生双重编码——原本正常的 中 变成 ä¸ 套娃。这就是很多人"升级完字符集,中文反而全乱了"的原因。
安全做法分情况:
- 如果表里的数据字节本来就是对的 UTF-8,只是想改默认字符集标注、且列已经是能容纳的 charset → 用
ALTER TABLE t DEFAULT CHARACTERSET utf8mb4(只改默认,不碰已有数据),然后逐列MODIFY时带上CHARACTER SET utf8mb4但用CONVERT要谨慎。 - 如果数据确实是被错误编码的(如前述第二种),先用
CAST...BINARY...USING修好列内字节,再做结构变更。 - 最稳的还是逻辑导出再导入:
mysqldump时指定正确的--default-character-set,在一个干净的目标库里按 utf8mb4 重建,让 mysqldump 在转储时正确处理编码转换。迁移类操作我基本都走这条,虽然慢,但每一步可逆、可校验。
索引长度那个老坑
升到 utf8mb4 之后,可能会撞上 Specified key was too long; max key length is 767 bytes。原因是:utf8mb4 一个字符最多 4 字节,而 InnoDB 单索引列以前默认上限 767 字节——VARCHAR(255) 在 utf8 下是 255×3=765 字节刚好够,升到 utf8mb4 变成 255×4=1020 字节就超了。
解法有两层:MySQL 5.7 可以开 innodb_large_prefix=ON 配合 ROW_FORMAT=DYNAMIC,把上限放宽到 3072 字节;MySQL 8.0 默认就是 3072 字节,一般不用管。但更本质的建议是:别给长字符串列建整列索引,用前缀索引 INDEX(col(100)),或者干脆换哈希列。utf8mb4 时代,VARCHAR 索引该多想一步。
新建库表的正确姿势
把上面所有点收成一个"以后别再乱"的模板:
CREATE DATABASE app_db
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_0900_ai_ci;
CREATE TABLE users (
id BIGINT PRIMARY KEY,
nickname VARCHAR(64) NOT NULL,
bio VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
然后应用连接一律 SET NAMES utf8mb4(或在连接串/DSN 里设好),连接池带上初始化语句。三件事同时做对,emoji、生僻字、中文就都不会再变问号。
我现在的习惯是,每次接手一个老库,第一件事不是看表结构,而是先 SHOW VARIABLES LIKE 'character_set%' 和 SHOW CREATE TABLE 看列级字符集,把那几处 utf8(utf8mb3)揪出来列个清单。哪怕当下没出乱子,只要哪天产品说"我们要支持 emoji 昵称 / 多语言",这些雷就会一个接一个爆。提前把字符集统一到 utf8mb4,比事后在数据已经部分损坏时再救,省的不止十倍力气。

