前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 18|回复: 0

数据库字符集与排序规则治理实战:从乱码问号到 utf8mb4 统一

[复制链接]

数据库字符集与排序规则治理实战:从乱码问号到 utf8mb4 统一

[复制链接]
dbaai

主题

0

回帖

226

积分

DBAAI

积分
226
昨天 07:48 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
一、具体的问题

上个月会员系统上线新功能,昵称允许自定义。上线第二天客服反馈:一部分用户昵称显示成了四个问号 ????,另一部分用户的昵称里带了 Emoji,保存时报错 Incorrect string value: '\xF0\x9F...' for column 'nick'。同一个接口,两种失败方式,看上去像两个问题。

排查下来其实是一个问题的两面。库里那张会员表建得早,nick 列是 utf8mb3,装不下 4 字节字符,Emoji 直接写不进去;而写入 ???? 的那批请求,走的是一个老版本的连接池配置,会话的 character_set_client 还是 latin1,服务端按 latin1 去解码客户端发来的 UTF-8 字节流,解不出来就替换成 ?,等数据落到磁盘上,原始字节已经没了。

这件事暴露的不是某个参数没设对,而是一个更基础的问题:字符集管的是"存不下",排序规则管的是"比不对",这两件事经常被混在一起谈,结果两边都没治理干净。

这篇文章给出一条能照着走的路径:先用 HEX() 判断数据是真坏还是只是显示坏,再用三个 SHOW 定位乱码发生在哪一跳,然后分层统一到 utf8mb4,最后处理排序规则切换会带来的唯一键与比较行为变化——这一步不做,治理完成当天就可能出现新的主键冲突。

二、核心原理

字符集与排序规则是两件独立的事

字符集(character set)决定"一个字符用哪几个字节表示",排序规则(collation)决定"两个字符串怎么比大小、怎么排序、是否区分大小写和重音"。同一个字符集可以挂很多个排序规则,比如 utf8mb4 就有 utf8mb4_general_ci、utf8mb4_unicode_ci、utf8mb4_0900_ai_ci、utf8mb4_bin 等几十个。

关键在于:唯一索引、JOIN、ORDER BY、GROUP BY、DISTINCT 全都按排序规则执行。所以排序规则不是"技术细节",它是业务语义的一部分。把一张 general_ci 的表和一张 0900_ai_ci 的表做 JOIN,MySQL 8.0 会直接抛 Illegal mix of collations,这不是锦上添花的问题,而是语句根本跑不过去。

MySQL 的字符集转换链

MySQL 的乱码几乎都出在这条链的某一跳上,每一跳都有一个对应的系统变量:

变量作用设错时的典型症状
character_set_client服务端认为客户端发来的字节是什么编码写入变 ????
character_set_connection服务端内部处理 SQL 文本时用的编码比较、LIKE 结果异常
character_set_results返回给客户端时的编码查询结果显示乱码,但数据本身是好的
character_set_database库的默认字符集新建表继承了错的默认值
character_set_server实例级默认新库新表持续复制错误
collation_connection会话比较规则临时表、字面量比较行为不一致


SET NAMES utf8mb4 做的事情就是同时把 client / connection / results 三项设成 utf8mb4,这是排查时最常用的止血动作。

这里有个判据要记牢:???? 是不可逆的。? 的字节是 0x3F,源字符表达不了时被替换成它,原始信息在那一刻就丢了,后面任何参数调整都救不回来,只能从业务侧重新录入。反过来,如果看到的是 å¼ 这类"看起来像乱码但字符还在"的形态,那是双重编码——UTF-8 字节被当成 latin1 存了一遍,还能用 CONVERT(BINARY(CONVERT(...))) 还原。

utf8 和 utf8mb4 不是一回事

MySQL 里的 utf8 是 utf8mb3 的别名,最多 3 字节,只能覆盖 BMP(基本多文种平面),Emoji、部分生僻汉字、化学符号都装不下。utf8mb4 才是完整的 UTF-8,最多 4 字节。8.0 里写 utf8 已经会收到 utf8mb3 的弃用告警,新建库表应该直接上 utf8mb4。

还有一个容易翻车的连带影响:VARCHAR(n) 的 n 是字符数,但索引长度上限是按字节算的。InnoDB 在 COMPACT 行格式下索引前缀上限 767 字节,utf8mb4 单字符最大 4 字节,于是单列索引最多只能用 191 个字符。这就是 MySQL 5.7 时代把表从 utf8mb3 转 utf8mb4 时最常见的报错 Specified key was too long; max key length is 767 bytes 的根因。解法是先把索引列从 255 缩到 191,或者把行格式改成 DYNAMIC(上限提升到 3072 字节)。

排序规则名字怎么读

以 utf8mb4_0900_ai_ci 为例拆开看:utf8mb4 是字符集,0900 表示基于 Unicode 9.0 的 UCA 算法,ai = accent-insensitive(重音不敏感,cafe 等于 café),ci = case-insensitive(大小写不敏感,A 等于 a)。

三代排序规则的实际差异主要在两处:

排序规则Unicode 版本特点与差异
utf8mb4_general_ci4.0快但粗糙,不做完整 UCA 展开
utf8mb4_unicode_ci4.0.1基于 UCA,排序更"正确",性能略低
utf8mb4_0900_ai_ci9.08.0 默认,排序结果与通用预期最接近


差异的典型体现是 ß 与 ss:general_ci 下两者不相等,0900_ai_ci 下 UCA 把它展开成 ss,两者相等。如果唯一索引建在昵称/名称列上,切换排序规则就可能让原本能共存的 ß 和 ss 变成冲突行,这是切换前必须查的。

其他库的对应概念

数据库"字符集"载体"排序规则"载体备注
MySQLcharacter_set_* + 列级 charsetcollation_* + 列级 collation库/表/列三层可不同
SQL Servervarchar(代码页)/ nvarchar(UTF-16)Collation,如 Chinese_PRC_CI_AS跨库 JOIN 易报 collation conflict
OracleNLS_CHARACTERSET(varchar2)/ NLS_NCHAR_CHARACTERSET(nvarchar2)排序行为由 NLS_SORT 等参数控制库字符集装完后基本不可改
PostgreSQL库级 encoding(创建后不可改)库级 collate/ctype,10+ 支持 ICU乱码主因是 client_encoding 不匹配


三、实例参考(动手步骤)

步骤 0:先把两种坏法复现出来

在测试库上造一张"混搭"的表,让问题可控地出现:
  1. CREATE DATABASE coll_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
  2. USE coll_demo;
  3. CREATE TABLE t_nick (
  4.   id   INT PRIMARY KEY AUTO_INCREMENT,
  5.   nick VARCHAR(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci,
  6.   memo VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
  7. ) ENGINE=InnoDB;
  8. -- 情形一:列装不下 4 字节字符
  9. INSERT INTO t_nick(nick, memo) VALUES ('张三😀', '张三😀');
复制代码

执行结果是 ERROR 1366: Incorrect string value: '\xF0\x9F\x98\x80...' for column 'nick'——注意 memo 列是 utf8mb4,同样的值没问题,说明问题精确定位在列级字符集上。

情形二需要另开一个会话,把连接层故意设错:
  1. SET NAMES latin1;
  2. INSERT INTO t_nick(nick, memo) VALUES ('李四', '李四');
  3. -- 会话恢复
  4. SET NAMES utf8mb4;
  5. SELECT id, nick, HEX(nick) FROM t_nick;
复制代码

第二条记录的 nick 会显示成 ????,HEX() 返回 3F3F,两个问号,原始信息已经丢了。

步骤 1:三个 SHOW 定位乱码发生在哪一跳
  1. -- 1. 连接层
  2. SHOW VARIABLES LIKE 'character_set_%';
  3. SHOW VARIABLES LIKE 'collation_%';
  4. -- 2. 存储层
  5. SHOW CREATE TABLE t_nick\G
  6. -- 3. 当前会话的即时值(连接池里最该看的一条)
  7. SELECT @@character_set_client, @@character_set_connection,
  8.        @@character_set_results, @@collation_connection;
复制代码

判读方法很直接:如果 character_set_client / character_set_results 是 latin1 而 character_set_server 是 utf8mb4,那么写入侧的乱码就来自连接层,属于"改配置就能防住";如果连接层全是 utf8mb4 但列上有 CHARACTER SET utf8mb3 或 latin1,那必须改列,改连接参数一点用都没有。

步骤 2:用 HEX 判断数据是真坏还是只是显示坏
  1. SELECT id, nick, HEX(nick), CHAR_LENGTH(nick), LENGTH(nick) FROM t_nick;
复制代码

对着结果分三类处理:

HEX 结果特征结论处理方式
全是 3F(问号)已不可逆丢失只能业务侧重新录入
形如 C3A5C2BC...,字节数明显翻倍双重编码,数据还在转码还原后可继续用
与预期 UTF-8 字节一致数据完好,仅显示问题修 client/result 编码即可


还原双重编码的写法(务必先用 SELECT 验证,确认无误再 UPDATE):
  1. SELECT id,
  2.        CONVERT(BINARY(CONVERT(nick USING latin1)) USING utf8mb4) AS fixed
  3. FROM t_nick
  4. WHERE HEX(nick) LIKE 'C3%' OR HEX(nick) LIKE 'C2%';
复制代码

步骤 3:库、表、列三层统一到 utf8mb4
  1. -- 库级默认(影响之后新建的表)
  2. ALTER DATABASE coll_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
  3. -- 表级默认(只改默认值,不动存量列)
  4. ALTER TABLE t_nick DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
  5. -- 列级转换(会真正转换数据,可能重建表)
  6. ALTER TABLE t_nick
  7.   MODIFY nick VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
复制代码

只做前两步是典型的"半治理":存量列还挂在旧字符集上,问题照旧。生产库上列很多,用 information_schema 批量生成语句比手写可靠:
  1. SELECT CONCAT('ALTER TABLE `', TABLE_NAME, '` MODIFY `', COLUMN_NAME, '` ',
  2.               COLUMN_TYPE,
  3.               ' CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci',
  4.               IF(IS_NULLABLE = 'NO', ' NOT NULL', ' NULL'),
  5.               IF(COLUMN_DEFAULT IS NULL, '', CONCAT(' DEFAULT ', COLUMN_DEFAULT)),
  6.               ';') AS stmt
  7. FROM information_schema.COLUMNS
  8. WHERE TABLE_SCHEMA = 'coll_demo'
  9.   AND CHARACTER_SET_NAME IN ('utf8mb3', 'utf8', 'latin1');
复制代码

顺手再查一遍排序规则不统一的列,这份清单就是治理的验收依据:
  1. SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME, COLLATION_NAME
  2. FROM information_schema.COLUMNS
  3. WHERE TABLE_SCHEMA = 'coll_demo'
  4.   AND (COLLATION_NAME <> 'utf8mb4_0900_ai_ci' OR COLLATION_NAME IS NULL);
复制代码

步骤 4:连接层和导入导出一起收口

改完列不代表结束,写入路径上还有三处要一起处理:
  1. -- 环节一:会话初始化
  2. SET NAMES utf8mb4;
  3. -- 环节二:连接池(以 JDBC 为例,参数要写在连接串里,并在池上配置初始化语句)
  4. -- jdbc:mysql://host:3306/db?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_0900_ai_ci
  5. -- connectionInitSql = SET NAMES utf8mb4
复制代码

环节三是导出导入,这一步最容易被漏掉:mysqldump 默认会读服务端字符集,如果实例里混着 latin1,导出的 SQL 文件本身可能就是错的。规范写法是两端都显式指定:
  1. mysqldump --default-character-set=utf8mb4 -u dba -p db1 t_nick > t_nick.sql
  2. mysql --default-character-set=utf8mb4 -u dba -p db1 < t_nick.sql
复制代码

另外,从 Excel 或 CSV 导入时要清楚文件本身的编码(GBK 的 CSV 很常见),导入前先 iconv -f GBK -t UTF-8 转一次,比事后修数据省事得多。

步骤 5:排序规则切换的实测影响

切换排序规则前,先在一张测试表上把影响量出来,重点看两件事:
  1. -- 影响一:唯一键行为变化(ß 与 ss 在 UCA 下相等)
  2. CREATE TABLE t_uniq (u VARCHAR(20) UNIQUE) ENGINE=InnoDB
  3.   DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
  4. INSERT INTO t_uniq VALUES ('straße'), ('strasse');   -- general_ci 下可共存,两条都成功
  5. ALTER TABLE t_uniq MODIFY u VARCHAR(20) COLLATE utf8mb4_0900_ai_ci;
  6. -- ERROR 1062: Duplicate entry 'strasse' for key 'u'
  7. -- 影响二:ORDER BY 顺序变化
  8. CREATE TABLE t_sort (name VARCHAR(20)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  9. INSERT INTO t_sort VALUES ('a'),('A'),('ä'),('z'),('ß'),('ss');
  10. SELECT name FROM t_sort ORDER BY name COLLATE utf8mb4_general_ci;
  11. SELECT name FROM t_sort ORDER BY name COLLATE utf8mb4_0900_ai_ci;
复制代码

切换前先做一次"冲突预检",避免上线当天才发现主键冲突:
  1. -- 以拟切换的排序规则分组,找出会被判定为重复的键
  2. SELECT u COLLATE utf8mb4_0900_ai_ci AS k, COUNT(*) AS c, GROUP_CONCAT(u)
  3. FROM t_uniq GROUP BY k HAVING c > 1;
复制代码

步骤 6:大表列转换怎么不锁死

VARCHAR 从 utf8mb3 转 utf8mb4 通常需要重建表。先试跑,让 MySQL 自己告诉你能不能在线做:
  1. ALTER TABLE t_big MODIFY c VARCHAR(191) CHARACTER SET utf8mb4
  2.   COLLATE utf8mb4_0900_ai_ci, ALGORITHM=INPLACE, LOCK=NONE;
复制代码

报 ALGORITHM=INPLACE is not supported 就说明必须走 COPY(全程锁写),这种时候用 gh-ost 或 pt-online-schema-change 做在线变更,或者走"先改从库、再切换"的路径。转换前务必先校验索引列长度,避免中途撞上 767/3072 字节的上限:
  1. SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME,
  2.        SUM(CHARACTER_MAXIMUM_LENGTH) AS chars
  3. FROM information_schema.STATISTICS s
  4. JOIN information_schema.COLUMNS c USING (TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME)
  5. WHERE s.TABLE_SCHEMA = 'coll_demo'
  6. GROUP BY TABLE_NAME, INDEX_NAME, COLUMN_NAME
  7. HAVING chars * 4 > 3072;
复制代码

步骤 7:其他库的对照处理
  1. -- SQL Server:查库与列的排序规则,列转 Unicode(想存 Emoji 必须用 nvarchar)
  2. SELECT DATABASEPROPERTYEX('db1', 'Collation');
  3. SELECT name, collation_name FROM sys.columns WHERE object_id = OBJECT_ID('dbo.t1');
  4. ALTER TABLE dbo.t1 ALTER COLUMN nick NVARCHAR(50);
  5. -- 跨库 JOIN 报 collation conflict 时显式指定
  6. -- ... ON a.n COLLATE Chinese_PRC_CI_AS = b.n
  7. -- Oracle:确认数据库字符集与国家字符集
  8. SELECT parameter, value FROM nls_database_parameters
  9. WHERE parameter IN ('NLS_CHARACTERSET', 'NLS_NCHAR_CHARACTERSET');
  10. -- PostgreSQL:库编码创建后不可改,乱码优先查 client_encoding
  11. SHOW server_encoding;
  12. SHOW client_encoding;
  13. SELECT datname, pg_encoding_to_char(encoding), datcollate FROM pg_database;
  14. SET client_encoding = 'UTF8';
复制代码

治理前后对比

指标治理前治理后
字符集分布utf8mb3 / latin1 / utf8mb4 三种并存,17 个列不符全部 utf8mb4,不符列为 0
会话 character_set_clientlatin1utf8mb4
排序规则general_ci / unicode_ci / 0900_ai_ci 三套并存统一 0900_ai_ci
Emoji 与生僻字写入报 1366 错误或写入 ???正常写入,HEX 为完整 4 字节
跨列 JOIN报 Illegal mix of collations正常执行
两表 ORDER BY 同名结果顺序不一致,对账脚本误报完全一致
唯一键冲突切换排序规则当天新增 3 条冲突预检后提前处理,变更零中断


四、实操检查清单


  • [ ] 执行 SHOW VARIABLES LIKE 'character_set_%',确认 client / connection / results 三项都是 utf8mb4
  • [ ] 查 information_schema.COLUMNS,列出所有 CHARACTER_SET_NAME 不是 utf8mb4 的列并登记数量
  • [ ] 查排序规则分布,列出 COLLATION_NAME 不一致的表与列,作为验收基线
  • [ ] 用 HEX() 抽样确认乱码数据类型:真丢失(3F)/ 双重编码 / 仅显示问题,分别制定处理方案
  • [ ] 库、表、列三层 DEFAULT 与列定义全部改为 utf8mb4,不留"半治理"的列
  • [ ] 索引列长度校验:utf8mb4 下 5.7 的 COMPACT 行格式单列索引不超过 191 字符,8.0 的 DYNAMIC 不超过 768 字符
  • [ ] 应用连接串与连接池初始化语句都带上 utf8mb4,验证连接池复用后仍生效
  • [ ] 备份与导入导出脚本统一加 --default-character-set=utf8mb4
  • [ ] 切换排序规则前跑冲突预检 SQL,确认不会有唯一键冲突
  • [ ] 大表变更先在预发环境试跑 ALGORITHM=INPLACE, LOCK=NONE,不支持则走在线 DDL 工具
  • [ ] 变更后回归验证:Emoji 写入、跨表 JOIN、排序结果、唯一键行为四项各测一次
  • [ ] 把字符集与排序规则检查项写进建表规范,新表建表语句必须显式声明 CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci


几个容易踩的坑

第一,把"改成 utf8mb4"当成一条 ALTER 就完事。 库级和表级的 DEFAULT 只影响将来新建的对象,存量列必须逐列 MODIFY,否则治理做完还剩一堆 utf8mb3 列在那儿继续报错。

第二,用 SET NAMES 去救已经写成 ???? 的数据。 ? 的字节是 0x3F,原始字符在转换那一刻已经不存在,任何字符集参数都变不回来。这部分数据只能从业务侧重新采集。

第三,以为 utf8mb4 一定比 utf8mb3 慢一半。 实际差异只体现在"需要 4 字节的字符"上,纯中文和 ASCII 的存储占用完全一样,真正需要评估的是索引长度上限和行格式,而不是整体性能。

第四,忽略排序规则切换对唯一键的影响。 general_ci 下 ß 和 ss 可以共存,切到 0900_ai_ci 就冲突。切换前不做预检,变更脚本跑到一半失败,索引可能停在中间状态。

第五,混用排序规则做 JOIN 却不写 COLLATE。 8.0 对 0900_ai_ci 与 general_ci 的混合比较是直接报错,不是隐式转换。老库升级到 8.0 后这类报错尤其集中。

第六,把 SQL Server 的 varchar 当 nvarchar 用。 varchar 走代码页,中文实例下是 GBK,存不下 Emoji;SQL_Latin1_General_CP1_CI_AS 的库里存中文更是全靠运气。要存 Unicode 就得用 nvarchar,长度按字符算。

第七,忘了连接池里的连接是复用的。 只在应用启动时执行一次 SET NAMES 是不够的,连接池新建连接时必须通过初始化语句统一设置,否则会随机出现"一部分请求乱码"的诡异现象。
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-9-26 06:11 , Processed in 0.044213 second(s), 9 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表