滚动新闻 →
俄第二大炼油厂遭重创!40万战损数据曝光 土耳其钢铁厂突发大爆炸!10亡4失踪 孩子犯错时父母应该怎样处理才不会推卸责任 什么情况下应该使用快速剪辑 辛亥革命为什么能够推翻清朝 伊南大爆炸!1小时轰塌38桥?黄出轨细节曝光! 台湾主持人郑弘仪揭秘父母长寿秘诀 三要素助力健康长寿 黄仁勋婚外情牵出共谍疑云 嫡系公司涉中共国资调查 陕西司机驾驶挖掘机冲击政府大楼 网民称其为英雄 霍建华女儿痴迷韩星引吃醋,自称‘上古帅哥’遭吐槽 阳台空间除了晾衣服还能怎么利用 奈良古寺旁的柿叶寿司 美军组建铁门特遣队阻断伊朗陆路通道 川普称战事已获突破 杭州万达广场疑似发生持枪抢劫案 民众披露更多细节 坐在加拿大金三角温泉的露天池里,看群山静静站在远方 王健林时隔14年重返万达商管董事长职位 中国男子东京银座涉嫌干扰通讯发送钓鱼短信 燃油泵异响应该如何判断 黄渤坦言无戏可拍 面临双重现实困境 山西太原高楼大火 至少14人死亡 高雄前镇发现缠附哑铃骨骸 疑为连环杀人案受害者 车道保持辅助到底有什么作用 桃园警方拦查可疑厢型车 7名移工跳车4人被捕3人逃逸 秦皇岛遭罕见特大暴雪袭击 深厚积雪画面曝光 加拿大投资房应该如何计算真实租金收入 塞内加尔海域非法移民船难致34人遇难 搜救持续 为什么长期投资需要耐心 民主党拟对马斯克及白宫幕僚长展开调查 黄仁勋婚外情牵涉间谍调查 川普马斯克回应 川普承认移民政策过于严苛 愿与民主党协商 二手游艇内部设备应该如何检查 新竹县议会成全台第18个通过反跨境镇压提案的县市议会 伊拉克霍尔莫尔气田遭无人机袭击 沙特阿美大楼遇空袭 电影项目如何寻找导演 张艺谋警示AI冲击电影业 中共新政引发演员失业担忧 美军特遣队封锁伊朗陆路 川普证实行动 中国中东部8省市遭遇强降雪 局地降温超14℃ 英伟达启动独立调查 黄仁勋被指涉华情报疑云 河南老君山现罕见气候奇观 一山分隔冬夏景色 加拿大为什么报税后反而需要补税

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

发布时间: 2026-10-19 09:00:02    最后更新: 2026-11-08 18:50:22    阅读:76  约10 分钟阅读     

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

设为 Google 新闻首选来源 让 Google 新闻优先显示 MNewsTV 的最新报道 ›
★ 我的收藏 查看已经收藏的文章
关于文章收藏 收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。 删除收藏请进入「我的收藏」进行管理。
分享这篇报道

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