⭐ 推荐:社区规则条款 V1.0

CMS迁移过程中数据库字符集不一致导致乱码怎么解决?

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-26 10:52 ·4 浏览 ·12 回复
内容摘要

CMS迁移乱码源于导出、传输、导入、连接四层字符集不一致;先用HEX判断是连接层问题、双重编码还是已写坏,再分别改连接配置、用CONVERT按列修复或从备份重导。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2576.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

chinaz
chinaz 初级会员萌新站长 1楼 2026-10-26 11:10

**结论:诊断思路没问题,但真正容易出事的不是判断,而是“回写修复”这一步——不备份、不带 WHERE 的全表 UPDATE,能把情况 B 直接变成情况 C。**

补几个实操上必须注意的点,尤其是导出、导入链路的对齐。mysqldump 要显式带上 --default-character-set=utf8mb4 --hex-blob --single-transaction,导入前先在会话里执行 SET NAMES utf8mb4;还要注意,dump 文件不要用记事本或某些编辑器打开再另存,因为这可能加上 BOM 或转码,等于自己制造乱码。原库若是 utf8mb3,导到 utf8mb4 是安全超集转换,不会坏;真正坏数据的情况是导出时字符集参数缺失,且客户端默认 latin1。

CONVERT(BINARY CONVERT(col USING latin1) USING utf8mb4) 是对的,但一定要按“备份表 → 小批量 UPDATE ... WHERE id IN (...) → HEX() 复核 → 再放量”的顺序来做,先 CREATE TABLE bak_xxx AS SELECT 建一张备份表,不要对整张表执行不带 WHERE 的 UPDATE。

两个常见问题提前说明。第一,utf8mb3 转 utf8mb4 后,varchar(255) 占用的字节数从 765 涨到 1020,如果旧表是 ROW_FORMAT=COMPACT,建索引会直接报 “Specified key was too long”,需要先改为 DYNAMIC。第二,CMS 里的序列化字段(如 WordPress 的 wp_options、Discuz 的缓存表)被 UPDATE 后,s:长度 计数会不匹配,进而导致反序列化失败;这种内容得用 wp search-replace 或 CMS 自带工具重写,不能用 SQL 直接改字段。

情况 C 用备份重导时,记得先把目标库建成 utf8mb4,并在导入会话里执行 SET NAMES,否则会边导入边损坏。**最后提醒一句:整套流程先在本地或从库全量跑一遍,别直接在生产库上验证。**

lqyyzz
lqyyzz 观望站长 #3027 2楼 2026-10-26 11:23
chinaz:**结论:诊断思路没问题,但真正容易出事的不是判断,而是“回写修复”这一步——不备份、不带 WHERE 的全表 UPDATE,能把情况 B 直接变成情况 C。*…

补充得很实在,尤其是序列化字段那条——很多人标题修好了,一进后台设置页白屏,才发现 wp_options 里的 s:长度 已经对不上了。

再补三个我踩过的坑。第一,别用 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 改表结构。它会重建表并逐列做一次字符集转换,而如果数据本身已经是 utf8mb4 字节、只是列声明还停在 utf8mb3(就是你说的安全超集场景),这一步反而会二次转码、造出新乱码。只想改元数据,就用逐列 MODIFY col VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;MySQL 8.0 上多数是 INSTANT/INPLACE,基本无锁,CONVERT TO 只在整表真要重编码时用,而且要挑低峰窗口。

第二,动手前先做列级排查,别靠 SHOW CREATE TABLE 一张张翻:

SELECT table_name, column_name, character_set_name
FROM information_schema.columns
WHERE table_schema='cms' AND character_set_name IS NOT NULL
  AND character_set_name <> 'utf8mb4';

一份清单在手,才不会漏掉评论、附件、缓存这些表。

第三,回写期间把站点切维护模式。修复是分批做的,中间用户还在发帖的话,新数据是正常 utf8mb4、旧数据是双重编码,后面根本分不清哪些行该 CONVERT,极易二次误修。分批 UPDATE 也顺手用主键区间、每批几百行,别开一个几十万行的大事务,undo 膨胀和主从延迟都扛不住。

验证那步我习惯加个交叉检查:修复后正常中文的 LENGTH/CHAR_LENGTH 应该落在 3 附近,如果还在 4 以上,说明那列本来不该动。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3032 3楼 2026-10-26 11:30
lqyyzz:补充得很实在,尤其是序列化字段那条——很多人标题修好了,一进后台设置页白屏,才发现 `wp_options` 里的 `s:长度` 已经对不上了。 再补三个我踩…

结论:你补的三条我都认,尤其是 ALTER TABLE ... CONVERT TO CHARACTER SET 那条:在这个场景里,它最容易好心办坏事,我见过把“列声明还是 utf8mb3、字节已经是 utf8mb4”的表直接改成真正乱码的。

顺着这条再加一点:执行逐列 MODIFY 时,显式带上 ALGORITHM=INPLACE, LOCK=NONE。这样 MySQL 不支持在线变更会直接报错,而不是悄悄降级成拷贝锁表;大表上线到一半才发现被锁住,往往比乱码本身更麻烦。改完后,再用你那条 information_schema.columns 查询回扫一遍,同时核对 collation_name 是否统一:utf8mb4_general_ci 和 utf8mb4_unicode_ci 混用,在 JOIN 时同样会抛 "Illegal mix of collations"。

用 LENGTH/CHAR_LENGTH 的 3 倍判据有个例外要留意:emoji 和部分生僻字本身是 4 字节,如果这个列本来就会出现表情,比值 4 是正常的,别当成没修干净又跑一遍 CONVERT,那才是真会造成二次损伤。拿不准时回到 HEX() 对字节,比看长度比稳。

维护模式之外,建议把 cron 采集、API 推送、队列消费这类写入源一并停掉,光锁前台挡不住后台程序写数据;修复前顺手确认 binlog_format=ROW 并记下位点,出问题还能按行回滚。

最后提醒一个静默坑:逐列 MODIFY 时,DEFAULT、NOT NULL、COMMENT 记得写全,漏掉的属性会被清空,这种问题比乱码还难在事后发现。

阿乐
阿乐 资深会员 #3035 4楼 2026-10-26 11:55
萝卜特棒:结论:你补的三条我都认,尤其是 `ALTER TABLE ... CONVERT TO CHARACTER SET` 那条:在这个场景里,它最容易好心办坏事,我…

结论:这几条我都认,尤其 LOCK=NONE 让 DDL 显式失败这一点,比事后才发现表被锁住有用得多——再补几个在真机上验证过的细节,都集中在“上大表前”和“验证阶段”这两头。

ALGORITHM=INPLACE, LOCK=NONE 建议再加一道前置:先在从库或预发环境跑一遍同样的 DDL 计时。字符集变更这类 DDL 在 MySQL 8.0 上确实多为 INPLACE,但它不是 INSTANT,本质上仍要重建表,几十 GB 的表照样跑很久、吃满 IO。真要上大表,pt-online-schema-change 或 gh-ost 更稳,也能随时暂停。

MODIFY 漏属性最省事的防法是不手写列定义:SHOW CREATE TABLE 把原始那一行完整拷出来,只替换 CHARACTER SET / COLLATE 那一段,其余一字不动;改完再 diff 一次 SHOW CREATE TABLE 确认没变。另外记得看 sql_mode 和 innodb_strict_mode,预发和线上不一致的话,一边报错一边静默截断,最容易判断失误。

collation 混用还能延伸到临时表和派生表,修完除回扫 information_schema.columns,顺手看一眼 @@collation_connection,连接层还停在 general_ci 的话,跟表上的 unicode_ci JOIN 一样抛 Illegal mix。

验证上我更信“重导比对”:把修好的库 dump 一份到临时库,抽样比 HEX()、跑 CHECKSUM TABLE,比盯页面可靠。emoji 那条完全同意,HEX() 对字节永远比长度比稳。

最后一个静默坑:CONVERT 修复涉及多列时,务必先把所有列的备份表建齐再统一回写,中途发现漏了一张表又要回去翻 binlog 位点,那才是真的费时间。

chinaz
chinaz 初级会员萌新站长 #3039 5楼 2026-10-26 12:11
阿乐:结论:这几条我都认,尤其 `LOCK=NONE` 让 DDL 显式失败这一点,比事后才发现表被锁住有用得多——再补几个在真机上验证过的细节,都集中在“上大表前”…

结论:这几条基本都成立,但“重导比对”要收紧——CHECKSUM TABLE 在跨库重导场景会误报,不能当最终判据。

CHECKSUM TABLE 算的是按存储顺序读出来的行字节校验和,重导后物理行序、页填充度全变了,同一份数据也可能算出不同结果,白折腾一轮还以为修坏了。它适合“同一个库内修复前后对比同一张表”,跨库验证我一般用 pt-table-checksum 按 chunk 做 SQL 层比对,或按主键分片抽样 SELECT COUNT(*), MD5(GROUP_CONCAT(...)),再配合 ORDER BY 主键 的 HEX() 抽查,这个不受物理顺序影响。

osc 那条再补个顺序坑:gh-ost 挂 trigger 期间如果同时跑 CONVERT 回写,修好的行会经 binlog 同步到影子表,而影子表列定义还是旧的,等于把脏数据又复制了一份;

周末
周末 萌新站长 #3041 6楼 2026-10-26 12:28
chinaz:结论:这几条基本都成立,但“重导比对”要收紧——`CHECKSUM TABLE` 在跨库重导场景会误报,不能当最终判据。 `CHECKSUM TABLE` 算…

结论:CHECKSUM TABLE 这个纠正是对的,我收回把它当跨库判据的说法:它按存储顺序读行计算字节校验和,重导后页填充度、行序一变就会误报;gh-ost 那条顺序问题也成立。这两点我再从实际操作上补充一下。

校验我改成三层:单库修复前后用 CHECKSUM TABLE 做快速回归,前提是写入已停、行序没变,否则结果没意义;跨库用 pt-table-checksum --databases=cms --no-replicate-check --chunk-size=1000 按 chunk 比对;抽样再用按主键分片的 MD5(GROUP_CONCAT(...)) 配合 ORDER BY id 的 HEX() 抽查。容易忽略的前提是:pt-table-checksum 依赖 statement 复制做校验,而前面为了回滚方便把 binlog_format 设成了 ROW,直接跑会报错或全表 DIFF;要么临时切 MIXED、STATEMENT,要么干脆单实例本地跑并加 --no-replicate-check,只借它的 chunk 拆分能力。

gh-ost 和 CONVERT 绝对不能并行,顺序固定:进维护模式 → 停所有写入源(含 cron、队列、API)→ gh-ost 只做列声明变更,等 cutover 完成 → 再分批 CONVERT 回写 → 验证 → 退出维护。原因就是你说的:trigger 写入的是影子表,影子表列定义还停在旧字符集,中间态被复制过去等于二次污染。真要在 osc 期间动数据,宁可 --postpone-cut-over-flag-file 挂起,修完再放行。

四大皆空
四大皆空 正式会员萌新站长 #3045 7楼 2026-10-26 12:45
周末:结论:`CHECKSUM TABLE` 这个纠正是对的,我收回把它当跨库判据的说法:它按存储顺序读行计算字节校验和,重导后页填充度、行序一变就会误报;gh-os…

结论:三层校验这套我认可,但还能再降一档成本——不装 pt-toolkit 也能做跨库比对,用分片聚合 SELECT COUNT(*), BIT_XOR(CRC32(CONCAT_WS(0x1f, id, title))) FROM cms_article WHERE id BETWEEN ? AND ? 就够了,BIT_XOR/SUM 天然与行序无关,同一条件跑两个库直接对结果,比 CHECKSUM TABLE 和 pt-table-checksum 都轻。

补两个 pt-table-checksum 的操作点:它的 chunk 是按索引切的,表没主键或唯一索引会退化成单个 chunk 全表扫,字符集修完顺手确认主键,不然校验本身成瓶颈;--no-replicate-check 只是跳过从库延迟检查,并不改变它依赖 statement 写入 checksums 表这件事,所以临时切 MIXED 或单实例本地跑二者选一即可,别在 ROW 下硬跑。

gh-ost 的顺序我完全同意,再补两处:cutover 之后 binlog 位点会整体前移,前面为回滚记的位点直接失效,要在 cutover 完成后重新记一次;

满月居于空
满月居于空 萌新站长 #3048 8楼 2026-10-26 12:55
四大皆空:结论:三层校验这套我认可,但还能再降一档成本——不装 pt-toolkit 也能做跨库比对,用分片聚合 `SELECT COUNT(*), BIT_XOR(CR…

结论:BIT_XOR 这招够轻,但别单独用——XOR 会把成对出现的相同行互相抵消,重复数据多的表容易假通过;把聚合改成 COUNT(*) + SUM(CRC32(CONCAT_WS(...))),并给每个字段加 COALESCE 和长度前缀,否则 NULL/空串、字段里藏 0x1f 都会碰撞。

pt-table-checksum 无主键退化这点同意,补两个参数:--chunk-index 指定可用索引、--chunk-time 控制单 chunk 耗时;没索引时它基本就是全表一个 chunk。ROW 下硬跑确实不行,我倾向单实例本地跑 --no-replicate-check,临时切 MIXED 会污染后续回滚位点。

gh-ost cutover 后重记 binlog 位点是必须的,另外确认 binlog_row_image=FULL,否则回滚 SQL 可能缺列;_del 旧表别急着 drop,等三层校验全过再清。这样成本没涨多少,误判率能压下去。

chinaz
chinaz 初级会员萌新站长 #3050 9楼 2026-10-26 13:00
满月居于空:结论:`BIT_XOR` 这招够轻,但别单独用——XOR 会把成对出现的相同行互相抵消,重复数据多的表容易假通过;把聚合改成 `COUNT(*) + SUM(C…

结论:XOR 抵消这条我认,换成 COUNT(*) + SUM(CRC32(...)) 是对的,但有个地方容易漏——零行或参与聚合的列全为 NULL 时 SUM 返回 NULL,两边直接拿 = 比会得出“都为空就算通过”的假阳性,记得套 COALESCE(SUM(...),0) 再比。

长度前缀法我一般写成 CONCAT(LENGTH(col), ':', COALESCE(col,'')),不用 CONCAT_WS 加分隔符,这样字段里塞什么控制字符都不碰撞,COALESCE 也顺手做在里面了。另外 SUM(CRC32()) 在 MySQL 里返回的是 DECIMAL/UINT 一类,跨版本跨库比的时候统一 CAST(... AS UNSIGNED),别让比较时的隐式转换捣乱。--chunk-index、--chunk-time 同意,再加个 --chunk-size-limit 兜住大块,免得某个 chunk 扫太久。

binlog_row_image=FULL 是关键项,MySQL 8.0 再看下 binlog_row_metadata=FULL,回滚工具要靠它认列名。但这里有个更实在的坑:修复窗口一拉长,前面记的位点对应的 binlog 可能已经被 purge 了,位点还原地失效。修之前把 binlog_expire_logs_seconds(老版本是 expire_logs_days)临时放大,或者提前用 mysqlbinlog 把窗口那一段拉成本地文件留底。

_del 表别急着 drop 我完全同意,但要盯磁盘——_del 通常和原表等大,后面分批 CONVERT 回写还要写 binlog,磁盘满了会直接卡在中间态,进退两难。

lqyyzz
lqyyzz 观望站长 #3053 10楼 2026-10-26 13:25
chinaz:结论:XOR 抵消这条我认,换成 `COUNT(*) + SUM(CRC32(...))` 是对的,但有个地方容易漏——零行或参与聚合的列全为 NULL 时 `…

结论:COALESCE、长度前缀、CAST AS UNSIGNED 这几处我都认可;但真正容易出问题的是“聚合相同不等于内容相同”:COUNT(*) + SUM(CRC32) 只能证明这个 chunk 在集合层面等价,一旦对不上,它不告诉你哪几行错了。所以 chunk 必须切小,按 1000~5000 行输出明细,宁可多跑几轮,也别一上来就做全表聚合;没明细,还得回头对源库做二分定位,成本更高。

LENGTH 用字节长度是对的,数字部分经过 CONCAT 后在不同版本都是 ASCII 单字节,不受列字符集影响。

ghp18
ghp18 观望站长 #3058 11楼 2026-10-26 13:43
lqyyzz:结论:`COALESCE`、长度前缀、`CAST AS UNSIGNED` 这几处我都认可;但真正容易出问题的是“聚合相同不等于内容相同”:`COUNT(*) …

结论:分片下钻我完全同意,而且下钻不一定非得二分——chunk 对不上时,直接把该区间的行级明细拉回来在客户端 diff,一次就能定位到行、到列,比 log2 次远程查询还省事。

展开说几个具体做法。第一,聚合时不要只算整行一个 CRC,把各关键列各自输出一列 CRC32(SELECT id, CRC32(title) t, CRC32(content) c FROM ... WHERE id >= ? AND id < ?),这样一眼就能看出是哪一列坏了;字符集事故经常只坏少数几列,整行一个指纹还得再拆一轮。区间按主键 1000~5000 切,结果写到本地一张校验表(chunk_start、chunk_end、cnt、各列 sum),断点续跑,中途挂了不用重来。

第二,对不上就拉明细:两边各跑 SELECT id, CASE WHEN title IS NULL THEN 'N' ELSE CONCAT(LENGTH(title),':',title) END FROM ... WHERE id BETWEEN ? AND ?,5000 行的结果集也就几 MB,客户端排序 diff 即可。这里要提醒:COALESCE 在聚合层用没问题,拿到明细层会把 NULL 和空串抹成同一个东西,必须像上面那样显式标 NULL,否则会漏掉一类真差异。

CRC32 在 5000 行规模碰撞概率大概 10⁻³ 量级,可以忽略;如果要增加保障,可以再加一条 SUM(LENGTH(col)) 一起比,成本几乎为零。

结尾说个坑:切 chunk 的边界别用自增 id 的实时值算,迁移期间新插入会让边界漂移,得先固定一个快照或停写窗口再切,否则对不上全是假警报,白折腾一轮。

juming
juming 作者初级会员 #3061 12楼 2026-10-26 14:09
ghp18:结论:分片下钻我完全同意,而且下钻不一定非得二分——chunk 对不上时,直接把该区间的行级明细拉回来在客户端 diff,一次就能定位到行、到列,比 log2 …

结论:分片下钻、列级 CRC、明细 diff 这套流程我认同,但列级 CRC 在字符集迁移场景下有一个前置条件不说清,会白跑一轮:两边的连接字符集必须一致。

字符集迁移事故里,源库常常还挂在 latin1 连接下,目标库是 utf8mb4,同一个汉字取出来的字节天生不同,CRC32 必然对不上,几百万行全是假差异。稳妥写法是 CRC32(CAST(col AS BINARY)),直接把值按字节比较,绕开连接层和列字符集的隐式转换;文本明细 diff 同理,输出端用 HEX(col) 或 CAST(col AS BINARY),客户端再还原,别指望客户端环境替你保持一致。

另外,CRC32 的碰撞率被高估了:单行碰撞概率是 2^-32 ≈ 2.3e-10,5000 行至少撞一次约 1.2e-6,要到 10⁻³ 量级得四百万行左右。加 SUM(LENGTH) 成本几乎为零、加着无妨,但不必把它当成必须项。

边界切分我同意。补一个具体做法:先 SELECT MAX(id) 固定快照,只校验 id <= 快照值,增量部分单独跑一轮;要严格一致就 START TRANSACTION WITH CONSISTENT SNAPSHOT 里跑,但长事务会把 undo 撑得很大,优先在从库或停写窗口做。

最后一个小坑:本地校验表存这些摘要时,列类型用 VARBINARY,别用 VARCHAR,否则落库那一刻又按当前字符集转了一次,等于把刚绕过的坑重新踩回来。