在一次 Nextcloud 大版本升级之后,系统表面上已经完成版本更新,维护模式也已经解除,但数据库和存储层仍然存在一个持续运行的后台迁移任务:
OC\Core\BackgroundJobs\PreviewMigrationJob
这个任务最终持续了接近一周。
它并不是传统意义上“数据库 Schema 升级跑了一个星期”,而是 Nextcloud 把一部分历史数据迁移拆成了后台任务,使主版本升级可以提前结束,而大规模历史数据转换则在服务恢复之后继续进行。
这类设计可以缩短维护窗口,但也带来了一个容易被忽略的问题:
升级完成,不代表升级相关的所有数据迁移都已经结束。
一、迁移的实际规模
该实例积累了大量历史 Preview 数据。
当时统计到的迁移规模大致为:
待处理记录:约 16 万
已迁移记录:数百
历史 Preview 文件:约 130 万
历史 Preview 数据规模:约 108 GiB
因此,这并不是一个几十秒能够结束的普通后台任务。
Nextcloud 会让 PreviewMigrationJob 分批处理数据,而不是在版本升级过程中一次性完成全部迁移。
这也是为什么:
occ upgrade
已经执行成功,
maintenance: false
needsDbUpgrade: false
也都已经恢复正常,
但后台仍然持续进行数据迁移。
二、真正的问题不是“慢”,而是普通 cron 调度下出现资源失控
最开始,这项任务完全交给 Nextcloud 正常的 cron 机制运行。
例如:
*/5 * * * * php -f <NEXTCLOUD_ROOT>/cron.php --define apc.enable_cli=1
正常情况下,这种方式没有问题。
但对于一个规模达到十万级数据库记录、百万级历史 Preview 文件的迁移任务来说,问题开始出现。
后台 PHP 任务逐渐堆积,部分进程进入:
D
也就是 Linux 中的不可中断 I/O 等待状态。
随后系统出现了明显异常:
load average 超过 200
大量 PHP 进程堆积
部分进程处于 D-state
系统出现 soft lockup
出现内存压力
journald 也受到影响
CPU 本身并不是主要瓶颈。
真正的问题更接近:
大量长时间运行的后台任务
+
存储 I/O
+
数据库操作
+
普通 cron 周期继续触发
最终形成资源竞争。
这说明一个重要事实:
Background Job 不等于低成本任务。
“后台执行”只意味着它不阻塞 Web 请求或版本升级,并不意味着它不会消耗大量 I/O、数据库和系统资源。
三、没有直接删除任务,也没有直接修改数据库
面对这种情况,最危险的方法通常有两种。
一种是直接从后台任务表中删除对应 Job。
另一种是直接修改 Nextcloud 内部数据库中的迁移状态。
这两种方法都可能让应用误以为迁移已经完成,从而留下不完整的数据状态。
因此,处理方向并不是“跳过任务”,而是:
让它继续执行
但控制它的执行节奏
首先确认了几个关键事实:
任务确实仍然在推进
不是死循环
数据库本身没有损坏
迁移存在明确的完成状态
任务允许分批持续执行
因此最终采用了单独调度方式。
四、把 PreviewMigrationJob 从普通 cron 中隔离出来
随后创建了一个独立的临时 systemd 服务,用于专门执行这项迁移。
服务概念上类似:
[Service]
User=<WEB_USER>
Nice=19
IOSchedulingClass=idle
其中:
Nice=19
表示把 CPU 调度优先级降到很低。
而:
IOSchedulingClass=idle
表示只有在其他进程没有明显 I/O 需求时,才优先让该任务使用磁盘。
目标并不是让迁移更快。
恰恰相反,目标是:
让它慢一点
但不要影响正常 Nextcloud 服务
五、每执行一轮,主动休眠
独立服务采用循环方式运行。
逻辑大致如下:
检查迁移是否完成
↓
执行一次 PreviewMigrationJob
↓
记录执行结果
↓
sleep 60
↓
再次检查
实际日志中可以看到类似:
Job executed!
Last duration: <DURATION>
Round finished; sleeping 60 seconds.
这样做有几个优点。
首先,一轮任务结束之后不会立刻继续压磁盘。
其次,即使迁移过程很长,也可以清晰看到每轮执行时间。
再次,如果系统其他服务突然产生 I/O,高优先级业务仍然可以先使用磁盘。
六、任务持续了数天,但系统恢复稳定
采用低优先级单独执行之后,PreviewMigrationJob 仍然没有迅速结束。
整个迁移从开始到最终完成持续了大约五天多,接近一周。
但与最开始不同的是:
系统负载恢复正常
没有继续大量堆积 D-state PHP
Nextcloud 可以正常使用
其他 cron 任务继续运行
数据库没有发生异常
也就是说,问题并不是“任务存在”,而是原来的调度方式不适合这个实例的数据规模。
七、最终完成状态
最后一次迁移结束后,日志出现:
Job executed!
Last duration: <DURATION>
Round finished; sleeping 60 seconds.
Preview migration completed.
随后 systemd 服务自动退出:
Deactivated successfully.
Nextcloud 内部对应迁移完成标志也已经写入:
previewMovedDone = 1
之后再次检查:
没有 PreviewMigrationJob 迁移进程
没有 D-state 迁移任务
临时 systemd 服务已经消失
previewMovedDone = 1
说明这项持续数天的数据迁移真正结束。
八、为什么升级完成后还会有这种任务
现代大型应用越来越倾向于把升级过程拆成两部分:
必须在维护模式内完成的升级
+
可以恢复服务之后慢慢执行的数据迁移
前者通常包括:
数据库 Schema
核心版本号
应用注册
必要的数据结构变更
后者则可能包括:
历史数据格式转换
索引重建
预览迁移
Metadata 生成
缓存重构
大型文件数据整理
如果所有工作都塞进:
php occ upgrade
那么拥有大量历史数据的实例可能需要几个小时甚至更久才能退出维护模式。
因此后台迁移本身并不是设计缺陷。
真正需要关注的是资源调度。
九、这次事件得到的一个重要经验
以后遇到类似情况,不应该只问:
这个任务为什么这么慢?
更应该问:
它是否仍在推进?
它是否允许中断后继续?
它的完成条件是什么?
它的 I/O 特征是什么?
它是否可以降低调度优先级?
它是否正在和普通 cron 发生竞争?
如果任务仍然稳定推进,那么很多时候最好的方案不是“消灭任务”,而是让它以系统能够承受的速度完成。
这次 PreviewMigrationJob 最终证明:
数据没有损坏
数据库没有损坏
迁移逻辑本身可以正常完成
真正需要修正的,是调度策略。
对于长期运行、积累了大量历史数据的自托管实例来说,这种区别非常重要。