PAYATHON 2026

MySQL、PostgreSQL 或 Oracle 如何定时触发计费

支付老李

结论

不能创建“由系统日期触发”的普通 database trigger。Trigger 只能响应 INSERT、UPDATE、DELETE 等数据库事件,不会在某个时间点自动执行。

定时计费需要使用数据库的任务调度功能:

  • MySQL:Event Scheduler
  • Oracle:DBMS_SCHEDULER
  • PostgreSQL:核心数据库没有通用的内置定时任务调度器,可以安装 pg_cron 等扩展。如果不允许安装扩展,则需要使用 cron、pgAgent 或应用任务服务等外部调度器。

通常的做法是让调度器定期扫描已到期的账单,而不是为每个客户单独创建定时任务。

推荐的数据模型

每个订阅都保存下一次计费时间和计费周期,例如:

CREATE TABLE subscriptions (
    id              BIGINT PRIMARY KEY,
    status          VARCHAR(20) NOT NULL,
    next_billing_at TIMESTAMP NOT NULL,
    interval_days   INTEGER NOT NULL
);

计费任务按固定间隔执行以下查询:

SELECT *
FROM subscriptions
WHERE status = 'active'
  AND next_billing_at <= CURRENT_TIMESTAMP;

处理成功后,系统写入账单并更新 next_billing_at。实际系统通常还需要一张带有唯一业务键的账单表,防止任务重试时重复计费。

不建议直接这样计算下一次计费时间:

next_billing_at = CURRENT_TIMESTAMP + interval_days

如果任务延迟执行,这种写法会逐渐改变客户的计费周期。通常应从原定时间继续推进:

next_billing_at = next_billing_at + interval_days

具体的日期运算语法取决于所用数据库。

MySQL:使用 Event Scheduler

先检查并启用事件调度器:

SHOW VARIABLES LIKE 'event_scheduler';

SET GLOBAL event_scheduler = ON;

SET GLOBAL 是否持久生效,取决于 MySQL 版本和服务器配置。生产环境还应在数据库配置中启用事件调度器,或者使用当前版本支持的持久化系统变量设置方式。

可以将计费逻辑封装为存储过程:

DELIMITER //

CREATE PROCEDURE process_due_billing()
BEGIN
    /*
      1. 查找 next_billing_at <= CURRENT_TIMESTAMP 的订阅
      2. 创建账单或计费任务
      3. 更新 next_billing_at
      4. 记录执行结果
    */
END//

DELIMITER ;

然后创建一个每天执行的事件:

CREATE EVENT billing_daily_event
ON SCHEDULE EVERY 1 DAY
STARTS CURRENT_TIMESTAMP
DO
    CALL process_due_billing();

检查事件:

SHOW EVENTS;

如果需要缩短处理延迟,也可以每小时或每几分钟扫描一次:

CREATE EVENT billing_scan_event
ON SCHEDULE EVERY 10 MINUTE
DO
    CALL process_due_billing();

数据库重启后,事件是否继续运行,取决于 event_scheduler 是否仍然处于启用状态。

PostgreSQL:使用 pg_cron 扩展

PostgreSQL 核心没有与 MySQL Event Scheduler 或 Oracle DBMS_SCHEDULER 完全对应的通用内置调度器。如果允许安装扩展,可以使用 pg_cron。

pg_cron 需要由数据库管理员安装和配置,通常还要将其加入 shared_preload_libraries,然后重启 PostgreSQL。具体配置方式应以当前使用的 PostgreSQL 发行版和托管平台为准。

创建扩展:

CREATE EXTENSION pg_cron;

假设计费逻辑已经封装为函数:

CREATE OR REPLACE FUNCTION process_due_billing()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
    /*
      处理到期订阅。
      生产实现需要考虑事务、并发和重复执行。
    */
END;
$$;

每天凌晨 1 点运行:

SELECT cron.schedule(
    'daily-billing',
    '0 1 * * *',
    $$SELECT process_due_billing();$$
);

每十分钟运行一次:

SELECT cron.schedule(
    'billing-scan',
    '*/10 * * * *',
    $$SELECT process_due_billing();$$
);

如果不能安装 pg_cron,就无法只依靠 PostgreSQL 核心实现可靠的定时唤醒,此时必须使用外部调度器。

Oracle:使用 DBMS_SCHEDULER

先创建计费存储过程:

CREATE OR REPLACE PROCEDURE process_due_billing AS
BEGIN
    /*
      处理 next_billing_at <= SYSTIMESTAMP 的订阅,
      创建账单并推进下次计费时间。
    */
    NULL;
END;
/

创建每天凌晨 1 点运行的任务:

BEGIN
    DBMS_SCHEDULER.CREATE_JOB(
        job_name        => 'DAILY_BILLING_JOB',
        job_type        => 'STORED_PROCEDURE',
        job_action      => 'PROCESS_DUE_BILLING',
        start_date      => SYSTIMESTAMP,
        repeat_interval => 'FREQ=DAILY;BYHOUR=1;BYMINUTE=0;BYSECOND=0',
        enabled         => TRUE,
        auto_drop       => FALSE
    );
END;
/

查看任务状态:

SELECT job_name, enabled, state, next_run_date
FROM user_scheduler_jobs
WHERE job_name = 'DAILY_BILLING_JOB';

查看运行记录:

SELECT job_name, status, actual_start_date, run_duration
FROM user_scheduler_job_run_details
WHERE job_name = 'DAILY_BILLING_JOB'
ORDER BY actual_start_date DESC;

创建和运行任务需要相应权限。具体权限应由数据库管理员按照最小权限原则授予。

计费任务必须考虑的问题

防止重复计费

数据库重启、执行超时或人工操作都可能导致定时任务重试,因此计费过程必须具备幂等性。例如,可以使用“订阅 ID + 计费周期”作为唯一键:

CREATE UNIQUE INDEX uq_invoice_billing_period
ON invoices (subscription_id, billing_period_start);

这样即使同一周期被多次扫描,也只能生成一条账单。

防止多个任务实例同时处理同一订阅

如果计费过程耗时较长,下一次调度可能在上一次任务结束前启动。可以通过数据库锁、任务状态或行级锁控制并发。

在支持相应语法的数据库中,常见写法如下:

SELECT *
FROM subscriptions
WHERE status = 'active'
  AND next_billing_at <= CURRENT_TIMESTAMP
FOR UPDATE SKIP LOCKED;

不同数据库和版本对 SKIP LOCKED 的支持及实现细节并不相同,使用前需要根据目标版本确认。

区分“生成应收账单”和“调用支付渠道”

数据库适合判断哪些订阅已经到期、生成应收记录并维护任务状态,但通常不适合直接在存储过程中调用支付网关。外部支付还涉及网络超时、重试、限流、密钥管理和回调处理。

较为可靠的处理流程是:

  1. 数据库定时生成待支付任务。
  2. 使用唯一键保证每个计费周期只有一个任务。
  3. 支付工作进程读取并执行任务。
  4. 将支付结果写回数据库。
  5. 按照明确的重试策略处理失败任务。

如果“完全不能使用数据库外部程序”是硬性要求,数据库可以完成内部账单生成,但通常无法安全替代完整的外部支付执行流程。

时区和日期边界

不能默认服务器本地时间就是客户的计费时区。建议统一保存 UTC 时间,或者同时记录客户的计费时区。还需要处理以下情况:

  • 夏令时切换
  • 月末日期,如每月 29、30、31 日
  • 闰年和 2 月 29 日
  • 数据库停机期间错过的执行时间
  • “每 30 天”与“每自然月”的差异

定时器只负责唤醒任务。实际计费规则应根据持久化的 next_billing_at 和业务周期计算,不能只依赖“每天凌晨运行一次”这个假设。

备注:内容仅供参考。