写这篇东西的起因很简单,有个从MySQL迁到PostgreSQL的团队连续两周在日报统计上对不上数,排查到最后发现全栽在时间函数上——不是不会用,而是没弄明白PG的时间类型逻辑就直接套用了MySQL的习惯。PostgreSQL的时间函数在主流数据库里属于又全又严谨的那一类,从类型系统到函数设计都有一套自己的底层逻辑,摸不透这套逻辑,光靠搜函数名拼SQL,一定会踩坑。
这篇文章不打算把官方文档搬过来复述一遍,而是围绕实际开发里最常见的几类需求:拿到当前时间、做时间加减、从时间戳里提取年/月/日/周/季度、按时间分组统计、计算两个时间点之间的差,把PostgreSQL的时间函数和时间计算讲清楚。全程用可执行的SQL示例说话,涉及的坑也都标注出来,适用于后端开发、数据分析师、DBA,以及正在从其他数据库迁到PostgreSQL的团队。
1. 先厘清类型体系,不然函数用得越熟错得越离谱
很多PG新手容易犯一个认知错误:以为时间函数难在记不住函数名。真正的问题往往出在类型上。PostgreSQL对时间类型的划分比MySQL细很多,类型没选对、没理解透,后面用extract、date_trunc、interval做计算,结果就会出现偏差。这里我把PG的时间类型系统先拆开捋一遍,这是理解所有时间函数的前提。
1.1 timestamp与timestamptz:一个带时区,一个不带,行为完全不同
PostgreSQL里最容易引发事故的,就是timestamp without time zone(简称timestamp)和timestamp with time zone(简称timestamptz)的区别,光看名字很容易理解为"有没有时区信息",实际上真正的区别是:timestamptz存储的是UTC绝对时间点,展示时根据数据库会话的TimeZone设置换算成当地时间;timestamp存储的就是字面时间,不关心你在地球哪个位置。
举个具体例子。我在上海,会话时区是Asia/Shanghai:
sql复制SHOW TimeZone;
-- Asia/Shanghai
SELECT
now() AS timestamptz_value,
now()::timestamp AS timestamp_value;
假设当前UTC时间是2026-05-01 04:30:00,那么上海本地时间是12:30。上面这条SQL里,timestamptz_value显示的就是2026-05-01 12:30:00+08,而timestamp_value显示的是2026-05-01 12:30:00,看起来除了时区后缀外没区别。但如果把会话时区切到UTC再查一遍:
sql复制SET TimeZone = 'UTC';
SELECT
now() AS timestamptz_value,
now()::timestamp AS timestamp_value;
结果就变成:timestamptz_value显示2026-05-01 04:30:00+00,而timestamp_value依然显示2026-05-01 12:30:00——因为这个类型压根不管时区,只保存你写进去的那个字面量。
这个特性直接决定了建表时的选型。存的是"某个时刻发生了什么事"(订单创建时间、登录时间、日志时间),就选timestamptz,因为这类数据在物理世界里有一个绝对坐标,无论业务系统部署在哪个地区,它代表的都是同一个瞬间。存的是"业务上的日历时间"(比如一个排课表固定每周三上午10点上课,不需要考虑学生在美国还是中国),才考虑用timestamp。绝大部分业务表应该用timestamptz。
1.2 date、time与interval:别小看这几个基础类型
除了timestamp系,PostgreSQL还有三个常用类型:
date:只存日期,精度到天,范围4713 BC到5874897 AD,日常业务足够用。time:只存一天内的时间,比如14:30:00。不带时区,所以如果你需要存"每天UTC早上8点执行"这种带时区的时间点,time类型做不了,得用timetz或者干脆存timestamptz。interval:时间跨度,比如1 day、3 hours 30 minutes。它既能存一个时间段,也能参与算术运算,是时间计算里的核心角色。
interval有一个让新手困惑的点:它内部是按月、日、秒三部分分开存储的。interval '1 month'加到一个日期上,和interval '30 days'加上去,结果可能不同。2026年2月1日加1个月,答案是3月1日;而如果2月1日加30天,答案是3月3日(按非闰年算)。这个区别后面展开讲。
1.3 类型转换的基本规则
PostgreSQL允许在date、timestamp、timestamptz之间做显式或隐式转换,但不同转换会丢失或补充信息,必须心里有数:
sql复制SELECT
current_date::timestamptz, -- 当天零点(带时区)
current_timestamp::date, -- 丢掉时间部分,只留日期
current_timestamp::time, -- 只留时间部分
current_timestamp::timestamp; -- 丢掉时区,变成字面量
注意current_date::timestamptz这种转换,PG会拿当前会话时区去解释这个日期,得到的是"本地时区当天的零点"。如果你的数据库没有正确设置时区,这里得到的UTC零点对应的北京时间其实是早上8点,日期虽然没变但语义已经偏了。
此外,interval和字符串之间的转换也常用,因为PG允许直接用字符串字面量表示interval。字符串转到interval时,如果格式不规范会报错:
sql复制SELECT '1 day 02:03:04'::interval; -- 合法
SELECT '1 day, 2 hours'::interval; -- 合法,PG会解析逗号分隔
SELECT '2 days ago'::interval; -- 不合法,除非你能在SQL标准模式下解析
所以如果要写动态interval,最好用make_interval函数,安全可控,后面会专门介绍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 获取当前时间:now、clock_timestamp、current_timestamp到底该怎么选
PG里获取当前时间的函数太多,now()、current_timestamp、current_date、clock_timestamp()、statement_timestamp()、transaction_timestamp(),第一次接触会觉得眼花缭乱。我从实际使用场景出发帮各位理一下,不需要全部记住,选两个主力就好。
2.1 事务内稳定vs实时变化:now家族的本质区别
先做一个实验。在一个事务里连续执行:
sql复制BEGIN;
SELECT now(), clock_timestamp(), statement_timestamp();
-- 第一次输出,假设三者都是 2026-05-01 12:30:00 附近
SELECT pg_sleep(2);
SELECT now(), clock_timestamp(), statement_timestamp();
-- 第二次输出:now()不变,statement_timestamp()不变,clock_timestamp()会变大
结论非常清晰:
now()等同于transaction_timestamp(),在整个事务里返回同一个时间戳,表示"当前事务开始的时间"。statement_timestamp()表示"当前SQL语句开始执行的时间",同一个事务里的多条语句,时间会不同。clock_timestamp()返回的是"这句话执行到这一瞬间的真实时钟时间",同一SQL里多次调用结果都可能不同。
绝大多数业务逻辑应该用now()。日志表加created_at DEFAULT now()时,同一个事务里插入多条记录,它们拿到的是同一个时间戳,逻辑上更自洽。报表查询里WHERE created_at > now() - interval '1 hour'这种写法,也是期望拿事务开始时间去过滤,而不是语句执行时的时间。
clock_timestamp()很少用,一般只在调试慢SQL或者需要精确测语句间延迟时才用得上。
2.2 current_date与current_time:一天一个样,小心时间边界
current_date返回date类型,current_time返回time类型(带时区的是timetz,不带的是time)。很多人喜欢直接用current_date拼业务,但它有几个隐藏坑:
第一个坑是跨时区。如果你的PostgreSQL会话时区没有设置成业务所在地时区,current_date可能不是你理解的"今天"。比如客户端在北京,但服务器数据库时区设成了UTC,那么北京时间凌晨0点到8点期间,数据库里的current_date还是昨天的日期。
第二个坑是转成timestamptz之后的时间偏移。
sql复制SELECT current_date::timestamptz;
上面提到过,这条SQL在时区为Asia/Shanghai的会话里,得到的是当天零点;但如果会话时区是UTC,得到的是UTC零点,也就是北京时间的早上8点。如果你的代码里拿这个值去和timestamptz列做比较,会在临界日期上出问题。
2.3 practical建议:你只需要记住这三条
经过大量项目实践,我对获取当前时间这件事给出的建议是:
- 默认用
now(),获取事务开始时间,语义最稳定。 - 需要"当天0点"做范围查询时,用
date_trunc('day', now())而不是current_date::timestamptz。 - 需要精确测量某段代码执行耗时,才用
clock_timestamp()。
这里放一条实际业务里很经典的范围查询写法,统计昨天全天数据:
sql复制SELECT *
FROM orders
WHERE created_at >= date_trunc('day', now()) - interval '1 day'
AND created_at < date_trunc('day', now());
左闭右开区间是时间范围查询的黄金法则,能避免23:59:59.999这类精度漏数据问题,也完全绕开"要不要加1秒"的纠结。
3. 时间加减乘除:interval的正确食用方式
时间运算的核心就是interval。你可以把interval理解为时间计算里的"砝码",加到日期上、从时间点里减掉、或两个时间点相减得到它,查询速度、边界处理都取决于你会不会用这个砝码。
3.1 别再对时间戳直接加减数字了
MySQL里写WHERE created_at > NOW() - 3600也许能用(在旧版本里),但在PostgreSQL里这样写近乎行不通,因为PG的类型系统不允许timestamp直接减整数。你得把3600秒转成interval。最简单的写法是:
sql复制-- 1小时前
WHERE created_at > now() - interval '1 hour';
-- 30分钟前
WHERE created_at > now() - interval '30 minutes';
这种字面量方式可读性高,唯一问题是如果单位要动态拼,很容易把字符串拼错。比如interval后面跟一个字段值,常规做法是:
sql复制-- 查询最近n小时的数据,n是参数
WHERE created_at > now() - (n || ' hours')::interval;
注意'小时'和'hours'混写的风险,PG对interval单位的关键字解析比较严格,拼错了会直接报错或者被当成不合法单位。这里更推荐下面要讲的make_interval函数,参数化方式更安全。
3.2 make_interval:动态时间跨度不用怕
make_interval是构造interval最稳妥的方式,参数直观,完全不需要字符串拼接:
sql复制-- 30天前
SELECT now() - make_interval(days => 30);
-- 1年2个月3天前
SELECT now() - make_interval(years => 1, months => 2, days => 3);
-- 90分钟前
SELECT now() - make_interval(mins => 90);
make_interval支持的参数包括years、months、weeks、days、hours、mins、secs,注意没有直接支持"天+小时+分钟混在一起"那种单位缩写,但可以多参数一起传。除此之外还有一个make_timestamptz、make_date系列,后面再提。
3.3 加减运算的常见组合拳
interval加上timestamp是PG里非常高频的运算。举几个实战场景:
给指定时间加一个月:
sql复制SELECT timestamp '2026-01-31 10:00:00' + interval '1 month';
-- 结果:2026-02-28 10:00:00(PG会做月末截断,而不是报错或进位到3月)
这个结果非常容易引起业务争议。MySQL里有些版本也采用月末截断逻辑。如果你需要的是"1月31日加1个月得到2月28日,再加1个月得到3月28日"这种基于原始日期的算法,必须自己控制,比如固定取每月15号加一个月,规避月末差异。
减法的用法更常见,从时间点里减去一段时长:
sql复制SELECT now() - interval '1 day'; -- 昨天同一时刻
SELECT now() - interval '2 weeks'; -- 14天前
SELECT now() - interval '3 months'; -- 3个月前的今天
如果想算下个月1号:
sql复制SELECT date_trunc('month', now()) + interval '1 month';
想要当前月的最后一天:
sql复制SELECT date_trunc('month', now()) + interval '1 month - 1 day';
interval '1 month - 1 day'是PG特有的语法,允许一个interval字面量里同时含多种单位,会自动归约。少写括号也能跑,但为了可读性建议加括号,写清楚。
3.4 interval与整数还是字符串相乘
有些高效率场景需要对interval做倍数运算,比如把基础超时时长放大三倍:
sql复制SELECT interval '1 hour' * 3; -- 03:00:00
SELECT interval '30 minutes' * 2.5; -- 01:15:00
interval除整数也行,偶尔用来做均摊时间:
sql复制SELECT interval '1 day' / 3; -- 08:00:00
这些在"重试间隔计算""均摊调度"里有用武之地。但不要做两个interval直接相乘,PG不支持这样的运算。
3.5 PG15之后的友好排序与筛选:interval排序要注意什么
直接在PG里使用ORDER BY interval_column没什么坑,注意显示格式差异。但interval比较大小要注意单位换算,1 day和24 hours相等吗?答案是相等,PG做interval比较时会统一换算到微秒去比较,所以它们相等。但1 month比较特殊,它不等于固定的30天,PG内部比较间隔时会用30天来做近似换算,所以interval '1 month'和interval '30 days'会被判定为相等,这经常让对精度敏感的人大跌眼镜。实际业务中如果要精确比较时间段,最好统一换算成秒(epoch)之后再比较,避免月份这种"可变长"单位捣乱。
4. 从时间戳里提取年、月、日、星期与季度
提取时间字段是这个标题里的重头戏。PostgreSQL提供的提取函数有两个主力:EXTRACT和date_part,实际上date_part就是EXTRACT的历史别名,参数格式稍有差异,功能完全一致。日常推荐统一用EXTRACT,语义更清晰,也符合SQL标准。
4.1 EXTRACT的完整字段清单与用法
EXTRACT的基本语法是:
sql复制EXTRACT(field FROM source)
source可以是timestamp、timestamptz、date、time或interval。field常见的有:
YEAR:年份MONTH:月份(1-12)DAY:日(1-31)HOUR:小时(0-23)MINUTE:分钟(0-59)SECOND:秒数,含小数部分DOW:星期几,周日是0,周六是6ISODOW:星期几,ISO标准,周一是1,周日是7,实际业务中强烈推荐用这个DOY:一年中的第几天(1-366)WEEK:ISO周数,一年中的第几周(1-53)QUARTER:季度(1-4)EPOCH:从1970-01-01 00:00:00 UTC到该时间点的秒数(带小数)MILLENNIUM、CENTURY、DECADE:比较少用,但个别业务会查
举个例子,把当前时间拆开看:
sql复制SELECT
EXTRACT(YEAR FROM now()) AS year,
EXTRACT(MONTH FROM now()) AS month,
EXTRACT(DAY FROM now()) AS day,
EXTRACT(HOUR FROM now()) AS hour,
EXTRACT(MINUTE FROM now()) AS minute,
EXTRACT(SECOND FROM now()) AS second;
返回的都是numeric类型。如果后续要跟整数比较,EXTRACT返回的是numeric,和int比较没问题,但如果要精确做除法和取余,numeric也不会给你意外。如果你需要整数类型,可以直接EXTRACT(YEAR FROM now())::int,或者在PG14以后使用date_part时自己强转。
4.2 提取星期几:用DOW还是ISODOW
这是实际业务里最容易搞错的一个点。中国习惯是周一作为一周的第一天,很多报表统计"本周一到今天"或者按周几分组,如果用了DOW,周日是0,周一是1,需要在SQL里额外处理。强烈建议直接用ISODOW,它严格符合ISO 8601:周一到周日对应1到7。
实测:
sql复制SELECT
CURRENT_DATE AS today,
EXTRACT(DOW FROM CURRENT_DATE) AS dow_sunday_is_0, -- 比如星期日返回0
EXTRACT(ISODOW FROM CURRENT_DATE) AS isodow_monday_is_1; -- 比如星期日返回7
如果希望把周一作为一周的起始日来做周报,可以用:
sql复制SELECT
EXTRACT(ISODOW FROM created_at) AS weekday,
COUNT(*)
FROM orders
WHERE created_at >= date_trunc('week', now()) -- PG的date_trunc('week')就是周一零点
GROUP BY weekday
ORDER BY weekday;
注意PG里date_trunc('week', ...)周起始日就是周一,和ISODOW的逻辑是一致的,这俩配合起来很顺畅。
另外,想要周几的中文名,用to_char更好:
sql复制SELECT to_char(now(), 'dy'); -- 英文缩写,如 mon
SELECT to_char(now(), 'day'); -- 英文全称,如 monday
中文环境下通常自己维护一个映射表,或者在SQL里用CASE。追求简单的话,直接to_char(now(), 'ID')等价于EXTRACT(ISODOW FROM now())的字符串形式,但返回的是text,注意比较时转类型:
sql复制SELECT * FROM orders
WHERE to_char(created_at, 'ID') = '1'; -- 周一,但created_at字段是timestamptz时,to_char会按会话时区走
后面会说时区陷阱,这里先记住:任何提取函数碰上timestamptz都会按会话时区去算,跨时区部署时一定要确认TimeZone。
4.3 提取季度、周数和年内第几天
季度提取直接:
sql复制SELECT EXTRACT(QUARTER FROM TIMESTAMP '2026-08-15 10:00:00');
-- 3
周数提取得到的是一年中的第几周(ISO周),需要注意跨年边界问题。比如2027年1月1日如果按ISO周算可能属于2026年的第53周,这在做周报分组时特别容易产生"年初几天被算到去年WEEK"的现象:
sql复制SELECT
DATE '2027-01-01' AS d,
EXTRACT(WEEK FROM DATE '2027-01-01') AS week_number;
-- 返回 53(因为2027年1月1日是周五,按ISO规则属于2026年第53周)
如果业务上要求"自然周的周数"而不是ISO周,就不能直接用EXTRACT(WEEK FROM ...)。想获取自然年的第几周,经典做法是计算这个日期距离当年1月1日的天数除以7向上取整,或者使用EXTRACT(DOY FROM ...)来推导:
sql复制SELECT
d,
CEIL(EXTRACT(DOY FROM d) / 7.0)::int AS natural_week;
不要笑,这种业务需求很常见。
4.4 提取小时分钟秒与毫秒
高频查询:
sql复制SELECT
now() AS ts,
EXTRACT(HOUR FROM now()) AS hour,
EXTRACT(MINUTE FROM now()) AS minute,
EXTRACT(SECOND FROM now()) AS second, -- 包含小数秒
EXTRACT(MILLISECONDS FROM now()) AS ms, -- 当前秒后的毫秒数
EXTRACT(MICROSECONDS FROM now()) AS us; -- 当前秒后的微秒数
注意EXTRACT(MILLISECONDS FROM ...)返回的是从当前时间秒开始的毫秒部分,不是自纪元以来的总毫秒数,而是秒内的小数部分乘以1000。要拿完整的Unix毫秒时间戳,必须用EPOCH去乘:
sql复制SELECT EXTRACT(EPOCH FROM now()) * 1000 AS unix_ms;
这个细节有好几个项目踩过坑,有人在代码里拿EXTRACT(MILLISECONDS FROM now())当毫秒时间戳用,结果所有时间都落在0到999之间,排序全乱了。
5. 日期时间截断与搬移:date_trunc和to_char的灵活组合
数据统计里最常规的需求其实是"按某个时间粒度分组"。按天分组、按月分组、按小时分组。实现这个需求有两个主流手段:date_trunc截断到某精度,或者to_char格式化成字符串。二者各有优劣,实际项目中经常搭配使用。
5.1 date_trunc:分组统计的神兵利器
date_trunc的语义是把时间戳从高精度截断到指定精度,类似于"向下取整"。支持的单位有microseconds、milliseconds、second、minute、hour、day、week、month、quarter、year、decade、century、millennium。
常用实例如下:
sql复制SELECT
date_trunc('hour', now()) AS hour_start,
date_trunc('day', now()) AS day_start,
date_trunc('week', now()) AS week_start, -- 周一零点
date_trunc('month', now()) AS month_start, -- 当月1号零点
date_trunc('quarter', now()) AS quarter_start,-- 季度首日
date_trunc('year', now()) AS year_start; -- 1月1日零点
比如统计每天的订单数和GMV:
sql复制SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS order_cnt,
SUM(amount) AS gmv
FROM orders
WHERE created_at >= now() - interval '30 days'
GROUP BY date_trunc('day', created_at)
ORDER BY day;
注意GROUP BY子句里使用了date_trunc('day', created_at),它和SELECT列里的表达式一致,PG允许这样GROUP BY。也允许在GROUP BY里写GROUP BY 1这种序号写法,但可读性差,不推荐。
如果需要按小时统计访问量:
sql复制SELECT
date_trunc('hour', visited_at) AS hour_bucket,
COUNT(*) AS visit_cnt
FROM page_views
WHERE visited_at >= now() - interval '24 hours'
GROUP BY 1
ORDER BY 1;
这种查询的结果里,hour_bucket是一个timestamptz,比如2026-05-01 12:00:00+08,表示12点到12点59分59秒这一整个小时的全部数据都归到这一桶。这种颗粒度清洗方式比字符串格式化更利于后续做时间运算。
5.2 date_bin:PG14提供的任意起点时间桶
PG14引入了date_bin,之前的date_trunc只能从固定自然单位起点截(比如小时、天、月都从零点/1号开始),而date_bin可以指定任意的时间桶大小和参考起点。比如需要把一天分成8个3小时的桶,并且希望从早上6点开始,而不是凌晨0点开始:
sql复制SELECT date_bin('3 hours', now(), TIMESTAMPTZ '2026-01-01 06:00:00+08') AS bucket;
date_bin用来做会话时长分桶、自定义统计窗口,非常灵活。语法是date_bin(stride, source, origin),stride是interval类型,origin是一个固定起点。实际使用时注意起点要带上时区,否则会按会话时区解释。
5.3 to_char格式化:提取展示与精细控制
to_char的另一个强项是可以做到任意格式输出,比如统计接口直接返回"2026年5月"这样的文案,或者在SQL里拼出"第x季度"。
常见的模板有:
YYYY-MM-DD:日期YYYY-MM-DD HH24:MI:SS:完整时间YYYY-MM:年-月,用于按月分组Mon、Month:月份缩写/全称ID:ISO星期几(1-7)IW:ISO周数Q:季度
举例说明:
sql复制SELECT
to_char(now(), 'YYYY-MM-DD HH24:MI:SS') AS std_time,
to_char(now(), 'YYYY-MM') AS month_str,
to_char(now(), 'Q') AS quarter_str,
to_char(now(), 'YYYY"年第"Q"季度"') AS quarter_cn;
注意HH24是24小时制,如果写成HH则代表12小时制,默认不带AM/PM时不建议使用,容易造成早晚混乱。
用to_char做按天分组合并显示:
sql复制SELECT
to_char(created_at, 'YYYY-MM-DD') AS day_str,
COUNT(*)
FROM orders
GROUP BY day_str
ORDER BY day_str;
这样分组的好处是展示层拿到的直接就是字符串,不需要再做格式化。缺点是这个字段如果走了索引,分组不一定能高效利用,因为对字段做了函数转换。数据量大的时候还是建议用date_trunc分组,到展示层再格式化成字符串。
5.4 date_trunc + to_char联用的标准报表SQL
实际项目中我经常在报表里写这种组合:
sql复制SELECT
to_char(date_trunc('month', created_at), 'YYYY-MM') AS month,
COUNT(*) AS order_cnt,
SUM(amount) AS gmv
FROM orders
WHERE created_at >= date_trunc('month', now()) - interval '11 months'
AND created_at < date_trunc('month', now()) + interval '1 month'
GROUP BY 1
ORDER BY 1;
这条SQL能生成最近12个月的月报。左边界取21个月前当月1号,右边界取下个月1号,左闭右开、不需处理月末最后一天的23点59分59秒问题,这就是date_trunc对查询带来的最直接好处。
6. 计算两个时间点的差:age与extract(epoch)怎么选
时间差计算是时间函数里的高频场景。PG里两个timestamp相减直接得到interval,这个大家都知道,但后面面临一个灵魂拷问:这个interval到底代表多少天、多少小时、多少秒?不同函数给结果的方式完全不一样,甚至会误导人。
6.1 直接相减返回interval后的两种解读
先看基础操作:
sql复制SELECT
TIMESTAMP '2026-05-10 12:00:00' - TIMESTAMP '2026-05-01 12:00:00' AS diff_interval;
-- 结果:9 days
diff_interval是interval类型,如果你想从里面提取"相差多少天",直接写:
sql复制SELECT EXTRACT(DAY FROM TIMESTAMP '2026-05-10 12:00:00' - TIMESTAMP '2026-05-01 12:00:00');
-- 结果:9
这个看起来没问题,但如果两个时间间隔超过了30天,问题就来了:
sql复制SELECT EXTRACT(DAY FROM TIMESTAMP '2026-06-10 12:00:00' - TIMESTAMP '2026-05-01 12:00:00');
-- 结果:9?10?
为什么不对?因为PG内部可能把间隔表示成40 days,也可能是1 mon 9 days,取决于运算路径和服务器版本。直接用EXTRACT(DAY FROM interval)提取的是interval里"天"这一部分,不是总天数。这是个隐蔽性极高的坑。
6.2 age函数:计算精确的年月日
PG提供了一个专门的函数age,用来计算两个日期之间的"人话"间隔:多少年多少个月多少天。
sql复制SELECT age(TIMESTAMP '2026-05-10 12:00:00', TIMESTAMP '2026-05-01 12:00:00');
-- 9 days
SELECT age(TIMESTAMP '2026-06-10', TIMESTAMP '2020-02-10');
-- 6 years 4 mons
注意age是第一个参数减第二个参数。如果只传一个参数,默认拿当前日期去减:
sql复制SELECT age(TIMESTAMP '2000-06-01');
这在做用户年龄计算时非常好用:
sql复制SELECT
user_id,
EXTRACT(YEAR FROM age(birthday)) AS age_years
FROM users;
但注意这里有个性能隐患:如果user表很大,birthday字段如果没有索引,这种计算是全表扫描,对超大表做年龄过滤(比如WHERE EXTRACT(YEAR FROM age(birthday)) > 18)不要指望索引,建议在应用层算好年龄区间或者维护冗余字段。
6.3 用EPOCH统一算总秒数、总小时数、总天数
真正的终极方案是:不要费劲从interval里"拆"总天数,直接用EPOCH换算成秒再换算成任何想要的单位。
sql复制SELECT
EXTRACT(EPOCH FROM (TIMESTAMP '2026-06-10 12:00:00' - TIMESTAMP '2026-05-01 12:00:00')) AS total_seconds;
-- 3463200
SELECT
EXTRACT(EPOCH FROM (TIMESTAMP '2026-06-10 12:00:00' - TIMESTAMP '2026-05-01 12:00:00')) / 86400 AS total_days;
-- 40.083333333333
想取整就FLOOR或ROUND。这种写法能避免所有interval内部表示带来的歧义。
需要计算两个会话间隔的分钟数:
sql复制SELECT
EXTRACT(EPOCH FROM (ended_at - started_at)) / 60 AS duration_minutes
FROM user_sessions;
这是最优雅的跨平台标准方法,任何语言都能拿到一个浮点秒数然后自己换算。对于聊天会话的平均时长统计,用秒数求平均再展示给前端,性价比很高。
同时需要注意,如果希望得到的是四舍五入到整数秒,也可以使用ROUND(EXTRACT(EPOCH FROM ...)),但保留浮点可以让前端自由决定精度。
6.4 计算"本月第几天""今年第几天"以及边界日
回到提取功能,做几个真实业务里会被反复查询的值:
sql复制SELECT
EXTRACT(DAY FROM now()) AS day_of_month, -- 今天是本月第几天
EXTRACT(DOY FROM now()) AS day_of_year, -- 今天是今年第几天
EXTRACT(ISODOW FROM now()) AS iso_weekday; -- 今天星期几(1-7)
计算某日期所在月的天数,用date_trunc和下月第一天:
sql复制SELECT
(date_trunc('month', DATE '2026-02-15') + interval '1 month')::date
- date_trunc('month', DATE '2026-02-15')::date AS days_in_feb;
-- 28
这种写法没有闰年判断的麻烦,PG帮你处理。
7. 实际业务场景SQL速查:从订单统计到会话分析
函数讲了不少,最终都要落到业务SQL里。这里我给几个高频场景的完整示例,可以直接抄去改表名和字段名,基本覆盖了日常80%的时间统计需求。
7.1 场景一:统计最近7天逐日订单量(含当天)
很多人第一版SQL喜欢直接GROUP BY to_char(created_at, 'YYYY-MM-DD'),但这样如果中间某天没有订单,这条日期的记录会缺失,前端图表就会断掉。如果要求不中断,最好用generate_series先生成日期序列,再左连订单表。
sql复制WITH days AS (
SELECT generate_series(
current_date - 6,
current_date,
interval '1 day'
)::date AS day
)
SELECT
days.day,
COUNT(o.id) AS order_cnt,
COALESCE(SUM(o.amount), 0) AS gmv
FROM days
LEFT JOIN orders o ON o.created_at::date = days.day
GROUP BY days.day
ORDER BY days.day;
注意这里o.created_at::date做了cast,如果有索引可能失效。小数据量没问题,数据量大时更推荐写成范围连接:
sql复制WITH days AS (
SELECT generate_series(
current_date - 6,
current_date,
interval '1 day'
)::date AS day
)
SELECT
days.day,
COUNT(o.id) AS order_cnt,
COALESCE(SUM(o.amount), 0) AS gmv
FROM days
LEFT JOIN orders o
ON o.created_at >= days.day
AND o.created_at < days.day + 1
GROUP BY days.day
ORDER BY days.day;
这样左连接条件能走索引,性能更好,语义也准确:当天零点到第二天零点之前都算当天。
7.2 场景二:用户年龄计算与统计分桶
针对用户表:
sql复制SELECT
user_id,
EXTRACT(YEAR FROM age(birthday)) AS age
FROM users;
如果要按年龄段分组统计(0-17、18-29、30-39等):
sql复制SELECT
CASE
WHEN age < 18 THEN '0-17'
WHEN age < 30 THEN '18-29'
WHEN age < 40 THEN '30-39'
WHEN age < 50 THEN '40-49'
ELSE '50+'
END AS age_bucket,
COUNT(*)
FROM (
SELECT EXTRACT(YEAR FROM age(birthday)) AS age
FROM users
WHERE birthday IS NOT NULL
) t
GROUP BY 1
ORDER BY 1;
这个SQL在千万级用户表上会比较慢,因为每行都要算年龄。如果只是做离线报表,没关系;如果这是线上接口,建议预计算年龄字段或范围字段。
7.3 场景三:最近一次订单距今N天
要查出"超过30天没有下单的沉默用户",这类需求常用来做流失预警。
sql复制SELECT
user_id,
MAX(created_at) AS last_order_time,
EXTRACT(DAY FROM now() - MAX(created_at)) AS days_since_last_order
FROM orders
GROUP BY user_id
HAVING MAX(created_at) < now() - interval '30 days';
注意这里EXTRACT(DAY FROM now() - MAX(created_at))是从interval里取天部分,如果time间隔超过30天,interval可能是40 days,提取DAY完全没有问题。但如果差值包含月份,比如某个用户最后一次下单是3个月前,interval可能在内部表示为3 mons,此时EXTRACT(DAY FROM interval)会返回0,造成严重误判。因此,判断"距今多少天",永远不要用EXTRACT(DAY FROM 差值),应该用:
sql复制SELECT
user_id,
FLOOR(EXTRACT(EPOCH FROM (now() - MAX(created_at))) / 86400) AS days_since_last_order
FROM orders
GROUP BY user_id
HAVING MAX(created_at) < now() - interval '30 days';
这是最容易踩的坑,再次单独强调:interval里提取DAY不等于总天数。
7.4 场景四:近30天每小时流量分布
用date_trunc('hour', created_at)分桶,并统计每个小时窗口的请求数:
sql复制SELECT
date_trunc('hour', created_at) AS hour_bucket,
COUNT(*) AS req_cnt
FROM access_log
WHERE created_at >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1;
如果你想把小时对齐到自然小时,而不是最近24小时滚动窗口,就把WHERE条件的左边界也对齐到当天零点:
sql复制WHERE created_at >= date_trunc('day', now()) - interval '29 days'
这样每组统计的都是整点对齐的数据,分析时更容易解释。
8. 容易翻车的时间函数边界条件
最后把实际项目里反复出现过的问题集中列一遍。每个问题后面都写了避免踩坑的具体做法,当作备忘录使用。
8.1 时区设置不统一导致提取结果隔天错乱
最经典的事故场景:数据库服务器时区是UTC,业务方在上海,然后开发者写:
sql复制SELECT date_trunc('day', created_at), COUNT(*)
FROM orders
GROUP BY 1;
因为created_at是timestamptz,PG会先按会话时区转换成当地时间再做截断。如果会话时区是UTC,那么在北京时间上午8点前下的单,created_at对应的UTC日期还是前一天,截断后就会被算进前一天。解决方式:
- 所有数据库连接都显式设置
SET TIME ZONE 'Asia/Shanghai',或者连接池参数指定。 - 在SQL里明确要求按某个时区提取,例如
date_trunc('day', created_at AT TIME ZONE 'Asia/Shanghai')。
如果你希望查询结果不随会话时区漂移,一种做法是把timestamptz转成固定时区的timestamp再处理:
sql复制SELECT
date_trunc('day', created_at AT TIME ZONE 'Asia/Shanghai') AS local_day,
COUNT(*)
FROM orders
GROUP BY 1;
注意timestamp AT TIME ZONE 'Asia/Shanghai'这个语法,当timestamp不带时区时是给一个时区解释;当timestamptz使用时是先转到该时区,返回timestamp。写错位置容易让人糊涂,建议先在本地试几行再推广。
8.2 interval的单位进位与month陷阱
再次强调interval '1 month'不是30 days,在跨月运算中会造成"看似对实则错"的结果。举一个真实翻车例子:业务里会员有效期是1个月,用户在1月31日购买,程序写expire_at = now() + interval '1 month',实际上PG得到的是2月28日。如果整个系统都接受月末截断,那没问题;但如果销售承诺的是31天后失效,一定要用精确天数:
sql复制expire_at = now() + interval '31 days';
或者反过来,如果系统希望"无论几月购买,都是次月同一天失效",就要用date_trunc('month', now()) + interval '1 month' + (EXTRACT(DAY FROM now()) - 1) * interval '1 day'这种组合计算,非常繁琐。所以建表时在注释里定义清楚业务语义,比事后讨论算法重要得多。
8.3 不能用ISO周数直接排序跨年日期
前面提过,EXTRACT(WEEK FROM date)是ISO周数,跨年时2027-01-01可能属于2026年的第53周。如果直接按年份+周数排序,会出现2027年第1周排在2026年第53周前面,因为按字符串排序2026-53比2027-01小,但从ISO语义上周数是交叉的。要做跨年周统计,正确分组词是ISO年份和周:
sql复制SELECT
to_char(created_at, 'IYYY-"W"IW') AS iso_week,
COUNT(*)
FROM orders
GROUP BY 1
ORDER BY 1;
to_char的IYYY是ISO年份,IW是ISO周号,比分开取两个字段再拼接靠谱得多。
8.4 date_trunc返回类型与索引失效
date_trunc('day', created_at)返回的是带时区的timestamptz,如果和date类型直接比较,PG有时会做隐式转换,但索引还是可能失效。比如:
sql复制WHERE date_trunc('day', created_at) = current_date
这种写法通常没法用created_at上的普通索引,因为对列做了函数变换。正确的做法是把条件改写为范围:
sql复制WHERE created_at >= date_trunc('day', now())
AND created_at < date_trunc('day', now()) + interval '1 day'
这也是我在团队代码评审时反复强调的:不要在WHERE子句中对索引列做函数包裹,改成对条件的边界做函数包裹,执行计划才有机会走索引。数据量几十万的时候差异不大,上亿条日志时差异能到几十倍。
8.5 date类型与timestamptz混用比较时的隐性转换方向
当date列和timestamptz列做比较时,PG一般会把date转成timestamp再和timestamptz比较,这个转换依赖会话时区。曾经有团队在JDBC连接串里忘了配时区,默认取了服务器时区,结果不同环境日期边界判定全错。比较稳妥的做法是业务SQL中主动统一类型,明确转换方向:
sql复制-- date列和timestamptz列比较,最好把date显式转成timestamptz
WHERE created_at >= create_date::timestamptz
或者反过来把timestamptz强制转成date再比较。哪一种都不绝对,核心是要保证应用连接、数据库、展示层三层时区设置一致,理论上最好都在代码里写死业务时区,不要依赖环境默认值。
8.6 注意now()是事务开始时间,不适合做行级精确时间
如果在一个长事务里往日志表插了几万行,所有行的created_at默认值如果都写成now(),它们全是事务开始的那个时间点,不是每行插入的真实时间。这可能不是业务期望的。而如果希望每行都记录真实插入瞬间,默认值可以改成clock_timestamp(),不过这个函数在不同场景下可能带来额外的性能开销。设计表结构时要想清楚:日志表一般用now()就够了,审计某些精确到毫秒的流水记录才考虑clock_timestamp()。
9. 给初学者的练习建议与常用速查表
很多初学者刷完一遍文档就想去写复杂报表,结果到处碰壁。我建议按顺序完成三个练习,基本能把时间函数用熟:
练习一:生成最近30天的日期序列,并关联订单表,输出每天订单量。把generate_series和date_trunc、范围连接都过一遍。
练习二:从一个订阅表里计算每个用户的订阅到期日距今还有多少天,并按"小于7天""7-30天""大于30天"分桶统计。这里会用到EXTRACT(EPOCH FROM ...)、CASE WHEN、GROUP BY。
练习三:把一个包含时间戳的日志表按15分钟粒度统计请求量,并且要求每个15分钟桶的起始时间从0点0分对齐。可以使用date_bin或者date_trunc('hour', ts) + (EXTRACT(MINUTE FROM ts) / 15) * interval '15 min'这样的思路自定义分桶。
最后放一张常用速查表,方便丢在手边:
| 需求 | 推荐写法 | 说明 |
|---|---|---|
| 当前时间 | now() |
事务开始时间 |
| 今天零点 | date_trunc('day', now()) |
避免时区歧义 |
| 昨天零点 | date_trunc('day', now()) - interval '1 day' |
左闭右开 |
| 本月1号 | date_trunc('month', now()) |
配合group by |
| 加/减天数 | now() + interval '7 days' |
注意month陷阱 |
| 动态加天数 | now() + make_interval(days => $1) |
安全参数化 |
| 提取年份 | EXTRACT(YEAR FROM ts) |
返回numeric |
| 提取星期几 | EXTRACT(ISODOW FROM ts) |
周一为1 |
| 提取季度 | EXTRACT(QUARTER FROM ts) |
1-4 |
| 按天分组 | date_trunc('day', ts) |
性能优于to_char |
| 两个时间的总秒数 | EXTRACT(EPOCH FROM (t2 - t1)) |
避免DAY陷阱 |
| 用户年龄 | EXTRACT(YEAR FROM age(birthday)) |
适合小表/离线 |
| 月份跨期判断 | ts >= date_trunc('month', now()) AND ts < date_trunc('month', now()) + interval '1 month' |
范围条件走索引 |
我自己在多个项目里的切身体会是:PostgreSQL的时间函数并不缺能力,缺的往往是对类型语义的敬畏。凡是线上出现过时间统计问题的,追到最后几乎都能落到"时区不统一"或"interval理解错误"这两个根因上。所以,拿到一个新环境先跑一句SHOW TIMEZONE;,建表时想清楚该用timestamptz还是timestamp,写时间范围查询默认左闭右开,做完这三件事,大部分时间相关的坑在项目早期就能被挡在门外。
