做数据库运维和开发这些年,我越来越觉得,一个系统里最容易被低估的其实是"定时任务"。业务代码里那些"每天凌晨清一下日志、每小时刷一次统计、定期回收空闲连接"的需求,看着不起眼,可真要靠人肉或者外部脚本去维护,麻烦事一串接一串。后来我在PostgreSQL里引入了pg_cron,把这类定时调度直接变成数据库内部的扩展能力,才算是把这块短板补上了。这篇文章就是我从安装到实战的完整记录,包括使用方式、调度原理、完整SQL案例,以及我在生产环境里踩过的一些坑。如果你正在用PostgreSQL,不管版本是14还是16,都建议花几分钟看完,尤其是第5部分,那些都是文档里不会写的东西。
1. 先说清楚:什么时候选pg_cron而不是外部crontab
1.1 外部crontab + psql脚本的隐形问题
很多团队的第一反应是:定时任务不是有crontab吗?为什么非要跑到数据库里装一个扩展。我承认,如果只是"每天凌晨跑一条SQL",外部crontab加psql完全够用。但我实际维护的库里,定时任务是会持续增长的:先加一个清理任务,再添一个报表任务,后来客户又要一个每天生成统计数据的任务,最后你手上会同时维护几十条cron记录和对应的Shell脚本。任务一多,问题就全出来了。
首先是脚本和数据库割裂。任务跑挂了,你得先去翻服务器日志,再手动执行一遍里面的SQL,才能确认是脚本的问题还是SQL的问题。排查链路特别长,而且每次都要在两个上下文之间来回切。其次是权限和隔离难做。多个业务共用一个PostgreSQL实例时,如果给每个业务都开一个服务器账号来部署crontab,账号管理和安全审计都是麻烦事。还有数据库高可用切换的场景。主备切换之后,外部脚本里写的连接信息如果没有同步改,任务会静默失败,这种问题往往要到第二天业务方反馈才发现。
所以在我的体感里,外部crontab适合"少量、简单、和数据库外部环境强相关"的任务。而大量、纯粹以SQL为载体的内部定期操作,更适合直接交给数据库自己来管。
1.2 pg_cron与外部定时方案的对比
pg_cron是Citus团队开源、后来进入微软维护的一个PostgreSQL扩展。它的核心能力是让PostgreSQL自己拥有一个常驻调度进程(后台worker),按cron语法计时,定时执行SQL命令。我用下来的最大感受就是:调度逻辑离数据更近了,任务状态和数据状态在同一套系统里,排查问题不需要跳来跳去。
这里给一张对比表,是我当年选型时参考的几个维度:
| 对比维度 | pg_cron | 外部crontab + psql |
|---|---|---|
| 任务定义 | 数据库扩展对象 | 服务器文件系统配置 |
| 执行身份 | 数据库用户,受PG权限控制 | 操作系统用户 |
| 日志记录 | cron.job_run_details表,可SQL查询 | 系统日志或自定义日志文件 |
| 迁移备份 | 随数据库dump/restore迁移 | 需要单独管理脚本和crontab条目 |
| 数据库不可用时 | 任务无法执行 | 可在数据库恢复后自行补充执行 |
| 调度粒度 | 分钟级 | 分钟级 |
选择建议其实很直接:如果你的定时任务本质就是SQL操作,比如清理、汇总、刷新物化视图,并且希望任务定义能跟着数据库走,pg_cron是更顺手的方案。如果你需要调用数据库之外的外部程序,比如写本地文件、调HTTP接口、操作其他组件,那外部crontab依然不可替代。实际生产里两者也常常配合使用:外部crontab负责触发更宏观的编排,数据库内部的周期细节全部交给pg_cron。
1.3 这篇文章适合谁
我按自己的使用经验把内容分成三个层次:安装启用、核心语法、实战排坑。零基础的同学可以从头看到尾,跟着命令敲一遍就能在本地库跑起来;已经用上pg_cron的同学可以直接跳到第5和第6部分,那两块是我在生产环境踩过坑之后整理出来的重点,很多问题不遇到一次真的很难注意到。
2. pg_cron安装与启用:编译、包管理、Docker三条路
2.1 版本兼容性要提前确认
pg_cron对PostgreSQL版本比较敏感,不同PostgreSQL版本要对应不同版本的扩展代码。目前官方支持的版本覆盖到16,比如PostgreSQL 16需要pg_cron 1.6以上。我建议动手之前先去pg_cron的Release页面确认最新版本支持哪些PG版本,避免编译完加载不进shared_preload_libraries。
还有一个更关键的问题要提前想好:任务库放在哪个数据库。pg_cron通过参数cron.database_name指定"任务所在的数据库",所有任务定义都存储在这个库的cron schema里,调度worker也只连接这个库来读取和执行任务。我建议就放在默认的postgres库,或者专门建一个maintenance管理库,不要放业务库,因为业务库可能随时被删掉或重建,任务定义也就跟着没了。这个决策直接影响后面所有操作,一定要在一开始定下来。
2.2 源码编译安装的完整步骤
这是最通用也最能说明问题的一条路。我以Ubuntu + PostgreSQL 16为例写一遍:
第一步,确认系统已经安装PostgreSQL的开发包。编译扩展必须依赖pg_config和头文件:
# 如果系统里没有pg_config,说明缺开发包 sudo apt install postgresql-server-dev-16 which pg_config第二步,克隆源码并编译安装:
git clone https://github.com/citusdata/pg_cron.git cd pg_cron # 如果默认分支版本不匹配,可以切到对应PG版本的分支 make sudo make installmake之后,pg_cron.so和pg_cron.control会被安装到PostgreSQL的扩展目录。可以验证一下:
pg_config --sharedir ls $(pg_config --sharedir)/extension | grep pg_cron看到pg_cron.control文件就说明编译安装成功。第三步就是配置shared_preload_libraries并重启数据库。这一步最容易漏,pg_cron必须作为预加载库在数据库启动时加载,否则CREATE EXTENSION会直接报错。建议用SQL改配置再重启:
ALTER SYSTEM SET shared_preload_libraries = 'pg_cron'; ALTER SYSTEM SET cron.database_name = 'postgres';sudo systemctl restart postgresql重启后检查配置是否生效:
SHOW shared_preload_libraries; -- 期望输出包含 pg_cron第四步,在cron.database_name指定的库里创建扩展:
CREATE EXTENSION IF NOT EXISTS pg_cron;注意:创建扩展和后续所有调度任务,都必须在cron.database_name指定的库里执行。如果你换了一个库作为任务库,需要先改cron.database_name并重启,再在新库中执行CREATE EXTENSION。
2.3 Docker和包管理器安装的要点
Docker里部署pg_cron有点特殊,因为容器重启后配置必须能自动生效。最直观的方式是启动时直接传参数:
docker run -d --name postgres-pgcron \ -e POSTGRES_PASSWORD=mysecretpassword \ postgres:16 \ -c shared_preload_libraries=pg_cron \ -c cron.database_name=postgres容器起来后再进入容器创建扩展:
docker exec -it postgres-pgcron psql -U postgres -c "CREATE EXTENSION pg_cron;"如果想把pg_cron固化在自定义镜像里,可以参考下面这种Dockerfile方式,它的原理是在官方镜像基础上现场编译扩展,再把初始化脚本放进去:
FROM postgres:16 RUN apt-get update && apt-get install -y postgresql-server-dev-16 make gcc git \ && git clone https://github.com/citusdata/pg_cron.git \ && cd pg_cron && make && make install \ && rm -rf /pg_cron \ && apt-get purge -y gcc git make && apt-get autoremove -y COPY docker-entrypoint-initdb.d/init-pgcron.sh /docker-entrypoint-initdb.d/init-pgcron.sh里面可以先创建扩展:
#!/bin/bash set -e psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "postgres" <<-EOSQL CREATE EXTENSION IF NOT EXISTS pg_cron; EOSQL这样的方式适合企业内部把镜像作为标准化交付物来管理。至于包管理器安装,Debian/Ubuntu系可以使用官方或社区维护的仓库直接安装postgresql-16-pg-cron这样的二进制包,优点是升级方便,缺点是包版本可能滞后,需要根据实际PostgreSQL版本来选择。
2.4 验证调度worker是否真的在跑
扩展建好之后,一定要检查后台worker是否存在:
SELECT pid, backend_type FROM pg_stat_activity WHERE backend_type LIKE '%cron%';正常情况下会看到一条backend_type = 'pg_cron launcher'的记录。这一步非常重要。如果后台worker没起来,即使CREATE EXTENSION成功了,cron.schedule也能执行,但到了时间任务根本不会运行,而且不容易第一时间发现。我见过不止一次,任务配置得井井有条,结果全都在裸奔,就是因为没检查这一步。
3. 调度核心不复杂,但有两个表必须搞懂
3.1 cron.schedule函数的几种用法
pg_cron的调度语法和Linux crontab保持一致,五个字段依次是:分钟、小时、日、月、星期。表示任意,/5表示每5个单位,逗号可以并列多个取值,a-b表示范围。特别提醒:pg_cron最小粒度是分钟,不支持秒级调度,这是设计使然,做数据库内部任务根本不需要秒级。
创建任务用cron.schedule函数,常见有四种调用方式:
第一种,最小调用,只传调度表达式和执行命令:
SELECT cron.schedule('*/10 * * * *', 'SELECT 1');第二种,命名任务,方便后续维护:
SELECT cron.schedule('cleanup-logs', '0 3 * * *', $$DELETE FROM operations_log WHERE ts < now() - interval '30 days'$$);第三种,指定数据库:
SELECT cron.schedule('cleanup-logs-prod', '0 3 * * *', $$DELETE FROM operations_log WHERE ts < now() - interval '30 days'$$, 'appdb');第四种,指定数据库和执行用户:
SELECT cron.schedule('cleanup-logs-prod', '0 3 * * *', $$DELETE FROM operations_log WHERE ts < now() - interval '30 days'$$, 'appdb', 'appuser');生产环境里我几乎都用命名方式。名字建议带业务前缀,例如clean_audit_log、refresh_sales_summary,彻底避免任务多了之后互相无法区分。
关于命令内容,有几个规则要记牢:
- 命令可以是任意SQL,但多个独立语句不能简单用分号拼在一起让pg_cron执行。最简单的做法是包进DO块。
- 更推荐的方式是把复杂逻辑封装成函数,任务命令只写SELECT my_function(),这样单独测试和排障都方便。
- 如果命令返回结果集,结果会进入job_run_details的return_message字段,但内容有限,别指望它保存全量数据。
3.2 cron.job:任务定义的真实存储表
当你调用cron.schedule之后,实际是在cron.job表里插入了一条记录。这个表是pg_cron运作的根基,日常维护时我经常直接拿SQL来查询和修改。常见字段如下:
| 字段 | 作用 |
|---|---|
| jobid | 任务ID,自增 |
| jobname | 任务名称 |
| schedule | cron表达式 |
| command | 要执行的SQL |
| database | 执行任务的数据库 |
| username | 以哪个数据库用户执行 |
| active | 是否启用 |
| nodename / nodeport | 后台worker节点信息,默认本机 |
暂停任务、恢复任务、修改调度时间,都可以用普通UPDATE完成:
UPDATE cron.job SET active = false WHERE jobname = 'cleanup-logs'; UPDATE cron.job SET schedule = '0 4 * * *' WHERE jobname = 'cleanup-logs';删除任务用cron.unschedule函数更规范:
SELECT cron.unschedule('cleanup-logs');从维护角度看,直接UPDATE cron.job比反复schedule/unschedule更灵活,因为任务ID和命令内容都不会变,只是把执行计划改了。
3.3 cron.job_run_details:运行历史与失败定位
任务跑没跑、成功还是失败、花了多久,全部记录在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字段主要有三种:succeeded、failed、running。失败时return_message里会带数据库的报错信息,排查SQL问题时基本可以当作错误日志直接使用。
这个表还是判断"任务是否真的在调度"的重要依据。如果时间已经过了,但表里完全没有新记录,说明launcher进程异常,或者数据库连接配置有问题。我之前改过cron.database_name后忘记重启,导致所有任务静默失效,就是靠查这个表发现异常并定位到的。
4. 三个我长期在用的自动化任务(完整SQL)
4.1 每天凌晨清理过期数据
这是pg_cron最典型的应用场景。以审计日志表为例,最直观的写法是:
SELECT cron.schedule( 'clean_audit_log', '0 3 * * *', $$ DELETE FROM audit_log WHERE created_at < now() - interval '90 days'; $$ );这条SQL看起来没问题,但生产环境里我强烈不建议直接在任务里执行这种大数据量的DELETE。日志表往往是百万行起步,一次性删除会引发长事务和锁问题。我实际使用的方案是循环分批删除,每批5000行,批与批之间sleep一秒,避免对业务造成冲击:
CREATE OR REPLACE FUNCTION maintain_cleanup_audit_log() RETURNS void LANGUAGE plpgsql AS $$ DECLARE affected integer; BEGIN LOOP DELETE FROM audit_log WHERE id IN ( SELECT id FROM audit_log WHERE created_at < now() - interval '90 days' ORDER BY id LIMIT 5000 ); GET DIAGNOSTICS affected = ROW_COUNT; EXIT WHEN affected < 5000; PERFORM pg_sleep(1); END LOOP; END $$; SELECT cron.schedule('clean_audit_log', '0 3 * * *', 'SELECT maintain_cleanup_audit_log();');这样做的好处有三个:第一,单次事务只锁最多5000行,不会长时间占锁;第二,函数可以单独测试,直接在psql里执行SELECT maintain_cleanup_audit_log()就能验证逻辑;第三,后续如果需要调整保留天数,只改函数不动调度任务。这是老生产库的经验,新同学可以直接抄。
4.2 每半小时刷新汇总物化视图
业务方经常要"接近实时的统计",但底层查询太重,不能每次都实时算。我的方案是建物化视图,用pg_cron控制刷新窗口:
SELECT cron.schedule( 'refresh_sales_summary', '*/30 * * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY v_sales_summary_daily' );这里有两个关键点。第一,物化视图必须有唯一索引,否则不能用CONCURRENTLY模式刷新,这意味着刷新时会锁表,业务查询会受影响。第二,REFRESH MATERIALIZED VIEW CONCURRENTLY本身不能在事务块中执行,而pg_cron的任务是在独立会话中执行的,所以反而没有这个问题。但是如果你自作主张把这条SQL包进DO块,就会直接报错"REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a transaction block",所以不要把这条命令塞进函数或DO块,直接作为任务命令即可。
4.3 清理空闲连接的保命脚本
线上曾出现过连接池配置不当,空闲连接占满max_connections的情况。数据库实例完全无法承接新连接,业务大面积报错。从那之后我就在pg_cron里加了一个检查任务,每5分钟清理超过30分钟的空闲连接:
SELECT cron.schedule( 'terminate_idle_connections', '*/5 * * * *', $$ SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle' AND state_change < now() - interval '30 minutes' AND pid <> pg_backend_pid() AND usename <> 'postgres'; $$ );pg_terminate_backend是PostgreSQL内置的函数,用来强制结束指定PID的后端进程。注意这里把postgres用户自己的空闲连接排除了,避免误杀DBA的维护连接。如果你有专门的监控账号或admin账号,也要记得加入白名单,别一刀切。
这三个任务组合起来,基本能替代日常80%的机械性运维工作。我只需要偶尔查一下job_run_details看是否有失败,其他时间可以安心处理真正复杂的问题。
5. 实际运行中我踩过且你大概率会踩的坑
5.1 时区不一致导致任务"乱跑"
这是pg_cron新手最经典的坑。pg_cron的调度时间依赖PostgreSQL会话的时区,而很多云数据库或Docker容器默认是UTC。你想每天北京时间凌晨2点清理数据,写的schedule是'0 2 * * *',但实际上任务会在UTC凌晨2点,也就是北京时间上午10点执行。第一次遇到时,我盯着job_run_details看了半天,始终觉得时间特别诡异。
解决办法有两种。一种是在数据库层面统一时区:
ALTER DATABASE postgres SET timezone TO 'Asia/Shanghai';另一种是使用带时区参数的调度函数,pg_cron 1.5之后支持:
SELECT cron.schedule_in_timezone( 'clean_audit_log', 'Asia/Shanghai', '0 2 * * *', 'SELECT maintain_cleanup_audit_log();' );我的建议是双管齐下:先统一实例时区,再在关键任务上显式指定时区,防止以后团队接手时再次踩坑。还有一个隐藏问题:修改时区后,已有的任务不会自动转换,因为schedule字符串是原样存储在cron.job里的,你需要自己核对一遍现有任务的期望时间。
5.2 任务运行时间超过调度间隔导致重叠
早期我把一个耗时40分钟的数据汇总任务设成每30分钟跑一次,结果就是上一个任务还没结束,下一个任务又启动了,两个任务同时修改同一批数据,出现了死锁和统计错乱。pg_cron没有内置的"上一轮未结束就不启动"的防重入机制,必须自己控制。
我常用两种方式。方式一,使用PostgreSQL会话级咨询锁:
SELECT cron.schedule( 'long_running_summary', '*/30 * * * *', $$ SELECT pg_advisory_lock(20241001); SELECT refresh_long_summary(); SELECT pg_advisory_unlock(20241001); $$ );pg_advisory_lock是会话级锁,只要任务所在连接不关闭,锁就会一直持有。pg_cron每次任务使用独立连接,任务结束后连接被关闭,锁自然释放,所以能保证同一时间最多只有一个该业务的任务在跑。方式二,在任务里先查询job_run_details有没有同任务running状态记录,有则跳过,但这样依赖历史表,逻辑上不如咨询锁干净。
5.3 非超级用户权限和版本升级的坑
pg_cron早期版本只有超级用户能用。1.5之后可以授权给普通用户:
GRANT USAGE ON SCHEMA cron TO app_maintainer; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA cron TO app_maintainer; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA cron TO app_maintainer;注意,即使普通用户能创建任务,任务执行时用的仍然是cron.job里的username字段。默认情况下超级用户创建的任务会以超级用户运行,普通用户创建的任务只能以该普通用户运行。所以授权时要清楚"谁能建任务"和"任务以谁的身份跑"是两回事。
还有一个升级时的坑:PostgreSQL大版本升级后,扩展需要重新编译或安装,shared_preload_libraries里也要保留pg_cron。如果你从PG 13迁移到16,dump/restore时记得带上cron schema,并在新实例上安装对应版本的pg_cron,否则会看到extension不存在之类的错误。遇到过太多次"DBA带着库走了,cron任务全丢"的案例,在这里提醒一下。
5.4 运行历史表会无限膨胀,用pg_cron管pg_cron
cron.job_run_details表如果不主动清理,会无限增长。特别是那些每5分钟跑一次的任务,一天就要新增近300条记录,一个月就是上万条。我在跑起来的第二个月发现这张表已经超过一百万行,查询历史突然变慢,才意识到必须做清理。
解决办法相当自洽:给pg_cron自己加一个清理任务:
SELECT cron.schedule( 'clean_pg_cron_history', '0 4 * * *', $$ DELETE FROM cron.job_run_details WHERE start_time < now() - interval '7 days'; $$ );这算是"用pg_cron管理pg_cron"的经典案例。第一次清理大表时还是要小心,一次性删除上百万行也可能产生长时间锁,建议先按时间分批删。我的习惯是首次运行前手动执行一次带LIMIT的分批删除,之后靠每日增量清理就够了。
5.5 多语句任务的事务陷阱
很多同学第一次用pg_cron,喜欢把几条SQL用分号拼在一起放进command。结果发现有时执行成功,有时报错,非常困惑。原因在于,pg_cron不是在事务块里执行你这一整串命令的,它只是在一个新会话里把command当作一条普通命令执行。如果你写的是多个语句,需要用DO块包起来,才能保证原子性:
SELECT cron.schedule( 'multi_step_task', '0 4 * * *', $tg$ BEGIN DELETE FROM slow_log; INSERT INTO summary_log(total, day) SELECT count(*), CURRENT_DATE FROM activity; END $tg$ );但要注意"包进DO块就万事大吉"也是错的。像REFRESH MATERIALIZED VIEW CONCURRENTLY、VACUUM这类命令本身禁止在事务块中运行,放进DO块反而会报错。所以原则是:普通读写操作可以包DO块保证原子性,特殊维护命令保持单独裸执行。没有万能写法,需要你理解每类命令的事务特性。
6. 进阶:把pg_cron纳入你的监控与变更体系
6.1 任务失败时如何第一时间知道
只靠主动查job_run_details是被动的。我的做法是写一个健康检查任务,每小时扫一次失败记录,发现异常就写入告警表,让监控系统统一捕获:
SELECT cron.schedule( 'alert_failed_jobs', '0 * * * *', $$ INSERT INTO job_alert_log(job_name, fail_time) SELECT j.jobname, r.start_time FROM cron.job_run_details r JOIN cron.job j USING (jobid) WHERE r.status = 'failed' AND r.start_time >= now() - interval '60 minutes' ON CONFLICT (job_name, fail_time) DO NOTHING; $$ );这个健康检查任务需要建一张job_alert_log表,并给job_name和fail_time加上唯一约束,利用ON CONFLICT实现幂等,避免同一时段反复告警。更关键的是,这种告警任务本身也可能失败,所以我会让外部监控再单独盯着"健康检查任务是否有输出",把兜底工作放到数据库之外。
6.2 批量管理任务的实用SQL
任务多到几十个之后,手工逐个管理不现实。下面两条SQL是我最常用的维护工具。
查看所有任务及其最近一次运行结果:
SELECT j.jobname, j.schedule, j.command, r.status AS last_status, r.start_time AS last_start, r.end_time AS last_end FROM cron.job j LEFT JOIN LATERAL ( SELECT status, start_time, end_time FROM cron.job_run_details d WHERE d.jobid = j.jobid ORDER BY start_time DESC LIMIT 1 ) r ON true ORDER BY j.jobid;一次性批量暂停某一类任务,比如大版本迁移前停掉所有凌晨任务:
UPDATE cron.job SET active = false WHERE schedule ILIKE '0 %';这类批量UPDATE操作执行前,我强烈建议先SELECT确认影响范围,别手滑把所有任务都停了。数据库变更遵循一条铁律:先查后改。
6.3 一个综合的任务编排案例
最后给一个相对完整的组合设计,帮你把前面的知识点串起来。假设业务要求是:每晚23:30把前一天明细归档到历史表,接着刷新当月汇总物化视图,第二天早上9:00把汇总结果写入业务通知表。
我的设计思路是拆成三个任务,而不是一个大而全的任务:
-- 任务一:归档 SELECT cron.schedule('archive_yesterday_details', '30 23 * * *', 'SELECT archive_details_to_history();'); -- 任务二:刷新汇总,依赖归档完成,所以时间错开15分钟 SELECT cron.schedule('refresh_monthly_summary', '45 23 * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY v_monthly_summary;'); -- 任务三:生成通知,依赖汇总完成 SELECT cron.schedule('generate_morning_notice', '0 9 * * *', 'SELECT generate_notification_if_summary_ready();');三个任务互相独立,用时间线串联业务依赖。任何一个环节失败时,你只需要对单个任务unschedule后再按原表达式重新调度,完全不影响其他环节。如果业务上必须严格保证顺序,可以在第二个任务的函数里先检查归档是否完成,第三个任务里先检查汇总是否刷新,把隐式依赖做成显式校验,这样比盲目堆时间戳更可靠。
拿我自己来说,第一次在生产环境引入pg_cron时,心里其实也没底,总觉得把调度交给扩展不如外部脚本可控。跑了两个月之后,我的态度完全变了:任务失败有表可查,任务变更可以UPDATE,任务备份可以进dump,这种统一管理带来的长期收益,远远超过了我当初对"多一个后台进程"的担忧。如果你正在纠结要不要引入pg_cron,我的建议是小步快跑,先挑一两个低频清理任务迁移过来,跑两周看看job_run_details里的记录,再逐步扩大范围。这套东西一旦用顺了,你就再也不想回去写那堆散落各处的定时Shell脚本了。