PHP 定时任务处理大量 SaaS 数据时,应该怎样分批执行
图片说明:示意图 图片来源:Public Domain(公有领域)
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 突然重启,也应该能够从上一次可靠的位置继续运行,而不是要求管理员半夜登录服务器手工“再跑一次”。
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP