一个表情符号能把你的数据库搞瘫?MySQL 字符集 utf8mb4 的六宗罪

2026-10-30 7 0

一个表情符号能把你的数据库搞瘫?MySQL 字符集 utf8mb4 的六宗罪

用户发了个笑脸 emoji,你的后台直接 500,日志里躺着一个 Incorrect string value: '\xF0\x9F\x98\x80'。更狠的是中文加表情加英文混排的昵称,插入成功了一半,查出来成了乱码。

你打开代码库一看:DB 连接的 charset 是 utf8,表是 utf8mb4,某个历史表是 utf8,还有一个运营手工建的表干脆什么都没写。然后你陷入了长达两天的排查。

这篇聊聊字符集这口锅有多深,以及为什么 MySQL 里明明写着 utf8,却存不下一个表情。


一、先搞清楚:utf8 不是 utf8,MySQL 骗了你二十多年

RFC 3629 定义的 UTF-8 最多用 4 字节表示一个字符,但 MySQL 里的 utf8 只支持 3 字节,也就是所谓的 utf8mb3。为什么少一字节?它诞生于 3 字节时代的 MySQL 4.x,改不动了,于是干脆起了个名叫「utf8」的坏名字。MySQL 5.5.3 之后才提供真正的 UTF-8,叫 utf8mb4——mb4 就是「最多 4 字节」。

新系统一律用 utf8mb4,没有例外。utf8 是历史遗留,不是特性。

表情符号需要 4 字节编码,utf8mb3 存不下;而中文、日文这些常用字符 3 字节就够了——这就是为什么这坑能潜伏三年,直到某个倒霉用户发了个表情才炸。

二、坑一:索引长度,和那个著名的 191

MySQL 5.7 及更早版本,InnoDB 单个索引键长度上限 767 字节(COMPACT 行格式):

-- utf8mb3,一个字符 3 字节
767 / 3 = 255 字符  -- 老手都写 varchar(255)

-- utf8mb4,一个字符最多 4 字节
767 / 4 = 191 字符  -- 所以你见过 varchar(191) 吧?

于是行业里流传一个「191 魔法数字」:要建唯一索引的 varchar 字段,长度统统砍到 191,理由是万一以后要改字符集,索引还能保住不重建。

我当时信了,还觉得是前人的智慧。真相是这只是历史包袱:MySQL 5.7 之后索引前缀限制提到 3072 字节,8.0 直接给到 3072。今天用 utf8mb4,写 varchar(255) 建索引毫无压力。还写 varchar(191) 的,要么是十年前的代码抄来的,要么是面试题看多了。

三、坑二:排序规则不只是「大小写敏感」

字符集决定「一个字符占几字节」,排序规则(collation)决定「谁排在谁前面」。这俩经常被混为一谈,坑位完全不同。常见的有 utf8mb4_0900_ai_ci(8.0 默认)、utf8mb4_general_ci(老默认)、utf8mb4_unicode_ci 和 utf8mb4_bin(按字节比,大小写敏感)。

真正的坑在于:排序规则决定等值判断。

-- utf8mb4_0900_ai_ci
SELECT 'a' = 'A';  -- 1(相等!)

-- utf8mb4_bin
SELECT 'a' = 'A';  -- 0

这直接影响唯一索引。排序规则是 ai_ci 时,Tom 和 tom 在 UNIQUE(username) 上撞了——用户说这是两个账号,你告诉他不行。更坑的是:username 在 A 表是 utf8mb4_bin、在 B 表是 ai_ci 时,跨表 join 会冒出「同一个人两条数据」,因为两边比较规则不同,转换时有优先级裁决。

建议全库统一一个排序规则,建库第一天就定死——改排序规则在大表上等于全表重建加索引重建。

四、坑三:ALTER TABLE 转 utf8mb4 的正确姿势

网上抄来的命令十有八九是错的:

-- 错误示范:只改表,不改列
ALTER TABLE users DEFAULT CHARACTER SET utf8mb4;
-- 列上如果显式写了 charset=utf8,它不会跟着变!

-- 正确做法:连列一起改,并强制在线 DDL
ALTER TABLE users
  CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
  ALGORITHM=INPLACE, LOCK=NONE;

不加 ALGORITHM=INPLACE, LOCK=NONE,MySQL 会 copy 整张表,锁到天荒地老。CONVERT TO 会把表里所有字符列一起转,并同时改表默认字符集;MODIFY ... CHARACTER SET 只动你指定的那几列。

还有一点:转换要有回滚预案。utf8mb3 转 utf8mb4 理论上不丢数据,但表里若有非法字节序列(比如外部系统导入的坏数据),转换会直接报错。提前用 SELECT CONVERT(col USING utf8mb4) FROM t 全表跑一遍。

五、坑四:连接字符集才是运行时的大boss

库里存的是 utf8mb4,不代表应用就一定读写正常。问题出在「应用连库时用的字符集」。典型翻车链条:应用连接时执行 SET NAMES utf8(即 utf8mb3),驱动在客户端把字符串编码成 utf8mb3 字节发给服务端,服务端却按 utf8mb4 解释——一个 emoji 该 4 字节,客户端只给了 3 字节,于是截断或报错。

现在的做法是让驱动自己协商,别手动 SET NAMES:JDBC URL 写 ?characterEncoding=utf8&connectionCollation=utf8mb4_0900_ai_ci,Go 的 DSN 用 charset=utf8mb4,Node 的 mysql2 用 charset: 'utf8mb4'。也别用 SET NAMES 兜底——它会改连接池里某条连接的状态,而连接是会复用的,某条被改过之后你排查起来想死。

六、坑五:emoji 存进去了,展示不出来了

写入成功不代表显示正常。很多老系统默认「一个字符两个字节」,而 utf8mb4 是 4 字节。经典 bug:

SELECT CHAR_LENGTH('😀') AS chars,   -- 1
       LENGTH('😀') AS bytes,        -- 4
       LENGTH('中') AS bytes2;       -- 3

在 Java 里用 String.length() 截断字符串(比如按 50 字符截摘要),emoji 会被从中间劈开,劈出非法序列,上层再解析就是乱码。应该用 codePointCount,截断用 offsetByCodePoints。

更狠的是 emoji 的 VS16 变体选择符:一个「☹️」其实是两个码位,占 6 字节。用户输入一个看着两字符的符号,校验说 2,存进去 6 字节——那些用 VARCHAR(255) 存用户输入再算签名的,就等着被反斜杠转义吧。

还有个更隐蔽的:general_ci 认为「ä」和「a」、「ß」和「s」是等价的。如果你用邮箱或用户名做唯一索引,遇到「用户说自己注册不了,系统说已存在」,先去查排序规则。

七、一份自查清单

跑一遍就知道有没有病:

-- 1. 还在用 utf8mb3 的表
SELECT table_schema, table_name, table_collation
FROM information_schema.tables
WHERE table_collation LIKE 'utf8%' AND table_collation NOT LIKE 'utf8mb4%';

-- 2. 列级别没转的字符列
SELECT table_schema, table_name, column_name, character_set_name
FROM information_schema.columns
WHERE character_set_name IS NOT NULL AND character_set_name <> 'utf8mb4';

-- 3. 排序规则是否统一
SELECT table_schema, table_collation, COUNT(*)
FROM information_schema.tables
GROUP BY table_schema, table_collation;

-- 4. 连接层字符集
SELECT @@character_set_client, @@character_set_connection, @@character_set_results;

新建项目记住三句话:

1. 库、表、列、连接,四处统一 utf8mb4 + utf8mb4_0900_ai_ci(MySQL 8)或 utf8mb4_unicode_ci(5.7)。
2. 别再用 varchar(191) 那个魔法数字了。
3. 长度校验别用字节数,用码点数。

字符集这东西平时完全看不见,炸起来能把整条业务链路掀翻。它不像慢查询可以加索引硬扛,也不像超时可以调参重试——它错在你还没写下第一行业务代码的时候,就埋好了。

所以新项目第一行配置就把它定对,这是成本最低的做法。别等第一个 emoji 教你做人。

相关文章

你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写
你刚写进去的数据怎么就读不出来了:主从延迟的七种死法
限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞
别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账
生成一个 ID 而已,怎么就变成了事故现场:分布式 ID 的五层坑
epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?

发布评论