一个 Emoji 引发的排查:MySQL utf8mb3 导致接口返回异常

17 阅读9分钟

摘要:MySQL 的 utf8 是 UTF-8 的残缺实现,最多只存 3 字节,而 Emoji 是 4 字节——只要表级没改成 utf8mb4,写入就会报 Incorrect string value。Mall4j 电商系统里,含 Emoji 的输入让搜索和系统参数两个互不相干的接口同时返回通用异常码 A00005。我通过换接口验证、翻后端堆栈、逐层查字符集,最终锁定根因是建表脚本遗留的 CHARSET=utf8mb3。修复只需一行 SQL,但排查路径比 bug 本身更值得记。

前言:这篇不是教程,是一次完整排查的还原。很多人踩这个坑可能是以为配了 utf8mb4 就万事大吉,实际上表级和列级可能还停留在老字符集上。

一、一条边界值用例挂了

上周我在给 Mall4j 补搜索接口的边界值测试,矩阵里有这么一组:

@pytest.mark.parametrize("keyword, desc", [
    ("A" * 500,                    "超长文本"),
    ("  test  ",                   "前后带空格"),
    ("admin' OR '1'='1",           "SQL 注入"),
    ("<script>alert(1)</script>",  "XSS 脚本"),
    ("😀😂🤔",                      "Emoji 表情"),
    ("!@#$%^&*()",                 "特殊字符"),
])
def test_search_with_invalid_keyword(buyer_headers, keyword, desc):
    product = BuyerProduct(buyer_headers)
    result = product.search_prod(prod_name=keyword)
    assert result["code"] != "A00005", f"[{desc}] 返回服务器异常"

5 条全绿,唯独 Emoji 那条返回了服务器异常。

01-test-failed.png

图1: 5 条边界值用例,唯一 Emoji 那条挂了

A00005 是 Mall4j 的通用异常码,由全局异常处理器兜底,看不出任何原因。

我当时以为只是搜索接口没处理好特殊字符。结果这只是血案的开始。


二、第一步:换个接口试试

搜索接口异常,可能是它自身的问题。换个接口验证一下。

管理员端有个"系统参数创建"接口,功能简单、参数少、跟搜索完全不搭边:

def test_config_value_emoji(api_session):
    api = SysConfigCrudApi(api_session)
    resp = api.create(
        param_key="autotest_emoji",
        param_value="😀😂🤔🎉"
    )
    assert resp.json()["code"] != "A00005"

也返回了服务器异常。

Emoji 参数值响应: code=A00005, msg=服务器出了点小差

两个完全不同的接口,都对 Emoji 返回同一个异常码。

这就不是"某个接口没写好",是"底层有问题"。


三、第二步:看后端日志

我去看 mvn spring-boot:run 的日志窗口,找 A00005 那行附近的堆栈。

日志里出现了这个:

java.sql.SQLException:
    Incorrect string value: '\xF0\x9F\x98\x80\xF0\x9F...'
    for column 'param_value' at row 1

翻译一下:

  • \xF0\x9F\x98\x80 是 Emoji 😀 的 UTF-8 编码(4 字节)

  • Incorrect string value = MySQL 在说"这个字符我存不进去"

问题从"Java 接口异常"变成了"MySQL 存不了 Emoji"。

这是关键转折点。 接下来要确认的不是代码,是数据库。


四、第三步:逐层排除字符集

在开始查之前,我先在脑子里列了一张表,打算一层一层验证,而不是一通乱翻:

层级字符集范围状态
MySQL 服务utf8mb4全局默认✅ 正常
yami_shops 库utf8mb4新建表默认继承✅ 正常
tz_sys_config 表utf8mb3历史建表脚本遗留❌ 异常
tz_prod 表utf8mb3历史建表脚本遗留❌ 异常
tz_user 表utf8mb3历史建表脚本遗留❌ 异常

下面的四次查询,就是在验证这张表。

查询 1:服务级字符集

SHOW VARIABLES LIKE 'character_set%';
character_set_database  | utf8mb4
character_set_server    | utf8mb4

✅ 服务级没问题。

03-sql-layer1.png

图 2:服务级字符集 = utf8mb4

查询 2:库级字符集

SELECT DEFAULT_CHARACTER_SET_NAME
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME = 'yami_shops';
DEFAULT_CHARACTER_SET_NAME
--------------------------
utf8mb4

✅ 库级也没问题。

04-sql-layer2.png

图 3:库级字符集 = utf8mb4

查询 3:表级字符集

SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'yami_shops'
AND TABLE_NAME IN ('tz_sys_config', 'tz_prod', 'tz_user');
TABLE_NAME      | TABLE_COLLATION
----------------|------------------
tz_sys_config   | utf8mb3_general_ci
tz_prod         | utf8mb3_general_ci
tz_user         | utf8mb3_general_ci

❌ 发现问题了。表级字符集是 utf8mb3,不是 utf8mb4。

05-sql-layer3-utf8mb3.png

图 4:表级字符集 = utf8mb3(关键发现)

查询 4:确认建表语句

SHOW CREATE TABLE tz_sys_config\G
CREATE TABLE `tz_sys_config` (
  ...
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3

石锤了。

06-sql-layer4-create.png

图 5:建表语句里 CHARSET=utf8mb3

关键理解

表是在数据库升级之前就建好的,不会继承后来改的库级默认字符集。

所以只跑SHOW VARIABLES检查字符集没有意义——那只能证明"服务配置正确",证明不了"数据能写进去"。


五、根因:utf8mb3 是什么鬼

这里要解释一个 MySQL 的历史遗留问题:

名称字节数能存什么
utf8(旧)最多 3 字节拉丁字母 + 中文
utf8mb3最多 3 字节和 utf8 同义(新名字)
utf8mb4最多 4 字节所有 Unicode,包括 Emoji

Emoji 是 4 字节字符,必须用 utf8mb4。

为什么 Mall4j 会这样?

它的初始化 SQL 脚本写于 MySQL 5.5 之前——那时 utf8(后来改名叫 utf8mb3)就是 MySQL 支持的最大字符集。utf8mb4 是 5.5.3 才引入的,很多开源项目的建表脚本从此再没更新过。

这是技术债务,不是 Mall4j 独有的 bug。


六、这个 bug 有多大影响

不只是 Emoji。 麻将牌 🀄、音乐符号 🎵、CJK 扩展区的生僻汉字(如"㛃""𡎚")、部分货币符号 〄——所有 4 字节 Unicode 都会触发。

用户如果名字里带生僻字,注册时一样会返回异常。

这不只是"支持表情包"的问题,是部分用户根本无法使用系统的问题。我给它定了 P1(高)。

七、修复建议

方案 A:改表字符集(推荐,但别在线上直接跑)

ALTER TABLE tz_sys_config
    CONVERT TO CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

ALTER TABLE tz_prod
    CONVERT TO CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

ALTER TABLE tz_user
    CONVERT TO CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

注意三点:

① CONVERT TO 会重写整张表并锁表。tz_prod 这种大表在线上跑会堵死写入——小表原地改,大表建议走 pt-online-schema-change 或 gh-ost,先备份、先在从库验证。

② 排序规则建议用 utf8mb4_unicode_ci(MySQL 8.0 可用 utf8mb4_0900_ai_ci),general_ci 对部分语言的排序处理不准确。

③ 改完用 SHOW FULL COLUMNS FROM 表名 再确认一遍字段级字符集。有些字段上硬编码了字符集,CONVERT TO 不一定覆盖得到。

方案B:改建表脚本

搜索所有建表脚本里的 CHARSET=utf8,改成 CHARSET=utf8mb4。

这一步和方案 A 要一起做,否则下次重建库又回到原点。

❌ 为什么不加方案 C: JDBC 连接参数

很多人第一反应是在 JDBC URL 里加 characterEncoding=utf8mb4。

这个方案是错的。

MySQL JDBC 驱动的 characterEncoding 参数只接受 Java 编码名(如 UTF-8),不接受 MySQL 字符集名(如 utf8mb4)。即使强行指定,驱动也会忽略或报错。

更重要的是:即使 JDBC 层面设置了,表级还是 utf8mb3,Emoji 还是存不进去。

这个问题的修复只能在数据库层面解决,JDBC 层面无能为力。

补充:如果现象是乱码而不是报错

那可能是另一类问题。用 HEX() 看原始字节,对比标准 UTF-8 编码,判断是不是连接层或双重编码导致的。

SELECT HEX(param_value) FROM tz_sys_config WHERE id = 1;


```sql
SELECT HEX(param_value) FROM tz_sys_config WHERE id = 1;

八、为什么我没执行修复

这是测试项目。测试的职责是"发现 + 定位 + 建议",不是"修复"。

我把这条记录为 BUG-004,两条对应用例标记为 xfail:

@pytest.mark.xfail(
    reason="BUG-004: Mall4j 后端对 Emoji 处理异常,根因是 MySQL 表级 utf8mb3",
    strict=False,
)
def test_search_with_emoji_should_not_crash(buyer_headers):
    ...

开发修复后去掉 xfail 标记,用例自动变成"回归验证"。

这比我自己偷偷改表更合适——保留了 bug 现场,也留下了复现手段。

删掉失败用例是掩盖问题,自己改代码让用例通过是越权。


九、我从这件事学到的 3 件事

  1. 一个接口返回异常,先看看别的接口 如果只测搜索接口,我会以为"是搜索没写好"。

两个完全不相关的接口都对同一个输入报同一个错,才说明问题在底层。这种判断能力是排查的核心。

  1. 从"应用层报错"追到"数据库层根因"
  • A00005 是表象
  • Incorrect string value 是日志层
  • utf8mb3 是根因

测试的价值不在于"发现异常",在于"把异常压缩到一个可修复的点上"。

  1. 用 xfail 保留 bug 现场 记录 bug 编号、写明根因、等开发修复后自动回归——比删用例或改代码都有用。

十、如果你也遇到类似问题

排查路径

  1. 单个接口异常 → 换个接口验证
  2. 多个接口都异常 → 看后端日志堆栈
  3. 日志里有 SQLException → 查数据库字符集
  4. 按 4 层查:服务级 / 库级 / 表级 / 列级
  5. 大部分时候,问题在"表级"或"列级"

常用 SQL

-- 服务级
SHOW VARIABLES LIKE 'character_set%';

-- 库级
SELECT DEFAULT_CHARACTER_SET_NAME
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME = 'your_db';

-- 表级
SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_db';

-- 列级
SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND CHARACTER_SET_NAME IS NOT NULL
AND CHARACTER_SET_NAME != 'utf8mb4';

把字符集从 utf8mb3 升级到 utf8mb4,不只是支持 Emoji——还支持生僻汉字、数学符号、古文等所有 4 字节 Unicode。


十一、系列文章

这是 Mall4j 自动化测试系列的第 1 篇,后续会继续写:

第 2 篇:订单状态机测试,一个接口发现 4 个 P0 漏洞

不用等价类,不用边界值。我画了一张状态图,对每一条"不存在的边"写逆向用例,抓到了开发完全没想到的 4 个状态越权漏洞。

第 3 篇:双端电商系统的测试差异分析

同一个功能,管理员端和买家端的测试策略为什么完全不同?

第 4 篇:JMeter 三档梯度压测,发现商品列表性能拐点

200 并发时 P99 响应时间从 26ms 暴涨到 677ms,涨了 26 倍,怎么定位的?

第 5 篇:从 Mall4j 的 3 个角色,聊聊真实项目需要几种自动化脚本

买家、管理员、平台运营,鉴权方式和测试策略完全不同。

相关链接

你在测试中追过哪些"追到根因才恍然大悟"的 bug?欢迎评论区交流 👇