乱码问题是PHP开发中最让人头疼的问题之一。明明数据库里存的是正常中文,页面一显示就变成问号;或者反过来,页面上输入正常,存进数据库就成了“ 测试”这类天书。绝大多数情况下,这不是数据坏了,而是字符集在各个环节没有对齐。MySQL的字符集涉及服务器层、数据库层、表层、列层,再加上PHP连接层的编码,任何一环出问题都会导致乱码。本文就来梳理这些环节,给出一个完整的排查和修复方案。

一、先弄清楚乱码的根源在哪里
很多人一遇到乱码就去改页面header,结果改了半天没效果。正确的做法是先定位问题发生在哪一层。可以分三步排查:第一步看数据库里存的数据是否正常,用命令行登录MySQL直接查询:
SELECT * FROM users WHERE id = 1; -- 如果命令行显示正常中文,说明存储没问题,问题出在连接或输出层 -- 如果命令行也是乱码,先执行 SET NAMES utf8mb4; 再查一次 -- 设置后显示正常,说明数据实际存的是正确的字节,只是客户端显示编码不对
第二步检查变量。执行SHOW VARIABLES LIKE 'character%';会输出一串结果,重点关注character_set_client、character_set_connection和character_set_results这三个变量。它们分别代表客户端发送的SQL用什么编码、连接层内部转换用什么编码、返回结果用什么编码。三者不一致时,MySQL会在中间做隐式转换,转换过程中如果目标字符集无法表示某个字符,就会变成问号。
第三步检查表和列的实际编码。注意数据库的默认字符集和表的字符集是两回事,建库时设置了utf8,不代表后来建的表也用utf8。用SHOW CREATE TABLE users;可以看到建表语句末尾的CHARSET=utf8或CHARSET=utf8mb4,这才是决定数据存储格式的关键。排查清楚了,修复才有方向。
二、utf8和utf8mb4必须搞清楚的区别
MySQL中的utf8是一个历史遗留问题。它最多只支持3个字节的UTF-8字符,而标准的UTF-8最长4个字节。这就带来一个坑:存Emoji表情或者生僻字时,utf8会直接报错Incorrect string value或者把字符截断成问号。utf8mb4才是真正的完整UTF-8实现。
所以现在的项目统一推荐使用utf8mb4。对应的排序规则建议用utf8mb4_unicode_ci或utf8mb4_general_ci。两者的区别在于对字符串比较的规则不同,unicode_ci更符合语言学习惯(比如德语中ß等于ss),general_ci速度稍快但不够精确。中文场景下两者差别不大,如果没有历史包袱,选unicode_ci更稳妥。
还要注意一点:从旧utf8升级到utf8mb4时,索引长度会变化。MySQL 5.7之前InnoDB单列索引最长767字节,utf8mb4下一个VARCHAR(255)就是1020字节,会超出限制。MySQL 5.7及以上默认开启了innodb_large_prefix,支持到3072字节,问题不大;但如果跑的是老版本,要么把VARCHAR长度降到191,要么升级数据库。新项目直接用MySQL 8.0就可以避开这个坑。
三、修改已有库表的字符集
确认要统一到utf8mb4后,需要把已有的库和表改过来。修改数据库默认字符集的语句是:
ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 注意:这只改变默认值,不影响已存在的表 -- 已存在的表需要逐个执行 ALTER TABLE 转换 ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CONVERT TO 会真实转换表中已有数据的存储格式 ALTER TABLE users CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 只写 CHARACTER SET 不写 CONVERT 时,只影响之后新增的列,不转换已有数据
这里有一个非常容易踩的坑:CONVERT TO CHARACTER SET和单纯的CHARACTER SET效果完全不同。前者会真正转换数据,后者只是改默认值。如果库里已经存了乱码数据,直接执行CONVERT可能把乱码“固化”成永久损坏。转换前务必先备份数据,并确认当前数据在客户端显示是正常的。
如果表很多,可以查询information_schema批量生成ALTER语句:
SELECT CONCAT( 'ALTER TABLE `', table_name, '` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' ) AS sql_stmt FROM information_schema.tables WHERE table_schema = 'mydb' AND table_collation NOT LIKE 'utf8mb4%';
把生成的语句导出执行即可完成批量转换。执行完后再抽查几张表确认数据没有损坏。
四、正确设置PHP连接字符集
库表统一之后,PHP连接层的编码也必须跟上。用PDO连接时,推荐在DSN里直接指定字符集:
$dsn = 'mysql:host=127.0.0.1;dbname=mydb;charset=utf8mb4';
$pdo = new PDO($dsn, 'root', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// 注意:DSN中的charset参数要求PHP 5.3.6及以上
// 老版本可以用 $pdo->exec("SET NAMES utf8mb4"); 代替这里有个细节:ATTR_EMULATE_PREPARES设为false表示使用MySQL原生的预处理语句。在老版本PHP中,模拟预处理加上长字符集名称有时会触发已知的安全问题,关闭模拟预处理并显式指定charset是最稳妥的组合。
用MySQLi的话,提供了专门的方法:
$mysqli = new mysqli('127.0.0.1', 'root', 'password', 'mydb');
if ($mysqli->connect_error) {
die('连接失败: ' . $mysqli->connect_error);
}
// 设置连接字符集,等价于 SET NAMES utf8mb4
$mysqli->set_charset('utf8mb4');不推荐只靠query("SET NAMES utf8mb4")来设置,因为set_charset方法会同时通知mysqli扩展当前的字符集,这样mysqli_real_escape_string等函数才能正确工作。直接执行SET NAMES虽然MySQL层面生效了,但PHP扩展层并不知情,转义函数可能出错,存在安全隐患。
五、别忘了页面输出层的编码
最后还要保证PHP文件本身和HTTP输出的编码一致。PHP文件必须保存为UTF-8无BOM格式,用记事本另存为时容易带上BOM头,导致页面顶部出现莫名其妙的空行或影响header输出。HTTP响应头要明确声明:
header('Content-Type: text/html; charset=utf-8');
// HTML页面中同时声明
// <meta charset="utf-8">对于JSON接口,使用json_encode时注意参数。PHP 5.4之前json_encode遇到中文会转成\uXXXX形式,需要加JSON_UNESCAPED_UNICODE参数才能原样输出中文:
echo json_encode($data, JSON_UNESCAPED_UNICODE);
至此整条链路就对齐了:PHP文件是UTF-8,HTTP输出声明UTF-8,连接层用utf8mb4,库表存储也是utf8mb4。四个环节全部统一后,乱码问题自然就消失了。日常维护中建议在项目初期就定好字符集规范,写进团队的编码标准里,比事后补救省心得多。