PAYATHON 2026

如何判断每分钟运行的 cron 是否会使 Apache 过载?

支付老李

结论

不要让 payment gateway 的 HTTP 请求一直等待 60 秒。收到支付通知后,应尽快完成校验和第一阶段处理,把订单记录为“等待发送”,立即返回成功响应,再交给独立的后台任务处理到期订单。

每分钟运行一次 cron 通常不会让 Apache 过载。cron 的调度开销很小,真正需要评估的是每次任务扫描的数据量、数据库查询是否使用索引、文件操作需要多长时间,以及多次任务是否会重叠。只要控制并发、为查询建立合适的索引,并保证处理过程具备幂等性,这种方案是可行的。

推荐的处理流程

可以把支付回调和库存系统传输分成两个阶段:

  1. 验证 payment gateway 的签名或认证信息。
  2. 使用 gateway 提供的交易 ID 去重。
  3. 在数据库事务中更新支付状态,完成第一阶段任务。
  4. 写入后台任务,把执行时间设为当前时间之后 60 秒。
  5. 立即向 payment gateway 返回成功响应。
  6. 后台 worker 定期领取到期任务,并向客户的库存系统发送订单。
  7. 发送成功后将任务标记为完成;发送失败则记录错误并安排重试。

这样不会占用 Apache/PHP worker 等待,也不需要考虑 payment gateway 是否允许请求持续 60 秒。

“60 秒后才能执行”和“必须恰好在第 60 秒执行”是两种要求。cron 每分钟运行一次时,任务一般会在写入后的 60~120 秒内得到处理。如果执行时间需要更接近准确的 60 秒,应使用常驻队列 worker,或者支持延迟任务的队列系统。

数据库任务表示例

可以把待处理任务直接保存在 PostgreSQL 中:

CREATE TABLE order_transfer_jobs (
    id bigserial PRIMARY KEY,
    order_id bigint NOT NULL,
    status text NOT NULL DEFAULT 'pending',
    available_at timestamptz NOT NULL,
    attempts integer NOT NULL DEFAULT 0,
    locked_at timestamptz,
    last_error text,
    completed_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX order_transfer_jobs_pending_idx
    ON order_transfer_jobs (available_at)
    WHERE status = 'pending';

支付成功后,创建一项延迟任务:

INSERT INTO order_transfer_jobs (order_id, available_at)
VALUES (:order_id, now() + interval '60 seconds');

gateway 可能会重复发送支付通知,因此还要建立业务唯一约束,防止同一订单生成多个相同类型的传输任务:

CREATE UNIQUE INDEX order_transfer_jobs_order_id_idx
    ON order_transfer_jobs (order_id);

能否只用 order_id 作为唯一键,需要根据业务规则决定。如果一个订单可以合法传输多次,就应增加任务类型或版本字段,并将其纳入联合唯一键。

使用 cron 执行后台任务

crontab 可以每分钟调用一次 PHP CLI 脚本:

* * * * * /usr/bin/flock -n /var/run/order-transfer.lock /usr/bin/php /opt/shop/bin/process-order-transfers.php >> /var/log/order-transfer.log 2>&1

需要注意以下几点:

  • flock -n 会在前一次任务尚未结束时,让新任务直接退出。
  • 应使用 PHP CLI,不要通过 HTTP 请求访问网站上的脚本。
  • 所有路径都使用绝对路径,因为 cron 的 PATH、工作目录可能与交互式 shell 不同。
  • 锁文件所在目录必须存在,运行 cron 的用户也要有写入权限。
  • 日志需要配置轮转,以免长期运行后占满磁盘。

也可以在脚本中添加非阻塞锁:

<?php

$lock = fopen('/var/run/order-transfer.lock', 'c');

if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
    exit(0);
}

try {
    processDueOrderTransfers();
} finally {
    flock($lock, LOCK_UN);
    fclose($lock);
}

外部 flock 一般更简单。即使 PHP 脚本异常退出,操作系统也会释放文件描述符和对应的锁。

安全领取任务

如果以后需要运行多个 worker,可以使用 PostgreSQL 的 FOR UPDATE SKIP LOCKED,防止多个进程领取同一项任务:

WITH selected AS (
    SELECT id
    FROM order_transfer_jobs
    WHERE status = 'pending'
      AND available_at <= now()
    ORDER BY available_at
    FOR UPDATE SKIP LOCKED
    LIMIT 50
)
UPDATE order_transfer_jobs AS jobs
SET status = 'processing',
    locked_at = now(),
    attempts = attempts + 1
FROM selected
WHERE jobs.id = selected.id
RETURNING jobs.*;

领取任务后应尽快提交事务,不要在持有数据库行锁时发起网络请求或执行耗时较长的文件传输。事务提交后再逐项处理,并分别更新任务的成功或失败状态。

使用单进程 flock 时,SKIP LOCKED 不是必需的。不过,保留这套领取机制可以改善容错,也方便以后增加 worker。

任务必须具备幂等性

避免任务重叠只是第一层保护。进程可能在文件已经发送、数据库还没来得及标记成功时崩溃。下次运行时,这个文件就可能被再次发送。因此,传输操作本身也要尽量做到幂等:

  • 使用稳定且唯一的订单编号作为外部请求 ID。
  • 如果库存系统支持幂等键,每次都发送相同的 idempotency key。
  • 使用确定的文件名生成文件,并明确规定如何处理重复文件。
  • 在数据库中记录发送状态、尝试次数、响应结果和错误信息。
  • 谨慎处理超时。超时不代表对方没有收到,应先查询对方的状态,再决定是否重发。
  • 为失败任务设置最大重试次数,超过上限后发出告警。

还要处理长时间停留在 processing 状态的任务。例如,worker 崩溃后,可以把锁定时间超过合理范围的任务重新设为 pending。这个时限应根据实际的最长处理时间确定。如果设得太短,反而可能造成重复处理。

每分钟 cron 的实际负载如何判断

是否会过载,不能只看 cron 是不是每分钟运行一次,还要看每次运行的成本以及最坏情况下的积压量。

操作系统指标

在 cron 运行前后和业务高峰期观察:

  • CPU 使用率和 load average
  • 可用内存、swap 使用情况
  • 磁盘 I/O 延迟和 I/O wait
  • 网络吞吐量及连接错误
  • 任务运行时间和同时运行的进程数

如果每次运行只执行一次带索引的小查询,再移动少量本地文件,开销通常很低。如果“传输文件”指的是通过网络发送到客户系统,那么网络延迟、超时和重试往往比 cron 调度本身更值得关注。

Apache 和 PHP 指标

后台任务虽然不经过 Apache,却仍会与网站争用 CPU、内存、磁盘和网络资源。需要监控:

  • Apache busy/idle workers
  • PHP-FPM active、idle 和 queued requests;使用其他 PHP 运行方式时,则查看相应的进程指标
  • 网站请求延迟的 p50、p95、p99
  • HTTP 5xx、timeout 和连接拒绝数量

如果 cron 运行期间网站的尾延迟明显上升,即使平均 CPU 使用率不高,也说明两者存在资源争用。

PostgreSQL 指标

重点检查:

  • 查询执行时间
  • 扫描和返回的行数
  • 查询是否使用预期索引
  • 锁等待和长事务
  • 数据库连接数
  • 磁盘读取量
  • 待处理任务的积压数量

可以使用 EXPLAIN (ANALYZE, BUFFERS) 检查领取任务的查询,但最好先在测试环境或其他可控条件下执行,因为 ANALYZE 会实际运行语句。测试修改数据的 SQL 时,应将其放入可回滚事务,或者改用等价的只读查询来评估访问路径。

建议的测试方法

先在测试环境模拟正常流量和突发流量,包括:

  • 正常情况下每分钟产生的订单数。
  • 促销期间预计达到的订单峰值。
  • 库存系统中断数分钟后产生的积压量。
  • 文件传输变慢或持续超时。
  • worker 在处理过程中异常终止。

测试时记录:

  • 每批处理的任务数量。
  • 每次 cron 的总运行时间。
  • 单个任务的平均耗时和高分位耗时。
  • pending 队列长度以及最老任务的等待时间。
  • 数据库查询耗时和锁等待。
  • 网站请求延迟和错误率的变化。
  • 重试次数和最终失败数量。

如果任务经常运行超过一分钟,应先找到耗时较长的环节,不要直接增加并发。提高并发量可能让数据库或外部库存系统承受更大的压力。

cron 与任务队列如何选择

如果这只是临时 workaround,订单量不高,而且业务可以接受几十秒的调度误差,那么数据库任务表配合每分钟一次的 cron,是一种简单可靠的方案。

出现以下情况时,可以改用支持延迟任务的队列系统或常驻 worker:

  • 执行时间需要更接近准确的 60 秒。
  • 每分钟有大量订单等待处理。
  • 需要指数退避、优先级、死信队列或更完善的重试管理。
  • 单个任务的耗时不稳定。
  • 需要多个 worker 并行消费。
  • 需要更完整的任务监控。

无论使用 cron 还是队列,处理原则都一样:支付回调要快速响应,延迟工作通过异步方式执行,任务状态应当可以追踪,重复执行也不能破坏业务数据。

备注:内容仅供参考。