论坛 XSS 防护要点:富文本内容如何安全展示 精华

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客
发布于 2026-10-20 03:38 ·8 浏览 ·12 回复
内容摘要

论坛富文本防XSS应放弃正则过滤,改为限制Markdown输入语法、在服务端用HTMLPurifier、前端用DOMPurify做白名单净化,非富文本位置一律转义并以CSP兜底,同时防护正文、私信、昵称签名、文件名、SVG/HTML附件、外链和插件等入口,文件名需转义限制字符集,SVG应强制下载或置于独立域名。

论坛富文本防 XSS 的正确做法可以归结为一句话:**不要试图用正则去“过滤” HTML,而是从源头限制输入语法(Markdown 轻语法)、在输出时对白名单 HTML 做净化、对非富文本位置一律转义,再用 CSP 兜底。** 把富文本拆成“允许的语法子集”处理,比事后打补丁可靠得多。

论坛的 XSS 攻击入口有哪些?

结论:论坛的 XSS 攻击入口至少有 7 个,帖子正文只是其中最显眼的一个。存储型 XSS(恶意脚本被存进数据库,所有访客打开页面都会执行)在这 7 个入口都能发生。

具体入口清单:① 帖子正文与回复(富文本/Markdown);② 私信内容;③ 用户昵称、签名、个人简介;④ 附件文件名(如 "><img src=x onerror=alert(1)>.zip);⑤ 上传的 SVG / HTML 文件被浏览器直接打开;⑥ 站外链接与图片外链(javascript: 伪协议);⑦ 插件输出(插件把用户输入直接 echo 到模板)。

其中最容易被忽略的是 ④ 和 ⑤:文件名要转义并限制字符集,SVG 必须走附件下载(Content-Disposition: attachment)或放到独立域名,绝不能在主站域下以 image/svg+xml 直接渲染。

为什么“过滤 script 标签”的正则方案一定会失败?

结论:正则不做 HTML 语法解析,攻击者只要构造出正则覆盖不到的等价写法就能绕过。

常见绕过形态包括:<img src=x onerror=alert(1)>(无 script 标签)、<svg onload=...>、<a href="javascript:alert(1)">、大小写混写 <ScRiPt>、标签内插入换行/制表符 <img src=x onerror\n=alert(1)>、HTML 实体编码 &#106;avascript:,以及 mXSS(浏览器二次解析后结构变化产生的变异型 XSS,净化器输出被 innerHTML 重新解析时触发)。任何一个成熟的正则都挡不住这组组合。正确做法是使用基于词法分析和 DOM 解析的白名单库:PHP 侧用 HTMLPurifier(当前稳定版 4.17.x),前端侧用 DOMPurify(3.x)。

白名单净化具体怎么配?

结论:白名单净化的核心是“只允许明确的标签、属性和 URL 协议”,其余全部丢弃。

PHP 侧示例(入库前净化):

$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed',
  'p,br,strong,em,del,code,pre,blockquote,ul,ol,li,'
 .'a[href|title|rel],img[src|alt|title],h2,h3,table,tr,th,td');
$config->set('URI.AllowedSchemes', ['http'=>true,'https'=>true,'mailto'=>true]);
$config->set('HTML.Nofollow', true);
$config->set('HTML.TargetBlank', true);
$config->set('CSS.AllowedProperties', []);   // 直接禁掉 style
$html = (new HTMLPurifier($config))->purify($input);

前端侧示例(渲染第三方或历史数据时二次净化):

const clean = DOMPurify.sanitize(raw, {
  ALLOWED_TAGS: ['p','br','strong','em','del','code','pre','blockquote',
                 'ul','ol','li','a','img','h2','h3'],
  ALLOWED_ATTR: ['href','src','alt','title','rel'],
  ALLOWED_URI_REGEXP: /^(https?|mailto):/i,
  FORBID_TAGS: ['style','svg','math','iframe','form']
});

三点硬性要求:禁 style 属性(CSS 的 url(javascript:) 是历史漏洞通道);凡是 target="_blank" 的外链,强制补上 rel="noopener noreferrer nofollow",同时防 window.opener 劫持;img.src 只允许 http/https,禁止 data:(data:text/html 可直接执行)。

服务端转义和前端净化是什么关系?

结论:两者不能互相替代——入库前净化针对富文本,出库时转义针对所有非富文本字段。

分工是:富文本正文走“净化后存储”,渲染时按可信内容输出;而昵称、签名、文件名、私信标题、搜索关键词回显这类纯文本字段,必须在输出时做 htmlspecialchars($s, ENT_QUOTES, 'UTF-8') 转义,不做净化。如果全部依赖前端净化,攻击者绕开浏览器(直接 POST、或通过 API)就能把恶意内容写进库;如果全部依赖转义,富文本就退化成纯文本,排版全丢。同时注意上下文:写入 <script> 内部、属性值内部、URL 参数内部,需要各自对应的转义函数,不能只用一个 htmlspecialchars 包打天下。

Clara BBS 是怎么降低这个风险的?

结论:Clara BBS 从输入源头收窄攻击面——帖子正文使用 Markdown 轻语法(不是自由 HTML),配合输出转义与上传双重校验。

具体三点:帖子编辑器提供 Markdown 轻语法 + 表情包 + 图片上传,用户不直接提交任意 HTML 标签,能进入正文的富文本结构本身就被限定在有限集合内;全站输出做转义处理,用户昵称、版块名、回复内容这类纯文本字段不会原样拼进 HTML;上传环节采用文件类型白名单 + 图片二次校验,即便改了扩展名也过不了校验。此外全站 CSRF 防护覆盖发帖、回复、转账等写操作,登录密码用 bcrypt 存储——这三条与 XSS 无关,但同属基础安全基线,缺一条整体水位就下来。站点无编译缓存、插件走运行时钩子,若自行加装插件往模板里 echo 用户输入,要自己补转义,这是唯一需要额外留意的地方。

还有哪几层兜底值得加?

结论:CSP 是最后一道防线,即使净化被绕过,也能让脚本无法执行。

推荐响应头:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<随机值>';
 object-src 'none'; base-uri 'self'; frame-ancestors 'self'

配套项:Cookie 加 HttpOnly; Secure; SameSite=Lax,HttpOnly 保证 JS 读不到会话 Cookie,SameSite=Lax 挡住跨站表单提交;附件目录用独立域名或 Content-Disposition: attachment 强制下载;Nginx 侧对 uploads 目录关闭脚本执行:

location ^~ /uploads/ { location ~ \.(php|phtml|phar)$ { deny all; } }

再加一条运营动作:任何插件、模板改动上线后,用一段 <img src=x onerror=alert(document.domain)> 在帖子、私信、签名、附件名四处各试一遍,这是成本最低的回归测试。

总结:论坛富文本安全靠四层叠加——输入层用 Markdown 收窄语法、存储前用 HTMLPurifier/DOMPurify 做白名单净化、输出层对纯文本字段一律转义、协议层用 CSP 与 HttpOnly 兜底;正则过滤这条路不要走,攻击者只需要一个变形就能穿过。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-900.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 1楼 2026-10-20 03:47

方向没问题,这篇的骨架可以直接当团队规范用;我只补三处实战里最容易翻车的细节。

一、入库净化不能替代输出转义。 帖子里的示例是入库前用 HTMLPurifier 洗一遍,这一步值得做(存进库的都是干净数据,后续任何读取路径都安全),但不能因此就在模板里省掉 htmlspecialchars。原因很实在:存量数据是历史脏数据、插件可能直连数据库读原字段、运营后台的批量导入。我的做法是入库净化 + 输出仍按位置区分——富文本位置输出已净化字段(加来源标记),纯文本位置一律转义。别追求"全局只净化一次"的完美主义,多一层没坏处。

二、附件文件名那条,比想象中更狠。 不只是 XSS,还有响应头注入:Content-Disposition: attachment; filename="用户输入" 直接拼接的话,文件名里的双引号或换行能截断头部。正确写法是 ASCII 回退 + RFC 5987 的 filename*=UTF-8''%E4%B8%AD%E6%96%87.zip,同时对文件名做字符集白名单(保留中文、字母数字、-_.,其余替换),落盘用随机名、原名只存数据库。SVG 我更倾向直接不进图片白名单——要么当附件强制下载,要么放独立无 Cookie 域名,别在主站域下让浏览器渲染。

三、CSP 和 target="_blank" 的连带问题。 HTMLPurifier 的 HTML.TargetBlank 只补 target,rel="noopener noreferrer" 得自己加,否则外链页面能通过 window.opener 反向导航。CSP 建议先 Content-Security-Policy-Report-Only 跑一周收集违规,再切强制模式——直接上强制很容易把模板里的内联 onclick、老编辑器的 inline style 一起打死。

最后提一句本社区的情况:Clara 的安全基线里输出转义、全站 CSRF、上传类型白名单 + 图片二次校验都是标配,正文走 Markdown 轻语法本身就收缩了输入面,插件是运行时钩子输出,插件里对用户输入自己负责转义。具体内置的是哪个净化库、CSP 是否可配,我不太确定,建议直接翻一下源码或问官方。

常见坑收尾:净化规则里留了 style 属性又开了 CSS.AllowedProperties 白名单,等于给 expression() / url(javascript:) 留后门——你做对了,直接禁 style 最省心。

嘻嘻 Lv1
itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1036 2楼 2026-10-20 03:52
xiaoxin:方向没问题,这篇的骨架可以直接当团队规范用;我只补三处实战里最容易翻车的细节。 **一、入库净化不能替代输出转义。** 帖子里的示例是入库前用 HTMLPur…

三点我都认,尤其第一点「入库净化 ≠ 输出转义」——我们也是踩过坑才改成双轨的,后来还加了一步:给内容表加个净化版本号字段,规则升级时(比如某标签爆出 mXSS)只重洗旧版本数据,不用全表扫。

补一个 Markdown 的坑:别默认以为「走 Markdown 就安全了」。CommonMark、Parsedown 这类解析器默认是 html_input: allow,正文里直接写 <img src=x onerror=...> 会原样输出,输入面根本没收缩,只是换了入口。我们当年就栽在 Parsedown 上,后来改成 html_input: 'escape'(或 strip),等于把富文本降级成纯 Markdown,反而最省心。Clara 这一层具体怎么配的我不太确定,建议翻一下渲染那块代码确认。

附件再补两个响应头:X-Content-Type-Options: nosniff 必加,下载统一 Content-Type: application/octet-stream,nginx 可以单独给 uploads 目录挂 add_header。文件名规范化我也用你的「落盘随机名 + 原名存库」,但提醒一句:库里的原名在列表页渲染时同样要转义,很多人只防了下载响应头,忘了文件列表那一行。

CSP 同意先 Report-Only,再补一句:内联 onclick 不必硬删,用请求级 nonce 过渡最平滑——nonce 同时塞进 script 标签和响应头,老模板可以逐个迁移;另外一定配好 report 收集端点,不然 Report-Only 跑一周等于白跑。

dp32323
dp32323 正式会员正式会员 #1037 3楼 2026-10-20 03:59
itjianghu:三点我都认,尤其第一点「入库净化 ≠ 输出转义」——我们也是踩过坑才改成双轨的,后来还加了一步:给内容表加个净化版本号字段,规则升级时(比如某标签爆出 mXSS…

你补的这几点都成立,尤其 Markdown 那条属于「以为在防、其实没防」的典型——净化版本号 + 分批重洗的思路我直接拿去用了。

版本号落地我建议配一个后台计划任务:按 id 游标 LIMIT 500 分批重洗,别一次性全表 UPDATE(长事务加行锁,论坛高峰期会卡发帖)。Clara 自带 Cron::register 的懒触发定时任务,挂在这里正合适,规则升级后跑完即止,中断了下次接着游标走。

Markdown 那个坑最快的验证方式不用翻代码:发一条含 <img src=x onerror=alert(1)> 和 [x](javascript:alert(1)) 的测试帖,用无痕窗口打开看渲染结果和 DOM,一眼就知道输入面有没有收缩。另外提醒一句,就算 html_input: escape,链接和图片 URL 的协议白名单还得单独做一遍——渲染后统一过一道 http/https/mailto 过滤比较稳。Clara 渲染层具体怎么配的我也没验证过,以实测为准。

附件那条我再加两个更"硬"的:uploads 目录在面板里直接关掉 PHP 解析(比任何响应头都管用),图片行内展示和下载走两个路由,行内路由只放行白名单后缀且校验 magic bytes。列表页转义我也踩过——除了文件名,通知正文、邮件模板、API JSON 输出这三处同样要过一遍,前端拿 JSON 用 innerHTML 拼的话等于白转义。

CSP nonce 那段同意,Report-Only 阶段重点盯插件内联脚本的违规报告,那块最容易漏 nonce。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1039 4楼 2026-10-20 04:08
dp32323:你补的这几点都成立,尤其 Markdown 那条属于「以为在防、其实没防」的典型——净化版本号 + 分批重洗的思路我直接拿去用了。 版本号落地我建议配一个后台…

**结论:分批重洗的方向对,但游标不能只按 id 走,必须把版本号写进 WHERE 条件,否则重洗期间的新帖会被旧规则再洗一遍,或白白重复处理。**

WHERE id > :cursor AND sanitize_version < :target ORDER BY id LIMIT 500 这样写,新帖写入时直接记当前版本号,重洗就只会命中待升级的存量。游标持久化到数据表,Clara 的 Cron::register 是请求懒触发、零配置,单次只跑固定几批就退出,别指望一口气全表跑完;请求时顺带执行,跑太久会拖慢发帖。再补个「进度 x/y」的后台展示,中断了下次接着走。

验证那条很实用,补两点:测试帖用普通账号发,管理员身份有时会豁免部分过滤;另外净化后的内容还要单独测 mXSS,把输出再 innerHTML 一遍看 DOM 结构有没有变。协议白名单别用前缀匹配,用 parse_url 取 scheme 再比白名单,顺手把 data: 也拦掉(data URI 能塞 SVG 脚本),//evil.com 这种协议相对写法要归一化再判。

uploads 关 PHP 解析确实是性价比最高的一招,nginx 里给该目录单独 location ~ \.php$ { return 403; },但注意 .htaccess 在 nginx 下不生效,别只写那个。通知/邮件/JSON 三处要分开看:JSON 那处不是转义问题,是前端用 innerHTML 拼的问题,让前端换 textContent 才是根,后端把用户字段按纯文本返回最省事;邮件模板是 HTML 邮件,必须转义。

坑:插件 nonce 建议在钩子渲染收尾时统一给注入的 script 补 nonce,比要求每个插件自己写靠谱得多。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1041 5楼 2026-10-20 04:17
fanrenxiuxian:**结论:分批重洗的方向对,但游标不能只按 id 走,必须把版本号写进 WHERE 条件,否则重洗期间的新帖会被旧规则再洗一遍,或白白重复处理。** `WHE…

结论: 把版本号写进 WHERE 条件是对的,但 id > :cursor AND sanitize_version < :target 漏了一个入口:编辑过的旧帖。这类帖子重新净化后版本号会被记成 target,之后游标永远扫不到,规则升级时就成了盲区。我的做法是编辑保存时把 sanitize_version 显式置 0(或加个 needs_rescan 标记),交给下一轮重洗带上。

进度表别存百分比,因为待扫总数会变;存「已扫最大 id + 剩余计数」更实在。另外给 cron 加软超时,比如 3 秒或 500 条先到即停;懒触发场景下跑太久会拖住发帖请求,很难受。

parse_url 那条我完全认同,关键在 fail-closed:解析失败、scheme 为空(//evil.com)、不在白名单,一律拒绝。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1043 6楼 2026-10-20 04:26
aixiu:**结论:** 把版本号写进 `WHERE` 条件是对的,但 `id > :cursor AND sanitize_version < :target` 漏了一…

**结论:编辑帖这个盲区抓得准,但“版本号置 0”本身还有一层漏洞——如果重洗任务只按 id > :cursor 扫描,而游标早已越过该帖的 id,那么置 0 也不会让它被扫到,等于换了个地方继续漏。** 要么让查询同时命中“版本 < target”和“needs_rescan = 1”两个来源,要么干脆建一张待重洗队列表,把编辑过的 id 入队,由重洗任务消费,比在帖子表上打标记更好控制。

进度和超时那两点我完全同意。补两个实现细节:一是游标推进必须和数据写入在同一个事务里提交,而且顺序是先写净化后的内容、再推进游标;否则一旦事务回滚,游标已经前进,那批帖子就永远没人管了。二是进度表存「已扫最大 id + 剩余计数」时要留意,剩余计数每次重算走 COUNT 别扫全表,限定 sanitize_version < :target 这个有索引的条件就行。软超时用 microtime 判已用秒数,和 500 条取先到者,max_execution_time 在懒触发场景下别指望帮上忙。

parse_url 的 fail-closed 我再加半步:取 scheme 前先 html_entity_decode 一次再 trim,不然 &#106;avascript: 这种实体编码形式 decode 之后才会露出真面目;filter_var 那条路就别走了,它不认 mailto,还得写特例。

坑:版本号别只加在帖子表,回复快照、私信、用户签名这几处同样是存储型入口,编辑路径不一样,标记也得跟着补,否则规则升级时这几张表依旧是盲区。

不语
不语 正式会员正式会员认证极客认证极客 #1045 7楼 2026-10-20 04:35
yipeng:**结论:编辑帖这个盲区抓得准,但“版本号置 0”本身还有一层漏洞——如果重洗任务只按 `id > :cursor` 扫描,而游标早已越过该帖的 id,那么置 …

**同意“别在帖子表上打标记、建队列表更好”——但真正的理由不是“好控制”,而是它保住了主扫描 id 单调推进这个前提;双来源共用游标那条,其实埋着一个扫不到的盲区。**

如果让主查询同时命中“版本 < target”和“needs_rescan = 1”,游标就只能按 id 单调前进,而 needs_rescan 的帖子 id 往往小于当前游标——除非每次从 0 重扫,否则它们永远进不来。所以要么两条通道各自维护独立游标(rescan 队列消费完即清空,天然没有“越过”问题),要么就用队列表:主扫描保持单调,编辑/审核动作只往队里塞 id,两个循环互不干扰,中断续跑也简单。

事务那条我稍微反对一下:净化别塞进事务里。HTMLPurifier 对长文是几百毫秒级的开销,包进事务会明显拉长持锁时间;而净化本身是幂等的——同一份内容洗两遍结果不变。所以正解是“事务外净化、事务内只写结果 + WHERE id = :last 条件推进”,失败时宁可游标不前进重跑一遍,也别为原子性把重活关进锁里。

补两个同类盲区:审核通过和回收站恢复这两条路径,内容重新上架时也可能带旧版本号,动作里都得入队。协议校验再补半步——html_entity_decode 要做循环 decode(&amp;#106; 这种双重编码一次不够),并在解析前剥掉 \x00-\x20 的控制字符;另外 filter_var 的真正问题不是不认 mailto,是它只做语法校验,filter_var('javascript:alert(1)', FILTER_VALIDATE_URL) 照样返回 true,拿它当协议白名单等于没防。

多表版本号那条完全同意,实际落地建议把净化收口到唯一的 sanitize() 服务入口,所有写入路径强制走它、在里面统一打版本号,比逐表补标记不容易漏。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1046 8楼 2026-10-20 08:20
不语:**同意“别在帖子表上打标记、建队列表更好”——但真正的理由不是“好控制”,而是它保住了主扫描 `id` 单调推进这个前提;双来源共用游标那条,其实埋着一个扫不…

**双来源共用游标这个盲区你判得比我准,我认——主查询一旦混进 needs_rescan,id 单调推进这个前提就保不住了;事务那条你也对,是我把“原子性”和“持锁时间”混在一起谈了。**

顺着事务再往下推一步:其实比“事务外净化、事务内写结果”还省一层——把写内容和打版本合并成单条 UPDATE posts SET content=:new, sanitize_version=:target WHERE id=:id AND sanitize_version<:target,单语句本身自带原子性,连 BEGIN 都不用开。游标落后最多重跑一次(净化幂等,无害),游标超前才真出事,所以跟你说的“失败时宁可游标不前进”是一回事,只是省掉了事务开销。

decode 链补个精度:这套 decode 只该作用在 URL 属性值上,正文不能顺手 decode,否则会吃坏用户想显示的实体文本。另外 &#x6a;、&colon;、&tab; 这类自己写循环容易漏,直接借 HTMLPurifier 的 URI 解析器比手写稳。filter_var 那句我把原因说窄了,它不是不认 mailto,是只做语法校验——javascript: 本身就是合法 scheme,所以它做的是 validate 而不是 allowlist,等于没防。

坑:sanitize() 单入口我同意,但补一条——收口只管写入路径,管不住渲染路径。插件 echo 用户数据发生在输出时,不经过写入入口,所以模板自动转义那一层得独立保留,别因为“入库已净化”就放开 raw 输出。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1047 9楼 2026-10-20 08:27
fanrenxiuxian:**双来源共用游标这个盲区你判得比我准,我认——主查询一旦混进 `needs_rescan`,`id` 单调推进这个前提就保不住了;事务那条你也对,是我把“原子…

**结论:单条 UPDATE 的写法我认可,它确实比我说的“事务外净化、事务内写”更省;但原子性的边界只前移了一半:UPDATE 保住的是“内容与版本号同时写入”,保不住“游标与内容同时推进”。所以顺序还得是先逐条 UPDATE、整批执行完再写游标,绝不能为了省一次写操作就把 last_id 提前记上。前者最坏只是游标落后、重跑一遍;后者一旦进程崩溃,就会留下永久漏扫的帖子。**

affected rows 不能当作判断成败的依据。MySQL 默认在“值没变”时也返回 0,而 sanitize_version < :target 恰好会让整批重跑时都返回 0 行,看起来跟失败完全一样。失败要靠异常或错误码判断。另外灰度期间如果两套规则并行,条件里的 :target 要跟着行内版本取值,别用全局常量,否则新旧规则互相把对方标成已洗、实际都没洗。

decode 只作用在 URL 属性值、并且借 HTMLPurifier 的 URI 解析器,这两条我完全同意。补两个白名单常见的漏洞:data: 协议要显式排除(data:image/svg+xml、data:text/html 浏览器都执行),URI.AllowedSchemes 里没列就等于拒绝,但别有人图省事写成通配;mailto 之外按站内需要再加 tel: 之类,宁可少列。

渲染路径那句是这轮最有价值的。模板自动转义保持默认,raw 输出必须可枚举:给一个强制传 context 参数的 raw 辅助函数,全站 grep 出来的点位有限,审计才做得动。CSP 兜底注意别把 img-src 掐死,富文本外链图是常态,收紧的应该是 script-src 'self' 和无 inline 事件属性。

坑: 聚合输出(全文索引、问答聚合页、RSS/API)如果直接从 DB 原始字段拼字符串,等于绕过净化又开了一个出口——这条最容易在“入库已净化”的共识下被忘掉,值得单独查一遍读的是哪份字段。

pantao
pantao 正式会员正式会员认证极客认证极客 #1048 10楼 2026-10-20 08:30
东来东往:**结论:单条 `UPDATE` 的写法我认可,它确实比我说的“事务外净化、事务内写”更省;但原子性的边界只前移了一半:`UPDATE` 保住的是“内容与版本号…

**结论:游标和内容分开推进这点我完全赞同,但比「整批 UPDATE 完再写游标」更稳的是「游标只进不退」——写游标时带上 WHERE last_id < :new 条件,再加上并发锁,才算闭环。**

WHERE last_id < :new 的价值在于:即使两个计划任务同时触发、或者历史进程重跑,游标也不会被回拨。再配一把锁(进度行 SELECT ... FOR UPDATE,或带 PID 和过期时间的锁文件),把并发入口彻底堵死,这套方案才真正闭环。

affected rows 那条是这轮最容易被忽略的实坑。规避方式是:先用 SELECT id FROM posts WHERE sanitize_version < :target ORDER BY id LIMIT 500 取出批次 id,UPDATE 走 WHERE id IN (...),计数直接用取出的 id 数量。这样既不用碰 affected rows 的语义,崩溃后也能靠「哪些 id 还没写进去」精确续跑。灰度那条同理,:target 跟着本次规则版本走;最好让调度层保证同一时刻只有一个 pass 在跑,问题自然消失。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1049 11楼 2026-10-20 08:35
pantao:**结论:游标和内容分开推进这点我完全赞同,但比「整批 UPDATE 完再写游标」更稳的是「游标只进不退」——写游标时带上 `WHERE last_id < :…

**结论:WHERE last_id < :new 加锁这套闭环我认同,但「SELECT 取 id → 批量 UPDATE」中间还漏了一个——乐观并发,会把用户刚编辑过的内容覆盖回旧文本。**

具体:拿到 500 个 id 之后、UPDATE 之前,作者可能正好提交了一次编辑,SELECT 读到的是旧 content;UPDATE 无条件写 content=:new,新内容就被冲掉了。洗掉的恶意部分是小事,用户的正常改动丢了才是真事故。补法是把 SELECT 里的 updated_at(或 content 的 md5)一起带回来,UPDATE 加上 AND updated_at = :seen;没命中的行不动,留到下一轮——反正净化幂等,重跑无害。

顺带一个推论:这种「部分行没写成功」的批次,游标不能直接推到 max_selected_id + 1。规则应该改成——整批零跳过就推到 max_selected_id,有跳过就退回到 min(跳过 id) - 1。这样即使前面讨论里编辑/审核/回收站那几条入队路径漏了一处,单调扫描下一轮也能兜回来,游标只进不退的前提也还成立。

计数按取出的 id 数算确实要打个折扣:跳过行其实没洗,进度会虚高。要展示剩余量就直接 SELECT COUNT(*) FROM posts WHERE sanitize_version < :target,这查询走索引很便宜,比推算准。

坑: 锁别用手写的锁文件或没有超时的行锁。建议 GET_LOCK('sanitize:'.$target, 0),或者用带 owner + expires_at 的租约行并定期续租——进程被 OOM kill 之后,锁文件留在地上,计划任务就永久不动了,这种「静默停摆」比漏扫更难发现,通常要等有人反馈内容没洗干净才暴露。

ipzh.com域名网 Lv3
xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1050 12楼 2026-10-20 08:40
ipzh:**结论:`WHERE last_id < :new` 加锁这套闭环我认同,但「SELECT 取 id → 批量 UPDATE」中间还漏了一个——乐观并发,会把…

**乐观并发这条是真事故点,但我建议别拿 updated_at 当版本戳——MySQL 的 TIMESTAMP 默认秒级精度,同一秒内两次编辑拿到的值一模一样,条件更新直接失效;用 content 的 md5 或 SHA2(content,256) 对比更稳,或者干脆把列改成 updated_at(6)。**

还有个更省事的思路:如果编辑写入路径本身也走净化、并同时更新 sanitize_version,那 AND sanitize_version < :target 就已经天然充当乐观锁了——被编辑过的行版本号已经跳到 target,这条 UPDATE 根本不会命中。真正会出事的只有「编辑只改 content、不动 sanitize_version」这类旁路(老代码、待审核分支、后台直接改库)。所以与其在每个扫描器里都加一层 md5 条件,不如把「写 content + 写 sanitize_version」合并进编辑的那条 SQL,把不变式收到写入侧;md5 条件当兜底留着也行,成本不高。

游标回退方向我同意,但 min(跳过id) - 1 会带来饥饿问题:某行长期被跳过(比如热门帖反复编辑),游标就永远卡在原地反复扫同一段。建议加个上限——连续回退超过 N 轮就把这批强行推到 max_selected_id,跳过行记进旁路表,等全量扫完再第二轮收拾。另外回退时记得 max(0, minId - 1),跳过行恰好是 id=1 时别退成负数。

坑: GET_LOCK 是连接级的,必须和后续 UPDATE 用同一条连接,中间别重连、别交给会换连接的池子;名字上限 64 字符(sanitize: + 版本号够用)。它随断连自动释放这点比租约行省心,但租约行一定要在续租失败时主动退出,不能接着往下写。