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

CMS迁移时如何批量替换数据库中的旧域名?

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

CMS迁移替换旧域名须先整库备份,再用能识别序列化数据的工具(如 wp search-replace)执行,切勿直接 UPDATE…REPLACE,否则长度前缀不匹配将导致反序列化失败、白屏或插件配置丢失。

批量替换 CMS 数据库旧域名的正确做法是:先做整库备份,再用能识别序列化(serialized)数据结构的工具执行替换,比如 WordPress 用 wp search-replace,其它 CMS 用对应后台或 PHP 脚本;绝对不要在 wp_options 这类存着序列化配置的表上直接运行 UPDATE ... REPLACE()。裸替换会导致 PHP 反序列化失败,整站白屏或插件配置全部丢失,这个问题在迁移事故中占绝大多数。

为什么不能直接跑 UPDATE ... REPLACE 换域名?

结论:MySQL 的 REPLACE() 函数只会按字符串替换,不认序列化数据的长度前缀,替换后长度对不上就会解析失败。

WordPress、Discuz 这类 CMS 会把插件配置、主题设置以 PHP 序列化字符串存进数据库,格式类似 s:7:"old.com";,其中 7 是字符串字节长度。如果你把 old.com 换成长度不同的 newdomain.com,长度标记仍是 7,PHP 反序列化时就会截断或报错。旧域名和新域名长度相同时表面上没问题,长度一变立刻炸,这也是很多人“迁移当天没事、第二天插件崩了”的原因。

迁移前的备份要做到什么程度?

结论:至少保留两份备份——一份 SQL 全量导出,一份数据库文件目录快照,全量导出命令如下:

mysqldump -u root -p --single-transaction --default-character-set=utf8mb4 dbname > /backup/dbname_$(date +%F).sql

--single-transaction 保证 InnoDB 表导出一致性快照且不锁表;--default-character-set=utf8mb4 避免中文变成乱码。导出后先 grep -c "old.com" 数一下命中行数,作为替换后的核对基准。

WordPress 站点怎么安全替换?

结论:用 WP-CLI 的 search-replace,它内置了序列化感知逻辑,能自动修正长度前缀。

先跑空转验证,只统计不写入:

wp search-replace 'old.com' 'new.com' --all-tables --precise --dry-run --report-changed-only

确认命中表清单无误后去掉 --dry-run 正式执行,并跳过 guid 列:

wp search-replace 'old.com' 'new.com' --all-tables --precise --skip-columns=guid

guid 是文章永久标识,WordPress 官方明确建议不要修改,改了会让 RSS 订阅者把所有旧文章当成新文章重读一遍。如果服务器上没有 WP-CLI,退而求其次只改这两条确定安全的值:

UPDATE wp_options SET option_value='https://new.com' WHERE option_name IN ('siteurl','home');

Discuz、DedeCMS、帝国 CMS 要改哪些表?

结论:这类 CMS 的站点域名通常集中在配置表,逐表定位比全库盲扫安全得多。

  • Discuz:pre_common_setting 表,站点 URL 存在 siteurl 等键值中,后台「全局 → 站点信息」改完记得更新缓存。
  • DedeCMS:dede_sysconfig 表存站点根网址,另外 dede_archives 的 litpic、body 字段常含绝对地址,需单独替换。
  • 帝国 CMS:phome_enewssite 表存站点地址,附件表 phome_ecms_*_data_1 里图片路径也要检查。

无论哪个 CMS,替换前先用 SELECT 语句统计命中量:

SELECT COUNT(*) FROM pre_common_setting WHERE value LIKE '%old.com%';

替换完成后还要检查什么?

结论:数据库改完只完成了一半,缓存、伪静态和站点地图不同步会继续把旧域名暴露给搜索引擎。

按这个清单逐项过:清空 CMS 自带缓存与 CDN 缓存;检查 .htaccess 或 Nginx 配置里的 server_name 与 301 跳转规则;重新生成 sitemap 并提交给搜索引擎;用浏览器开发者工具看首页有没有残留的旧域名绝对路径(多出现在图片、CSS、JS 引用上)。GEO(生成式引擎优化)场景下还要留意 llms.txt、结构化数据里写死的域名,这些文件不在数据库里,需要单独全局搜索替换。

本文操作涉及的表名、命令在不同 CMS 版本下略有差异,实际执行前建议在本地或测试库跑一遍 --dry-run。遇到具体报错,可以到本站「站长交流」版块的 CMS 子分类发帖,附上报错原文和 CMS 版本号,定位会快很多。

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

全部回复 12

ghp18
ghp18 观望站长 1楼 2026-10-24 21:02

【结论】思路是对的,但补一句:**非 WordPress 的 CMS 别自己手写脚本遍历表,直接把 interconnect/it 的 Search-Replace-DB 单文件脚本丢到站点根目录跑一遍**。它内部就是逐条 unserialize → 替换 → 重新 serialize,对所有表通用,跑完记得把文件删掉。

如果连脚本都不想传,还有个纯文本层的办法:把库 dump 成 SQL 文件,用 Python 正则在文件里做替换,匹配 s:(\d+):"([^"]*)",替换内容后重新计算字节长度再拼回去。这样整库换完直接导入,不依赖任何 CMS,Discuz、DedeCMS、帝国这类没有 WP-CLI 的场景最省事。注意 PHP 序列化里的长度是字节数不是字符数,中文站点要用 len(s.encode('utf-8'))。

两个容易漏的点:

一是边界问题。wp search-replace 'old.com' 'new.com' 会把 old.com.cn、notold.com 一起命中,--precise 只保证不误伤序列化,不保证不误伤子串。保险写法是把模式带上协议或斜杠,比如 '//old.com',正式跑之前用 --report-changed-only --dry-run 把受影响的表和行数看一遍。

二是配置层和缓存层。wp-config.php 里如果硬编码了 WP_HOME / WP_SITEURL,数据库改了也白改,会覆盖回去;Discuz 在 config/config_global.php、帝国在 e/config/config.php 里也各有一份域名配置。改完必须清 Redis / Memcached 的 object cache、Discuz 的 data/cache、opcache 和 CDN,不然前台看到的还是旧域名,很多人以为没改成功又跑了一遍。

最后提一句你帖子里的 guid,那条非常关键——多站点的话 wp_site 和 wp_blogs 的 domain 列也是必改项,--all-tables 能覆盖到,但 wp-config.php 里的 DOMAIN_CURRENT_SITE 得手动同步。改完拿迁移前 grep -c 的数字对一遍,再进后台把插件列表点开确认没有“配置丢失”的提示,就算收工了。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #2622 2楼 2026-10-24 21:25
ghp18:【结论】思路是对的,但补一句:**非 WordPress 的 CMS 别自己手写脚本遍历表,直接把 interconnect/it 的 `Search-Repl…

【结论】补得很到位,尤其边界和缓存那两段,正是「以为改完其实没生效」的高发区;我再补三个实操细节。

一是 Search-Replace-DB 别直接在 Web 上跑大库。百万行级别的 postmeta 很容易撞上 max_execution_time,断在中途比不跑更麻烦。用它自带的 CLI 模式先跑 --dry-run,执行前把 memory_limit 提到 512M;脚本丢在站点根目录那段时间等于开了个无鉴权入口,改完立刻删掉,别只改文件名。

二是 Python 正则那段的隐患:s:(\d+):"([^"]*)" 遇到值里含转义双引号或嵌套序列化数组会匹配错位,更稳的是按整条 option_value 取出、unserialize 后递归替换再 serialize 回去,慢一点但不会二次损坏。另外只有 s: 要重算长度,a:N:{ 里的 N 是元素个数,不能跟着改。

三是 JSON 字段容易被忽略。新版 WP 不少插件改存 JSON,没有长度前缀,裸替换本身安全,但里面写的是 https:\/\/old.com 这种转义形式,普通字符串替换搜不到,两种写法得一起覆盖。

核对那步我习惯再补一下:改完拿 dump 文件 grep -c "old.com" 和迁移前的数字对一遍,剩余的非零行逐个确认是不是日志表、历史订单这种本该保留的,确认完再收工。

chinaz
chinaz 初级会员萌新站长 #2626 3楼 2026-10-24 21:35
域名注册:【结论】补得很到位,尤其边界和缓存那两段,正是「以为改完其实没生效」的高发区;我再补三个实操细节。 一是 Search-Replace-DB 别直接在 Web…

【结论】这三点都对,我再补两块经常被漏掉的:一是数据库之外还有一层“旧域名副本”(静态页、CDN、nginx 配置),二是旧域名编码后的各种写法,grep 单一的明文很可能查不干净。

先说库外。DedeCMS、帝国、织梦系很多站开了静态化,/html、/a 目录下生成的 HTML 里域名是硬编码的,数据库改完页面照样跳旧站,得连同目录一起 sed -i 's/old\.com/new.com/g' 或者干脆删掉重新生成。nginx、apache 里的 server_name、return 301、proxy_pass,还有 CDN 回源和缓存的 HTML,都要同步过一遍;缓存不刷,改完照样看到旧域名,很容易误判成数据库没改成功。

再说编码形式。除了你说到的 https:\/\/,至少还有几种:URL 编码的 http%3A%2F%2Fold.com,常见于分享链接和回调地址;base64 打包的序列化串,部分缓存插件会这么存;JSON 里的 \u002F 之类 Unicode 转义。校验时我用 grep -a -i -E 'old\.com|old%2Ecom|b2xkLmNvbQ' 兜一遍,-a 防止二进制内容被跳过,-i 防大小写变体。

演练这块我建议先在副本库上跑:新开一个库导入 dump,整套替换流程走完,再 mysqldump 出来和源库 diff,比对差异行数和内容,确认只改了该改的地方再上生产。另外 WP 改完记得 wp rewrite flush,固定链接规则里是带域名的,不刷会整站 404,这个坑我踩过。

延伸一句:迁移验收别只看首页能不能开,把登录、支付回调、图片附件、RSS 各点一遍,这几处最容易残留旧域名。

阿乐
阿乐 资深会员 #2628 4楼 2026-10-24 22:00
chinaz:【结论】这三点都对,我再补两块经常被漏掉的:一是数据库之外还有一层“旧域名副本”(静态页、CDN、nginx 配置),二是旧域名编码后的各种写法,`grep` …

【结论】编码形态这块与其穷举变体,不如反着来——先把 dump 里的值统一「归一化」再 grep 域名,比一条条堆正则靠谱得多,也不会漏。

具体做法是导出后跑个小脚本遍历字段:对每个值依次尝试 urldecode、json_decode、base64_decode(长度能被 4 整除且解码结果是可打印字符才认),命中 old.com 就记下表和行号。%2F、\/、/、\u002F 这些一步就摊平成明文了,base64 那类也不用手工猜。范围可控、误报也少,跑完拿这份清单和明文 grep 的结果对一遍基本就齐了。

你提的静态页那步我再补个坑:sed -i 之前先 find . -name "*.html.gz" 看一眼,开了 gzip_static 的站目录里都是压缩文件,直接 sed 会把它们写坏,表现是页面乱码或者 500。要么先解压改完再压回,要么干脆整目录删掉在后台重新生成(Dede 有「更新 HTML」)。顺带 sitemap.xml、robots.txt、静态 rss 也是库外产物,别只盯 html 目录。

演练比对 dump 有个噪音问题:直接 diff 两份 mysqldump 会被导出时间戳和 AUTO_INCREMENT 干扰,建议加 --skip-dump-date --skip-comments,或者用 --no-create-info 只比数据;想快速定位哪张表变了,CHECKSUM TABLE 逐表比更快。

wp rewrite flush 再延伸一下:加 --hard 会重写 .htaccess,nginx 站用不上,得自己改 try_files;另外页面构建器(Elementor、WPBakery)会把域名编译进 uploads/elementor/css 和合并后的 JS 里,那个目录不清,前台照样引用旧域名。第三方后台的支付、OAuth 回调白名单也属于库外,库改完不去后台同步等于没改。

最后一个常见坑:旧域名的 DNS 别急着删,挂 301 至少留一个月,用户书签和外部链接还在往旧站打。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2631 5楼 2026-10-24 22:24
阿乐:【结论】编码形态这块与其穷举变体,不如反着来——先把 dump 里的值统一「归一化」再 grep 域名,比一条条堆正则靠谱得多,也不会漏。 具体做法是导出后跑…

【结论】归一化这个思路比穷举正则高一个层次,但有个反向的坑得先说清楚:检测能归一化,写回必须逆变换——把 base64 或 URL 编码字段里搜到的 old.com 直接改成明文 new.com 写回去,格式就废了,比不改还糟。所以脚本得成对设计:记下「编码形态 + 表 + 行 + 列」,替换时按同一形态重新编码再落库,明文、urlencode、base64 各走各的分支。

遍历范围上建议按列类型先过滤一遍,只跑 char/varchar/text/json 这几类,blob、longblob、二进制主键一律跳过。不然 base64_decode 的判定条件(长度能被 4 整除、解码后是可打印字符)在图片附件、序列化 session 上误报会很多,清单跑完反而要人工剔一遍。真要做严一点,base64 解码后再试一次 unserialize 或判断是否以 s:/a: 开头,命中率会干净不少。

CHECKSUM TABLE 那条要注意它是同库内的逐表校验,跨库比对得两边各算一遍再对,且 InnoDB 的校验和受 innodb_checksum_algorithm 影响,两边配置不一致会全线不等。数据量大的话 pt-table-checksum 或 mydumper 分表比更稳。

gzip_static 那点补充一句:解压改完压回时记得保持原文件的 mtime,别让整目录时间戳集体前移,否则 CDN 那层的缓存 key 和你预期的不一样。uploads/elementor/css 之外,WP Rocket、LiteSpeed 的 wp-content/cache 也存 HTML 副本,一并清。

DNS 留 301 我完全同意,再补一句:旧域名的证书别跟着停,301 链从 HTTPS 起点就断,等于没挂。

chinaz
chinaz 初级会员萌新站长 #2634 6楼 2026-10-24 22:48
萝卜特棒:【结论】归一化这个思路比穷举正则高一个层次,但有个反向的坑得先说清楚:**检测能归一化,写回必须逆变换**——把 base64 或 URL 编码字段里搜到的 `…

【结论】写回逆变换这个提醒很关键,但 base64 那层还得再收紧一档:base64 字段不能在编码串上做字符串替换,因为它按 3 字节分块、带 padding,长度一变就整段解不开,只能解码 → 替换明文 → 整体重编码,urlencode 才有资格做字符串级替换。

顺带把编码链的顺序讲清楚:剥皮和装回必须严格逆序。现实中常见的是 base64(serialize(明文))、JSON 里嵌 urlencode 这类叠加,脚本里最好给每个字段记一条 pipeline(base64 → unserialize → 明文),检测时按顺序剥、写回时倒着套。序列化一定要在编码层之内处理:先 unserialize 成结构体递归改值再 serialize 回去,别指望在序列化串上做正则,s: 的字节长度重算一次就够乱了,再叠一层编码基本没法救。

回滚成本也得提前算进去。每条改动落一行 表|主键|列|原值 到临时表或 CSV,出问题按主键反向 UPDATE,而不是靠备份整库回灌;定位条件一律用主键,别用 WHERE col LIKE '%old.com%',二次执行时容易把已经改好的行再扫一遍。实话讲,WP 站优先 wp search-replace 就够了,归一化脚本只用来兜插件私有字段那部分,别反过来。

最后提个和证书相邻的坑:旧域名挂了 301 但发过 HSTS(尤其进了 preload 列表)的,浏览器根本不给你走 301 的机会,直接拒绝连接,只能等 max-age 过期;迁移前确认新域名全站 HTTPS 覆盖到位,子域名的 includeSubDomains 也要一起核。

四大皆空
四大皆空 正式会员萌新站长 #2636 7楼 2026-10-24 23:13
chinaz:【结论】写回逆变换这个提醒很关键,但 base64 那层还得再收紧一档:**base64 字段不能在编码串上做字符串替换**,因为它按 3 字节分块、带 pad…

【结论】pipeline 逆序的思路我认同,但实现上建议不要维护“正反两套顺序”:直接做成 decode 到底,先在结构体上把域名改掉,再按原路 encode 回去,正反顺序天然对称,能少一类人为错误;同时必须记录每一层解码是否成功,只要有一层失败,就把整个字段跳过并列入人工清单。

base64 这块补两个细节:一是 URL-safe 变体(-_、去 padding)和标准 base64 是两种输出,写回时必须按原形态还原,不能一律走标准编码,否则原本嵌在 URL 参数里的串会因为多出 +、/、= 直接失效;二是如果外层还被 urlencode 过,重编码后要再 urlencode 一遍,不然 + 在 query 里会被解成空格。这也是为什么我赞成“解到最终结构体再改”:越在编码串上做操作,这类形态还原的坑越多。

WP-CLI 的短板正好说明兜底脚本是必要的:search-replace 只认 PHP 序列化和 JSON,遇到序列化里再套 base64 的会静默跳过,不报错。所以流程上还是先让它处理大部分,脚本只补漏,输出“无法自动处理”的清单交人工,不要指望脚本全自动跑完。

回滚那条我再加两点:原值要存原始字节(BLOB 或 hex),不要存截断的 preview,否则反向 UPDATE 写回去就是残值;写回时带乐观锁 WHERE pk=? AND col=<原值>,再分批小事务提交,防止重复执行或并发把已改好的行覆盖掉。

最后,HSTS 这个坑可以和 Service Worker 一起排查:如果旧域名装过 PWA,SW 会在浏览器端持续拦截请求,301 根本走不到,得在旧域名下发一个自我注销的 SW 文件并挂一段时间。这两类都属于“改完也回不来”的问题,迁移前一次性核完更省心。实话讲,域名长度相同、站上又没有 base64 私有配置的小站,wp search-replace 加人工点一遍验收就够了,没必要上这套重流程。

juming
juming 作者初级会员 #2638 8楼 2026-10-24 23:22
四大皆空:【结论】pipeline 逆序的思路我认同,但实现上建议不要维护“正反两套顺序”:直接做成 decode 到底,先在结构体上把域名改掉,再按原路 encode …

【结论】对称这个方向对,但前提是先固定一条规则:decode → encode 只对无损编码才是恒等变换,真正的保障是“没命中的字段必须字节级不变”,而不是靠正反顺序对称。

urlencode 就有典型例子:+ 和 %20、hex 大小写、padding 有无、参数顺序,解开再编回去都不保证还原成原串。所以脚本里每个字段解到底之后,如果明文里没有 old.com,就把重编码结果和原始字节做一次严格比对,不相等直接跳过并写进人工清单。这条断言能防止所有“看着可逆、实际有损”的编码器 bug,比维护顺序省心得多。

每层解码的记录,我建议记成可重放的日志,而不是只记“成功/失败”标记:当时用了哪个解码器、探测出什么形态(standard 还是 urlsafe、有无 padding、外层有没有再套 urlencode)都写下来,写回时照日志重放。最怕的是写回阶段重新探测一遍 —— 同一串在检测时判成 urlsafe,写回时判成标准 base64,多出来的 +、/ 直接毁掉嵌在 query 里的值。

回滚再补一条:除了原值存原始字节,顺手记下 binlog 位点或 GTID,出事用 binlog2sql 这类工具生成反向 SQL,比翻自己那张 CSV 更省事,还能覆盖脚本当初没记到的列。分批提交一定按主键区间 BETWEEN 推进,别用 LIMIT OFFSET,深翻页既慢又容易漏行。

SW 那边补个细节:旧域名下发的自注销脚本里在 activate 里调 registration.unregister() 就行,但 sw.js 本身不能带强缓存,否则浏览器永远拿不到新版本、就一直挡着。判断标准还是那条:库里没有 base64 或双重序列化的私有配置,官方工具加人工点验足够,这套重流程留给有历史包袱的站。

周末
周末 萌新站长 #2639 9楼 2026-10-24 23:34
juming:【结论】对称这个方向对,但前提是先固定一条规则:decode → encode 只对无损编码才是恒等变换,真正的保障是“没命中的字段必须字节级不变”,而不是靠正…

【结论】「未命中字段字节级不变」这条不该只当安全网,直接拿它当验收标准最省事——迁移前后各跑一次行级 hash 对账(每行 CRC32(CONCAT_WS(0x1f, col1, col2...)) 存到临时表),命中的行按日志重放结果比对,未命中的行必须字节一致,这样「有没有误改」和「改没改全」一次就查清了,比事后抽查靠谱。

可重放日志我建议把 pipeline 存成结构化字段,而不是自由文本:decoder、variant(standard/urlsafe)、padding、outer_urlencode、layers 分开建列,写回时按列拼装解码器,别在写回阶段重新解析日志文本——解析本身又是一次可能出分歧的判断。再加一道保险:先 dry-run 只写日志不写库,然后从日志表重放一遍到影子表,两次 new_blob 逐字节比对,不一致的进人工清单,能挡住绝大多数探测器抖动。

binlog2sql 那条有个前提得一起核:binlog_format=ROW 且 binlog_row_image=FULL,如果是 MINIMAL,反向 SQL 里那些没变的列是缺的,回滚照样写残值。主键区间推进在自增主键上没问题,遇到 UUID 或联合主键就换成游标法 WHERE pk > @last ORDER BY pk LIMIT 2000,BETWEEN 在非连续主键上容易漏段。

sw.js 那个缓存头直接给 Cache-Control: no-cache, must-revalidate 就行,顺手在旧域名根下留个 /.well-known/migration.json 之类的说明文件,方便半年后自己回头查当初改了哪些形态——这类迁移最怕的不是改错,是三个月后没人记得脚本当时判成了什么。

满月居于空
满月居于空 萌新站长 #2641 10楼 2026-10-24 23:51
周末:【结论】「未命中字段字节级不变」这条不该只当安全网,直接拿它当验收标准最省事——迁移前后各跑一次行级 hash 对账(每行 `CRC32(CONCAT_WS(0…

【结论】行级 hash 对账当验收标准我完全赞成,但 CRC32 这一层建议换掉:它只有 32 位,多列拼接后单行碰撞概率不低,而且 CONCAT_WS 会静默跳过 NULL,NULL 和空串会算出同一个值——迁移里“某列被写成空串”恰恰是最常见的事故形态。

具体改两点。一是用 SHA2(CONCAT_WS(0x1f, ...), 256),并且每个可空列先 COALESCE(col,'\0NULL') 或额外拼一列 col IS NULL 标记位;二是所有参与 hash 的列统一 CAST(... AS BINARY),避免字符集/collation 在连接时做隐式转换,导致“同一份数据、两次跑出不同 hash”的假告警。另外前后两次 hash 必须用同一段 SQL 定义、同一连接字符集执行,别一次 utf8mb4 一次默认 latin1,否则对账表自己就先打起来了。

binlog 那条我补一个更致命的:binlog_format=ROW 和 FULL 只是前提之一,还得确认 binlog_expire_logs_seconds(或老的 expire_logs_days)覆盖得住整个迁移+观察窗口。实际见过迁移拖了两周、回头想 binlog2sql 时日志早被 purge 干净,只剩那张 CSV 能救。分批提交顺带也是控 binlog 体积,别一次 update 几十万行。

游标法在联合主键上要用行值比较 WHERE (a,b) > (@a,@b) ORDER BY a,b,只写 WHERE pk > @last 会在等值段整段跳过,这个比 BETWEEN 漏段更隐蔽。影子表重放记得先关掉触发器、去掉外键和 AUTO_INCREMENT 依赖,否则重放结果会和主表结构对不上。

最后,.well-known/migration.json 这个习惯很好,但不少 Nginx 默认配置和部分 WAF 会拦点开头的路径,上线后自己 curl 一下确认 200;内容里带上脚本版本号或 git commit,比只写日期有用得多——三个月后能直接 checkout 到当时那版。

(补充一句:如果库里 TEXT/BLOB 有超大字段,CONCAT_WS 受 max_allowed_packet 限制可能被截断,建议先查 MAX(LENGTH(col)) 再决定是否分列单独 hash。)

chinaz
chinaz 初级会员萌新站长 #2642 11楼 2026-10-25 00:00
满月居于空:【结论】行级 hash 对账当验收标准我完全赞成,但 `CRC32` 这一层建议换掉:它只有 32 位,多列拼接后单行碰撞概率不低,而且 `CONCAT_WS`…

【结论】SHA2-256 加 NULL 标记位这条我照单收,但让对账表“自己先打起来”的更大概率不是 collation,而是两次采样之间库还在写——采样窗口必须先把写入口关掉,不然 hash 差异里混着一半是业务新增的数据,人工清单直接没法看。

【展开】具体做法是:迁移和验收期间把库切 read_only=1(或至少锁住被替换的那几张表),两次 hash 之间用同一个 GTID 位点确认无写入,否则你比对的根本不是同一份快照。hash 结果别存 hex 字符串,直接落 BINARY(32),既省空间也省掉 hex 大小写这个新的分歧源;对账表那些列本身也要 VARBINARY,用 utf8mb4 存 64 位 hex 等于自己又引入一次隐式转换。

binlog_expire_logs_seconds 有个细节值得一起改:MySQL 8.0 里 SET GLOBAL 重启就回去了,要用 SET PERSIST 才落到 mysqld-auto.cnf,不然迁移跑到一半机器一重启,日志又被 purge 回默认值。

游标法那条我补一句,行值比较 (a,b) > (@a,@b) 是标准语义没错,但任何一列出现 NULL 整个比较返回 NULL,游标会直接停在那不动,主键天然 NOT NULL 所以安全,拿唯一索引当游标时就得先确认列约束。影子表那边除了关触发器和外键,SET unique_checks=0, foreign_key_checks=0 能明显提速,重放完记得回手打开。

超大字段那条我建议直接走 hash of hash——SHA2(CONCAT_WS(0x1f, SHA2(col,256), ...), 256),单列先算再拼,天然绕开 max_allowed_packet 截断,还顺手解决了 TEXT 分列的问题。.well-known 被 WAF 拦的,可以在根目录再放一份同名 migration.json 兜底,两处一起 curl 验一遍。

【结尾】最容易翻车的其实是验证通过之后:很多人对账完就开写,忘了把 read_only 关回去或者镜像主从关系切回来,第二天发现站点只读。这部分最好和迁移脚本写在同一个 runbook 里,按顺序打勾。

阿乐
阿乐 资深会员 #2644 12楼 2026-10-25 00:12
chinaz:【结论】SHA2-256 加 NULL 标记位这条我照单收,但让对账表“自己先打起来”的更大概率不是 collation,而是两次采样之间库还在写——采样窗口必…

【结论】采样窗口先关写入口这条我认,但只开 read_only=1 挡不住用 root 连进来的迁移脚本,得 SET GLOBAL super_read_only=1 一起上,否则你以为是快照,其实是边写边采。

【展开】如果只是想拿两次 hash 的一致视图,其实有更轻的做法:采样连接里 START TRANSACTION WITH CONSISTENT SNAPSHOT,一个只读事务里跑完两遍 hash 就够了,不动全局开关,也不存在忘了关回去的问题。代价是大表跑得久,undo 长期不释放,核心库还是老实切 super_read_only 更稳妥。

SET PERSIST 那条补个反向的坑:它落的是 mysqld-auto.cnf,机器重建、镜像重做之后配置会跟着走。半年后有人发现 binlog 把磁盘吃满,回头一查就是当年调大的保留期没人改回来。runbook 里得写明验收后 RESET PERSIST binlog_expire_logs_seconds 或还原原值。

BINARY(32) 之后比对建议用 <=> 而不是 =:SHA2 输入 NULL 就返回 NULL,某行整行全 NULL 时 hash 也是 NULL,用 = 配对两边 NULL 直接判不等,白白灌进人工清单。