Discuz论坛批量替换SQL实战:5步零事故指南
这篇文章我想认真聊一下 Discuz 论坛批量替换文章正文与帖子正文这件事,因为这是我从 2014 年开始做地方社区论坛代运营,到 2020 年陆续帮七八个站长处理迁移和清洗任务过程中,反复用到的一个操作。看上去就是一条 SQL,但实际上里面藏着不少坑,我自己翻过车,也见别人翻过车,所以这次写一份相对完整的版本,覆盖前置准备、SQL 写法、回滚思路、迁移场景下的注意事项,以及大表分批、缓存一致性这些容易被忽略的细节。
什么时候需要批量替换
几个我经历过的真实场景,列出来你大概就明白这个需求的重要性:
- 论坛域名换了。原先用
bbs.olddomain.com,迁到bbs.newdomain.com,过去几年帖子里所有手动写的链接、图片地址都还指向老域名,必须批量换掉,否则前台一堆图片裂掉。 - 网盘服务商倒闭。比如当年 360 云盘关闭那一阵,论坛里大量分享帖里的
yunpan.cn链接全部失效,得换成新的网盘地址或者直接换成补档提示。 - 图片附件路径从相对路径改成 CDN 全路径,需要把
/data/attachment/全部前缀替换成https://cdn.example.com/data/attachment/。 - 老板突然要求把品牌名从"XX 社区"改成"XX 论坛",已经发布的几万条历史内容都得跟着改。
- 涉及合规风险的关键词清理,比如某些早年帖子里包含敏感词需要替换成
***或者删掉对应链接。 - SEO 改造:把帖子里指向 nofollow 站点的链接统一改成自家锚文本,或者把混合内容(http 图片)批量改成 https。
这些场景如果让管理员一篇一篇手动改,几万条数据下来,得改到天荒地老。所以走 SQL 是唯一务实的方案。但 SQL 是一把双刃剑,错一个字符就可能毁掉整个论坛,所以下面每一步都不能偷懒。
Discuz 后台执行 SQL 的前置开关
出于安全考虑,Discuz X3 系列默认禁止后台直接执行 UPDATE、DELETE 这类高危 SQL。要打开它,需要改 config/config_global.php 这个文件。我习惯先 SSH 上服务器或者用 SFTP 把文件拉下来,本地用 VS Code 打开,避免在 FTP 里直接编辑容易出现编码问题。
找两个配置项:
$_config['security']['querysafe']['status'] = 1;
$_config['admincp']['runquery'] = 0;
把它们分别改成:
$_config['security']['querysafe']['status'] = 0;
$_config['admincp']['runquery'] = 1;
第一个是 querysafe 安全机制开关,关掉之后才能让某些含有 UPDATE、SET 的语句通过。第二个是后台 runquery 入口的总开关,开了之后管理员后台才会出现"升级"里的 SQL 执行框。
重要提醒:执行完批量任务之后,一定要把这两个值改回 1 和 0。我见过有站长改完忘了关,半年后被攻击者通过其他漏洞拿到管理员权限,第一时间利用这个开放的 runquery 入口直接 DROP TABLE,整个论坛数据全没。安全是第一位的,别图省事。
如果你的服务器装了 phpMyAdmin 或者更现代的 Adminer,那就完全可以跳过上面这两步,直接连数据库执行 SQL。我现在更推荐这条路,因为 phpMyAdmin 有可视化的影响行数提示,比 Discuz 后台那个粗糙的执行框友好太多。如果连后台 SQL 都禁了又没装 phpMyAdmin,可以临时在服务器上跑 mysql -u root -p 命令行,操作完直接退出,留下的痕迹最少。
备份永远是第一步
这一段我必须单独拎出来强调。批量替换 SQL 是不可逆操作,一旦写错条件或者替换字符串多了一个空格,几万条数据全废,没有备份就只能跑路。
我的备份习惯是:
mysqldump -u root -p \
--single-transaction \
--default-character-set=utf8mb4 \
--tables ultrax pre_forum_post pre_forum_thread pre_portal_article \
> backup_before_replace_$(date +%Y%m%d_%H%M%S).sql
几个参数解释一下:
--single-transaction让 dump 在一个事务里完成,不会锁表,对在线运行的论坛友好。--default-character-set=utf8mb4强制用 utf8mb4 输出,避免 emoji 和生僻字被截断成乱码。- 只 dump 即将被改的几张表(
pre_forum_post、pre_forum_thread、pre_portal_article),文件小、恢复快。 - 文件名带时间戳,方便万一出事的时候按时间回滚。
如果是云数据库(阿里云 RDS、腾讯云 CDB),建议除了 mysqldump,再额外触发一次实例快照,这是最稳妥的兜底。我的经验是:mysqldump 适合小范围、单表回滚;快照适合"整个操作全部撤销"。两者一起做,多出来的成本可以忽略不计,但能救命。
备份完成后顺手验证一下文件可读:
head -50 backup_before_replace_*.sql
gzip -t backup_before_replace_*.sql.gz # 如果压缩过
我见过最离谱的事故是站长 mysqldump 时磁盘满了,文件只写到一半就被截断,结果备份"成功"但实际不可用。所以备份完必须验证。
核心替换 SQL 的写法
Discuz 的论坛帖子内容存在 pre_forum_post 表的 message 字段里(注意是 pre_ 这个前缀,每个站点可能不一样,看你 config_global.php 里的 tablepre 配置)。门户文章的正文则在 pre_portal_article 表的 content 字段。
最基础的语句是这样:
UPDATE pre_forum_post
SET message = REPLACE(message, '要替换的内容', '新内容');
UPDATE pre_portal_article
SET content = REPLACE(content, '要替换的内容', '新内容');
但我从来不直接这么用,原因后面会讲。我推荐的稳妥写法多两步:
先用 SELECT 看影响范围
SELECT pid, tid, LEFT(message, 80) AS preview
FROM pre_forum_post
WHERE message LIKE '%要替换的内容%'
LIMIT 50;
SELECT COUNT(*) AS total_rows
FROM pre_forum_post
WHERE message LIKE '%要替换的内容%';
这两条语句先让你看到大概会影响多少行、内容大概长什么样。如果 total_rows 数字和你预估差距很大,立刻停下来排查,不要往下走。我自己有个硬性规则:差距超过 30% 就先暂停,先把 LIKE 模式打印出来反复看,别冲动 UPDATE。
加 WHERE 条件再 UPDATE
UPDATE pre_forum_post
SET message = REPLACE(message, 'bbs.olddomain.com', 'bbs.newdomain.com')
WHERE message LIKE '%bbs.olddomain.com%';
带上 WHERE 条件的好处有两个:一是减少要扫描和更新的行数,对大表性能差异巨大;二是 SQL 显示影响行数等于实际匹配行数,便于核对。
如果不带 WHERE,MySQL 会扫描全表,每一行都做一次 REPLACE 调用,即使没匹配也算"写入"(因为引擎不知道有没有变化),在动辄千万级的论坛帖子表里能跑一两个小时,期间 IO 直接飙满,论坛前台会卡到打不开。我自己 2017 年就因为这个经验不足被站长在群里骂了一晚上。
几个常见替换场景的实战 SQL
场景一:替换论坛帖子里所有 HTTP 链接为 HTTPS
UPDATE pre_forum_post
SET message = REPLACE(message, 'http://bbs.example.com', 'https://bbs.example.com')
WHERE message LIKE '%http://bbs.example.com%';
场景二:把图片附件的旧 CDN 域名换成新的
UPDATE pre_forum_post
SET message = REPLACE(message, 'https://cdn-old.example.com/', 'https://cdn-new.example.com/')
WHERE message LIKE '%cdn-old.example.com%';
UPDATE pre_portal_article
SET content = REPLACE(content, 'https://cdn-old.example.com/', 'https://cdn-new.example.com/')
WHERE content LIKE '%cdn-old.example.com%';
记得连带改 pre_common_attachment 表的 filepath,否则附件下载链接还是指向老 CDN:
UPDATE pre_common_attachment
SET filepath = REPLACE(filepath, 'old-bucket/', 'new-bucket/')
WHERE filepath LIKE 'old-bucket/%';
场景三:清理某个失效的网盘链接,整段替换为提示文字
UPDATE pre_forum_post
SET message = REPLACE(
message,
'链接:https://yunpan.cn/abcdef 提取码:1234',
'原网盘链接已失效,请联系楼主补档'
)
WHERE message LIKE '%yunpan.cn/abcdef%';
场景四:只对某个版块的帖子做替换(联表)
这个需要 JOIN,因为版块信息在 pre_forum_thread 表里:
UPDATE pre_forum_post p
INNER JOIN pre_forum_thread t ON p.tid = t.tid
SET p.message = REPLACE(p.message, '旧关键词', '新关键词')
WHERE t.fid = 38
AND p.message LIKE '%旧关键词%';
这里 fid = 38 是版块 ID,你可以在管理后台版块编辑页面的 URL 里看到它。如果你的 Discuz 版本有 fid 分表(pre_forum_post_1、pre_forum_post_2 这种),需要先确认这个 fid 对应哪张分表,再分别执行。
场景五:文章标题里的关键词替换
标题在 pre_forum_thread 表的 subject 字段,门户文章标题在 pre_portal_article 表的 title 字段:
UPDATE pre_forum_thread
SET subject = REPLACE(subject, '【活动】', '【公告】')
WHERE subject LIKE '%【活动】%';
注意,subject 是建了索引的,更新后 Discuz 的搜索缓存可能会过期,必要时去后台清一下缓存。门户分类标题、专题标题分别在 pre_portal_category、pre_portal_topic,别漏了。
场景六:清理签名档里失效的外链
签名档在 pre_common_member_field_forum 表的 sightml 字段:
UPDATE pre_common_member_field_forum
SET sightml = REPLACE(sightml, 'http://failed-domain.com/', '')
WHERE sightml LIKE '%failed-domain.com%';
签名档失效链接如果不清理,每个被签名档命中的帖子页都会请求一次 404 资源,对 SEO 和加载速度都不利。
性能与一致性的注意事项
几个进阶的点,我自己处理大表(百万级以上 pre_forum_post)时的经验:
分批执行避免事务过大
如果一次性 UPDATE 几十万行,事务日志会非常大,可能撑爆 InnoDB 的 buffer。建议加 LIMIT 配合主键分批:
UPDATE pre_forum_post
SET message = REPLACE(message, 'bbs.old.com', 'bbs.new.com')
WHERE message LIKE '%bbs.old.com%'
AND pid BETWEEN 1 AND 100000;
然后改区间继续跑:100001 到 200000,依此类推。每批跑完看一眼主从延迟(如果有从库的话),等延迟降下来再跑下一批。如果你愿意写脚本,可以包一层 bash 自动循环:
MAX_PID=$(mysql -N -e "SELECT MAX(pid) FROM ultrax.pre_forum_post")
STEP=50000
for ((i=0; i<MAX_PID; i+=STEP)); do
mysql ultrax -e "
UPDATE pre_forum_post
SET message = REPLACE(message, 'bbs.old.com', 'bbs.new.com')
WHERE pid BETWEEN $i AND $((i+STEP))
AND message LIKE '%bbs.old.com%';"
echo "done batch $i ~ $((i+STEP))"
sleep 1
done
每批之间 sleep 1 是给从库追日志的时间。我跑 800 万行的帖子表用过这种方式,全程主从延迟没超过 5 秒,前台用户毫无感觉。
关闭论坛或者贴公告
批量更新期间帖子表会被加锁,前台用户发帖、回帖会卡顿甚至失败。我一般选半夜两三点用户最少的时段操作,并提前在站内公告告知维护时间。如果你愿意更激进一点,可以临时把 pre_forum_post 设成只读:
FLUSH TABLES pre_forum_post WITH READ LOCK;
-- 但这会让前台发帖直接报错,慎用
缓存一致性
Discuz 自己的全文索引(DZ X3.4 之后用的是自建的 forum_post_tableid 之类的方案)在内容大批量变化后需要重建。门户文章如果接了百度收录、Google Search Console,路径变化要去站长平台提交 URL 改造规则。data/cache/ 目录下的所有 cache_*.php 也建议删一遍,让下次访问重建。
UTF-8 与 GBK 编码问题
Discuz X3.2 之前的某些版本是 GBK,X3.4 之后默认 utf8mb4。如果你站的字符集是 GBK,phpMyAdmin 连接的字符集要选 gbk,不然中文替换字符串过去全是问号,REPLACE 永远匹配不到。命令行 mysql 客户端可以用:
mysql -u root -p --default-character-set=gbk ultrax
批量替换前用 SHOW CREATE TABLE pre_forum_post 看一眼字段的 charset,遇到 mix-charset 数据库(一个站点 utf8 + gbk 混用过的)要分别用对应字符集连接两次。
三个真实迁移案例对照
抽象的 SQL 模板讲完,把我亲历过的三个迁移案例分别还原一下,方便你对照判断自己属于哪一类。
案例一:地方社区 olddomain.com → newdomain.com
这是 2017 年帮一个浙江本地论坛迁域名。论坛积累了 12 年,pre_forum_post 表 480 万行。我们提前把替换 SQL 在测试库跑通,正式上线时凌晨 2 点开维护页面,分批每批 50 万行,跑了 40 分钟。事后核查时发现还遗留了 137 条帖子里有老域名,原因是某些用户用了 BBS.OLDDOMAIN.COM 大写——MySQL 默认 LIKE 大小写不敏感但 REPLACE 大小写敏感。后续补了一条 LOWER() 转换的 SQL 才完全清干净。
案例二:360 云盘关闭潮
2016 年 360 云盘关停,论坛里几千条共享帖里都有 yunpan.cn 链接。我们没办法逐条找到对应的新链接,所以策略是统一替换为"原网盘已失效,回帖联系楼主补档"。SQL 简单,难的是说服楼主们陆续补档——后台批量发送 PM 通知了一遍,最终大约 30% 的帖子在两周内补上了新百度网盘链接。
案例三:CDN 从阿里云 OSS 迁到 Cloudflare R2
这次的难点是不仅 pre_forum_post 里有 CDN 链接,pre_common_attachment、pre_portal_article、pre_common_member_field_forum 多张表都有。我用一个 shell 脚本顺序处理了 5 张表,每张表替换前都先 SELECT COUNT 确认数量,再 UPDATE,全程留 SQL 审计日志。这次教会我一件事:跨表批量替换必须有一个清单,写在 markdown 文件里逐项打勾,避免遗漏。
回滚与事后核查
替换完不要马上关数据库连接,建议立刻做几件事:
- 用上一节的 SELECT 语句反向查一下:还能不能搜到旧字符串?理论上应该是 0 行。如果还有,说明替换没生效,需要排查编码或者条件。
- 抽样打开几个被改过的帖子,从前台访问看一下渲染是否正常,有没有 HTML 标签被破坏的情况。这个尤其重要——如果你替换的是 HTML 片段,前后字符串不对称会导致剩余 HTML 失效。
- 把刚才用过的 SQL 全部存档,命名
replace_yyyymmdd.sql,留作日后审计。 - 查看慢查询日志:
tail -200 /var/log/mysql/slow.log,确认刚才的 UPDATE 没有产生意外的全表扫描。 - 清一遍 Discuz 后台缓存:"工具 → 更新缓存"全选执行一次。
万一发现替换错了:
- 如果数据没动几条,手动改回去最快。
- 如果错得多,从 mysqldump 备份恢复整张表:
mysql -u root -p ultrax < backup_before_replace_20260507_023000.sql - 如果是云数据库,按快照恢复实例,注意快照恢复一般是恢复到新实例,需要再切换连接配置。
- 如果整体没错但局部漏掉,写一条更精确的补丁 SQL 单独跑,别把整张表回滚回来重做——回滚的代价远比补丁大。
把这套流程沉淀为团队 SOP
我现在带的运维小组里,每次有人要执行批量替换,都得按这个清单走一遍:
- 需求确认:要替换什么、影响哪些字段,写在 issue 里。
- 权限申请:管理员账号临时开通,操作后立即回收。
- 备份:mysqldump + 云快照,双重保险。
- 测试:在测试库跑同一条 SQL,对比影响行数。
- 变更窗口:选用户最少的时段,公告 30 分钟。
- 分批执行:每批 5-10 万行,间隔 1 秒。
- 核查:反向 SELECT、抽样前台访问、清缓存。
- 归档:SQL + 日志保存到内部 Wiki,至少 6 个月。
把这八步写在 confluence 上,每个新同事入职第一周必须熟读。这种"无聊的流程"看似浪费时间,但救过我们至少 3 次差点出大事的场景。最近一次是新人本来想 UPDATE pre_forum_thread SET subject = '新标题' 漏了 WHERE,被代码 review 截下来。如果是直接连库,几万个帖子标题就全废了。
常见问题解答
Q1:能不能用正则表达式替换,而不是简单的字符串替换?
MySQL 5.7 及以下不支持原生的 REGEXP_REPLACE,只能用第三方 UDF 或者把数据导出到外部脚本(比如 Python)处理后再导回去。MySQL 8.0 之后内置了 REGEXP_REPLACE 函数,可以这样用:
UPDATE pre_forum_post
SET message = REGEXP_REPLACE(message, 'http://(www\\.)?old\\.com', 'https://new.com')
WHERE message REGEXP 'http://(www\\.)?old\\.com';
但正则替换风险更大(贪婪匹配、回溯爆栈),建议先在测试库跑过再上线,并把每条匹配前后字符串都 SELECT 出来人工抽查。
Q2:替换之后帖子最后修改时间会变吗?
Discuz 帖子的修改时间是存在 pre_forum_post 表里另一个字段(dateline 是发布时间,不会变;如果用了帖子修改记录功能,会写 pre_forum_postlog)。直接用 UPDATE ... SET message = ... 不会触发 Discuz 的应用层逻辑,所以发布时间和"最后回复"之类的统计字段都不会动。这通常是好事,但如果你需要让搜索引擎重新抓取(比如改了大量内容),可以考虑顺便更新 pre_forum_thread 的 lastpost 字段。
Q3:替换会不会破坏 Discuz 的 BBCode 标签?
如果你替换的内容碰巧落在 BBCode 标签内部(比如 [url=...] 的 URL 部分),且替换后字符串结构发生变化,是有可能让 BBCode 解析失败的。最佳实践是先在测试服务器上跑一遍,然后随机抽 20 篇帖子人工检查渲染。BBCode 是闭合标签敏感的,[url]aaa[/url] 里的 aaa 被改成包含 [/url] 字符串后,整个标签会提前闭合。
Q4:除了直接 SQL,还有别的批量替换方案吗?
有几个:一是 Discuz 自身的"数据 → 数据备份/恢复"工具其实没批量替换功能;二是第三方插件,比如"应用中心"里有"内容批量替换"类插件,但很多年没更新了,不推荐用在生产环境;三是写个 PHP 脚本,分页读取数据,应用程序层做替换后写回,灵活度最高,但工作量也最大。对于一次性、不复杂的替换,直接 SQL 是性价比最高的选择。
Q5:UPDATE 大表跑了一半被卡死了怎么办?
先 SHOW PROCESSLIST 找到对应连接的 Id,再 KILL <Id>。被 KILL 的事务 MySQL 会自动 ROLLBACK,但 ROLLBACK 本身也很慢(已写入的脏页要逐一回滚)。如果你的事务很大,ROLLBACK 时间可能比正向 UPDATE 还久。所以一开始就分批,能避免这种深坑。
Q6:替换之后前台显示没变化,是不是被缓存了?
大概率是。Discuz 的页面缓存有几层:data/cache/ 文件缓存、Memcached(如果开了)、OpenResty/Nginx fastcgi_cache(如果有反向代理)。最稳的清缓存顺序:后台"工具 → 更新缓存"全选 → rm -rf data/cache/* → systemctl restart php-fpm → nginx -s reload。如果用了 Cloudflare,再去 Cloudflare dashboard 点 Purge Everything。
Q7:能不能在 INSERT/UPDATE 的同时只替换、不重写其他字段?
当然,UPDATE pre_forum_post SET message = REPLACE(message, 'a', 'b') WHERE ... 这种语法本来就只改 message 字段,其他字段保持不变。但如果你用了 ORM 框架(比如 Laravel Eloquent)读出来再写回去,框架可能会把整行字段都 UPDATE 一遍,连 NULL 字段都会被显式赋 NULL,要注意。所以批量替换我永远走原生 SQL,不走 ORM。
Q8:替换后 SEO 排名会受影响吗?
视替换内容而定。如果只是改了图片 CDN 域名、签名档失效链接这类对用户感知极小的内容,搜索引擎几乎不会有任何反应。如果替换的是大量正文关键词(比如品牌名),搜索引擎会重新抓取并更新索引,短期可能波动 1-2 周,长期看影响不大。最好的做法是替换完之后去 Google Search Console 主动提交 sitemap,加速重抓。
小结
以上是我对 Discuz 批量替换这件事比较完整的总结。原文那种几行字的"设置一下,输入 SQL,提交"的写法,对没踩过坑的新人是有迷惑性的。希望我这版能帮你绕开我当年踩过的那些坑:开 querysafe 忘关、没 WHERE 全表扫描、没分批撑爆 buffer、忘了清缓存、忘了改附件表 filepath,以及最经典的"忘了备份"。
批量替换不是一条 SQL 的事,是一整套流程的事。把流程写清楚、把每一步都做完,10 分钟能搞定的活就不会拖成一晚上的事故。
因本文不是用Markdown格式的编辑器书写的,转换的页面可能不符合AMP标准。