做数据库开发这几年,要说用得最频繁、也最容易翻车的,时间函数绝对排得上号。尤其是 PostgreSQL,时间处理能力很强,但正因为强,类型多、函数多、细节更多。同样一个需求,写法不同,结果可能天差地别。这篇文章我不想铺开讲教科书上的函数清单,而是结合我自己在实际项目和报表开发里踩过的坑,把 PostgreSQL 里关于时间的函数从类型、算子、格式化、时区到性能优化,完整梳理一遍。无论你刚接触 PG,还是已经写了不少 SQL,这篇都值得存下来当作手册翻。
1. 先搞懂 PG 的时间类型,函数才不会白学
很多人上来就背函数名,结果连timestamp和timestamptz都分不清,后面所有逻辑都建立在错误的理解上,排查问题能把你逼疯。所以我先把类型讲透,这是地基。
1.1 四种基础时间类型
PostgreSQL 官方文档里列了一堆时间相关类型,但实际开发中你基本只碰四种:
| 类型 | 存储内容 | 典型用法 |
|---|---|---|
date | 只存年月日 | 生日、账单日、自然日 |
time | 只存时分秒 | 开门时间、每日定时任务时刻 |
timestamp | 年月日 + 时分秒,无时区信息 | 本地时间记录 |
timestamptz | 年月日 + 时分秒 + 时区信息 | 业务订单时间、日志时间 |
interval | 时间段 | 时长、偏移量、时间差 |
timestamptz这个名字有迷惑性。它实际上存储的是 UTC 时间,只是在显示时转换为你当前会话的时区。也就是说,不管你在哪个时区插入数据,PG 内部都按 UTC 存,查询时再根据TimeZone参数换算显示。这一点理解到位了,时区问题就解决了一半。
1.2 类型选择的一条黄金法则
我的经验法则很简单:只要这个时间将来可能涉及“事件发生时你所在的时区是否影响它”,就一律用timestamptz。订单创建时间、用户操作日志、支付流水,这些都是会发生在一个“具体世界时刻”的事件,必须用timestamptz。而像“每天早上 9 点跑批”这种业务规则,它不依赖于你人在哪里,用time甚至timestamp反而更合适。
我之前接手过一个项目,老库用的是timestamp存订单创建时间,后来业务扩展到跨时区场景,几百万条历史数据全部要改类型。虽然 PG 提供了ALTER TABLE ... ALTER COLUMN TYPE timestamptz USING ...的方式,但数据量大时锁表时间非常吓人,而且历史上对时区的错误判断很可能让数据本身就偏差了几个小时,后期根本没法精准修正。所以你在设计表结构的阶段就得想清楚,不要留坑。
2. 核心时间函数全景拆解:常用的、易混的、隐藏的
接下来是主场。我按“取当前时间、做运算、格式化、提取部分”四个纬度拆,每个都配上真实可用的写法。
2.1 获取当前时间:now()、CURRENT_TIMESTAMP、clock_timestamp() 的区别
先说最常用的取当前时间。PostgreSQL 里至少有now()、CURRENT_TIMESTAMP、CURRENT_DATE、CURRENT_TIME和clock_timestamp()这几个。很多人以为它们差不多,实际差别很大。
SELECT now(); SELECT CURRENT_TIMESTAMP; SELECT CURRENT_DATE; SELECT CURRENT_TIME; SELECT clock_timestamp();核心区别在于事务内是否稳定:
now()和CURRENT_TIMESTAMP返回的是事务开始时刻,同一个事务里多次调用,结果一致。这在做批量插入、对账报表时非常有用,能保证同一批操作时间戳一致。clock_timestamp()返回的是实时时钟,每次调用都不一样,更接近 MySQL 的NOW()行为。适合在长事务里记录真实操作时间,比如 trigger 里记录每一行的修改时间。
直觉上你可能会想用clock_timestamp(),其实绝大多数业务场景反而用now()更合理。比如批量插入一万条记录,如果用clock_timestamp(),每条数据插入时间都不同,虽然看起来精确,但如果你后续要按“批次”过滤数据,反而没法区分。用now()就能保证同一个事务批次的插入时间一致。
关于CURRENT_DATE,它返回date类型,没有时分秒。很多新手喜欢这样拼:
-- 不太推荐的写法 WHERE create_time >= CURRENT_DATE AND create_time < CURRENT_DATE + 1功能没问题,但如果未来逻辑复杂了,到处拼接日界容易出错。更优雅的写法是:
WHERE create_time >= CURRENT_DATE::timestamptz AND create_time < (CURRENT_DATE + 1)::timestamptz或者直接:
WHERE create_time >= date_trunc('day', now()) AND create_time < date_trunc('day', now()) + interval '1 day'后面在索引优化部分会专门讲为什么这种写法重要。
2.2 时间加减:interval是灵魂
PostgreSQL 的时间加减不靠DATE_ADD这种函数,而是直接用+/-配合interval类型。interval可以是字符串字面量,也可以组合成更复杂的时间段。
-- 当前时间加 1 天 SELECT now() + interval '1 day'; -- 加 3 小时 30 分钟 SELECT now() + interval '3 hours 30 minutes'; -- 加 1 个月 SELECT now() + interval '1 month'; -- 混合写法 SELECT now() + '2 days 4 hours 15 minutes'::interval;这里有一个非常容易踩的坑:加一个月不等于加 30 天。比如:
SELECT '2024-01-31'::date + interval '1 month'; -- 结果是 2024-02-29 00:00:00PG 会智能地做月份天数处理,落到 2 月份的最后一天。但如果你需要按“固定 30 天”算,那就得明确写interval '30 days'。这两种语义在业务上是完全不同的,比如算“免费试用 1 个月”和算“库存周转 30 天”,场景不同,处理方式绝对不能混用。
另一个很实用的组合是生成序列,比如生成连续 7 天的日期列表:
SELECT generate_series( CURRENT_DATE, CURRENT_DATE + interval '6 days', interval '1 day' )::date AS day;这种写法在报表补数据、生成日期维度表的时候极其好用,比在应用层循环拼接 SQL 高效得多。
2.3 时间差计算:减法直接得到interval
两个时间相减,结果是interval。这是 PG 最直观也最强大的地方之一。
SELECT (now() - '2024-01-01 00:00:00'::timestamptz) AS diff_interval;结果类似188 days 15:32:11.234567。你可以对这个interval继续提取部分:
SELECT EXTRACT(DAY FROM (now() - '2024-01-01'::timestamptz)) AS day_part, EXTRACT(HOUR FROM (now() - '2024-01-01'::timestamptz)) AS hour_part, (now() - '2024-01-01'::timestamptz) / interval '1 day' AS days_float;注意interval / interval可以得到一个实数。这在算“已用多少天”的场景里非常灵活,不用再走EXTRACT拼凑。
算两个时间之间有几个星期的需求也很常见:
SELECT (now() - CURRENT_DATE) / interval '7 days' AS weeks_float;这个写法去掉了EXTRACT的嵌套,语义也更清晰。
2.4 时间格式化:to_char和to_timestamp是一对黄金搭档
不要指望DATE_FORMAT,PG 里格式化函数叫to_char。
SELECT to_char(now(), 'YYYY-MM-DD HH24:MI:SS'); -- 2025-07-06 14:32:07 SELECT to_char(now(), 'YYYY-MM-DD'); -- 2025-07-06 SELECT to_char(now(), 'Mon DD, YYYY'); -- Jul 06, 2025 SELECT to_char(now(), 'HH24:MI:SS'); -- 14:32:07格式化字符串的细节非常多,我把关键区分点列一下,建议收藏:
| 模式 | 含义 | 示例 |
|---|---|---|
YYYY | 4位年份 | 2025 |
YY | 2位年份 | 25 |
MM | 两位月份 | 07 |
Mon | 缩写月份名 | Jul |
DD | 两位日 | 06 |
HH24 | 24小时制 | 14 |
HH12 | 12小时制 | 02 |
MI | 分钟 | 32 |
SS | 秒 | 07 |
MS | 毫秒 | 123 |
US | 微秒 | 123456 |
TZ | 时区缩写 | CST |
反向操作——把字符串解析成时间——用to_timestamp:
SELECT to_timestamp('2025-07-06 14:32:07', 'YYYY-MM-DD HH24:MI:SS');注意一个细节:to_timestamp返回的是timestamptz,它会把字符串当成你当前时区的时间来解释。如果你处理的字符串是纯本地时间、不包含时区信息,那么用to_timestamp拿到的是经过时区换算后的 UTC 值。这在批量导入外部数据时非常容易出偏差。
遇到这种情况,建议先显式指定会话时区,或者用timestamp类型配合转换:
-- 先把字符串转成 timestamp(无时区解析) SELECT '2025-07-06 14:32:07'::timestamp AT TIME ZONE 'Asia/Shanghai';AT TIME ZONE的语义是:“这个无时区的时间,在指定时区里实际对应的是哪个 UTC 时刻。”它是时区转换里最精确的工具,后面专门讲。
2.5 提取时间部分:EXTRACT 和 date_part 选哪个?
两个函数效果基本等价,EXTRACT是 SQL 标准写法,date_part是传统写法。我习惯用EXTRACT,它更现代一点。
SELECT EXTRACT(YEAR FROM now()), EXTRACT(MONTH FROM now()), EXTRACT(DAY FROM now()), EXTRACT(HOUR FROM now()), EXTRACT(MINUTE FROM now()), EXTRACT(SECOND FROM now()), EXTRACT(DOW FROM now()), EXTRACT(DOY FROM now()), EXTRACT(EPOCH FROM now());几个容易记混的点:
DOW:周日是 0,周一是 1,一直到周六是 6。这个和很多开发者的直觉相反,也跟 Python 的weekday()不同,写判断”今天是不是工作日”的时候非常容易错。DOY:一年中的第几天,1 到 366。EPOCH:从 1970-01-01 00:00:00 UTC 到该时间的秒数。这个在对接外部系统、计算时间戳、生成签名时非常常用。ISODOW:ISO 周几,周一是 1,周日是 7。如果你需要“周一为一周起点”的业务逻辑,用ISODOW而不是DOW。
还有一个特别容易忽视的:EXTRACT在提取interval的MONTH时,数值范围是 0-11 中间的某个值,而不是累计月份数。比如:
SELECT EXTRACT(MONTH FROM interval '1 year 8 months'); -- 结果是 8,不是 20如果你要总月数,得用EXTRACT(YEAR FROM interval) * 12 + EXTRACT(MONTH FROM interval)。
2.6 几个隐藏款函数:date_trunc、age、isfinite
date_trunc是我个人最爱用的函数。它可以把时间截断到指定精度,在做报表聚合时是神器。
-- 按小时截断 SELECT date_trunc('hour', now()); -- 按天截断 SELECT date_trunc('day', now()); -- 按周截断(周一为一周开始) SELECT date_trunc('week', now()); -- 按月截断 SELECT date_trunc('month', now());GROUP BY date_trunc('hour', create_time)这种写法比to_char(create_time, 'YYYY-MM-DD HH24:00')更高效,因为它保留的是时间类型而不是字符串,排序和索引友好得多。
age()函数用来计算年龄语义的时间差:
SELECT age(now(), '1995-05-20'::date); -- 返回类似 30 years 1 mon 16 days它按日历的月和年来做减法,而不是用固定秒数换算。算生日比“总秒数除以 365”精确得多。
isfinite()用来判断时间是否有限。PG 支持infinity和-infinity这个特殊时间值,某些业务用它们表示“永不失效”,比如优惠券有效期。判断的时候用isfinite(expire_at)可以避免不可见地引入无穷值。
3. 六个实战场景:从取数到报表全覆盖
函数拆完,我拿六个真实业务场景串一遍。每个场景我都给出从问题到写法的完整链路。
3.1 场景一:最近 7 天的日活曲线
需求很简单:按天统计最近 7 天每天活跃用户数。
WITH days AS ( SELECT generate_series( CURRENT_DATE - 6, CURRENT_DATE, interval '1 day' )::date AS day ) SELECT d.day, COUNT(DISTINCT ua.user_id) AS dau FROM days d LEFT JOIN user_activity ua ON ua.activity_date = d.day AND ua.activity_type = 'login' GROUP BY d.day ORDER BY d.day;这个写法里LEFT JOIN是为了让没有数据的日期也显示为 0,避免报表缺天。generate_series在 Postgres 里是生成日期维度的标准姿势,不需要在应用层手动拼。
3.2 场景二:按月统计订单金额,并附上月环比
WITH monthly AS ( SELECT date_trunc('month', order_time) AS month, SUM(amount) AS total_amount FROM orders WHERE order_time >= date_trunc('month', now()) - interval '6 months' GROUP BY 1 ) SELECT m.month::date AS month, m.total_amount, LAG(m.total_amount) OVER (ORDER BY m.month) AS prev_month_amount, CASE WHEN LAG(m.total_amount) OVER (ORDER BY m.month) = 0 THEN NULL ELSE ROUND( (m.total_amount - LAG(m.total_amount) OVER (ORDER BY m.month)) * 100.0 / LAG(m.total_amount) OVER (ORDER BY m.month), 2 ) END AS change_percent FROM monthly m ORDER BY m.month;用date_trunc('month', order_time)聚合,比to_char(order_time, 'YYYY-MM')更好。原因有两个:一是返回类型还是时间类型,排序自然正确;二是 Group 的同时可以在窗口函数里直接用同一个表达式,不需要再解析字符串。
3.3 场景三:计算用户平均会话时长
这里要区分“两个时间点相减”和“interval 转秒数”。假设会话表里有login_time和logout_time,都是timestamptz:
SELECT user_id, AVG(EXTRACT(EPOCH FROM (logout_time - login_time))) AS avg_seconds FROM user_sessions WHERE login_time >= now() - interval '30 days' GROUP BY user_id;EXTRACT(EPOCH FROM interval)会返回interval的总秒数,比如1 day返回86400。用这种方式做平均最稳妥,不要直接AVG(logout_time - login_time),聚合类型会变成interval,很多客户端工具显示起来很别扭。
如果你想把秒数再变成“X小时Y分Z秒”的可读形式,再包一层:
SELECT user_id, AVG(EXTRACT(EPOCH FROM (logout_time - login_time)))::interval AS avg_session_interval FROM user_sessions WHERE login_time >= now() - interval '30 days' GROUP BY user_id;对,double precision可以直接强转成interval,PostgreSQL 会按秒数去解释。这个技巧非常实用。
3.4 场景四:判断今天是否是工作日
这里典型误区是拿DOW和数字直接比,但忽略周日=0 的坑。
SELECT CASE WHEN EXTRACT(ISODOW FROM CURRENT_DATE) BETWEEN 1 AND 5 THEN 'working day' ELSE 'weekend' ENDISODOW的范围是 1-7,周一到周日。这样不用记忆DOW里 0 和 6 的语义差异。如果你还要排除法定节假日,那就需要维护一张节假日表,判断逻辑就会变成子查询或LEFT JOIN,但是周末判断用上面这个写法就够干净了。
3.5 场景五:日志按小时分桶
日志分析里面最常见的是“近 24 小时,每个小时有多少条请求”。
SELECT date_trunc('hour', request_time) AS hour_bucket, COUNT(*) AS request_count FROM api_logs WHERE request_time >= date_trunc('hour', now()) - interval '23 hours' GROUP BY hour_bucket ORDER BY hour_bucket;这里用date_trunc('hour', request_time)而不是EXTRACT(HOUR ...),因为前者返回的是完整时间戳,方便结果里直接展示成2025-07-06 14:00:00这种格式,做图表时横轴是连续时间点,而后者只能得到数字小时。这两种需求虽然看着像,但输出形态完全不同。
3.6 场景六:区间重叠的排期冲突
这个稍微进阶一点。假设会议室预订表有start_time和end_time,要查“某个时间段是否被占用”。
SELECT COUNT(*) FROM room_bookings WHERE room_id = 101 AND start_time < :end_time AND end_time > :start_time;两个区间重叠的判断标准是“开始小于对方结束,结束大于对方开始”。这个逻辑在 PG 里你既可以直接用AND写,也可以用OVERLAPS关键字:
SELECT COUNT(*) FROM room_bookings WHERE room_id = 101 AND (start_time, end_time) OVERLAPS (:start_time, :end_time);OVERLAPS语法更贴近自然语言,能减少不等式写反的失误。不过要注意,如果start_time和end_time边界刚好相接,OVERLAPS默认不认为它们重叠,因为 PG 实现的是“半开区间”语义。
4. 时区:最容易翻车的环节,没有之一
4.1timestamp和timestamptz是怎么存的?
再强调一次:timestamptz存储时全部换算成 UTC,而timestamp就是“墙上时钟时间”,不带任何时区偏移。当你执行:
SHOW TimeZone;看到的是会话级时区设置。它影响的是:
timestamptz类型数据的显示格式now()、CURRENT_TIMESTAMP的计算基准- 字符串直接转成
timestamptz时的解释方式
所以同一台 PG 服务器,不同客户端连上来设置不同的TimeZone,查询同一张timestamptz表看到的“本地时间”会不同,但背后的 UTC 值是同一个。这是正确行为,不是 Bug。
4.2 时区转换的三种姿势
场景一:把“上海时区的字符串”转成 UTC 存进timestamptz列。
SELECT '2025-07-06 14:30:00'::timestamp AT TIME ZONE 'Asia/Shanghai';注意:前半段先转成timestamp(无时区),然后AT TIME ZONE 'Asia/Shanghai'把它解释成“上海本地时间”,输出结果是 UTC 视角。如果这条记录存进timestamptz列,内部存的就是这个换算出来的 UTC 值。
场景二:把存储的timestamptz展示为太平洋时间。
SELECT order_time AT TIME ZONE 'America/Los_Angeles' AS la_time FROM orders;对一个timestamptz应用AT TIME ZONE会把它转成无时区类型,表示“在该时区看起来是什么时间”。输出的类型是timestamp,不再带时区信息。
场景三:直接改会话时区。
SET TimeZone = 'Asia/Shanghai'; SELECT now();适合临时性查看,但不要大面积在生产查询里依赖会话时区,隐性状态最容易引发事故。
4.3 时区实践的三条铁律
第一,应用层连数据库时,连接字符串里最好显式带上时区参数。比如 Java 的 JDBC 连接串里serverTimezone=,Python 的连接串里options=-c timezone=...,不要让两边的默认时区“碰运气”。第二,数据库服务器时区设置和业务逻辑无关。很多人喜欢把数据库服务器的操作系统时区改成“北京时间”,但这只影响 PG 进程启动时的默认值,真正可靠的是会话级TimeZone或者查询里AT TIME ZONE。第三,存历史事件时间一律用timestamptz,存业务规则时刻用timestamp。这是我从一个跨时区项目中用两周时间填坑换来的教训。
5. 时间函数与索引优化:写错条件就全表扫描
5.1 时间列上的 B-tree 索引
PostgreSQL 默认的 B-tree 索引对时间类型支持良好。使用WHERE order_time >= '2025-01-01' AND order_time < '2025-02-01'这种范围条件,可以直接命中索引。
但如果你写成:
-- 低效:函数包裹索引列 WHERE date_trunc('day', order_time) = '2025-01-15'即使order_time上有索引也基本没用,因为你是对表达式做比较,索引里存的是原始值。除非你为date_trunc('day', order_time)建了表达式索引:
CREATE INDEX idx_orders_trunc_day ON orders (date_trunc('day', order_time));这个语法在 PG 11+ 里是支持的。它能让按天查询命中索引,但代价是每次写入都要计算一次表达式。所以我的建议是:能用范围条件就别用表达式条件。上面那条date_trunc的查询改成:
WHERE order_time >= '2025-01-15'::date AND order_time < '2025-01-16'::date效果完全一样,但可以直接命中普通索引,还不需要维护表达式索引。这就是一开始要强调“日界写法”的原因。
5.2BETWEEN和半开区间的隐藏差异
我现在很少写BETWEEN,因为它包含两端边界。对绝大多数据业务日志、订单时间来说,更严谨的写法是“大于等于开始,小于结束”。
-- 低风险更稳的写法 WHERE create_time >= '2025-07-01' AND create_time < '2025-08-01'如果某条记录的create_time正好是2025-08-01 00:00:00,BETWEEN会把它算进 7 月,而半开区间不会。这个差异在按月对账时能导致金额差一分钱,对账系统排查起来非常痛苦。
5.3 按时间做分区表
数据量上了亿级之后,单表索引再快也扛不住。PostgreSQL 内置的分区表支持范围分区,而按时间分区是最常见的用法:
CREATE TABLE orders ( id bigserial, order_time timestamptz ) PARTITION BY RANGE (order_time); CREATE TABLE orders_2025_07 PARTITION OF orders FOR VALUES FROM ('2025-07-01') TO ('2025-08-01');查询WHERE order_time >= '2025-07-01' AND order_time < '2025-08-01'时,PG 会做分区裁剪,只扫描对应分区。需要注意FOR VALUES FROM ... TO ...是半开区间,FROM包含,TO不包含。这个语义和上面半开区间的查询习惯完全一致,如果你本来就熟练用这种写法,分区裁剪也不会造成边界误差。
6. 常见问题排查与避坑速查
最后这部分是我整理的排查手册,每一个都是实际项目中真实遇到过的,点列得比较密,可以直接当速查表用。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 查询结果时间差 8 小时 | 会话时区与业务时区不一致 | SET TimeZone = 'Asia/Shanghai'或查询里用AT TIME ZONE |
date_trunc按周分组和预期不符 | 一周起点概念不同 | 确认EXTRACT(ISODOW FROM ...)和date_trunc('week', ...)都以周一为起点 |
| 加一个月结果日期不对 | interval '1 month'按日历月走 | 需要固定 30 天就用interval '30 days' |
字符串转时间后写入timestamptz值不对 | 直接用to_timestamp被当前时区解释 | 先::timestamp再AT TIME ZONE |
BETWEEN统计边界多一天 | BETWEEN包含边界 | 改为>= 和 <的半开区间 |
EXTRACT(DOW FROM ...)和业务判断不符 | 周日是 0 | 用ISODOW替代 |
用date_trunc(create_time)过滤导致全表扫描 | 表达式无法命中普通索引 | 改用范围条件或建表达式索引 |
| 两个时间相减结果看起来像字符串 | 结果是interval类型,客户端展示各异 | 用EXTRACT(EPOCH FROM ...)转成秒数 |
| 时间写入自动丢了毫秒 | 目标列类型是timestamp(0) | 改为timestamptz不带精度后缀,默认最高微秒 |
这中间我特别想点名一个坑:to_char格式化中文场景下的 “MM” 和 “MI” 区分。写'YYYY-MM-DD HH:MI:SS'的时候,如果你把“分”写成了MM,那么分钟会被渲染成月份数。一个典型的错误写法是:
SELECT to_char(now(), 'YYYY-MM-DD HH:MM:SS'); -- 会得到 2025-07-06 14:07:06,这个 07 其实是月份,不是分钟这种错误日志一出现就能把时间排查搞晕,而且横跨好几个小时看不出来,要拿真实时间戳对比了半天才能定位。
另外,AGE()函数看起来和EXTRACT(EPOCH FROM ...)都能算两个时间差,但语义完全不同。AGE()返回的是“日历间隔”,比如 30 天跨一个月时会因为月份长短返回不同的年月日组合;而EXTRACT(EPOCH)是纯粹的秒数。你如果只是算时长,尽量用 EPOCH,别拿AGE做加减乘除,结果很难和直觉对齐。
排查的时候还有一个习惯我非常推荐:手边备一个“参照时间”
-- 先确认两边的理解一致 SELECT now(), CURRENT_TIMESTAMP, CURRENT_DATE, now()::timestamp;所有心理预期的偏差,先用这一行 SQL 对齐,再往下排查。很多所谓“PG 时间不准”的疑问,到最后发现只是时区显示问题或者类型解释问题,数据本身没有偏差。
最后再分享一个我自己的习惯。写时间相关的 SQL 时,我永远会把“当前时间”显式作为参数传入查询,而不是在 SQL 里到处写now()。就是应用层取一次时间,然后传入:query_time供 SQL 使用,这样既方便测试、也方便复现问题,更重要的是排除了运行时各语句执行时间不一致带来的边际偏差。时间函数用得越多越要明白一个道理——真正精确的前提永远是语义明确:先确定你用的是什么类型,再说用什么函数,顺序反了,踩坑是迟早的事。