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 的支持及实现细节并不相同,使用前需要根据目标版本确认。
区分“生成应收账单”和“调用支付渠道”
数据库适合判断哪些订阅已经到期、生成应收记录并维护任务状态,但通常不适合直接在存储过程中调用支付网关。外部支付还涉及网络超时、重试、限流、密钥管理和回调处理。
较为可靠的处理流程是:
- 数据库定时生成待支付任务。
- 使用唯一键保证每个计费周期只有一个任务。
- 支付工作进程读取并执行任务。
- 将支付结果写回数据库。
- 按照明确的重试策略处理失败任务。
如果“完全不能使用数据库外部程序”是硬性要求,数据库可以完成内部账单生成,但通常无法安全替代完整的外部支付执行流程。
时区和日期边界
不能默认服务器本地时间就是客户的计费时区。建议统一保存 UTC 时间,或者同时记录客户的计费时区。还需要处理以下情况:
- 夏令时切换
- 月末日期,如每月 29、30、31 日
- 闰年和 2 月 29 日
- 数据库停机期间错过的执行时间
- “每 30 天”与“每自然月”的差异
定时器只负责唤醒任务。实际计费规则应根据持久化的 next_billing_at 和业务周期计算,不能只依赖“每天凌晨运行一次”这个假设。
备注:内容仅供参考。
