后端 · 数据库

utf8 不是 UTF-8:MySQL 最坑的一次命名,和 emoji 变问号的真相

用户昵称里带个 😀,存进 MySQL 再读出来变成了 ?,或者直接报 Incorrect string value 插不进去。八成的人第一反应是去查前端编码、查连接字符集——其实根都不在那。在于 MySQL 干了一件至今没法洗的事:它把一个只支持 3 字节的残缺编码命名为 utf8。你以为你在用 UTF-8,其实没有。

一句话真相:MySQL 里的 utf8utf8mb3 的别名,每个字符最多 3 字节;而真正的 UTF-8 一个字符最多 4 字节。emoji、一部分生僻汉字、各种补充平面字符,都需要 4 字节。用 utf8 存它们,要么报错,要么被替换成 ?。要存得下,必须用 utf8mb4(mb4 = maximum bytes 4)。

为什么是 3 字节,问题出在哪

Unicode 的字符分布在多个"平面"。基本多文种平面(BMP,U+0000–U+FFFF)里的字符,UTF-8 编码最多 3 字节,常用汉字、拉丁字母都在这。但 emoji(😀 在 U+1F600)、部分 CJK 扩展区汉字落在补充平面(U+10000 以上),UTF-8 需要 4 字节

MySQL 早年实现 utf8 时只做到 3 字节就收工,还占了 utf8 这个名字。等到 2010 年(5.5.3)补上 4 字节支持时,名字已经被用掉了,只能另起一个 utf8mb4,原来那个追认成 utf8mb3。于是留下一个全世界踩了十几年的命名陷阱。

🚫

典型现场:建表时写 DEFAULT CHARSET=utf8,自我感觉良好。插入带 emoji 的数据时,MySQL 报 Incorrect string value: '\xF0\x9F\x98\x80' for column ...——那串 \xF0\x9F\x98\x80 正是 😀 的 4 字节 UTF-8 编码。3 字节的列装不下 4 字节的字符,于是报错或截断。

那个 191 的魔数是怎么来的

很多人见过 Laravel 等框架把字符串列默认设成 VARCHAR(191),一脸问号——为什么是这么个怪数字?这背后是 InnoDB 的索引前缀字节上限

MySQL 5.6 及更早,InnoDB 在 REDUNDANT/COMPACT 行格式下,单个索引前缀最长 767 字节。换算成字符要除以每字符的最大字节数:

字符集每字符字节767 字节能索引
latin11767 字符
utf8 (utf8mb3)3255 字符(255×3=765,刚好塞下)
utf8mb44191 字符(767÷4≈191)

看出来了:utf8mb3 时代 VARCHAR(255) 加索引正好压线(765 字节),所以 255 成了惯例。一旦切到 utf8mb4VARCHAR(255) 的索引变成 255×4=1020 字节,超了 767,于是经典报错:

Specified key was too long; max key length is 767 bytes

把列降到 191(191×4=764,刚好不超),报错就消失了。这就是 191 魔数的全部来历——它不是什么最佳实践,是一个被字节上限逼出来的妥协。

💡

好消息:191 在现代 MySQL 上基本不需要了。MySQL 5.7 起,DYNAMIC 行格式(现在的默认)+ innodb_large_prefix 把上限提到了 3072 字节utf8mb4 下能索引到 768 字符,VARCHAR(255) 随便加索引。前提是 16KB 默认页大小——如果你把 innodb_page_size 调到 8KB/4KB,上限会按比例降到 1536/768 字节,191 的幽灵又会回来。

迁移:四个地方都得是 utf8mb4,漏一个就白干

utf8 改成 utf8mb4 不是改一处。它是一条链路,从客户端到磁盘任何一环还是 3 字节,emoji 照样丢。逐个查:

  1. 数据库 / 表 / 列字符集:三个层级都要改。已有数据的表用 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4,它会真正转换列里的数据,不是只改元信息。
  2. 连接字符集:客户端连上来得声明 utf8mb4,否则数据在传输层就被降级。JDBC URL、SET NAMES utf8mb4、连接池配置都查一遍。
  3. 服务端默认my.cnfcharacter-set-server=utf8mb4。MySQL 8.0 默认已经是 utf8mb4,新项目省心,老项目升级上来的要确认。
  4. collation(排序规则):选 utf8mb4_unicode_ci 或 8.0 的 utf8mb4_0900_ai_ci。注意 emoji 在很多 collation 下被当作"相等"——WHERE name = '😀' 可能匹配到别的 emoji,做唯一约束时尤其要留神。
-- 把一张老表整体转成 utf8mb4(会重写数据,大表请挑维护窗口)
ALTER TABLE users
  CONVERT TO CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

-- 检查现状:哪些列还不是 utf8mb4
SELECT table_name, column_name, character_set_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND character_set_name IS NOT NULL
  AND character_set_name <> 'utf8mb4';
⚠️

转换前先评估索引列长度。如果你还在 5.6 或者用了小页大小,CONVERT 之后某些索引列会撞 767 字节上限直接失败。先把超长的索引列缩短、或改成前缀索引(INDEX(col(191))),再做转换。别在生产上裸跑 CONVERT

顺带纠正一个常见误解

"那我把所有列都设 utf8mb4 是不是更费空间?"——VARCHAR 是变长存储,实际占用看真实内容。一个纯英文字符串在 utf8mb4 列里依然是 1 字节/字符,不会因为"最多 4 字节"就凭空膨胀。变大的只有索引(按最大字节预留)和内存里的临时表。对绝大多数业务,直接全站 utf8mb4,省得哪天又冒出个存不下的字符。

顺便提一句字符和字节的区别:一个 emoji 在数据库里占 4 字节,但它在用户眼里是"1 个字符",在 JavaScript 里 .length 又可能是 2——这套"到底算几个"的混乱,另一篇专门讲了。要快速数一段文本里中英文/emoji 各算几个,可以用 在线字数统计

在线字数统计

中英文混排、字符数、字节数分开计,看清一段文本到底"算几个"

打开字数统计