滚动新闻 →
推拉窗为什么越来越难开 奥斯卡小金人与神有关?2700前的铜像告诉你 法国周末骚乱背后是哪些人? AI 系统为什么需要 MLOps,它与传统 DevOps 到底有哪些不同 Cloud SQL 数据库连接数过高怎么办,从应用连接池到实例配置逐项检查 汶川地震余震?绵阳发生5.0级地震 多地震感明显 马斯克宣布给全美儿童每人一股股票 普京提议美中俄峰会 泽连斯基被晾一边? 美国向中东12国发警报 以军进入高度戒备 超强台风蔷琵略过关岛 恐直扑台湾 PHP 定时任务处理大量 SaaS 数据时,应该怎样分批执行 笔记本电脑电池掉电速度突然加快,如何判断是电池老化还是硬件故障 亚股跳水!何时止跌?关键看利率 下季财报是重点! 1000步的缤纷台湾:坪林一日游 太好玩了! 美军前狙击手:曾在51区秘密作战 击毙外星人 Windows 11 文件属性怎么看?大小、位置、创建日期等信息全面了解 MySQL 表应该怎么设计?从一个简单新闻网站建立数据库结构 李清照前后期作品为什么变化如此明显 薏米在传统饮食文化中的地位 金鸡奖提名无辛芷蕾惹议 观众:不能办别办了! 中国第3季GDP4.5% 依赖出口加剧失衡 书法为什么不仅仅是把字写漂亮 让孩子理解集体利益不是个人利益的牺牲品 【概览】“国手山脉杯”国际围棋赛(个人赛) 全红婵现身上海参加活动 重返训练未来仍不确定 视频格式和视频编码有什么区别 唐朝为什么在安史之乱后逐渐衰落 大陆网红王扁重庆摆摊遇袭 遭人挥锤殴打入院 中国人疯抢“蟑螂粉”替代蛋白粉 专家示警 沈阳高架桥上惊现三匹马 车流中狂奔画面曝光 洗钱集团干部土地公庙求财 高雄警现场逮人 北京预计2035年进入“重度老龄化” 四大危机来临 旧房采光不好可以有哪些低成本改善方法 发酵食品为什么能够体现一个地区的生活智慧 张爱玲美国英文演讲 原声录音热传(录音) 湖北一男子酒后摔死 家属索赔33万余元引热议 川普:不想俄国退出谈判 我要战争结束! 澳洲“幽灵鲨潜舰”长獠牙 配备无人机与水雷强化战力 清晨推开窗,班夫之外的加拿大库特尼国家公园已经被群山包围 史上最大规模 美日“利剑27”军演今启动

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

发布时间: 2026-10-19 09:00:02    最后更新: 2026-10-19 10:53:40    阅读:8  约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