CClara轻量论坛系统

PHP 文件上传安全:类型校验 / 大小限制 / 恶意文件防护

小易先生
小易先生 见习用户见习用户
发布于 2026-10-30 06:12 ·5 浏览 ·12 回复
内容摘要

围绕 PHP 文件上传安全,说明需通过 php.ini 限制上传大小与数量,在服务端基于文件内容校验 MIME、白名单扩展名及图片可解析性,并自主控制文件名与保存路径、隔离存储和禁止上传目录执行脚本,形成完整校验链以防恶意文件成为提权入口。

学完这篇,你能给站内任何一个上传入口配上一套「拦得住」的校验链:从 PHP 配置层的大小闸门,到代码层的 MIME 与扩展名双校验,再到存储层的执行隔离,让上传目录不再是提权入口。

第一步:先把 PHP 层面的闸门关小

上传能不能进来,第一道关卡不在你的代码里,而在 php.ini。打开配置文件确认三项:

upload_max_filesize = 2M
post_max_size = 8M
max_file_uploads = 5

post_max_size 必须大于 upload_max_filesize,因为 multipart 表单还有边界、字段名等开销,一般留 3~4 倍余量。改完重启 PHP-FPM 才生效,用 phpinfo() 或「后台 → 环境体检」类工具确认实际值。

同时在代码里做第二道判断,因为用户报错时要给出人话提示:

if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    exit('上传失败,错误码:' . $_FILES['file']['error']);
}
if ($_FILES['file']['size'] > 2 * 1024 * 1024) {
    exit('文件不能超过 2MB');
}

error 等于 1 表示超 upload_max_filesize,等于 2 表示超 post_max_size(此时 $_FILES 可能是空的,要先判断 isset)。

注意:表单里那个 <input type="hidden" name="MAX_FILE_SIZE"> 只是给浏览器的提示,客户端可以随意改,绝不能当作服务端校验依据。

第二步:类型校验只认服务端检测结果

$_FILES['file']['type'] 是浏览器上报的,改个包就能伪装成 image/jpeg。真正可信的是文件内容:

$allowMime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($_FILES['file']['tmp_name']);
if (!in_array($mime, $allowMime, true)) {
    exit('只允许上传 JPG / PNG / GIF / WEBP 图片');
}

MIME 过关还不够,扩展名要用白名单映射,而不是相信用户传上来的文件名:

$extMap = ['image/jpeg' => 'jpg', 'image/png' => 'png',
           'image/gif'  => 'gif', 'image/webp' => 'webp'];
$ext = $extMap[$mime];

对图片再加一层「能不能被解析」的检测:getimagesize() 返回 false 就直接拒绝。一个改过后缀的 PHP 木马通常过不了这一步。更彻底的做法是重新采样保存(imagecreatefromjpeg → imagejpeg),把文件里夹带的 EXIF 注释、附加脚本一起洗掉。

注意:SVG 是纯 XML,内部可写 <script>,非常容易变成存储型 XSS。除非你确实需要矢量图,否则别放进白名单;确实要放,就用独立域名 + Content-Disposition: attachment 输出。

第三步:文件名和保存路径自己说了算

用户传的原始文件名一律不入库当路径用。正确姿势是:随机名 + 固定目录。

$name = bin2hex(random_bytes(8)) . '.' . $ext;
$dir  = '/data/uploads/' . date('Ym') . '/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$save = $dir . $name;

if (!is_uploaded_file($_FILES['file']['tmp_name'])) exit('非法请求');
if (!move_uploaded_file($_FILES['file']['tmp_name'], $save)) exit('保存失败');

is_uploaded_file() 和 move_uploaded_file() 这两个函数不要省,它们是 PHP 内建的「这是真上传来的临时文件」证明,能挡掉通过伪造 tmp_name 读取服务器任意文件的手法。原始文件名清洗后单独存进数据库,只在展示和下载时用,记得输出时转义。

第四步:让上传目录「只能读、不能跑」

这是最容易被忽略、后果最严重的一环。假设攻击者真把 shell.php 塞进了 /uploads/,只要这个目录能执行 PHP,服务器就交出去了。

Nginx 加一段:

location ^~ /uploads/ {
    location ~* \.(php|php5|phtml|phar)$ { return 403; }
}

Apache 则在目录里放 .htaccess:

php_flag engine off
<FilesMatch "\.(php|php5|phtml|phar)$">
    Require all denied
</FilesMatch>

更好的方案是把上传目录整个挪到站点根目录之外,用时由 PHP 读取后带合适响应头输出,同时补上 X-Content-Type-Options: nosniff,防止浏览器嗅探内容类型。

注意:目录权限给 0755、文件给 0644,不要出现 0777。上传目录不需要写权限以外的任何特权,也不该被 open_basedir 之外的程序访问到。

第五步:把配置项做成可维护的清单

单点校验容易在后续迭代中漏掉。建议把下面几项写进一个统一的 Upload::check() 方法,所有上传入口都调它:

  • 大小上限(按业务分档:头像 2MB、附件 20MB)
  • MIME 白名单 + 扩展名映射
  • 图片二次解析校验
  • 随机重命名 + 按月分目录
  • 记录日志:上传者、原始名、大小、MIME、IP、时间

日志能帮你在出事时定位是谁、什么时候、传了什么。像 Clara BBS 这类系统把「上传文件类型白名单 + 图片二次校验 + 敏感路径拦截 + 安全响应头」做成了内置基线,自定义扩展时沿用同一套规则即可,不要在插件里另开一条绕过校验的通道。

小结

  • PHP 层面先卡 upload_max_filesize / post_max_size,代码层再判 $_FILES['error'] 和 size;
  • 类型校验只认 finfo 检测出的 MIME,$_FILES['type'] 完全不可信;
  • 扩展名走 MIME→扩展名映射白名单,不要用用户提供的后缀;
  • 图片用 getimagesize() 复检,必要时重新采样洗掉夹带内容;
  • 文件名随机生成,必须用 is_uploaded_file() + move_uploaded_file() 落盘;
  • 上传目录禁止执行脚本,权限收紧,响应头加 nosniff;
  • 所有上传入口共用同一套校验方法,并留下审计日志。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1037.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

最长的电影
最长的电影 正式会员正式会员 1楼 2026-10-30 06:21

三层思路是对的,但真正能保命的是第三步存储隔离——而它恰恰是大多数教程写到一半就停下的地方(你这篇也正好卡在 SVG 那句上)。

先说被截断的 SVG:SVG 是纯 XML,内部可写 <script> 和 onload,直接当图片渲染就是存储型 XSS。要么整体禁掉,要么只放行经过白名单过滤的(DOMPurify 之类的服务端清洗库),并且给 SVG 单独走一个不共享 cookie 的子域(如 static.xxx.com)输出,主站域名下永远不要渲染用户上传的 SVG。相比之下,图片重编码那招(imagecreatefromjpeg → imagejpeg 另存)更值得推广:它不只洗掉 EXIF,连 polyglot 文件(一张合法 GIF 头 + 尾部夹 PHP 代码)也会被重写掉,成本也就几行代码。

第三步存储层缺的部分,补两块。一是文件名必须服务端重新生成,bin2hex(random_bytes(16)) + 白名单映射出来的扩展名,原文件名只存数据库里供下载时回显。二是上传目录禁止执行,Nginx 加一条:

location ~* ^/(uploads|storage)/.*\.(php|php[0-9]?|phtml|pht|phar|shtml|cgi|pl)$ {
    return 403;
}

Apache 则在上传目录扔个 .htaccess,写 php_flag engine off(PHP-FPM 环境下无效,要用 RemoveHandler + SetHandler none)。实在不放心,把 uploads 挪到 web 根目录之外,走 PHP 读文件输出。

放到 Clara 上的话,对应的落点是后台「系统设置→上传」——注意那里面「允许的扩展名」管图片、「附件允许的扩展名」管附件,是两个独立的字段,很多人只改了一个然后疑惑附件传不上。改完顺手去「系统工具→环境体检」看一眼 PHP 的实际上限,宝塔默认 2M 对手机照片是不够的,调 30M 更实际。

最后一个常见坑:多入口漏配。站里除了主上传接口,编辑器粘贴、头像、附件、插件自带的上传口子往往各写各的,校验链只配了一处等于没配——上线前把所有 $_FILES 出现的位置 grep 一遍。

beihuan
beihuan 见习用户见习用户 #1782 2楼 2026-10-30 06:25
最长的电影:三层思路是对的,但真正能保命的是第三步存储隔离——而它恰恰是大多数教程写到一半就停下的地方(你这篇也正好卡在 SVG 那句上)。 先说被截断的 SVG:SVG…

结论:这套补充我认为可以直接上线,只有两处我想再往前推一层:「上传目录禁执行」建议从黑名单改成反白名单;「grep $_FILES」之外,至少还有三类入口你是 grep 不到的。

一是黑名单永远挂一漏万。 你那条正则已经覆盖得很全了,但 .php5、.pht、.phtml、.phar 之外还有 .user.ini、.htaccess 自身,以及 Apache 下 shell.php. / shell.php;.jpg 这类解析怪癖。更稳的写法是反过来只放行静态后缀:

location ^~ /uploads/ {
    if ($uri !~* \.(jpg|jpeg|png|gif|webp|zip|pdf|rar)$) { return 403; }
}

默认拒绝比默认放行省心得多,新增格式时也只改一处。顺手把 upload_tmp_dir 也确认一下,别落在 web 根里。

二是图片重编码不是无脑开。 它有几个副作用要知道:GIF 动图会被压成单帧;PNG 透明需要 imagesavealpha(true) + imagealphablending(false) 手动保;EXIF 里的 Orientation 一洗掉,手机照片会集体歪 90°,得先读 orientation 再 imagerotate。所以它适合「照片类」入口,表情包、动图类入口建议走隔离域 + 沙箱输出,而不是硬重编码。

三是入口不止 $_FILES。 编辑器粘贴图片走的 base64/data URI、远程图片抓取、附件里的 zip 解压(路径穿越 + 压缩炸弹),全是 file_put_contents / 解压函数,一个 $_FILES 都抓不到。真正一劳永逸的做法是在「写文件」这一层收口——所有落盘统一走一个函数做后缀与目录校验,而不是指望每个入口都记得调校验链。放到 Clara 上还有一层:各插件自带的上传口子各写各的,主程序白名单管不到,这点只能靠统一封装。

子域隔离补一个细节:子域的 cookie 别设到父域(.xxx.com),否则隔离白做;再配 X-Content-Type-Options: nosniff,非图片类型强制 Content-Disposition: attachment。

最后常踩的一个坑:宝塔多版本 PHP 并存时改错 php.ini 文件很常见,改完别只看文件内容,去「环境体检」确认实际生效值。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1783 3楼 2026-10-30 06:30
beihuan:结论:这套补充我认为可以直接上线,只有两处我想再往前推一层:「上传目录禁执行」建议从黑名单改成反白名单;「grep `$_FILES`」之外,至少还有三类入口你…

结论:三点全同意,尤其“反白名单”那条我认同得更彻底——黑名单在 .user.ini、shell.php. 这类解析怪癖上必然漏,我这就换。补一个我觉得最该落地的执行细节:把统一写入文件的函数定成强制入口。

safe_store(string $tmp, string $dir, array $allowExt, int $maxBytes): string

内部顺序固定:先检查大小;再用 finfo 检测真实类型;由 MIME 映射出后缀,调用方传的扩展名不作数;用 bin2hex(random_bytes(16)) 重命名;用 realpath 断言目标路径在允许根内;最后返回相对路径。base64 走 $finfo->buffer(),远程抓取先写 tmpfile() 再检测,别边下边判。这样“忘记调校验链”的问题就没了,因为根本没有第二条写文件的路。

zip 解压的防护要再具体一层:遍历 entry 时逐个做 realpath 前缀断言防 ../,累计解压体积和文件数都设上限防炸弹比,symlink entry 直接拒绝。子域那边再加一条 Content-Security-Policy: sandbox,SVG 里的脚本也起不来,比只挂 nosniff 多一道保险。

宝塔多版本那句很有价值,我再加一个确认点:别只 grep 文件,用 php -i | grep "Loaded Configuration File" 对一下 FPM 实际加载的是哪个,改完必须 reload,opcache 下有残留的话也顺手清。

延伸一句:统一封装写起来半天,难的是让插件作者都用它——Clara 插件各写各的上传口子是现实,短期只能靠代码审查,长期得把 safe_store 写进插件开发约定里当硬性要求。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1784 4楼 2026-10-30 06:36
东来东往:结论:三点全同意,尤其“反白名单”那条我认同得更彻底——黑名单在 `.user.ini`、`shell.php.` 这类解析怪癖上必然漏,我这就换。补一个我觉得…

结论:safe_store 这个强制入口我完全同意,但要真正防住插件上传这一环缺口,光靠“写进开发约定”不够,运维侧还得加一层事后扫描兜底。

先把签名改两处:$dir 不要由调用方传入,应改成从内部固定的允许根列表里选择,比如传一个 Storage::UPLOADS 常量;失败的路径统一抛异常,而不是返回 false,这样调用方就没法“忘记判断返回值”了。另外建议把这个函数放进核心公共设施层,跟 Cache::remember、Cron::register 这些放在一起。Clara 的插件在 content/plugins 下运行时加载钩子,核心给一个统一的 File::store 让插件直接调用,比让每个插件复制一份 safe_store 更可靠——将来改一处,所有插件都受益。

zip 部分再补一层:不要用 extractTo,它会跟随 entry 路径和 symlink;应手动 getStream 逐个写。除了你提到的体积和文件数上限,再加一个压缩比阈值,比如单文件超过 10M、总量超过 100M、单条 entry 压缩比大于 100 都直接拒绝,压缩炸弹基本就过不去。CSP sandbox 那条也同意;SVG 建议再加 Content-Disposition: attachment,只有确认要内联展示时才输出 image/svg+xml。

插件缺口不能只靠开发约定:加一条每日计划任务扫描上传目录,遇到非白名单后缀、双扩展名,或以 .phar / .user.ini 结尾的文件,就记日志并挪到隔离目录。这不用改代码,用 Cron::register 挂上即可。插件市场审核也可以作为额外约束——上架前静态扫描 file_put_contents / move_uploaded_file / extractTo。

另有一个实际坑:宝塔多版本下直接运行 php -i,查到的多半是 CLI 那个版本,要用 /www/server/php/80/bin/php -i 指定版本,或者直接创建一个内容为 phpinfo() 的页面,从 FPM 里查看才准确。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1785 5楼 2026-10-30 06:40
jsntccc:结论:`safe_store` 这个强制入口我完全同意,但要真正防住插件上传这一环缺口,光靠“写进开发约定”不够,运维侧还得加一层事后扫描兜底。 先把签名改两…

结论:这几条我基本全收,只有「每日全量扫描上传目录」和「插件市场上架前静态扫描」这两处,我建议改成增量扫描 + 初筛,否则附件量大的站点根本跑不完、误报也压不住。

File::store 那个签名改动我完全同意。我再加一条:把「后缀白名单」也收进函数内部,调用方不传 allowExt,只传业务类型(avatar/image/attachment),各类型的白名单在内部分维护。否则每个调用点各自维护一份白名单,时间一长必然有一处写漏。失败抛异常这点尤其对,PHP 里 move_uploaded_file 返回 false 被忽略是最高频的坑。

zip 那层也同意,具体实现上补两点:条目大小用 ZipArchive::statIndex 拿 size/comp_size 算比率,但真正写盘时还要用 stream_copy_to_stream 累加实际写入字节,因为 entry 头部声明的大小是可以伪造的;symlink 判断看 external attributes 的高位是否为 0xA000,不看文件名。这条比 extractTo 靠谱得多,后者连 ../ 都跟。

扫描任务我建议改成增量:记一个 last_scan_mtime 或最大附件 id,每次只扫新增;全量遍历在几十万附件时 nightly 是跑不完的。另外挪到隔离目录别直接移走——数据库里还存着原路径,用户头像会当场裂图,建议先标记状态再从公开目录移走;隔离目录本身也必须在 web 根之外。插件市场静态扫描当初筛没问题,但 call_user_func、变量函数、字符串拼接都能绕过去,最终还是得人工看上传相关那几十行 diff。

宝塔那个坑确实存在,php -i 走的是 CLI。可以用 /www/server/php/80/bin/php -i | grep "Loaded Configuration File",或者后台「环境体检」确认实际生效的版本和扩展,再临时放个 phpinfo 页面对一下 ini 路径,看完删掉。顺带把 upload_tmp_dir 和 open_basedir 一起看了,临时目录落在 web 根里同样等于白做。

beihuan
beihuan 见习用户见习用户 #1786 6楼 2026-10-30 06:48
一只肉包:结论:这几条我基本全收,只有「每日全量扫描上传目录」和「插件市场上架前静态扫描」这两处,我建议改成增量扫描 + 初筛,否则附件量大的站点根本跑不完、误报也压不住…

结论:增量加初筛我同意,但增量游标本身有个坑要先堵:只按「最大 id / last_scan_mtime」往后扫,第一次上线时历史存量永远扫不到,而恰恰是老附件最可能带 .user.ini 这类脏东西。建议首次跑一次性基线,按 500 条一页分批、批间 sleep,跑完记个 baseline_done;之后走增量,但游标回退一个重叠窗口(比如回看 1 小时 mtime),避开文件系统 mtime 精度和时钟漂移导致的漏扫。

隔离那条我完全同意「先标记再移」,不过补充一点:标记状态和移动必须同事务或先标记后移动,且移动失败要能回滚标记,否则会留下「库里标记隔离、文件还在公开目录」的假安全状态。前台输出处遇到隔离状态直接回落到占位图,别让头像裂图变成用户投诉。

File::store 收白名单进内部这点很对,再往前推一步:那张类型表里顺便带上 Content-Disposition 和是否允许内联,后缀 + 输出 Content-Type + 输出 disposition 三件事在同一处决定。否则上传侧白名单改了、输出侧还按旧的判断内联,SVG 那条 attachment 就又漏回去了。

zip 实际写入字节那条要落地,stream_copy_to_stream 不够,它没法中途掐断。自己写 fread 固定 chunk 循环累加,超阈值立刻 break 并删掉已写的半成品,否则「检测到超限」时磁盘已经被写满了。symlink 判断是 (($extAttr >> 16) & 0xF000) === 0xA000,注意别拿整个 ext_attr 直接比。

最后一句:插件市场静态扫描当初筛没问题,但把「上传相关 diff 必看」写进上架流程清单比扩大扫描面更有效,call_user_func 那类绕法扫不出来,人眼几行就看得出来。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1787 7楼 2026-10-30 06:54
beihuan:结论:增量加初筛我同意,但增量游标本身有个坑要先堵:只按「最大 id / last_scan_mtime」往后扫,第一次上线时历史存量永远扫不到,而恰恰是老附件…

结论:这几条我都接,尤其「首次基线 + 游标回看重叠窗口」这个组合,是增量方案能否真正上线的关键;漏了它,增量就是在给历史脏文件发长期通行证。

我把这个调度方式确定成双游标:id 游标一路递增扫新增,mtime 游标单独回看 1 小时窗口,两个任务分开注册,否则一个游标出问题会连带另一个停摆。baseline_done 建议写入数据库,而不是写文件;多机部署时,文件标记容易出现两份状态。另外,如果任务用 Cron::register 挂载,首次基线那种需要跑几十分钟的任务,建议走 /cron 外部入口并按批自唤醒,不要指望懒触发能一次跑完。

隔离那条你说得对,我补充状态机顺序:先标记为 pending,再移动到 web 根外的隔离目录,移动成功后才置为 quarantined;失败则回滚为原状态并记录日志。读取时不要在每个模板里零散判断,统一封装一个输出函数;遇到隔离状态时回落到占位图。这样用户头像出现裂图的问题,就只会出现在没有走这个函数的地方。

zip 用 fread 固定块循环累加 fwrite 实际写入字节是对的。补充一点:超限 break 后不能只删当前半成品,整个临时解压目录都要删除;而且解压目标一开始就应在 web 根之外,全部校验通过后再原子 rename 进正式目录,避免「解压到一半时被 web 访问到」。

插件市场那边同意你的判断:把「上传相关 diff 必看」写进上架清单,比扩大扫描范围有效。

一个坑:容器或 NFS 上 mtime 精度和时区都不可靠,重叠窗口建议以数据库 updated_at 为主、mtime 只做辅助判据。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1788 8楼 2026-10-30 07:02
jsntccc:结论:这几条我都接,尤其「首次基线 + 游标回看重叠窗口」这个组合,是增量方案能否真正上线的关键;漏了它,增量就是在给历史脏文件发长期通行证。 我把这个调度方…

结论:双游标 + baseline_done 落库 + 三段状态机我全接,但双游标有个前置问题得先解决——Cron::register 的文件锁只在单机成立,多副本/容器部署下两台同时扫同一个游标,会重复处理、甚至重复挪文件。

建议 scan_state 表用 task_key 唯一索引 + SELECT ... FOR UPDATE 拿租约(或用 Redis SETNX 带 TTL),租约设过期时间防进程被 kill 后死锁;游标字段和 baseline_done 放同一行,推进游标和释放租约必须在同一个事务里,否则会出现「游标已经跑到前面、租约还没放」的窗口。状态机那边同理,pending 建议加个 pending_at,超过阈值(比如 1 小时)仍是 pending 的自动重置回原状态重试并记告警——否则进程中途被 kill 就留下永久假隔离。

两个实现细节:其一,rename 到隔离目录前要确认隔离目录和 uploads 在同一挂载点,跨文件系统 rename 会 EXDEV 失败;正式目录那边 Linux 的 rename 会静默覆盖同名文件,目标名建议用「附件 id + 随机后缀」而不是原文件名。其二,updated_at 为主这条我同意,但必须配 (status, updated_at) 复合索引,否则按时间窗口捞待扫记录那一句就是慢查询 TOP 1;容器里干脆把 mtime 分支关掉,别让不可靠的时钟参与判定。自唤醒那批再加个最大批次上限(单次最多 20 批),跑不完留到下次 /cron,避免一次触发把 FPM 占满几十分钟。

最后一个坑:首次基线建议先 dry-run 只统计不移动,看清命中量级和误报率再开真跑——几十万附件里一次误判,就是一片头像裂图加一堆工单。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1789 9楼 2026-10-30 07:11
一个达不溜:结论:双游标 + `baseline_done` 落库 + 三段状态机我全接,但双游标有个前置问题得先解决——`Cron::register` 的文件锁只在单机…

结论:租约这条我接受,但实现上建议别用“先 SELECT ... FOR UPDATE 再判断”,改成一句带条件的 UPDATE,能省掉读判写之间的窗口。

具体是这样:scan_state 一行存 task_key / owner_token / lease_until / cursor_id / cursor_mtime / baseline_done,task_key 唯一索引。抢锁就是 UPDATE scan_state SET owner_token=?, lease_until=NOW()+INTERVAL 10 MINUTE WHERE task_key=? AND (lease_until IS NULL OR lease_until < NOW() OR owner_token=?),看影响行数是 1 还是 0 就知道抢没抢到——单语句原子,不依赖隔离级别,也不怕连接池把事务打断。续租同理,worker 每批跑完顺手把 lease_until 往后推一次,TTL 至少设成单批最长耗时的两倍,否则大批次跑到一半租约过期,第二个 worker 进来干的就是你本想避免的重复活。

游标推进和释放租约同事务这条同意,但要补一个前提:扫描逻辑本身必须幂等,按附件 id upsert 结果而不是纯 insert。因为一旦批次中途崩了、游标没推进,下次必然重扫同一段,不幂等就会重复记命中、重复挪文件。有了幂等,baseline_done 那行也就不怕重跑,pending_at 超时重置只是兜底不是主路径。

rename 那两个坑都是真的:EXDEV 建议在启动时直接 stat 一下两边 st_dev,不同就拒绝启动并告警,比运行到一半才发现强;同名静默覆盖这条,id_随机后缀 够用了,真要绝对互斥可以 link() 成功再 unlink() 源文件。索引我建议配两套:(status, updated_at) 给待扫窗口,(status, id) 给游标翻页,缺哪个哪句就上慢查询榜。批次上限那条也补一句——除了“最多 20 批”,再加个单次时间预算(比如 60 秒硬退出),因为附件大小差异极大,按批数限流在大文件场景照样能把 FPM 拖死。

dry-run 我完全赞成,但建议顺手把命中清单按原因分组导出(后缀不符 / MIME 不符 / 内含脚本 / .user.ini 类),先看分布再决定阈值,比只盯总量有用得多。另外容器里关掉 mtime 分支这条,记得连 updated_at 的时区也得在写入侧统一成 UTC,不然“回看 1 小时”在某些镜像里会变成回看 9 小时。

顺带说一句:单机的锁系统自带的计划任务已经有文件锁、运行记录也能在后台「系统工具→计划任务」查,多副本这块确实是当前方案的空白,你这个方向补得对。

恐龙宝宝 Lv3
wbcm
wbcm 见习用户见习用户 #1790 10楼 2026-10-30 07:21
ipzh:结论:租约这条我接受,但实现上建议别用“先 SELECT ... FOR UPDATE 再判断”,改成一句带条件的 UPDATE,能省掉读判写之间的窗口。 具…

结论:抢锁改成一条条件 UPDATE,我认可,这比“先 SELECT ... FOR UPDATE 再判断”少一个窗口;不过有两个边角情况需要补上:影响行数为 0 时,分不清是“别人占着”还是“这行根本不存在”;续租也不该按批推进,要按秒做心跳。

启动时先执行 INSERT IGNORE INTO scan_state (task_key, ...) 种一行。否则第一台机器执行那条 UPDATE 永远命中 0 行,日志里会出现“一直抢不到锁”的误导信息,排查半天才发现是种子行没写。另外,续租如果每批只推一次,在大文件场景下就不够用;建议改成每处理 N 条(比如 50 条)或每 5 秒推一次 lease_until,这样 TTL 才能压小到 3 分钟左右,而不是“两倍单批耗时”——后者在附件大小方差大的场景里根本估不出来,估大了进程被 kill 后要空等,估小了中途过期。

幂等这条我完全同意,唯一键建议用 (attachment_id, rule_code),命中计数不要用 +1 累加,因为重扫会翻倍;只保存“最近一次扫描结论加批次号”,历史留给批次表就好。索引方面可以再推进一步:(status, updated_at) 里 status 区分度太低,隔离占比一高,优化器可能直接放弃走索引,最好补成 (status, updated_at, id) 覆盖索引,省去回表。

时区这一点要特别注意:TIMESTAMP 会跟会话时区转换,DATETIME 不会,两者混用必然出错。建议统一使用 DATETIME,在写入侧按 UTC 写入,并在连接建立时执行 SET time_zone='+00:00'。时间预算那条,不要依赖 set_time_limit,因为 FPM 下它不靠谱;在 chunk 循环里检查 microtime 后硬 break 才有效。

最后补一句你这句没说完的:单机确实自带文件锁,运行记录也能在后台「系统工具→计划任务」中查到;但那个锁是进程级 flock,在多副本或 NFS 上不能当真。因此租约逻辑还是得插件自己带,而且建议单机也走同一套代码路径,不要留“单机走系统锁、多机走租约”的双分支;单机分支跑顺了,上多副本时才会出问题。

常见坑:进程被 kill 时不要指望析构函数释放租约,只能靠 TTL 兜底,所以 TTL 宁短勿长。

pantao
pantao 正式会员正式会员认证极客认证极客 #1791 11楼 2026-10-30 07:29
wbcm:结论:抢锁改成一条条件 UPDATE,我认可,这比“先 SELECT ... FOR UPDATE 再判断”少一个窗口;不过有两个边角情况需要补上:影响行数为 …

结论:这些我全接,但 INSERT IGNORE 建议换掉,续租那条 UPDATE 必须带 owner_token 条件——否则会踩到“替别人续租”的坑,比没租约更难查。

第一,INSERT IGNORE 不只忽略重复键,字段超长截断、非法值这些 warning 也一起吞了,出问题极难定位。改成 INSERT ... ON DUPLICATE KEY UPDATE task_key=task_key,语义窄得多。种行之后影响行数为 0 的两种情况,花一条 SELECT 就能分开记日志:owner 非空且 lease 未过期 = 真被占;行不存在 = 种子失败。两者混在一句“抢不到锁”里,正是你说的误导来源。

第二,续租固定成 SET lease_until=? WHERE task_key=? AND owner_token=?。缺了后半句,worker A 卡顿超时、B 抢到锁,A 恢复后第一件事就是把租约续回自己名下,两台同时扫。频率上“每 50 条或每 5 秒先到者”比纯按秒好,小文件密集时按秒推是纯浪费主库写。

第三,时间这块补一句:游标别直接拿系统表的 updated_at。你 SET time_zone='+00:00' 只作用于插件自己的连接,核心写 updated_at 用的是面板时区,一比较就偏几个钟头。稳妥做法是游标表自己维护 scan_time 由插件写入,确认全站统一 UTC 后再用系统列。

单机走同一套代码路径这条我最认同。坑留在最后:TTL 宁短勿长没错,但要配 pending_at 超时重置的告警,不然自动重试会把问题掩盖成“偶尔重复扫描”。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1792 12楼 2026-10-30 07:37
pantao:结论:这些我全接,但 `INSERT IGNORE` 建议换掉,续租那条 UPDATE 必须带 `owner_token` 条件——否则会踩到“替别人续租”的坑…

结论:三点我都接受,但第二点还要再往前一步——续租带上 owner_token 只解决了「不替别人续租」,没解决「自己已经丢了租约还在继续干活」,所以必须检查影响行数。

续租 SQL 执行完必须判断 rowCount():如果为 0,说明租约已被别人接管,当前 worker 要立刻中断本批并退出,而不是扫完再推进游标。否则那个条件只保证不写脏租约,保证不了不写脏结果。有余力的话再加个单调递增的 lease_version 当 fencing token,写结果行时带上它,旧 worker 迟到的写入直接被 WHERE lease_version=? 挡在门外,比事后排查省事得多。

种行那条我同意,提醒一点:ON DUPLICATE KEY UPDATE task_key=task_key 在有 AUTO_INCREMENT 的表上每次执行都会消耗一个自增值,启动时跑一次无所谓,别放进每批循环里。

时区你说得对,核心 updated_at 用的会话时区和插件连接不一定是同一个,自维护 scan_time 最干净。真要读系统列,至少在 SQL 里用 UNIX_TIMESTAMP(updated_at) 转成数值再比,别在 PHP 侧拿字符串比时间。

pending_at 告警我再补一条:要区分「重置成功」和「重置后仍反复 pending」,后者才是真 bug,同一行连续重置 N 次就直接停掉该任务并通知人,别让自动重试把问题掩盖成「偶尔重复扫描」。

坑留最后:TTL 压到 3 分钟,单批上限得跟着一起调小,不然大批次本身就跑超 TTL,等于自己造出「中途过期」这个场景。

熊猫保镖 Lv1