热点





PHP 定时任务处理大量 SaaS 数据时,应该怎样分批执行

发表时间: 2026-10-19 09:00:02   人气:53 次   

PHP 定时任务一旦从几千条数据进入几十万、几百万甚至更大规模,真正需要解决的问题已经不只是“怎样把数据分成几批”,而是怎样让任务在有限的内存、数据库连接、CPU和执行时间内持续向前推进,同时允许失败、重试、暂停甚至多个 Worker 并行执行,而不会重复处理或把数据库拖垮。对于 SaaS 系统来说,一个成熟的批处理任务,本质上应该具备可恢复、可重入、可观测和可并发控制几个特征。

最容易出现的错误,是使用 LIMIT/OFFSET 从第一条一直翻到最后一条。例如每批处理1000条数据,代码不断增加 OFFSET。数据量较小时这种方式简单直观,但随着 OFFSET 增大,数据库为了找到目标位置可能需要扫描和跳过越来越多的记录。更麻烦的是,如果任务运行期间还有新数据插入、旧数据删除或者记录状态发生变化,分页边界还可能发生移动,造成重复或者遗漏。因此,对于按照递增主键处理的数据,生产环境通常更适合使用 Keyset Pagination,也就是“游标式分页”。

$batchSize = 1000;
$lastId = 0;

while (true) {
$stmt = $pdo->prepare("
SELECT id, tenant_id, status, payload
FROM user_data
WHERE id > ?
ORDER BY id ASC
LIMIT ?
");

$stmt->bindValue(1, $lastId, PDO::PARAM_INT);
$stmt->bindValue(2, $batchSize, PDO::PARAM_INT);
$stmt->execute();

$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

if (!$rows) {
break;
}

foreach ($rows as $row) {
// 处理数据
}

$lastId = (int)$rows[count($rows) - 1]['id'];

unset($rows);
}

这里的关键并不是 PHP 语法,而是查询条件必须与索引设计配套。对于上述查询,id 是主键时通常已经具备良好的访问路径。如果实际任务按照租户、状态和时间筛选,则应根据真实查询模式设计复合索引,例如 (tenant_id, status, id) 或 (status, id),而不是机械地给每一个字段都建立索引。批处理系统最怕的不是 SQL 写得长,而是执行计划最终变成大范围扫描。

不过,游标分页也不是万能钥匙。如果任务处理的是“待处理订单”,就不应该简单地使用 id > lastId 把整个表扫描一遍。更合理的方式往往是按照状态领取任务,让数据库负责并发控制。例如 MySQL 8 的 InnoDB 环境中,可以根据具体场景使用 SELECT ... FOR UPDATE SKIP LOCKED,让多个 Worker 从任务队列中领取互不冲突的记录。

SELECT id, tenant_id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 500
FOR UPDATE SKIP LOCKED;

然后在同一个事务中把这些任务标记为 processing,提交事务后再进行实际工作。这样可以避免两个 Worker 同时拿到同一批任务。不过,这里面还有一个非常重要的工程问题:数据库事务不应该长时间包住外部 API 调用、文件处理或者复杂计算。否则几百条任务可能把数据库行锁保持几十秒甚至几分钟,其他请求会跟着排队。

因此,生产环境更常见的设计是“短事务领取任务,事务外执行任务,完成后再次更新状态”。例如任务表可以包含 status、attempts、available_at、locked_at、finished_at、last_error 等字段。Worker 领取任务时设置状态和租约时间,执行完成后标记 done;如果 Worker 崩溃,没有完成的任务则可以根据 locked_at 超时重新进入待处理状态。这比简单依赖一个长事务可靠得多。

批次大小同样不能凭经验写死成1000或者5000。真正合理的批次大小取决于单条记录大小、SQL复杂度、索引命中率、事务持续时间、PHP内存、数据库 Buffer Pool、网络延迟以及业务处理时间。假设一条记录经过 fetchAll() 后占用约10KB,那么一次读取5000条数据,仅数据本身就可能达到几十MB,再加上 PHP 数组的巨大额外开销,实际内存占用可能远高于数据库中对应的数据大小。因此,PHP 中“数据库只有几十MB,为什么脚本内存却爆了”并不奇怪。

如果单条记录非常大,可以进一步避免 SELECT *,只查询真正需要的字段,并把大字段拆开处理。对于超大批次,还可以使用逐行读取或者数据库 Driver 提供的流式读取方式,让 PHP 不需要同时保存整个结果集。很多所谓的“分页优化”,最后真正解决的其实是内存模型,而不仅仅是 SQL。

时间范围分段也是大型 SaaS 数据处理中非常有用的方法。例如日志表可以按照小时、天或者月划分任务范围。但时间边界最好采用半开区间,而不是不断使用 BETWEEN,否则相邻批次很容易在边界处重复处理。

WHERE created_at >= ?
AND created_at < ?

例如第一批处理 00:00:00 到 01:00:00,第二批从 01:00:00 开始。这样每条记录天然只属于一个时间区间。对于百万级日志,还可以进一步结合 (created_at, id) 游标,让时间范围和唯一主键共同确定稳定的处理位置。

按租户分段也适合 SaaS,但不能简单地先把所有租户 ID 取出来,然后对每个租户执行一次 SELECT *。如果某一个大客户拥有数百万条数据,这个租户本身仍然会成为新的“巨型批次”。更合理的结构通常是“租户分片 + 租户内部 Keyset Pagination”,例如先确定租户,再按照 id 或 created_at 继续切成多个小批次。

事务设计方面,最常见的错误之一就是把整个定时任务放进一个大事务。假设一次任务需要处理100万条数据,如果从第一条开始直到最后一条才 COMMIT,事务日志、锁、Undo 数据以及数据库资源都会持续增长。一旦最后一步失败,前面几十分钟的工作可能全部回滚。因此,更合理的方式通常是让每一个可接受的批次成为一个独立事务。

foreach ($rows as $row) {
try {
$pdo->beginTransaction();

// 更新当前任务
// 写入业务结果
// 更新处理状态

$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}

// 记录失败原因
}
}

实际系统中通常不会真的“一条记录一个事务”,因为这样数据库交互次数太多。更常见的是500条、1000条或者根据负载调整为一个事务。关键是事务边界应该和业务一致,同时保持足够短。

乐观锁则适用于多个进程可能同时修改同一条业务数据的场景。例如使用 version 字段:

UPDATE data_table
SET value = ?, version = version + 1
WHERE id = ?
AND version = ?

执行之后必须检查 rowCount()。如果影响行数为0,并不能简单认为“更新成功”,它可能意味着记录已经被其他 Worker 修改,当前版本已经失效。此时需要重新读取、放弃本次处理或者进入重试队列。

对于 SaaS 定时任务,“重复执行”其实比“执行失败”更加危险。因为 Cron、队列 Worker、网络请求和进程重启都可能导致同一个任务被执行两次。所以业务处理最好设计成幂等操作。例如生成账单、发送通知、扣库存、同步第三方 API 等操作,都应该有唯一业务键或者幂等键,避免 Worker 重试一次就产生两笔账单或者发送两次相同通知。

重试机制也不能简单地写成 sleep(pow(2, $retryCount)) 就结束了。指数退避确实常用,但生产环境还需要区分异常类型。数据库临时连接失败、HTTP 429、HTTP 503等暂时性错误通常可以重试;数据格式错误、参数非法、业务规则拒绝等永久性错误继续重试只是在浪费 CPU 和数据库资源。

$maxRetries = 5;

for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
processJob($job);
break;
} catch (Throwable $e) {
if (!isRetryable($e) || $attempt === $maxRetries) {
markFailed($job, $e);
break;
}

$delay = min(60, 2 ** ($attempt - 1));
$jitter = random_int(0, 1000) / 1000;

usleep((int)(($delay + $jitter) * 1_000_000));
}
}

这里加入随机抖动 jitter 是为了避免大量 Worker 在同一秒同时重试,形成所谓的 Thundering Herd,也就是“惊群”效应。

当数据量继续扩大,Cron 本身也不应该承担所有工作。Cron 更适合负责“启动任务”,而不是亲自处理几百万条业务记录。一个更成熟的 SaaS 架构通常是 Cron 每分钟检查一次任务表,然后把任务投递到 Redis、RabbitMQ、Amazon SQS、Kafka 等消息系统,再由多个 Worker 按照并发度逐步消费。这样可以把调度和执行分离,也能够动态增加 Worker 数量。

例如 Cron 不需要扫描整张业务表,只需要查询任务调度表中已经到期的任务:

SELECT id
FROM scheduled_jobs
WHERE status = 'pending'
AND available_at <= NOW()
ORDER BY id
LIMIT 100;

再配合 (status, available_at, id) 等符合实际查询模式的索引,调度器就不必每分钟把整个数据表从头到尾扫描一遍。对于大型 SaaS,这种改变往往比单纯把 PHP 批次从500改成1000更加重要。

监控同样不能只记录“任务成功”或者“任务失败”。真正有价值的指标包括每批处理数量、每批耗时、SQL耗时、平均单条处理时间、失败率、重试次数、队列长度、Worker并发数、数据库连接池使用率、锁等待、CPU、内存以及任务从创建到完成的延迟。如果某一次部署之后,单批耗时从2秒变成15秒,即使任务最终没有报错,也已经说明系统性能发生了变化。

对于长时间运行的 PHP CLI Worker,还应特别注意进程内存不会因为变量离开作用域就一定完全回收。如果代码持续累积数组、日志对象、缓存对象或者第三方 SDK 对象,运行几个小时后可能出现明显的内存增长。因此可以让 Worker 处理固定数量的任务后主动退出,再由 Supervisor、systemd、容器编排系统或者其他进程管理机制重新拉起。对于稳定性,“让一个 PHP 进程永远活着”通常并不是必须追求的目标。

最终,一个可靠的大规模 PHP SaaS 批处理系统可以形成这样的结构:调度层负责产生任务,任务表负责记录状态,数据库负责稳定分页和并发领取,Worker负责执行实际业务,事务负责保证数据库状态一致,幂等机制负责防止重复执行,重试机制负责处理临时故障,失败队列负责保存无法自动恢复的问题,监控系统则负责告诉运维人员任务到底卡在哪里。

真正决定批处理系统能否长期稳定运行的,并不是“每批1000条”这个数字,而是任务有没有明确的游标、状态、租约、事务边界、幂等键和恢复路径。对于百万级甚至千万级 SaaS 数据,优先考虑 Keyset Pagination、短事务、任务领取机制以及消息队列,再根据实际数据库执行计划和 Worker 负载调整批次大小,通常比简单增加 PHP 内存限制更加可靠。一个好的定时任务,即使被杀死、数据库短暂故障或者 Worker 突然重启,也应该能够从上一次可靠的位置继续运行,而不是要求管理员半夜登录服务器手工“再跑一次”。

分享 Facebook | X | Whatsapp | Linkedin

捐助(Paypal): https://www.paypal.me/observeccp
订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP

责任编辑:雨轩  来源:中国观察  转载请注明作者、出处並保持完整。



今日热点:
更多新闻...
近期焦点:
最新视频

推荐阅读:


更多推荐文章:


相关新闻


更多新闻


推荐文章

赞比亚大选背后的三重博弈:铜矿财富、经济压力与伦古葬礼争议

赞比亚8月13日举行总统、议会及地方选举。这场选举表面上是一次普通的政权竞争,但如果把视线从投票站移开,就会发现,真正影响赞比亚政治走向的并不只有候...

2026年8月16日   17373

从顿巴斯炮火到伊斯坦布尔谈判 俄乌战争如何一步步失去和平的最后窗口

俄乌战争最容易被误解的地方,是人们习惯从2022年2月24日开始讲述它。那一天,俄罗斯军队越过乌克兰边界,在多个方向发动全面军事行动,战争由顿巴斯地区持...

2026年8月20日   26533

川普总统向加拿大划出选举红线 加美贸易战为何升级为政治冲突

发表:2026-10-23 12:45:55 | 更新:2026-10-23 12:45:55  人气: 3373

川普总统向加拿大划出选举红线 加美贸易战为何升级为政治冲突

本文由中国观察編輯部原创。为了促进信息自由传播,本平台原创内容全面开放转载,无须申请授权,只需注明来源、保留原文链接,即可全文转载、引用、翻译。我们不追究版权,只在乎公正的传播。

捐助(Paypal): https://www.paypal.me/observeccp
订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP

最近文章:
更多文章...


近期要闻:
更多新闻...

编辑推荐文章:

最新发表   ⏬
更多文章...
疫情