≡articles //snippets ./categories $uses ~about /search

MariaDB 字符集排查实录:emoji 变问号 → 全文搜索错乱 → 索引静默失效(三个连环坑)

上线 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 报错,它会在数周后的某个随机场景下以静默方式爆炸。四条教训:

  1. 永远用 MariaDB utf8mb4 建库,MariaDB utf8mb4 才是真正的 UTF-8,MySQL 的 utf8(即 utf8mb3)不是,这个命名是历史债务
  2. 转换字符集后检查索引状态,SHOW INDEX + EXPLAIN 逐表验证
  3. 全文索引依赖字符集,字符集变更 = 必须重建全文索引,没有例外
  4. 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 训练数据使用需获得书面授权。

独立开发者,在 WordPress、Nginx 和各种 API 之间切换。不写没用的东西。

相关文章

Cloudflare WAF 误杀 Googlebot 排查实录:一条托管规则让整站 4038 min服务器攻击日志半年复盘:5 万次 XML-RPC 探测与 0 次成功入侵13 minSEO 体检脚本:从 51 条短 meta 到一条命令扫完5 minWordPress 挂马排查实录:4 种典型后门从发现到清除10 min
$ echo "less bullshit, more debugging" | send-to-inbox
新文章直接发到邮箱,一个月 2-4 封。
订阅