1. 先从数据类型说起——为什么你写的SQL总出"怪结果"
今天要聊的是PostgreSQL里跟时间相关的函数,这类问题几乎每个写SQL的人都会遇到:明明字段里存的是时间,一排序顺序不对;明明减出来是2天,显示出来却是1 day 23:59:59;明明服务器和数据库在一个机房,查出来的时间却差了8个小时。这些坑十有八九不在于函数本身,而在于你根本没搞清楚PostgreSQL里那几种时间数据类型是怎么存的、怎么比较的。
PostgreSQL跟时间相关的内置类型主要有四个:timestamp(时间戳)、timestamptz(带时区的时间戳)、date(日期)、interval(时间间隔)。先说一个最常见的误解:timestamp全名叫timestamp without time zone,中文直译是"不带时区的时间戳",但很多新手会把它理解成"会自动转换成服务器本地时间"——恰恰相反,它存的就是你写入的那个字面值,没有半点时区转换。而timestamptz全名叫timestamp with time zone,它内部存储时也不是存带时区偏移的字符串,而是统一转成UTC存储,只是在你查询时会根据当前会话的时区设置自动转回来显示。
这个区别在跨时区业务里会直接导致结果差8个小时,这一点我在第4章会专门展开。这里先记住一个结论:在绝大部分应用系统里,业务表的时间字段都建议用timestamptz,除非你100%确定系统永远只在单一固定时区里跑,而且没有历史数据迁移的打算。
interval这个类型也特别容易出歧义。它支持day、hour、minute、second、month、year这些单位混在一起。比如interval '1 day 2 hours 30 minutes'是合法的。而当你做日期减法时,比如'2024-03-05 12:00:00'::timestamp - '2024-03-01 10:00:00'::timestamp,得到的结果不是数字,是一个interval类型的值4 days 02:00:00。很多初学者直接拿这个结果去和3比较,结果类型不匹配直接报错。你得用EXTRACT(EPOCH FROM ...)或者EXTRACT(DAY FROM ...)把这玩意拆成数字。
提示:判断一个时间类型问题,先用
pg_typeof(...)打印一下结果类型。我排查过太多"时间函数怎么不对"的问题,最后发现根本是类型和类型在做隐式转换时产生了歧义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频函数拆解——取当前时间、算差值、加减日期的正确姿势
2.1 now()、current_timestamp、clock_timestamp()有什么不同
这三个函数都是取当前时间,但行为差异非常大,事务场景下甚至会影响数据正确性。
now()和current_timestamp完全等价,返回的是timestamptz,而且它是事务开始时间。什么意思?就是不管你的长事务跑了5分钟,只要还没提交,多次调用now()返回的值都一样。这对报表、对账类逻辑非常友好,因为整个事务的时间基准一致,不会出现"这条记录的created_at是我这条SQL插入时间,另一条比它晚但时间还更早"的诡异现象。transaction_timestamp()跟now()也等价,是now()的完整、无歧义的叫法。statement_timestamp()返回的是当前语句开始执行的时间,不是事务开始时间。在一个事务里执行多条SQL时,每条SQL拿到的statement_timestamp()会不一样。clock_timestamp()返回的是真实时钟的当前时刻,不受事务控制,每次调用都可能不同。这个函数适合调试、测量某些操作耗时,比如在两个时间点之间记录差距,但绝不应该用于业务字段的默认值。
我做过一个数据迁移校验脚本,里面用clock_timestamp()记录每条数据开始处理的时间,最后统计这批数据处理花了多久,这是它的典型使用场景。而你建表时给created_at字段设置默认值,应该用now()或current_timestamp,而不是clock_timestamp()——否则在同一事务里批量插入的多条记录会有不同的created_at,后续按时间排序会得到不符合预期的结果。
2.2 算时间差的正确姿势:age()与EXTRACT(EPOCH FROM ...)
如果只想要两个日期之间相隔多少天,最简单的写法是直接相减:
sql复制SELECT '2024-03-10'::date - '2024-03-01'::date;
-- 输出 9,类型是 integer,注意这里是天数
但如果是timestamptz相减,得到的是interval。如果想把它转成秒数:
sql复制SELECT EXTRACT(EPOCH FROM ('2024-03-10 12:00:00'::timestamp - '2024-03-01 08:00:00'::timestamp)) AS diff_seconds;
EXTRACT(EPOCH FROM interval)返回的是这个interval总共包含多少秒,这是跨天跨月计算耗时最稳妥的方式。
还有一类场景是算年龄、算工龄,希望输出的是"几年几月几天"这种人类可读的结果。PostgreSQL提供了age()函数:
sql复制SELECT age('2024-03-05'::date, '1990-06-15'::date);
-- 输出 33 years 8 mons 20 days
age()会按月、日对齐做"减法",对日期顺序敏感。注意它的计算逻辑是日历式的,如果参数是timestamp,会精确到秒。
2.3 日期的加减:interval加法的运算优先级坑
给日期加一天,是date + integer;给时间戳加天数,得加interval:
sql复制SELECT '2024-02-28'::date + 1; -- 2024-03-01,因为2024是闰年
SELECT '2024-02-28 12:00:00'::timestamp + interval '1 day';
-- 2024-03-01 12:00:00
这里有个很隐蔽的坑:interval '1 day'在PostgreSQL里的处理方式会受IntervalStyle参数和具体语境影响,但这些都不恐怖。真正容易错的是混合运算优先级。请看下面的表达式:
sql复制SELECT '2024-03-01 12:00:00'::timestamp + interval '1 hour' * 2;
这个表达式实际上是'2024-03-01 12:00:00'::timestamp + (interval '1 hour' * 2),乘法优先级高于加号,结果是加2小时。但如果你写的没有括号,很容易读成"先加1小时再乘2",好在PostgreSQL的SQL语法里并不会这样解析。为了避免阅读歧义,我写日期加减时一律建议强制加括号。
再举一个真实场景里的例子:统计"昨天0点到今天0点"的数据。最直观但不建议的写法是:
sql复制WHERE created_at >= current_date - 1 AND created_at < current_date
这里current_date - 1表示"今天日期减1天",是date的加减,等价于current_date - integer 1,最终得到昨天的日期(带00:00:00)。这种写法能用,但如果你把current_date换成now(),就会变成now() - integer 1——类型不匹配会直接报错。所以日期加减一定要明确类型,别靠隐式转换续命。
3. 截断、格式化与解析——报表场景里的时间"造型术"
3.1 date_trunc():按天、按小时、按分钟聚合的核心
按天聚合报表是最常见的需求,但如果直接对created_at做::date转换再GROUP BY,当天只有部分数据的时候会出问题吗?其实不会,::date只是去掉时分秒,按天分组是没问题的。但按小时、按分钟、按周的聚合就没这么简单,这时候date_trunc()是首选。
sql复制SELECT date_trunc('hour', created_at) AS hour_bucket, COUNT(*)
FROM orders
GROUP BY 1
ORDER BY 1;
date_trunc支持的field包括microseconds、milliseconds、second、minute、hour、day、week、month、quarter、year、decade、century、millennium。需要注意:date_trunc('week', ...)返回的是这一周周一(或数据库配置的一周起始日)的零点,而不是周日零点,国内业务经常默认周一到周日为一周,这个刚好匹配。如果你的业务是周日为一周起始,就得自己往后再加一天。
date_trunc返回的类型和输入类型一致:输入timestamp返回timestamp,输入timestamptz返回timestamptz,并且按它所在时区的规则截断。跨时区报表在截断这里最容易埋雷,在第4章会细说。
另外,date_trunc是可以用在WHERE条件里的,但一旦你对时间列套了一个函数,那这列上的索引基本就废了。比如WHERE date_trunc('day', created_at) = '2024-03-01',这个查询即使created_at上有索引也用不上。
注意:对索引列使用函数会导致索引失效,正确做法是把条件写成范围查询:
WHERE created_at >= '2024-03-01' AND created_at < '2024-03-02'。而且范围查询还天然支持索引,速度提升可能不是一个量级,尤其是大表。
3.2 to_char():格式化成你想要的样子
to_char()是PostgreSQL里最灵活的格式化函数,它不仅能格式化时间,还能把interval格式化成"天 小时:分钟:秒"的任意样式。常用的几个格式模板:
| 模式 | 说明 | 示例输出 |
|---|---|---|
YYYY-MM-DD |
年-月-日 | 2024-03-05 |
YYYY-MM-DD HH24:MI:SS |
24小时制完整时间 | 2024-03-05 14:30:05 |
HH12:MI AM |
12小时制带上午下午 | 02:30 PM |
DAY |
英文星期全名(带补齐空格) | MONDAY |
DY |
英文星期缩写 | MON |
TMDay |
本地化星期名 | 星期一 |
IW |
ISO周数 | 10 |
D |
一周中的第几天(周日=1) | 2 |
ID |
ISO一周中的第几天(周一=1) | 1 |
其中容易被忽略的是DAY和D返回的结果都跟lc_time这个locale参数相关,而且星期几的编号规则在不同国家不一样。如果业务要的是"周一为一周第一天",用ID、IDDD这种ISO规则更靠谱,不要依赖D。
to_char的另一个用途是格式化interval,比如:
sql复制SELECT to_char(interval '3 days 04 hours 05 minutes 06 seconds', 'DD"天"HH24"小时"MI"分钟"SS"秒"');
-- 输出:03天04小时05分钟06秒
注意:HH24在格式化interval时,只显示interval里包含的小时部分,如果间隔超过24小时且天数部分被DD取走,那HH24不会把总小时数折算出来。想输出总小时数,得用EXTRACT(EPOCH FROM interval) / 3600计算,或者用justify_interval()先把interval规范化。
3.3 to_date()和to_timestamp():从字符串解析时间的边界问题
字符串转日期常用to_date(text, text);字符串转时间戳,分两种:
to_timestamp(text, text):返回timestamptz,会在当前会话时区下解析。to_timestamp(double precision):把unix epoch秒数转成timestamptz。
比如:
sql复制SELECT to_timestamp('2024-03-05 14:30:00', 'YYYY-MM-DD HH24:MI:SS');
SELECT to_timestamp(1709632200); -- unix时间戳转时间
这里容易踩的坑是格式串必须和字符串严格匹配,多一个空格或少一个前导零都会报错或报警告。比如to_timestamp('2024-3-5 14:30:00', 'YYYY-MM-DD HH24:MI:SS')是有可能失败的,或者被宽松解析,取决于版本和具体字符串。稳妥的做法是先用正则校验字符串格式,再转换。
还有to_date在解析2024-02-30这种不存在的日期时并不会报错,PostgreSQL会自动滚动到下一个月:2024-02-30会被解析成2024-03-01。这对做数据清洗来说非常危险。如果你要严格校验日期有效性,可以用EXTRACT回比,或者直接让应用层校验。
4. 时区处理——跨境业务里最容易翻车的三层问题
4.1 timestamptz到底存了什么:UTC存储,会话显示
很多人在timestamptz上踩坑,是因为不理解它的存储逻辑。PostgreSQL的timestamptz内部一律以UTC存储。举个例子:
sql复制SET timezone = 'Asia/Shanghai';
SELECT '2024-03-05 14:00:00+08'::timestamptz;
-- 显示:2024-03-05 14:00:00+08
SET timezone = 'UTC';
SELECT '2024-03-05 14:00:00+08'::timestamptz;
-- 显示:2024-03-05 06:00:00+00
看到没有,你写入的是"北京时间下午2点",当会话时区切到UTC时,它显示的是"UTC时间早上6点"。这不是数据错了,而是它保存的是一个时刻点,在不同时区下显示不同。同一时刻数据库里存的物理值只有一个,但用户感知的墙上时间会因为时区而变。
这对系统设计有个重要启发:如果你在应用层用new Date()拿到的是带时区的时间,写入数据库时如果列是timestamptz,PostgreSQL会先把它转成UTC存起来,读出来时再按会话时区转回来。只要会话时区设置一致,就不会出错。真正翻车的是:列类型用了timestamp,应用层传入带时区的字符串,PostgreSQL会丢弃时区偏移,直接按字面值存。等你跨时区查看时,那个时刻点已经彻底错了。
4.2 AT TIME ZONE:注意它有两种完全不同的语义
AT TIME ZONE是我见过最容易懵的语法,因为同一个关键字有两个方向。
第一种:把timestamp(无时区)按某个时区转成timestamptz。比如业务日志里存的都是北京时间字符串,但列是timestamp:
sql复制SELECT '2024-03-05 14:00:00'::timestamp AT TIME ZONE 'Asia/Shanghai';
-- 返回 timestamptz:2024-03-05 14:00:00+08
这个语义是"我告诉你这个墙上时间是哪个时区的,请你把它理解成那个时区的时刻"。
第二种:把timestamptz转成某个时区下的墙上时间,返回的是timestamp(无时区):
sql复制SELECT '2024-03-05 14:00:00+00'::timestamptz AT TIME ZONE 'Asia/Shanghai';
-- 返回 2024-03-05 22:00:00 (无时区的本地墙上时间)
这个语义是"请把这个时刻显示为上海时区的民用时间"。
跨时区报表最容易在这里翻车:date_trunc('day', created_at AT TIME ZONE 'America/New_York')和date_trunc('day', created_at)结果完全不同。如果业务方要的是"纽约当地自然日的订单数",你就必须在截断之前做AT TIME ZONE转换;如果你直接用数据库会话时区截断,那统计的就是"数据库时区下的自然日"。
我在处理一个面向北美用户的报表时,就吃过这个亏。当时数据库时区被一个迁移脚本临时设成了UTC,报表按天聚合出来的数据和业务后台对不上。查了半天才发现是会话时区的锅,最后在SQL里显式写AT TIME ZONE才彻底锁定结果。
4.3 时区参数与夏令时的实战建议
PostgreSQL的时区来源分为几层:postgresql.conf里的timezone参数、数据库级ALTER DATABASE ... SET timezone、会话级SET timezone、以及JDBC/ODBC连接串里设置的时区。最稳定的做法是:
- 数据库服务器操作系统时区统一定为UTC,
timezone参数也设为UTC。 - 应用连接串里显式设置时区,比如JDBC的
serverTimezone=Asia/Shanghai。 - 报表和查询SQL里,如果依赖具体时区,一律写死
AT TIME ZONE 'Asia/Shanghai',绝不依赖会话默认值。
夏令时是另一个大坑。AT TIME ZONE 'America/New_York'在夏令时切换时会自动处理偏移变化,但如果你用固定偏移AT TIME ZONE '-05:00',则不会自动切换。对于跨夏令时的历史时间,固定偏移反而可能更"稳定",但它不一定符合业务上"当地自然时间"的预期。比如美东时间2024年3月10日凌晨2点,那个小时在夏令时切换当天根本不存在(直接跳到3点)。如果你做按小时聚合,会发现那一整天少一个小时的数据。这种情况下,不能只靠数据库函数,业务侧也得明确"缺失的那一小时怎么处理"。
5. 进阶实战——日期序列、周期聚合和窗口计算
5.1 generate_series():生成连续日期填平缺失数据
做报表的同学应该都有过这种经历:按天统计订单量,某天没有订单,那天的数据就是0,但GROUP BY出来的结果里根本不会出现这一行。前端画趋势图的时候,那天的点直接断掉。解决方案是生成一个完整的日期序列,再左连接业务表。
sql复制SELECT d.dt, COUNT(t.order_id) AS order_cnt
FROM generate_series(
'2024-02-01'::date,
'2024-02-29'::date,
interval '1 day'
) AS d(dt)
LEFT JOIN orders t ON t.created_at >= d.dt
AND t.created_at < d.dt + interval '1 day'
GROUP BY d.dt
ORDER BY d.dt;
这里要注意几点:
generate_series的入参可以传date、timestamp、timestamptz、integer,但步长和类型必须匹配。生成日期序列用interval '1 day'作步长最顺手。- 左连接的关联条件写成了半开区间
[dt, dt+1 day),避免一个在23:59:59.999的订单被漏掉或重算。 COUNT(t.order_id)统计的是非空订单号,如果订单号字段允许NULL,请用主键或COUNT(t.id)。
同理,生成连续月份的序列,可以这样:
sql复制SELECT to_char(d, 'YYYY-MM') AS month
FROM generate_series('2024-01-01'::date, '2024-12-01'::date, interval '1 month') AS d;
5.2 按5分钟、按自然周聚合的三种写法
按固定时间窗口聚合是监控系统和交易分析里的高频需求。date_trunc('minute', ts)只能按整分钟切,要做5分钟窗口就先截断到分钟,再加分钟偏移量除以5取整:
sql复制SELECT
date_trunc('hour', ts) +
(floor(EXTRACT(MINUTE FROM ts) / 5) * INTERVAL '5 minutes') AS window_start,
COUNT(*) AS cnt
FROM events
GROUP BY 1
ORDER BY 1;
这个表达式的思路:先取整点,再加"当前分钟数对5取整后乘以5分钟",就把任意时刻归到了它所在的5分钟窗口起点。同理,10分钟、15分钟窗口改除数和乘数即可。
按自然周聚合,可以直接date_trunc('week', ts),但要注意返回的是周一的零点。如果业务需要"周一这个标签"能显示在报表上,通常还要再用to_char(date_trunc('week', ts), 'YYYY-MM-DD')格式化成周一的日期字符串。如果你想显示"2024-W10"这种形式,用to_char(ts, 'IYYY-"W"IW'),ISO周格式。
5.3 时间窗口内的累计值、环比和留存
EXTRACT(EPOCH FROM (now() - created_at))可以用来算"多久没活跃了":
sql复制SELECT user_id,
CASE
WHEN now() - last_active_at < interval '7 days' THEN '近7日活跃'
WHEN now() - last_active_at < interval '30 days' THEN '近30日活跃'
ELSE '流失用户'
END AS active_status
FROM users;
这个查询里,now() - last_active_at得到interval,直接和interval做比较,PostgreSQL能正确比较。注意now()返回的是timestamptz,而last_active_at建议也是timestamptz,两边类型一致才能相减。
环比场景,比如求"今天的订单量比昨天增长多少",用LAG窗口函数:
sql复制WITH daily AS (
SELECT date_trunc('day', created_at)::date AS dt, COUNT(*) AS cnt
FROM orders
WHERE created_at >= current_date - 30
GROUP BY 1
)
SELECT dt, cnt,
LAG(cnt) OVER (ORDER BY dt) AS prev_cnt,
cnt - LAG(cnt) OVER (ORDER BY dt) AS diff_cnt
FROM daily
ORDER BY dt;
注意LAG在窗口第一行返回NULL,计算diff_cnt时要COALESCE一下,否则第一天的环比会显示NULL而不是0。
留存分析的经典计算是"某个时间段进来的用户,在后续第N天是否活跃"。核心思路是先用date_trunc('day', first_seen)标记用户注册日,再用另一个自连接或LEFT JOIN LATERAL去关联活跃日志表。这里时间函数的配合很典型:活跃日志的时间用date_trunc('day', active_at)截断到天,再和注册日做偏差计算:
sql复制SELECT
u.reg_date,
COUNT(DISTINCT u.user_id) AS reg_users,
COUNT(DISTINCT a.user_id) FILTER (WHERE a.active_date = u.reg_date + 1) AS day1_retained,
COUNT(DISTINCT a.user_id) FILTER (WHERE a.active_date = u.reg_date + 7) AS day7_retained
FROM (
SELECT user_id, date_trunc('day', created_at)::date AS reg_date
FROM users
WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'
) u
LEFT JOIN (
SELECT DISTINCT user_id, date_trunc('day', active_time)::date AS active_date
FROM active_logs
WHERE active_time >= '2024-01-01' AND active_time < '2024-02-10'
) a ON a.user_id = u.user_id AND a.active_date IN (u.reg_date + 1, u.reg_date + 7)
GROUP BY u.reg_date;
5.4 用EXTRACT和to_char做更复杂的时间维度建模
日期维度表是数据仓库里常用的东西。用generate_series生成一整年的日期,然后一次性算出对应的年、月、日、星期几、ISO周、季度、是否工作日等字段,之后所有报表统一JOIN这张维度表,避免每个SQL里都写一大堆to_char和EXTRACT。
sql复制CREATE TABLE dim_date AS
SELECT
d::date AS dt,
EXTRACT(YEAR FROM d)::int AS year,
EXTRACT(MONTH FROM d)::int AS month,
EXTRACT(DAY FROM d)::int AS day,
to_char(d, 'TMDay') AS weekday_cn,
EXTRACT(ISODOW FROM d)::int AS iso_dow, -- 周一=1 ... 周日=7
EXTRACT(QUARTER FROM d)::int AS quarter,
to_char(d, 'IYYY-"W"IW') AS iso_week_str
FROM generate_series('2024-01-01'::date, '2024-12-31'::date, interval '1 day') AS d;
这里有几个细节值得注意:
EXTRACT(ISODOW FROM d)返回1-7,对应周一到周日,这是国际标准化组织的星期规则,比EXTRACT(DOW FROM d)(0-6,周日是0)更适合大多数业务。to_char(d, 'TMDay')输出的中文星期名带补齐空格,如果想去掉空格,用btrim(to_char(d, 'TMDay'))或rtrim(...)。- 季度可以通过
EXTRACT(QUARTER FROM d)直接取,但这个值是1-4的数字;如果想显示成"2024Q1",需要自己拼接。
有了这张维度表,报表SQL就会变得非常清爽。比如查"各月各工作日的订单金额均值":
sql复制SELECT d.year, d.month, d.weekday_cn, AVG(t.amount)
FROM orders t
JOIN dim_date d ON d.dt = t.created_at::date
GROUP BY 1, 2, 3;
优先用JOIN维表而不是在查询里反复调用时间函数,不仅可读性好,而且能给优化器更多机会走索引。特别是大表关联时,把created_at::date和维表主键做等值连接,PostgreSQL能利用哈希连接高效处理。
最后分享一个真实经验:别迷信某个时间函数"能搞定一切"。PostgreSQL的时间函数体系非常完整,但恰恰因为完整,选错函数的代价也高。我的建议是,在开始写任何带时间条件或分组的SQL之前,先花30秒确认三件事:字段类型是timestamptz还是timestamp、会话时区是什么、业务上要的是"时刻点"还是"墙上时间"。这三件事确认完,后面的函数选择基本不会出错。如果能在团队里把这几条做成规范,写在SQL Review的checklist里,能帮后来的人少踩一大半的坑。
