为什么你的 MySQL 越跑越慢,问题可能出在排序规则上
很多站长建库时顺手执行了 CREATE DATABASE xxx DEFAULT CHARACTER SET utf8mb4,以为把字符集指定成 utf8mb4 就万事大吉。结果过了一年,数据库里出现了一些奇怪的现象:两个字段明明都是 utf8mb4 的字符串,做 JOIN 时却走不了索引,全都变成全表扫描;原本秒出的 ORDER BY 排序查询,突然开始产生磁盘临时表;两边的数据看着一模一样,用等号比较却返回不等。这些症状十有八九不是索引写错了,而是排序规则(collation)不一致在作祟。这篇文章把 MySQL 排序规则讲透,从概念、选型、冲突排查到迁移,一次说清。
字符集和排序规则,到底谁管什么
先分清两个概念。字符集(character set)决定「这一个字符用哪些字节来表示」,比如字符「中」在 utf8mb4 里是三个字节 E4 B8 AD。排序规则(collation)决定「字符之间怎么比大小、怎么排序」,以及等号比较时是否区分大小写和重音。一个字符集下面可以挂很多个排序规则,它们共享同一套字节编码,但比较规则不同。
以最常用的 utf8mb4 为例,它下面常见的排序规则有一大串:
utf8mb4_general_ci:MySQL 5.7 及更早版本的默认值,排序规则比较简陋,对某些欧洲语言和 emoji 的处理不准确,性能略好但已不推荐。utf8mb4_unicode_ci:基于较完整的 Unicode 排序算法,准确性好,但排序速度稍慢,MySQL 5.7 时代的推荐值。utf8mb4_0900_ai_ci:MySQL 8.0 的新默认值,基于 Unicode 9.0,ai表示 accent-insensitive(不区分重音),ci表示 case-insensitive(不区分大小写),准确性和性能都更好。utf8mb4_bin:按字节的二进制值比较,区分大小写和重音,'A'和'a'不相等。utf8mb4_unicode_520_ci:基于 Unicode 5.2,属于中间版本,一些老项目还在用。
关键点:ci 结尾(case-insensitive)不区分大小写,cs 结尾区分大小写,bin 是纯二进制比较。而 ai 表示不区分重音,as 表示区分重音。当你写下 WHERE username = 'Admin',如果字段的排序规则是 utf8mb4_general_ci,那么 'admin'、'ADMIN' 都会被匹配上;如果是 utf8mb4_bin,则只有精确的字节匹配才算数。这个行为差异直接决定了你写的查询逻辑对不对。
排序规则不一致,为什么会让索引失效
这是最隐蔽也最致命的一类问题。假设有两张表,一张的字符集排序规则是历史遗留的 utf8mb4_general_ci,另一张是新建的 utf8mb4_0900_ai_ci。当这两个表的字符串字段做 JOIN 时,MySQL 必须先把两边统一到同一个排序规则才能比较。这个「隐式转换」发生在每一行数据的比较上,因此优化器无法使用任何一个字段上的索引,执行计划会退化成全表扫描。
真实案例:某个电商站点的订单表(10 万行)和用户表(5 万行)用 user_id 关联查询订单详情,正常应该毫秒级返回,某次上线新功能后变成每次 3 到 5 秒。用 EXPLAIN 一看,两条记录的 type 都是 ALL,key 都是 NULL,显然没走索引。查 SHOW FULL COLUMNS FROM orders LIKE 'user_id' 和用户表同名字段对比,发现订单表这一列的 Collation 是 utf8mb4_general_ci,而用户表是 utf8mb4_0900_ai_ci。原因很简单:用户表是最近用 MySQL 8.0 新建的,订单表是几年从 5.7 迁移过来时把旧结构一并带过来了。把订单表的排序规则统一到 utf8mb4_0900_ai_ci 后,查询立刻回到几毫秒。
更隐蔽的是,JOIN 里的转换不止发生在连接条件上,WHERE 子句里拿一个常量字符串去比较不同排序规则的字段时也会触发。比如 WHERE email = 'Foo@bar.com' 对一个 utf8mb4_bin 的字段,缓存下来的预编译语句可能因为大小写敏感而不命中;但同一个查询在另一个 _ci 字段上又能命中,于是同一个代码在不同表上表现不同,非常难查。
三层排序规则:服务器、库、表、列、连接
排序规则可以设置在很多层级上,而且是逐层继承的。搞不清继承链,就永远搞不清到底哪个设置生效了。从大到小依次是:
-- 1. 服务器级默认(my.cnf 里配置)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_0900_ai_ci
-- 2. 数据库级默认(建库时指定,影响之后新建的表)
CREATE DATABASE mydb
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_0900_ai_ci;
-- 3. 表级默认(建表时指定,影响之后新增的列)
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(64)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 4. 列级(最细粒度,直接写在列定义里)
ALTER TABLE t MODIFY name VARCHAR(64)
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- 5. 连接级(当前会话,只影响本次连接的比较行为)
SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci;
每一层都可以覆盖上一层。最坑的是第 5 层「连接级」:应用在连接数据库时会发送一套自己的 charset/collation,如果应用配置和表定义不一致,即使表结构完全正确,查询时也会发生隐式转换。PHP 的 PDO、Java 的 JDBC、Go 的驱动都有各自的默认连接字符集参数,很多 ORM 还会自作主张设置,务必确认应用连接层发的排序规则和表一致。
想快速看清当前各级设置,可以执行下面这组命令:
-- 服务器级
SHOW VARIABLES LIKE 'character_set_server';
SHOW VARIABLES LIKE 'collation_server';
-- 数据库级
SELECT schema_name, default_character_set_name, default_collation_name
FROM information_schema.schemata;
-- 表级(看整个库里哪些表排序规则不统一)
SELECT table_schema, table_name, table_collation
FROM information_schema.tables
WHERE table_collation NOT LIKE 'utf8mb4_0900%'
AND table_schema = 'mydb';
-- 列级(找出所有和主流不一致的列,这就是 JOIN 失效的元凶)
SELECT table_name, column_name, character_set_name, collation_name
FROM information_schema.columns
WHERE table_schema = 'mydb'
AND collation_name IS NOT NULL
AND collation_name != 'utf8mb4_0900_ai_ci';
-- 连接级
SHOW VARIABLES LIKE 'character_set_client';
SHOW VARIABLES LIKE 'collation_connection';
第四条和第五条查询尤其有用:它们直接列出所有「格格不入」的列,通常就是索引失效和乱码的源头。
怎么选:个人站长的排序规则选型建议
排序规则不是越新越好,也不是越准越好,要结合你的实际业务和 MySQL 版本。下面这张表给出常见场景的选型建议:
| 场景 | 推荐排序规则 | 理由与注意 |
|---|---|---|
| MySQL 8.0 全新站点 | utf8mb4_0900_ai_ci | 8.0 官方默认,性能与准确性最佳,直接无脑用 |
| MySQL 5.7 站点 | utf8mb4_unicode_ci | 5.7 无 0900 系列,unicode_ci 是当时最佳选择 |
| 用户名、邮箱、验证码 | utf8mb4_bin | 需区分大小写,避免 Admin 与 admin 被当成同一账号 |
| 订单号、流水号、token | utf8mb4_bin | 这些值必须精确匹配,绝不能因大小写被合并 |
| 普通文本标题、内容 | utf8mb4_general_ci 或 _unicode_ci | 不区分大小写更符合人类直觉的搜索体验 |
| 从 5.7 迁移到 8.0 | 保持 _general_ci,不要盲目改 | 整体改排序规则会重建索引,风险高,仅统一冲突部位 |
一个实际建议:新站点、新表统一用 utf8mb4_0900_ai_ci;对必须区分大小写的字段(用户名、验证码、各类 ID、token、哈希值)单独指定 utf8mb4_bin。这样既能享受 8.0 的性能优势,又不会在关键字段上栽跟头。老站点迁移时要克制,不要为了「统一」而一次性改掉所有表的排序规则——那会触发大量索引重建,在业务高峰可能是灾难。
实战:安全地把排序规则统一起来
如果排查出确实有不一致的列,修复要分步走,并且先在测试库验证。核心原则是:改列定义会重建该列的所有索引,必须避开业务高峰。
第一步,先只改列,不动表级默认,把冲突的列对齐到主流排序规则:
-- 只改数据类型和排序规则,保留原来的类型宽度
ALTER TABLE orders MODIFY user_id VARCHAR(32)
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- 大表上这个操作会锁表较久,可用 online DDL 尽量减少阻塞
ALTER TABLE orders MODIFY user_id VARCHAR(32)
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
ALGORITHM=INPLACE, LOCK=NONE;
第二步,改完之后立刻用 EXPLAIN 验证 JOIN 是否恢复走索引,而不是改完就以为完事:
EXPLAIN SELECT o.id, u.username
FROM orders o
JOIN users u ON o.user_id = u.user_id
WHERE o.status = 1;
-- 关注 key 列是否显示了索引名,type 是否为 ref/eq_ref,而不是 ALL
第三步,如果要改表级默认(影响未来新增列),语法是:
ALTER TABLE orders
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
注意 ALTER TABLE ... DEFAULT CHARSET 只改「默认值」,不会转换已有列的字节,因此是轻量操作,可以随时做。它常被误以为会全表转换,其实不会,这一点很多人搞混。
几个真实踩坑记录
坑一:连接级排序规则把查询拖慢。 某 ThinkPHP 站点在 database.php 里没显式设置 charset,框架默认发了一套和表不同的连接排序规则,导致所有涉及到中文的比较都走了隐式转换。排查时 SHOW FULL PROCESSLIST 看到连接实际使用的 collation 和表不一致,在配置里显式加 'charset' => 'utf8mb4' 并指定 collation 后解决。
坑二:大小写不敏感导致重复注册。 一个论坛的用户名字段用了 utf8mb4_general_ci,于是用户注册了 Tom,另一个用户又注册了 tom,唯一索引却因为不区分大小写而拒绝第二个——看似是「好事」,实则用户投诉「明明没人用这个名字」。如果业务需要允许并存,就必须把该列改成 utf8mb4_bin。
坑三:迁移时连带把旧排序规则搬了过来。 用 mysqldump 导出再导入新库时,如果 dump 文件里带着 COLLATE=utf8mb4_general_ci 的建表语句,即使你新库默认是 0900,导入后这些表仍然保留旧排序规则。正确做法是导入前用 sed 把 dump 里的排序规则统一替换,或在导入后逐一 ALTER。这个坑在跨版本迁移时几乎必踩。
一张排查清单,遇到问题照着走
- 查询突然变慢、
ORDER BY产生磁盘临时表 → 先查相关字段排序规则是否一致。 - JOIN 走不了索引 → 对比两表连接列的
collation_name。 - 数据看着一样却不相等 → 检查是否
_bin与_ci混用。 - 大小写敏感的字段出现重复或误匹配 → 该列应改用
utf8mb4_bin。 - 中文乱码 → 优先排查连接层
character_set_client和collation_connection,而非表结构。 - 改排序规则前 → 一定评估索引重建的锁表时间,大表用 online DDL 或低峰执行。
排序规则是 MySQL 里那种「平时毫无存在感、出问题时让人抓狂」的设置。它不像索引、参数那样天天被提起,但只要出现隐式转换,性能就会以数量级的方式下降。个人站长做运维,不必把每个细节都背下来,但务必记住一条:同一个库里,参与关联和比较的字符串字段,排序规则必须一致。把这条守住,你就躲掉了排序规则相关的一整类坑。