每个后端系统最终都需要有一些任务能够按照预定的时间表来执行。过期的会话需要被删除,汇总表格需要重新生成,物化视图需要更新,而维护工作也往往需要在大家睡觉的时候进行。
通常的解决办法是使用数据库之外的工具:比如系统的cron作业调度程序、Kubernetes的CronJob服务、Celery的工作进程,或者其他的调度服务。这些方法确实有效,但它们都会增加系统复杂性——你需要管理相应的账号密码,监控这些单独运行的进程,同时还要担心它们是否会突然停止工作。
pg_cron采取了不同的解决方案。它是一个PostgreSQL扩展模块,能够在数据库内部直接运行cron风格的调度任务。你可以使用普通的SQL语句来安排任务,数据库会负责执行这些任务,而所有的执行记录都会被保存在一个表格中,你可以像查询其他数据一样来查看这些记录。
在本教程中,你将了解pg_cron的工作原理,学习如何安装和配置它,以及如何利用它来完成实际的维护工作。你还会学会如何监控这些任务的运行情况,管理相关权限,并判断在什么情况下使用pg_cron是合适的,而在什么情况下则应该避免使用它。
目录
先决条件
要跟随本教程中的示例进行操作,你需要满足以下条件:
-
具备基本的SQL知识(包括SELECT、INSERT、UPDATE、DELETE等语句)
-
拥有正在运行的PostgreSQL实例(建议使用13版本或更高版本,不过pg_cron也支持10版本及以上的数据库)
-
对该实例具有超级用户或管理员权限,因为安装这个扩展模块需要这样的权限
-
需要一个SQL客户端工具,比如
psql、pgAdmin或DBeaver
<如果你没有自己运行服务器,那也没关系。大多数托管型的PostgreSQL服务——包括Amazon RDS、Azure Database for PostgreSQL、Google Cloud SQL、Supabase以及Neon——都支持pg_cron功能。在本教程的后半部分,我会详细介绍如何在这些服务上启用该功能。>
什么是pg_cron?
pg_cron是一个开源的PostgreSQL扩展程序,最初由Citus Data团队开发。它允许你使用熟悉的cron语法来安排SQL命令的执行。
你不需要在服务器上编写crontab条目,只需编写一条SQL语句即可:
SELECT cron_schedule(
'nightly-cleanup',
'0 3 * * *',
$$DELETE FROM sessions WHERE expires_at < now()$$
);
这条语句会让PostgreSQL在每天凌晨3点删除过期的会话记录。无需任何外部进程、shell脚本或额外的认证信息,任务定义直接存储在数据库中,如果你愿意,还可以将其与数据迁移脚本一起进行版本控制。
由于这个调度程序只是另一个扩展模块,因此你的任务会随数据库一同被迁移。任何能够连接并查询数据库的人都可以看到哪些任务被安排了、上次执行的时间以及执行结果如何。
pg_cron的工作原理
当PostgreSQL启用pg_cron时,该扩展程序会启动一个后台工作进程。这个进程的唯一任务就是监控cron.job表——这个表记录了所有被安排的任务,包括它们的执行时间、命令内容、目标数据库以及执行用户。
当某个任务的执行时间到来时,这个后台进程就会执行相应的命令。默认情况下,它会像普通应用程序一样,建立一个新的本地数据库连接来执行命令;你也可以配置它使用PostgreSQL的后台工作进程来代替连接,具体方法会在后续的设置章节中介绍。
有两条行为规则需要提前了解:
首先,pg_cron可以同时并行执行多个不同的任务,但永远不会同时运行同一个任务的多个实例。如果某个任务还在执行中,下一个执行时间就会到来,新的任务会进入队列等待,直到当前的任务完成才会开始执行。这样就可以避免慢速执行的清理任务不断堆积的情况。
其次,当服务器处于热备状态时,pg_cron不会执行任何任务。如果你使用的是流式复制机制,那么任务只会在中继节点上执行;当副本节点被提升为主节点时,调度程序会自动重新启动——因此切换主从节点并不会导致你的任务无法执行。
如何安装和配置pg_cron
在自主管理的服务器上配置pg_cron需要三个步骤:安装软件包、更新配置文件,然后创建相应的扩展模块。
第一步:安装软件包
在Debian或Ubuntu系统中,可以使用官方的PostgreSQL apt仓库来安装相应版本的软件包。对于PostgreSQL 17来说,命令如下:
sudo apt-get install postgresql-17-cron
在基于Red Hat的系统上,则可以使用PGDG yum仓库来安装:
sudo yum install pg_cron_17
如果你使用的是PostgreSQL 16或18版本,只需将版本号替换成相应的数值即可。如果你的操作系统没有对应的软件包,你也可以从源代码编译这个扩展模块来使用。
步骤 2:更新 postgresql.conf 文件
pg_cron 需要在 PostgreSQL 启动时自动启动其后台工作进程,因此必须提前将其加载到系统中。请在您的 postgresql.conf 文件中的 shared_preload_libraries 配置项中添加它:
shared_preload_libraries = 'pg_cron'
如果该配置项已经列出了其他库文件,只需将 pg_cron 加到以逗号分隔的列表中,而无需替换原有的库文件。
默认情况下,调度程序会将其元数据存储在名为 postgres 的数据库中。如果您的应用程序运行在另一个数据库中,并且您希望调度任务也在该数据库中执行,请进行如下设置:
cron.database_name = 'app_db'
还有一项需要了解的配置:pg_cron 默认会按照 GMT 时区来解析所有调度任务。如果您希望“凌晨 3 点的执行任务”实际上在当地时间凌晨 3 点运行,请明确指定时区:
cron.timezone = 'Africa/Lagos'
这些配置更改需要重新启动服务器才能生效:
sudo systemctl restart postgresql
步骤 3:创建扩展模块
连接到您在 cron.database_name 中配置的数据库,然后以超级用户身份创建该扩展模块:
CREATE EXTENSION pg_cron;
这样就会生成 cron 数据结构、元数据表以及调度函数。此时您就可以开始安排任务了。
需要注意的是,每个 PostgreSQL 实例中,pg_cron 只能在一个数据库中“安装”和使用。虽然听起来有些限制,但实际上并非如此。您仍然可以使用 cron.schedule_in_database() 函数在实例中的任何数据库中执行任务,我们稍后会详细介绍这个函数。
关于任务连接方式的说明
由于 pg_cron 默认会使用本地连接进行通信,因此您的 pg_hba.conf 文件必须允许这种连接方式。常见的处理方法是为任务的执行用户启用对 localhost 的 trust 认证机制,或者将密码保存在 .pgpass 文件中。
如果您完全不想进行连接认证,也可以让 pg_cron 使用后台工作进程来执行任务:
cron.use_background_workers = on
max_worker_processes = 20
使用后台工作进程时,并发执行的任务数量会受到 max_worker_processes 的限制。因此,如果您需要同时安排大量任务,请适当增加这个数值。
在托管数据库服务中使用 pg_cron
如果您使用的是托管数据库服务,通常无法直接编辑 postgresql.conf 文件,但服务提供商会通过其他方式提供相同的配置选项:
-
Amazon RDS 和 Aurora PostgreSQL:在您的数据库参数组中将
pg_cron添加到shared_preload_libraries配置项中,然后重启实例,最后以rds_superuser用户身份执行CREATE EXTENSION pg_cron;命令。调度程序会运行在postgres数据库中。 -
Azure Database for PostgreSQL:在服务器参数中启用 pg_cron(
shared_preload_libraries和azureextensions配置项),然后重启实例并创建扩展模块。 -
Google Cloud SQL:设置
cloudsql.enable_pg_cron参数,重启实例后创建扩展模块。 -
Supabase:在控制台的“数据库”→“扩展模块”选项中启用 pg_cron 扩展模块。
-
Neon
:pg_cron 是一个受支持的扩展模块,您可以根据需要为每个项目启用它。
之后你编写的SQL代码在所有地方都是相同的,而这正是它的魅力所在。
Cron语法的快速回顾
pg_cron使用与经典Unix cron相同的五字段调度格式:
┌──────────── 分钟(0–59)
│ ┌────────── 小时(0–23)
│ │ ┌──────── 月份中的日期(1–31,或用$表示最后一天)
│ │ │ ┌────── 月份(1–12)
│ │ │ │ ┌──── 星期几(0–6,周日=0)
│ │ │ │ │
* * * * *
星号表示“所有值”。你可以用逗号组合数值,用连字符表示范围,用斜杠表示间隔时间。有一些调度规则你会经常用到:
*/5 * * * * 每5分钟执行一次
0 * * * * 每小时整点执行
0 3 * * * 每天凌晨3点执行
0 3 * * 1-5 工作日每天凌晨3点执行
30 1 * * 0 每个周日凌晨1:30执行
0 0 1 * * 每个月1号午夜执行
pg_cron还在标准语法中添加了两个常规cron所没有的功能。
你可以在“月份中的日期”字段中使用$来表示该月的最后一天,而在标准cron中表达这一点其实相当麻烦:
0 23 $ * * 每个月最后一天晚上11点执行
而对于那些需要每分钟多次执行的作业,你可以使用1到59秒之间的时间间隔来设置调度规则:
crontab.guru可以将Cron表达式翻译成通俗易懂的英语。需要注意的是,pg_cron会按照:执行有针对性的cron.timezone设置的时区来解析调度规则,默认时区为GMT。如何安排你的第一个作业
核心函数是
cron.schedule()。它有两种形式:一种带有名称,另一种没有名称。建议使用带名称的形式,因为这样可以让作业更容易被查找、更新或删除:
SELECT cron_schedule( 'delete-expired-sessions', -- 作业名称 '0 3 * * *', -- 调度规则 $$DELETE FROM sessions WHERE expires_at < now()$$ -- 命令 );该函数会返回作业的ID:
schedule ---------- 1 (1 row)还有一些需要注意的地方:
命令需要用
$$ ... $$括起来,这是PostgreSQL特有的引用方式。这样就不需要在SQL代码中为单引号进行转义处理了。对于不需要引号的命令,使用普通的字符串字面量即可。作业会在调用
cron_schedule()的数据库中运行,而且会以调用该函数的用户的权限来执行任务。调度系统中不存在任何提升权限的功能——如果你的用户没有权限从sessions表中删除数据,那么作业也同样无法执行这个操作。如果你再次使用相同的作业名称调用
cron_schedule(),pg_cron会更新现有的作业而不是创建重复项。这样一来,调度任务就具备了幂等性,这对于在数据库迁移过程中定义作业来说非常方便。实用的pg_cron示例
让我们来看一些能够涵盖大多数实际应用场景的例子。这些示例都可以直接被你借鉴和使用。
示例1:每晚清除旧数据行
那些用于存储临时数据的表格——如会话信息、令牌、审计记录、通知日志等——如果不进行清理,这些数据就会不断累积。因此,每晚删除这些旧数据是一项非常常见的操作,这也是pg_cron的首个常用任务:
SELECT cron.schedule( 'purge-old-events', '0 2 * * *', $$DELETE FROM events WHERE created_at < now() - interval '90 days'$$ );每晚凌晨2点,所有创建时间超过90天的数据行都会被删除。如果表格的数据量非常大,可以考虑将删除操作封装在一个函数中,这样每次执行该函数的耗时就会较短,然后再安排这个函数定期运行。
示例2:每小时刷新一次物化视图
物化视图是一种用于缓存复杂聚合数据的优秀工具,但PostgreSQL本身并不会自动更新这些视图。pg_cron可以解决这个问题:
SELECT cron.schedule( 'refresh-daily-sales', '5 * * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_summary' );这个示例会每小时5分更新一次daily_sales_summary物化视图。由于使用了
CONCURRENTLY选项,因此即使在更新过程中也有数据被读取,只要该视图具有唯一的索引,这种操作就不会出现问题。示例3:生成每日汇总表
另一种常见的用法是生成每日汇总表:而不是在每次查看仪表盘时都重新计算数百万条原始数据,可以每天预先计算一次这些数值。
SELECT cron.schedule( 'rollup-daily-orders', '15 0 * * *', $$ INSERT INTO daily_order_stats (day, order_count, total_amount) SELECT created_at::date, count(*), sum(amount) FROM orders WHERE created_at >= current_date - 1 AND created_at < current_date GROUP BY created_at::date ON CONFLICT (day) DO UPDATE SET order_count = EXCLUDED.order_count, total_amount = EXcluded.total_amount $$ );每晚凌晨15分,昨天所有的订单信息会被汇总到一张表格中。
ON CONFLICT子句确保了这个任务可以安全地重复执行——即使它被执行了两次,也会只覆盖原有的数据而不会产生重复记录。示例4:每30秒运行一次任务
有些操作需要更频繁地执行,而cron默认的1分钟间隔显然无法满足这种需求。例如,需要定期清空缓冲表、从待处理队列中取出数据、推进轻量级任务的执行等。pg_cron支持每30秒执行一次这样的任务:
SELECT cron.schedule( 'process-outbox', '30 seconds', 'CALL process_outbox_batch()' );请记住之前提到的保证:当某个作业还在运行时,pg_cron不会启动该作业的第二个实例。如果某个批处理任务偶尔需要45秒才能完成,那么下一次执行时会等待轮到自己再开始运行,而不会抢在前面执行。
示例5:在每月的最后一天执行维护任务
在标准的cron系统中,处理月末任务会遇到一些麻烦,因为不同月份的天数长度各不相同。而pg_cron的
$语法让这种问题变得非常简单:SELECT cron.schedule( 'month-end-vacuum', '0 23 $ * *', 'VACUUM ANALYZE orders' );这条命令会在每个月的28日、29日、30日或31日晚上11点,对
orders表执行VACUUM ANALYZE操作——也就是在那个月的最后一天执行这个任务。如何查看和监控你的作业
pg_cron所记录的所有信息都存储在
cron模式下的两个表中,你可以像查询其他表格一样来查询这些表。如果你想查看已经安排好的作业任务,可以查询
cron.job表:SELECT jobid, jobname, schedule, command, active FROM cron.job;jobid | jobname | schedule | command | active -------+-------------------------+------------+--------------------------------+-------- 1 | delete-expired-sessions | 0 3 * * * | DELETE FROM sessions WHERE ... | t 2 | refresh-daily-sales | 5 * * * * | REFRESH MATERIALIZED VIEW ... | t (2 rows)如果你想了解这些作业实际是如何运行的,可以查询
cron.job_run_details表:SELECT jobid, status, return_message, start_time, end_time FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;每一行记录了一次作业的执行情况:包括作业是成功完成还是失败了、返回了什么信息,以及作业开始和结束的具体时间。如果作业失败了,其
status字段会显示为‘failed’,同时还会显示错误信息,因此调试工作通常都是从这个表开始的。需要注意的一点是:pg_cron本身永远不会自动清理这个表。如果某个作业每30秒执行一次,那么每天就会产生将近三千条记录。解决这个问题的方法也很简单——再安排一个pg_cron作业来清除这些历史记录:
SELECT cron.schedule( 'purge-cron-history', '0 12 * * *', $$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '14 days'$$ );如果你根本不希望记录作业的执行历史,可以在配置中将
cron.log_run = off这个参数设置成关闭状态。如何更新和删除作业
如果要修改现有的作业任务,可以使用
cron.alter_job()函数,并传入该作业的ID。只有你传递的参数会被修改,其他所有设置都会保持不变:-- 将作业1的执行时间从凌晨3点改为4点 SELECT cron.alter_job(1, schedule := '0 4 * * *'); -- 暂停某个作业而不删除它 SELECT cron.alter_job(1, active := false); -- 后来再恢复该作业的运行 SELECT cron.alter_job(1, active := true);使用
active := false来暂停某个作业,这一做法其实被低估了。在发生故障或进行大规模数据迁移时,你可以暂时停止某个运行中的作业,等任务完成后再重新启动它,而该作业的配置信息并不会因此丢失。如果要永久删除某个作业,可以使用
cron.unschedule()命令,只需提供作业的名称或ID即可:SELECT cron.un_schedule('delete-expired-sessions'); -- 或者 SELECT cron.un.schedule(1);无论使用哪种方式,当命令执行成功并删除了相应的作业时,都会返回
true值。如何在其他数据库中运行作业
需要记住的是,pg_cron在每个数据库实例中只会被安装一次,通常安装在
postgres数据库中。如果你的数据库实例托管了多个数据库,你并不需要在每个数据库中都安装pg_cron——你可以只在一个数据库中配置作业调度任务,然后使用cron.schedule_in_database()命令在其他数据库中执行这些作业:SELECT cron.schedule_in_database( 'analytics-nightly-vacuum', '0 4 * * *', 'VACUUM ANALYZE page_views', 'analytics_db' );这些作业虽然是在中央数据库中配置的,但实际会在
analytics_db数据库中执行。此外,这个函数还允许用户指定用户名(如果希望作业以其他用户的身份运行),以及设置active参数来控制作业是处于暂停状态还是正常运行。这种设计方式使得所有的调度任务都集中在一个数据库的同一个模式中,因此进行审计非常方便:只需执行一条
SELECT * FROM cron.job命令,就能查看整个实例中所有已安排的作业。如何允许其他用户安排作业
默认情况下,只有超级用户才能使用调度相关的功能。如果你想让某个应用角色能够自行管理它的作业,就需要授予它对
cron模式的使用权:GRANT USAGE ON SCHEMA cron TO app_user;这种权限设置既合理又安全:
作业是按照安排它们的用户的权限来执行的,不会超出这些权限范围。
由于
cron.job表采用了行级安全机制,因此用户只能看到和修改自己安排的作业;超级用户则可以查看所有作业。每个用户还可以自行删除
cron.job_run_details>表中与自己相关的记录,这样一来,即使没有超级用户权限,也能正常执行清理任务。在实际使用中,我建议专门为作业调度创建一个专用角色,而不是让个人账户来负责这些任务。因为如果负责安排作业的工程师离职了,他们的角色也会被删除,而此时就不应该让之前的作业安排继续生效。
何时使用pg_cron,何时避免使用它
当需要执行与数据库相关的操作时,pg_cron会显得非常有用。以下是一些适合使用它的场景:
数据维护:清理会话记录、日志文件、事件信息以及令牌表中的过期数据。
数据聚合:更新物化视图并生成汇总报表。
系统维护
VACUUM ANALYZE操作,重新计算统计信息,并管理数据库分区(这与pg_partman配合使用效果最佳)。
简单的数据处理流程:在不同的表之间迁移数据,处理待发送的消息,以及删除那些被标记为“软删除”的记录。
共同点在于:所有的任务都可以用SQL语句或存储过程来表示,而且这些任务不会涉及数据库之外的任何内容。
在以下情况下,你应该考虑使用其他工具:
-
如果任务需要调用外部系统。 pg_cron只能执行SQL语句,它无法发送HTTP请求、加入队列或发送电子邮件。这类任务应该由你的应用程序或工作流引擎来处理。
-
如果任务需要具备重试机制、延迟执行功能以及警报提醒。 pg_cron虽然会记录失败情况,但不会自动重试或通知用户。对于那些必须完成的任务来说,使用Temporal这样的工具或专门的工作流管理系统才是更合适的选择。
-
如果任务耗时较长、计算量较大。在主OLTP数据库中运行长达四小时的批处理任务会占用大量的CPU资源、内存以及锁资源。因此,应该将这类任务安排在其他地方执行。
-
如果任务之间存在复杂的依赖关系。
“只有当A任务成功完成后才能执行B任务,然后再依次执行C和D任务”这种逻辑属于工作流管理的范畴,而不是pg_cron的适用范围。
一个合理的经验法则是:pg_cron适用于替代那些在某些被遗忘的服务器上通过crontab来执行的、仅用于运行`psql -c "..."`命令的任务。但它不能替代你的工作流队列或工作流管理系统。
使用pg_cron的最佳实践
养成以下一些习惯,可以让你的定时任务更加高效、易于管理:
为每个任务起一个名称:仅通过ID来标识的匿名任务在六个月后会变得难以管理。给任务起名字还可以确保`cron.schedule()`函数的幂等性,这样你在进行数据库迁移时就可以安全地定义这些任务。
要明确设置时区:pg_cron的默认时区是GMT,如果你不想遇到“凌晨3点的任务实际上在凌晨4点才执行”这样的问题,可以在一开始就通过`cron.timezone`参数来指定正确的时区。
缩短每次任务的执行时间:将大规模的数据删除操作封装到批处理存储过程中去。那些能在几秒钟内完成的任务会占用较少的系统资源,也不会在系统中留下过多的等待队列。
确保任务具有幂等性:服务器可能会重启,任务也可能在执行中途失败。使用`ON CONFLICT`语句、时间窗口条件等机制,可以确保重新执行任务时不会产生问题。
定期清理不必要的任务:在表格的大小还没有大到足以引起麻烦之前,就通过调度清理任务来保持数据库的整洁。
不仅要关注任务是否失败,还要注意它们是否完全停止了执行:失败的任务会在`job_run_details`表中留下记录,但那些被彻底取消调度的任务则不会留下任何痕迹。定期检查每个关键任务是否最近有成功执行的记录,就可以及时发现这两种情况:
SELECT j.jobname, max(d.end_time) AS last_success
FROM cron.job j
LEFT JOIN cron.job_run_details d
ON d.jobid = j.jobid AND d.status = 'succeeded'
GROUP BY j.jobname
HAVING max(d.end_time) < now() - interval '1 day'
OR max(d.end_time) IS NULL;
这个查询返回的任何任务都已经超过一天没有成功执行了,因此值得我们关注。
结论
pg_cron将PostgreSQL转变成了一个独立的调度系统。你可以通过SQL语言定义这些任务,数据库会负责执行它们,而整个系统的运行情况——包括任务的定义、执行历史以及失败记录——都可以通过普通的查询语句来查看。
在本教程中,你了解了这个扩展模块的内部工作原理,学会了如何在自己的服务器或托管服务上安装它,掌握了如何编写调度任务(包括pg_cron提供的秒级调度功能以及每月最后一天的特殊处理机制),也知道了如何利用它来处理所有数据库都会遇到的维护工作,比如数据优化、汇总分析、数据更新以及磁盘清理等。此外,你还学会了如何监控这些任务的执行情况,如何管理相关权限,以及何时应该使用任务队列或专门的调度工具来更高效地完成任务。
如果你的基础设施中有一台专门用于通过crontab运行psql命令的服务器,那么现在你知道应该如何让这台服务器“退休”了。



