一个汉字存成了 6 个字节:我用 HEX 揪出 MySQL 双重编码

0 阅读6分钟

场景

当你第一次动手搭自己的全栈 demo,兴奋地接上接口,解决完跨域问题后(跨域那篇在这里),打开 network 看请求头,状态 200,心想着可以下班了,结果打开请求结果一看全是火星文,这不是一波未平一波又起么。

排查

我当时想着有没有可能是编码格式不对,这一看就不是 utf-8。

我第一反应是终端的锅,于是切了 chcp 65001,没用,还是乱。

那就不是终端的锅。而响应体里本身就是乱的,说明也不是前端渲染的锅。

从发现乱码到用 HEX 定位出双重编码,我大概鼓捣了半小时。

这半小时里真正管用的只有一步:别猜了,去看字节。

剩下来的可能性只有一种:数据从库里出来时就已经坏了。那就直接看库里存的是什么字节。

验证

眼睛不可信,但字节不会撒谎。MySQL 有个 HEX(),能把字符串在磁盘上的真实二进制摊开给你看:

-- 查库里存的字节到底对不对
-- LEFT(title, 3):按字符取 3 个。正常情况下 3 个汉字 = 9 字节,
-- 如果查出来是 6 字节,说明存的早就不是汉字了
SELECT HEX(LEFT(title, 3)) AS hex_head, LEFT(title, 20) AS title_head
FROM report_tasks LIMIT 2;

查出来的结果:

title_head:  æ¹–...              ← 前 20 个字符,肉眼可见乱码
hex_head:    C3A6C2B9C296...
                                 ↑ 3 个字符占了 6 个字节,而 UTF-8 的汉字只该占 3 个字节

拿种子数据里第一条 title 的第一个字「湖」把链条推一遍,整条链就对上了:

「湖」的正确 UTF-8       E6 B9 96            ← 3 字节,一个汉字
被当成 latin1 读一遍     E6→æ  B9→¹  96→\x96  ← 1 个字节被当成 1 个「字符」
再各自转回 UTF-8         C3A6 C2B9 C296      ← 3 个 × 2 字节 = 6 字节

第三个字节 96 在终端里常被显示成破折号 –(cp1252 里 0x96 就是 en dash)。我最早记的时候就是照着屏幕反推的,把它记成了 E28093——而真实字节是 C296。

这正好给"别信眼睛,信 HEX"配了个自己的反例:一个已经知道要查字节的人,照着屏幕抄照样会抄错。

同时我去核对了源文件本身——用 VS Code 打开 001_init.sql,右下角显示的编码是 UTF-8,e6 b9 96 正是「湖」这个字的字节。

**一边是干净的输入,一边是 6 字节的输出。**那事情就很清楚了:

UTF-8 的字节串被当成 latin1 读了一遍,再按 UTF-8 转存——双重编码。

也就是说:存的时候就错了,跟前端、跟显示层没有半点关系。

原理:坏的不是服务器,是送进来的那一段

这一段我一开始理解反了,值得说清楚,因为它是"为什么 MySQL 8.0 也会中招"的答案。

服务器这边一直是 utf8mb4。 我用的是 mysql:8.0 镜像,8.0 的默认字符集本来就是 utf8mb4,compose 里我又显式指定了一次。

坏的是送进来的那一段:./db/migrations 被挂进了容器的 /docker-entrypoint-initdb.d,容器首次启动时,由容器内的 mysql 客户端执行这些 SQL。而容器的 locale 是 POSIX/C,客户端据此把自己的字符集定成了 latin1。

于是 SQL 文件里的 UTF-8 字节被按 latin1 拆成 3 个「字符」,再各自转成 UTF-8 存盘 → 一个汉字 6 字节。

所以对症的那味药是 --skip-character-set-client-handshake:让服务器忽略客户端声明的字符集,强制按 utf8mb4 解读送进来的字节。

修复

知道了原因,那修复起来就好办了。我的 MySQL 是用 Docker 起的,直接在 compose 里加三个参数:

command:
  - --character-set-server=utf8mb4
  - --collation-server=utf8mb4_0900_ai_ci
  - --skip-character-set-client-handshake   # ← 关键:忽略客户端声明的 latin1,强制按 utf8mb4 解读

(前两个是服务器自己的字符集——在 mysql:8.0 上它们本来就是默认值,写上是为了显式锁死。真正解决问题的是第三个。)

⚠️ 这里有一步千万不能漏,我一开始就栽在这:改完配置必须重建卷。

docker compose down -v    # -v = 连数据卷一起删
docker compose up -d mysql

为什么必须 -v?因为数据卷里躺着的已经是坏数据了——只改配置重启容器,MySQL 还是会照样把那些 6 字节的乱码原封不动读出来给你,看起来就像「配置没生效」。

down -v 之后,初始化脚本会以正确的字符集重新跑一遍,数据重灌,世界才干净。

结果

修复后再 SELECT 全表,中文正常:

01-修复后SELECT中文正常.png

加餐:又遇乱码,但这次数据没错

没过几天,我在 VS Code 里起 express 服务,终端又甩了我一屏火星文。

但这次我先做了一件更简单的事:换块屏幕看同一份数据——前端页面一起看一下。

浏览器里一切正常:

02-前端页面正常显示.png

同一份数据,浏览器正常、终端乱码。答案就摆在那了:坏的不是数据,是这块用来看数据的屏幕。

03-VSCode终端乱码-显示层.png

原因也简单:Node 吐出来的是 UTF-8 字节流,而 VS Code 集成终端里的 PowerShell 5.1 默认按 GBK 解读。数据没坏,是这块屏幕戴了副错度数的眼镜。

改法一行:

chcp 65001

注意这次和存储层那次的区别:数据一个字节都没动,没有重建,没有重灌。

耗时也是两个量级:存储层那次我鼓捣了半小时,这一次不到十分钟。

顺带说明:图上还有个 EADDRINUSE 端口占用的报错,那是另一个坑(容器里的 api 和我本地的 npm run dev 抢 3000 端口),跟乱码无关。

结论

回头看这两次乱码,其实是两种病:一次数据真的坏了,一次数据压根没错。

这两次坑踩完,我才总结出一套顺序。以后再遇到乱码,别急着改代码,先走这三步:

第一步,先分层:换个屏幕看同一份数据。

浏览器、终端、数据库工具三处都乱 → 数据本身就坏了(存储层);只有一处乱 → 那一处的解读方式错了(显示层)。

第二步,用字节说话:不信眼睛,信 HEX()。

查出来的字节是对的,就说明存盘没问题,乱的一定是某个显示环节。

第三步,治法不同:存储层坏 → 改配置 + down -v 重建 + 重灌数据;显示层错 → 改那个程序的编码就够了。

排障最忌讳的是猜。我前面两次,分别猜了终端、猜了编码格式——真正让我收工的两次,都是同一件事:停下来,去看字节。


你被编码问题坑得最久的一次,卡在哪一层?

是存进去就坏了(存储层),还是只有某个地方显示乱(显示层)?评论区说说,我挺好奇有多少人是栽在 down -v 那一步的。