场景
当你第一次动手搭自己的全栈 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 全表,中文正常:
加餐:又遇乱码,但这次数据没错
没过几天,我在 VS Code 里起 express 服务,终端又甩了我一屏火星文。
但这次我先做了一件更简单的事:换块屏幕看同一份数据——前端页面一起看一下。
浏览器里一切正常:
同一份数据,浏览器正常、终端乱码。答案就摆在那了:坏的不是数据,是这块用来看数据的屏幕。
原因也简单:Node 吐出来的是 UTF-8 字节流,而 VS Code 集成终端里的 PowerShell 5.1 默认按 GBK 解读。数据没坏,是这块屏幕戴了副错度数的眼镜。
改法一行:
chcp 65001
注意这次和存储层那次的区别:数据一个字节都没动,没有重建,没有重灌。
耗时也是两个量级:存储层那次我鼓捣了半小时,这一次不到十分钟。
顺带说明:图上还有个
EADDRINUSE端口占用的报错,那是另一个坑(容器里的 api 和我本地的 npm run dev 抢 3000 端口),跟乱码无关。
结论
回头看这两次乱码,其实是两种病:一次数据真的坏了,一次数据压根没错。
这两次坑踩完,我才总结出一套顺序。以后再遇到乱码,别急着改代码,先走这三步:
第一步,先分层:换个屏幕看同一份数据。
浏览器、终端、数据库工具三处都乱 → 数据本身就坏了(存储层);只有一处乱 → 那一处的解读方式错了(显示层)。
第二步,用字节说话:不信眼睛,信 HEX()。
查出来的字节是对的,就说明存盘没问题,乱的一定是某个显示环节。
第三步,治法不同:存储层坏 → 改配置 + down -v 重建 + 重灌数据;显示层错 → 改那个程序的编码就够了。
排障最忌讳的是猜。我前面两次,分别猜了终端、猜了编码格式——真正让我收工的两次,都是同一件事:停下来,去看字节。
你被编码问题坑得最久的一次,卡在哪一层?
是存进去就坏了(存储层),还是只有某个地方显示乱(显示层)?评论区说说,我挺好奇有多少人是栽在 down -v 那一步的。