MySQL 字符集与中文乱码排查实战:utf8 与 utf8mb4 的差异、连接层 SET NAMES 与表级转换修复

一个「看不见」的故障:首页正常,搜索结果是乱码

这大概是我踩过的最有迷惑性的一类坑。现象是这样的:网站前台打开完全正常,中文标题、正文、评论都显示得好好的;后台管理也正常,能编辑能发布。但你打开百度或者 Google 搜自己网站,搜索结果里的标题和摘要是一串问号或者方块;更邪门的是,微信、QQ 分享链接时预览出来的标题乱码;RSS 订阅在某些阅读器里也乱。

这类故障的可怕之处在于:前台「看起来正常」,所以你根本不知道有问题,直到搜索引擎的收录质量开始下滑,或者有读者留言说「你网站标题怎么是乱码」。

根因往往不在 PHP,也不在模板,而在数据库连接的字符集

先搞懂:一条中文从浏览器到数据库要过几道关

要排查乱码,必须先建立一个正确的模型。数据在一个典型 LAMP 站点的流转路径是这样的:

浏览器 → HTTP 请求(+ charset)→ PHP 脚本 → MySQL 连接
   ↑                                              ↓
浏览器渲染 ← HTML meta charset ← PHP 输出 ← MySQL 返回结果

这条链上有四个独立的字符集决定点,任何一个对不上,就可能乱码:

  1. 浏览器提交表单时用的编码。 由页面的 <meta charset> 决定,现代站点基本都是 UTF-8。
  2. PHP 与 MySQL 之间的连接字符集。 由连接时的 SET NAMES 或驱动参数决定,默认值取决于 MySQL 版本和配置文件。
  3. 数据库/表/列的字符集。 存在 information_schema 里,由建库建表时的 CHARACTER SET 决定。
  4. PHP 输出时声明的编码。 由 HTTP 响应头 Content-Type: text/html; charset=xxx 或 HTML <meta> 决定。

关键的坑在于第 2 点:即使数据库、表、列都是 utf8mb4,连接字符集不对,依然会乱码。 因为 MySQL 在收到数据时会认为「这是你声明的字符集」,然后转换成列定义的字符集存起来。你在 utf8mb4 的连接里发 utf8mb4 数据,但连接声明成了 latin1,MySQL 就会把每个字节当成 latin1 字符,再转成 utf8mb4 存储——一存一取,两次错误转换,双倍乱码。

第一站:查清各个层级的字符集现状

排查第一步不是改配置,是把现状查清楚。登录 MySQL,逐层看:

-- 1. 服务端默认字符集
SHOW VARIABLES LIKE 'character_set%';
SHOW VARIABLES LIKE 'collation%';

输出里有几个变量需要区分:

  • character_set_server:服务端默认字符集,新库不带 CHARACTER SET 时用它。
  • character_set_database:当前默认库的字符集。
  • character_set_client:服务器认为客户端发来的数据是什么编码。
  • character_set_connection:服务器转换时用的中间编码。
  • character_set_results:服务器返回给客户端的编码。

如果 character_set_client 显示 latin1 而你的数据是 UTF-8,那基本就是它了。

-- 2. 查具体库和表的字符集
SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME
FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='你的库名';

SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES WHERE TABLE_SCHEMA='你的库名';

-- 3. 查具体列的字符集(重点看存中文的列)
SELECT COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='你的库名' AND TABLE_NAME='typecho_contents';

如果列字符集出现 utf8(注意不是 utf8mb4)甚至 latin1,就要记下来,这是需要重点处理的对象。

utf8 和 utf8mb4 的区别:一个 emoji 引发的血案

这是 MySQL 上一个经典的历史包袱,必须说清楚,否则后面修了也是白修。

MySQL 里的 utf8 是一个残缺的 UTF-8:它只支持最多 3 字节的字符,也就是 BMP 基本多文种平面内的字符。它能存所有常用汉字,所以很多老教程、老建库语句都用它,一直没出问题——直到你需要存 emoji。

emoji 几乎全在补充平面(4 字节编码),utf8 存不下。于是你在文章里插一个表情,或者在评论里留一个 emoji,MySQL 直接报错:

Incorrect string value: '\xF0\x9F\x98\x80' for column 'text' at row 1

更狡猾的情况:某些 MySQL 版本或宽松模式下不报错,而是把 emoji 静默截断成乱码,你事后才发现文章里多了几个「?」。

utf8mb4 才是真正的完整 UTF-8,支持 4 字节。现在建库建表的唯一正确选择就是 utf8mb4,配套排序规则优先用 utf8mb4_unicode_ci(兼容性好),新版本可以用 utf8mb4_0900_ai_ci(MySQL 8.0 默认,排序更准)。

另外注意一个实际限制:utf8mb4 下每行索引的字节上限比 utf8 更低(单个索引前缀最多 191 个字符 vs utf8 的 255),所以给 VARCHAR(255) 的列建唯一索引时,可能需要指定前缀长度 VARCHAR(191)KEY(col(191))。这是很多站长从 utf8 切到 utf8mb4 时踩的第二个坑。

修复方案一:只改连接(最快,不动数据)

如果库里数据本身是完好的 UTF-8,只是连接层声明错了,那最干净的做法是在应用层强制声明连接字符集。

PHP + mysqli:

$mysqli = new mysqli($host, $user, $pass, $db);
if (!$mysqli->set_charset('utf8mb4')) {
    error_log('set_charset failed: ' . $mysqli->error);
}
// 或者执行 SQL
$mysqli->query("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");

PHP PDO:推荐直接在 DSN 里带:

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=你的库名;charset=utf8mb4',
    $user, $pass,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

charset 参数会让 PDO 自动执行正确的 SET NAMES,比手动 query 更可靠。注意 PDO 的 charset 参数在某些老版本上和 utf8mb4 搭配会走回退逻辑,要确认 PHP 版本(7.0+ 都没问题)。

my.cnf 全局设置(治本):在配置文件的 [mysqld] 段加上:

[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
skip-character-set-client-handshake = 1

[client]
default-character-set = utf8mb4

[mysql]
default-character-set = utf8mb4

skip-character-set-client-handshake 这个参数很霸道但很有用:它让服务器忽略客户端声明的字符集,强制用服务端的 utf8mb4。适用于「客户端全是自己控制的、且都该用 utf8mb4」的场景。如果有第三方老旧客户端,慎用。

改完 systemctl restart mysql,然后重新 SHOW VARIABLES 确认。

修复方案二:表级转换(数据已存错)

如果数据本身在库里就是坏的——比如原来的列是 latin1,存进去的 UTF-8 字节被当成 latin1 存了两遍——那就得转换表。

转换前务必先备份!这是不可逆操作:

mysqldump --single-transaction --default-character-set=utf8mb4 你的库名 > backup_$(date +%Y%m%d).sql

然后是几个不同场景的转换语句,用错场景会二次损坏,请对照判断:

场景 A:列是 latin1,数据是按 UTF-8 字节存进去的(常见于从 GBK/老系统迁移)。

-- 先只改列的字符集声明,不转数据(第一步)
ALTER TABLE typecho_contents
  MODIFY title VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
  MODIFY text  LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 再把数据从 utf8mb4 转换为二进制,再按 latin1 解读,再转回 utf8mb4
ALTER TABLE typecho_contents CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

顺序不能反。如果数据是「双重编码」的(每个中文字符变成两个乱码字符,比如「䏿–‡」这种),正确的修法是先用 CONVERT(CAST(CONVERT(col USING latin1) AS BINARY) USING utf8mb4) 这种表达式单独 UPDATE 每一列,验证一小批数据没问题后再全量执行。

场景 B:只是改声明,数据本来是对的。

-- 只改列的字符集,不重新解释数据(注意是 MODIFY,不是 CONVERT)
ALTER TABLE 表名 MODIFY 列名 VARCHAR(200) CHARACTER SET utf8mb4;

这里的关键区分:ALTER TABLE ... MODIFY ... CHARACTER SET 只改声明,可能会去转换数据;ALTER TABLE ... CONVERT TO CHARACTER SET 一定会重新转换所有数据。搞混了就会把好数据弄坏。

转换后的验证:

SELECT id, title, HEX(LEFT(title,2)) FROM 表名 LIMIT 5;

HEX() 看字节。一个正常的中文 UTF-8 字符,十六进制是三字节,形如 E4B8AD(「中」)。如果看到 C3A4 C2B8 这种两个 Cx 开头的,就是被双重编码了。

别忘了 PHP 输出和 HTTP 头

数据库层修好了,页面还可能乱。检查 PHP 输出:

// 最直接的一行,放在任何输出之前
header('Content-Type: text/html; charset=utf-8');

HTML 模板里也要有对应的声明,让浏览器明确知道怎么解码:

<meta charset="utf-8">

有些老 Typecho/WordPress 模板用的是 <meta http-equiv="Content-Type" content="text/html; charset=utf-8">,也能工作,但 HTML5 里的 <meta charset> 更简洁,且应该放在 <head> 的前 1024 字节内,否则浏览器可能来不及识别就按默认编码解析了。

还有一个隐蔽的坑:PHP 文件本身的编码。如果你的 .php 文件是用 GBK 保存的,里面有中文字符串常量,那这些常量输出的就是 GBK 字节,在 UTF-8 页面里必然乱码。用编辑器统一保存为 UTF-8(无 BOM)。BOM 也是个麻烦——UTF-8 with BOM 的文件在 PHP 里会在 <?php 之前输出三个不可见字节,导致 header() 报「headers already sent」,页面顶部多出隐形内容。用 grep -rl $'\xEF\xBB\xBF' . 可以扫出所有带 BOM 的文件。

一套排查清单:乱码问题的定位流程

把上面的内容整理成一个可执行的排查顺序,以后遇到乱码照着走:

  1. 乱在哪里?前台页面乱 / 搜索引擎结果乱 / 分享预览乱 / 数据库里乱。定位到具体环节,不要瞎猜。
  2. 查 HTTP 响应头:curl -sI https://你的域名/ | grep -i content-type,看 charset 是否为 utf-8。不是就先改这个。
  3. 查连接字符集:SHOW VARIABLES LIKE 'character_set_%';,看 client/connection/results 三个是否 utf8mb4。
  4. 查列字符集:通过 information_schema 查存中文的列,看是不是 utf8mb4。
  5. 看数据十六进制:对同一段已知正确的文字,对比数据库 HEX() 输出和预期 UTF-8 字节。不一致就是数据被错误存储了。
  6. 区分「声明错」还是「数据错」:把库里的字段值导出一小份,用 file --mime-encoding 或十六进制编辑器判断实际编码,决定是改声明还是转数据。
  7. 改完必须回归:前台、搜索、分享、RSS、后台编辑各验一遍,别只测首页。

字符集问题的麻烦在于它很少「全站崩」,往往只在个别入口暴露。建立上面这套流程,比临时在搜索引擎里翻教程碰运气高效得多。绝大多数乱码故障,都是「哪一层声明了 latin1」这么简单的原因——难的是定位,而不是修复。

Last modification:September 22nd, 2026 at 07:55 pm

Leave a Comment