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

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

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

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

乱码的根因几乎都不是数据本身坏了,而是迁移链路上“导出、传输、导入、连接”四层的字符集不一致:把 utf8mb3(MySQL 里写作 utf8,单字符最多 3 字节)库导进 utf8mb4 库、或者导出时没带 --default-character-set、或应用连接没设 charset,都会让中文按 latin1 解释成 中文 这类鬼画符。先判断数据是“显示乱”还是“已经写坏”,再决定用改配置还是用 CONVERT 回滚,前者 5 分钟,后者必须逐列处理。

怎么判断乱码是连接层问题还是数据真的坏了?

结论:用 SELECT HEX(字段) 看真实字节,能立刻区分三种情况,判断错方向会把可救的数据改成不可救。

第一步连上库执行诊断三件套:

SHOW VARIABLES LIKE 'character_set%';
SHOW VARIABLES LIKE 'collation%';
SHOW CREATE TABLE cms_article\G

再看可疑字段的十六进制:SELECT id, HEX(title) FROM cms_article LIMIT 5;

  • 中文“测试”在 utf8mb4 下的 HEX 是 E6B58BE8AF95,说明数据是好的,乱码只出在页面或客户端 → 连接层问题(情况 A)。
  • 出现 C3A4C2B8C2ADC3A6E28093 这类成对膨胀的字节,是典型的“utf8 被当 latin1 再存一次”的双重编码 → 可逆修复(情况 B)。
  • 字段里全是 3F(问号)或长度明显变短,说明字符在入库时就被替换或截断了 → 不可逆,只能用备份重导(情况 C)。

情况 A:连接层不一致怎么改?

结论:数据没坏时不要动表,只改连接串和配置,改完刷新页面即可恢复。

三处要对齐成同一个值 utf8mb4:数据库服务端(my.cnf 的 [mysqld] 段加 character-set-server=utf8mb4 与 collation-server=utf8mb4_unicode_ci)、客户端命令行([client] 段加 default-character-set=utf8mb4)、应用连接(PHP PDO 用 mysql:host=...;dbname=...;charset=utf8mb4,或连上后立即 SET NAMES utf8mb4)。CMS 侧还要检查自己的配置文件:WordPress 从 4.2 版起支持 utf8mb4,需在 wp-config.php 里把 DB_CHARSET 设为 utf8mb4、DB_COLLATE 留空;Discuz! 等国产 CMS 要把 config/config_global.php 里的 dbcharset 同步改成 utf8mb4。只改数据库不改应用,仍然会乱。

情况 B:数据已被双重编码怎么救?

结论:双重编码是可逆的,用 CONVERT(BINARY CONVERT(col USING latin1) USING utf8mb4) 就能把错解的字节还原,但必须先做全表备份。

先在小范围验证:

SELECT CONVERT(BINARY CONVERT(title USING latin1) USING utf8mb4) AS fixed
FROM cms_article WHERE id = 123;

输出正常中文再批量改,且要连同字段的字符集一起改:

ALTER TABLE cms_article MODIFY title VARCHAR(255)
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE cms_article SET title = CONVERT(BINARY CONVERT(title USING latin1) USING utf8mb4);

注意 BINARY 关键字不能省,它是绕过当前字符集校验、强制按原始字节重新解释的关键;省略后会得到 NULL 或继续报错。含 emoji、生僻字的字段必须走 utf8mb4,utf8mb3 存不下 4 字节字符,这是迁移后“部分内容变问号”的直接原因。

迁移时怎么一次性避开字符集坑?

结论:把导出、建库、导入三步的字符集全部显式写死为 utf8mb4,比事后修数据省 10 倍时间。

标准动作:


mysqldump -u root -p --default-character-set=utf8mb4 \
  --single-transaction --routines --triggers cmsdb > cmsdb.sql

# 2. 目标库建库时就定死
CREATE DATABASE cmsdb DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

# 3. 导入前在会话里 SET NAMES,再 source
mysql -u root -p --default-character-set=utf8mb4 cmsdb < cmsdb.sql

导入后做两项验证:SELECT COUNT(*) FROM cms_article WHERE title LIKE '%?%' OR title LIKE '%ä%'; 应返回 0;再抽样比对源库与目标库同一行的 MD5(title) 是否一致。

一个高频连带的坑:切到 utf8mb4 后,InnoDB 单列索引长度上限是 3072 字节,varchar(255) 按 4 字节算已达 1020 字节,在 MySQL 5.6 及更早的 COMPACT 行格式下会报 “Specified key was too long”。解决方案是把行格式改为 ROW_FORMAT=DYNAMIC 并确认 innodb_large_prefix=ON(MySQL 5.7.7 起默认开启,8.0 已移除该参数并默认 DYNAMIC),或者把索引列长度收到 varchar(191)。

最后提醒:情况 C 的问号是永久性字符替换,只能从迁移前的备份重导,且重导时务必按上面的 --default-character-set=utf8mb4 参数执行。整套流程的核心就一句话——字符集要在导出、建库、导入、连接四个环节显式统一,任何一环依赖默认值都会在中文上翻车。

本文转载自 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,否则落库那一刻又按当前字符集转了一次,等于把刚绕过的坑重新踩回来。