上线 8 个月后,用户开始在文章评论区贴 emoji,然后 ??? 出现了。不是网络问题、不是编码问题,是 MariaDB utf8mb4 字符集从安装那天就是错的。
这篇文章从 emoji 变成问号开始查,发现三个连环坑:每个都是修了前一个才触发下一个,字符集错了改字符集,改完字符集全文搜索乱了,修完全文搜索发现索引又静默失效了。
症状1:MariaDB utf8mb4 缺失 — emoji 存进去变成 “????”
用户在文章评论区输入了一个 emoji,🍺。提交后在页面显示成了 ????。先查数据库里实际存了什么:
$ mysql -u sodebug_com -p sodebug_com -e "
SELECT comment_content, HEX(comment_content)
FROM sodebug_comments WHERE comment_ID=1042;
"
+-----------------+--------------------------------------------------+
| comment_content | HEX(comment_content) |
+-----------------+--------------------------------------------------+
| ???? | 3F3F3F3F |
+-----------------+--------------------------------------------------+
3F 是 ASCII 问号 ? 的十六进制。emoji 已经被 MySQL 替换成了四个 ?——不是客户端显示的问题,是写入时就丢了。
$ mysql -u sodebug_com -p sodebug_com -e "
SHOW CREATE TABLE sodebug_commentsG
" 2>/dev/null | grep -i charset
ENGINE=InnoDB DEFAULT CHARSET=utf8
根因:表字符集是 utf8。你以为的 “utf8” 其实是 MySQL 的 utf8mb3 别名,只支持最多 3 字节的 Unicode 字符。emoji(如 🍺 = F0 9F 8D BA)需要 4 字节,属于 utf8mb4 范围。MySQL 的 utf8 遇到 4 字节字符,静默截断为 ?。
$ mysql -u sodebug_com -p -e "
SELECT * FROM information_schema.CHARACTER_SETS
WHERE CHARACTER_SET_NAME IN ('utf8','utf8mb3','utf8mb4')G
" 2>/dev/null
*************************** 1. row ***************************
CHARACTER_SET_NAME: utf8
DEFAULT_COLLATE_NAME: utf8_general_ci ← 注意这个
MAXLEN: 3 ← 最多 3 字节
*************************** 2. row ***************************
CHARACTER_SET_NAME: utf8mb4
DEFAULT_COLLATE_NAME: utf8mb4_general_ci
MAXLEN: 4 ← 支持 4 字节(emoji 范围)
修复:ALTER TABLE 转 utf8mb4
$ mysql -u sodebug_com -p sodebug_com -e "
ALTER TABLE sodebug_comments CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
"
# 验证
$ mysql -u sodebug_com -p sodebug_com -e "
SHOW CREATE TABLE sodebug_commentsG
" 2>/dev/null | grep -i charset
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci
选 utf8mb4_unicode_520_ci 而不是 utf8mb4_general_ci 是因为前者遵循 Unicode 5.2 排序规则,emoji 之间的排序语义比 general_ci 正确。
症状2:转完 utf8mb4 后,全文搜索结果排序全乱了
把几张表按上面的方式转成 utf8mb4 后,站内搜索突然变了。搜索“nginx 缓存”返回的结果顺序完全不对,以前排第一的相关文章跑到了第 12 位。
$ mysql -u sodebug_com -p sodebug_com -e "
SELECT post_title,
MATCH(post_title,post_content) AGAINST('nginx 缓存') as relevance
FROM sodebug_posts
WHERE MATCH(post_title,post_content) AGAINST('nginx 缓存')
ORDER BY relevance DESC LIMIT 5;
"
# 结果顺序和上周完全不同。同样的查询词,排序变了。
用 SHOW INDEX 检查全文索引的状态:
$ mysql -u sodebug_com -p sodebug_com -e "
SHOW INDEX FROM sodebug_posts WHERE Key_name='ft_index'G
" 2>/dev/null
*************************** 1. row ***************************
Table: sodebug_posts
Key_name: ft_index
Index_type: FULLTEXT
Comment:
# Index_type 显示 FULLTEXT 但没标注字符集变化
查 InnoDB 全文索引内部状态表:
$ mysql -u sodebug_com -p information_schema -e "
SELECT * FROM INNODB_FT_INDEX_TABLE
WHERE word LIKE '%nginx%' LIMIT 3;
"
+--------+--------------+-------------+-----------+--------+----------+
| WORD | FIRST_DOC_ID | LAST_DOC_ID | DOC_COUNT | DOC_ID | POSITION |
+--------+--------------+-------------+-----------+--------+----------+
| nginx | 2 | 1200 | 15 | 2 | 0 |
| nginx | 2 | 1200 | 15 | 3 | 45 |
| nginx | 2 | 1200 | 15 | 5 | 120 |
+--------+--------------+-------------+-----------+--------+----------+
# 同一个 WORD 出现多条记录,doc_id 交叉混乱
根因:ALTER TABLE CONVERT 之后,InnoDB 全文索引没有自动重建。 字符集变更导致分词边界发生变化,原本在 utf8 下正确的 token 在 utf8mb4 下可能被切成不同的片段。InnoDB 全文索引保留着旧字符集下的分词结果,新的查询在新字符集下分词结果不同,全文匹配的 relevance 打分完全偏离。
修复:重建全文索引
$ mysql -u sodebug_com -p sodebug_com -e "
ALTER TABLE sodebug_posts DROP INDEX ft_index;
ALTER TABLE sodebug_posts ADD FULLTEXT INDEX ft_index (post_title, post_content);
"
$ mysql -u sodebug_com -p sodebug_com -e "
OPTIMIZE TABLE sodebug_posts;
" 2>/dev/null
+------------------------+----------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+------------------------+----------+----------+----------+
| sodebug_com.sodebug_posts | optimize | status | OK |
+------------------------+----------+----------+----------+
重建后验证搜索排序是否恢复:
$ mysql -u sodebug_com -p sodebug_com -e "
SELECT post_title,
MATCH(post_title,post_content) AGAINST('nginx 缓存') as relevance
FROM sodebug_posts
WHERE MATCH(post_title,post_content) AGAINST('nginx 缓存')
ORDER BY relevance DESC LIMIT 5;
"
# 排序恢复正常——Nginx FastCGI Cache 那篇回到第一位
症状3:wp_posts 转 utf8mb4 后,VARCHAR 索引失效
转完 wp_posts 后,用 post_name 查文章突然慢了几十倍:
$ mysql -u sodebug_com -p sodebug_com -e "
EXPLAIN SELECT * FROM sodebug_posts
WHERE post_name='nginx-fastcgi-cache-purge-guide' AND post_type='post'G
" 2>/dev/null
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: sodebug_posts
type: ALL ← 全表扫描!
possible_keys: post_name
key: NULL ← 索引没用到
Extra: Using where
post_name 是 VARCHAR(200)。在 utf8 下,200 个字符 = 最多 600 字节。在 utf8mb4 下,200 个字符 = 最多 800 字节。InnoDB 索引键最大 767 字节(默认),utf8mb4 的 800 字节超出了这个限制,索引被 MySQL 静默禁用。
$ mysql -u sodebug_com -p -e "
SHOW WARNINGSG
" 2>/dev/null
*************************** 1. row ***************************
Level: Warning
Code: 1071
Message: Specified key was too long; max key length is 767 bytes
修复:启用 innodb_large_prefix + 前缀索引
# my.cnf
[mysqld]
innodb_large_prefix = 1
innodb_file_format = Barracuda
innodb_default_row_format = dynamic
# 前缀索引 — 只索引 post_name 的前 191 字符
$ mysql -u sodebug_com -p sodebug_com -e "
ALTER TABLE sodebug_posts DROP INDEX post_name;
ALTER TABLE sodebug_posts ADD INDEX post_name (post_name(191));
"
# 验证
$ mysql -u sodebug_com -p sodebug_com -e "
EXPLAIN SELECT * FROM sodebug_posts
WHERE post_name='nginx-fastcgi-cache-purge-guide' AND post_type='post'G
" 2>/dev/null
type: ref ← 使用索引了
key: post_name
rows: 1
191 是怎么来的:767 字节 ÷ 4 字节(utf8mb4 每字符最多)= 191.75 → 取整 191。
完整修复清单
# 1. 修改数据库默认字符集
ALTER DATABASE sodebug_com CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
# 2. 转换所有表
# 生成批量 ALTER 语句
SELECT CONCAT('ALTER TABLE ', TABLE_NAME,
' CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;')
FROM information_schema.TABLES
WHERE TABLE_SCHEMA='sodebug_com' AND TABLE_TYPE='BASE TABLE';
# 3. 重建全文索引(每张有全文索引的表)
ALTER TABLE sodebug_posts DROP INDEX ft_index;
ALTER TABLE sodebug_posts ADD FULLTEXT INDEX ft_index (post_title, post_content);
OPTIMIZE TABLE sodebug_posts;
# 4. 修复长 VARCHAR 索引 — 改为前缀索引
ALTER TABLE sodebug_posts DROP INDEX post_name;
ALTER TABLE sodebug_posts ADD INDEX post_name (post_name(191));
总结
| 症状 | 表面原因 | 真正的根因 | 修复 |
|---|---|---|---|
| emoji → ???? | 编码错误 | 表字符集是 utf8(utf8mb3 别名),只支持 3 字节 | ALTER TABLE CONVERT TO utf8mb4 |
| 全文搜索排序错乱 | 索引损坏 | 字符集变更后 INNODB 全文索引未重建,分词边界不一致 | DROP + ADD FULLTEXT + OPTIMIZE |
| VARCHAR 索引失效 | 表扫全库 | utf8mb4 下 VARCHAR(200) 最大 800 字节,超出 767 字节索引限制 | 前缀索引 VARCHAR(191) |
字符集的问题不会在 ALTER TABLE 报错,它会在数周后的某个随机场景下以静默方式爆炸。四条教训:
- 永远用 MariaDB utf8mb4 建库,MariaDB utf8mb4 才是真正的 UTF-8,MySQL 的 utf8(即 utf8mb3)不是,这个命名是历史债务
- 转换字符集后检查索引状态,
SHOW INDEX+EXPLAIN逐表验证 - 全文索引依赖字符集,字符集变更 = 必须重建全文索引,没有例外
- VARCHAR 索引有字节上限,utf8mb4 下 VARCHAR(N) 的索引需求是 4×N 字节,超出 767 就失效
如果你在部署 WordPress 时用了 MariaDB utf8mb4 之外的字符集,VPS 上部署 WordPress 用的是默认的 MariaDB 安装,大概率字符集就是 utf8:因为很多一键脚本(宝塔、LNMP)的默认模板至今还是 CHARSET=utf8。这篇文章覆盖了 MariaDB utf8mb4 最常见的三个故障场景。建议现在就 SHOW CREATE TABLE 扫一遍。关于索引失效的排查,MySQL 慢查询排查实录 里有完整的 EXPLAIN 解读方法。字符集和 collation 的规范定义在 MySQL 官方文档 里。
© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。