AGE / JUSTIFY_DAYS / JUSTIFY_HOURS
计算「两个日期相差多久」看起来用减法就够了,但 DATE '2026-09-24' - DATE '2026-01-01' 只给出 266 这个天数——业务方要的是「8 个月 23 天」。age() 就是把天数差转换成年月日符号差的函数。而 justify_days() 与 justify_hours() 负责反向操作:把「35 天」这种累计天数规整成「1 月 5 天」,让 interval 更符合人类阅读习惯。理解这几个函数的关键,是明白 PostgreSQL 的 interval 把月、日、微秒三段分开存储,三者之间的换算并非固定。
前置知识
阅读本节前,建议先了解:DATE_TRUNC 截断
基础概念
| 概念 | 术语 | 说明 |
|---|---|---|
| 符号差 | Symbolic Difference | 用「年 月 日」表示的时间差,随月份长度自适应 |
| 绝对差 | Absolute Difference | 用「天数」或「秒数」表示的时间差,恒定不变 |
| 间隔类型 | interval | 由 months(月)、days(日)、microseconds(微秒)三个独立字段组成 |
| 规整 | Justify | 把溢出的小单位进位到大单位(30 天 → 1 月,24 小时 → 1 天) |
| 规整天数 | justify_days | 只做「天 → 月」的进位,换算比固定为 30 天 = 1 月 |
| 规整小时 | justify_hours | 只做「小时 → 天」的进位,换算比固定为 24 小时 = 1 天 |
| 全规整 | justify_interval | 同时执行上面两种进位,并统一各字段正负号 |
| 月长 | Month Length | 28~31 天不等,是「月」不能与「天」互相精确换算的根本原因 |
interval 的内部结构(这是理解全部 justify_* 的钥匙):
INTERVAL '1 year 2 mons 3 days 04:05:06.789'
months = 14 (1 year 折成 12 月后累加:12 + 2)
days = 3
microseconds = 14706789000 (4h 5m 6.789s 折算成微秒)
三段彼此独立存储,互不换算:
'30 days' ≠ '1 mon' (只是恰好等价于 justify 后的写法)
'24 hours' ≠ '1 day' (在夏令时切换日甚至有真实差异)
'1 mon' ≠ '30 days' (2 月里 1 mon 只有 28 天)正因为三段独立,INTERVAL '1 month' + INTERVAL '1 day' 才能精确表达「下个月的同一天再多一天」,而用固定 30 天换算就无法做到。
符号差 vs 绝对差:
| 表达式 | 结果 | 类型 | 适用场景 |
|---|---|---|---|
DATE '2026-09-24' - DATE '2026-01-01' | 266 | integer | 需要精确天数(计费、超期判定) |
age(DATE '2026-09-24', DATE '2026-01-01') | 8 mons 23 days | interval | 展示「多久以前」,给人看 |
TIMESTAMPTZ '2026-09-24 12:00+08' - TIMESTAMPTZ '2026-01-01 00:00+08' | 266 days 12:00:00 | interval | 需要精确到秒的耗时 |
EXTRACT(EPOCH FROM (...)) | 23025600 | numeric | 需要数值化的秒数 |
语法与用法
-- 1. 符号差:ts1 - ts2,用年月日表示
age(timestamp) → interval -- 等价于 age(current_date, timestamp)
age(timestamp, timestamp) → interval
-- 2. 规整天数:30 天进位为 1 月
justify_days(interval) → interval
-- 3. 规整小时:24 小时进位为 1 天
justify_hours(interval) → interval
-- 4. 全规整:先 justify_hours 再 justify_days,并统一正负号
justify_interval(interval) → interval最小可运行示例:
-- 官方文档示例:两个日期的符号差
SELECT age(TIMESTAMP '2001-04-10', TIMESTAMP '1957-06-13');
-- 结果:43 years 9 mons 27 days
-- 天数差 vs 符号差
SELECT
DATE '2026-09-24' - DATE '2026-01-01' AS days_diff,
age(DATE '2026-09-24', DATE '2026-01-01') AS age_diff;
-- 结果:266 | 8 mons 23 days
-- 第一个参数更早时结果为负
SELECT age(DATE '2026-01-01', DATE '2026-09-24');
-- 结果:-8 mons -23 days
-- 天规整为月(30 天 = 1 月)
SELECT justify_days(INTERVAL '35 days');
-- 结果:1 mon 5 days
-- 小时规整为天(24 小时 = 1 天)
SELECT justify_hours(INTERVAL '27 hours');
-- 结果:1 day 03:00:00
-- 全规整(官方文档示例)
SELECT justify_interval(INTERVAL '1 mon -1 hour');
-- 结果:29 days 23:00:00详细说明
age 的符号差是按字段逐级相减得到的
age(ts1, ts2) 的算法可以概括为:先算出「完整的年」,再算出「完整的月」,最后把余下的部分折算成日与时间。它不使用固定的月长,因此能正确表达自然语言中的「差 8 个月 23 天」:
官方文档专门举了一个「结果可能出人意料」的例子:age('2004-06-01', '2004-04-30') 返回 1 mon 1 day,而不是直觉上的 1 mon 2 days——因为 age 是按字段逐级借位计算的,借位时用的是较早那个日期所在月的天数。
SELECT
age(TIMESTAMP '2004-06-01', TIMESTAMP '2004-04-30') AS official_example,
age(TIMESTAMP '2026-09-24', TIMESTAMP '2026-01-01') AS this_year; official_example | this_year
------------------+--------------
1 mon 1 day | 8 mons 23 days上例说明
age的结果依赖具体月份的天数,与直接相减得到的固定天数有本质区别。
更重要的是:age 并不等于「先算天数差再做 justify」。用官方例子对照可以看出两者结果不同:
SELECT
age(TIMESTAMP '2004-06-01', TIMESTAMP '2004-04-30') AS symbolic,
TIMESTAMP '2004-06-01' - TIMESTAMP '2004-04-30' AS plain_sub,
justify_days(TIMESTAMP '2004-06-01' - TIMESTAMP '2004-04-30') AS by_justify; symbolic | plain_sub | by_justify
---------------+-----------+-------------
1 mon 1 day | 32 days | 1 mon 2 days
age给出1 mon 1 day,而把 32 天做justify_days(按 30 天/月折算)得到1 mon 2 days,两者并不相等。这正是age不能简单用固定换算比替代的原因:它借位时看的是真实日历月长,而justify_days永远按 30 天算。
age(timestamp) 单参数形式是 age(current_date, timestamp) 的简写,注意第二个参数被当成当天 0 点处理:
-- 当前日期为 2026-09-24
SELECT
age(TIMESTAMP '2026-09-24 15:00') AS with_time, -- 取 0 点比较
age(TIMESTAMP '2026-09-24') AS same_day; -- 结果:00:00:00 with_time | same_day
-------------+----------
15:00:00 | 00:00:00单参数版内部使用
current_date(当天 0 点),而不是now()。想精确到秒必须显式传两个参数:sqlSELECT age(now(), created_at) FROM orders;
justify_days / justify_hours 的换算比是硬编码的
两个函数的换算比在 PostgreSQL 内部是写死的常量,与日历无关:
| 函数 | 换算规则 | 官方示例 |
|---|---|---|
justify_days | 每 30 天进位为 1 月 | justify_days(INTERVAL '35 days') → 1 mon 5 days |
justify_hours | 每 24 小时进位为 1 天 | justify_hours(INTERVAL '27 hours') → 1 day 03:00:00 |
justify_interval | 两者都做,并统一符号 | justify_interval(INTERVAL '1 mon -1 hour') → 29 days 23:00:00 |
SELECT
justify_days(INTERVAL '60 days') AS d60, -- 2 mons
justify_days(INTERVAL '29 days') AS d29, -- 29 days(不足 30 天不进位)
justify_hours(INTERVAL '24 hours') AS h24, -- 1 day
justify_hours(INTERVAL '23 hours') AS h23, -- 23:00:00
justify_hours(INTERVAL '1500 minutes') AS h1500; d60 | d29 | h24 | h23 | h1500
-------+----------+--------+----------+-----------
2 mons| 29 days | 1 day | 23:00:00 | 1 day 01:00:00justify_days 的 30 天固定换算意味着它会改变时间差的实际含义,只在「展示层规整」时才安全:
-- 危险:规整后语义变了
SELECT
INTERVAL '30 days' = INTERVAL '1 mon' AS raw_equal, -- false
justify_days(INTERVAL '30 days') = INTERVAL '1 mon' AS after_justify; -- true raw_equal | after_justify
-----------+---------------
f | tjustify_interval 的独特之处在于统一符号:当各字段正负混杂时,它会把负值向更高位借位,使结果的所有字段同号。
SELECT
INTERVAL '1 mon -1 hour' AS raw,
justify_interval(INTERVAL '1 mon -1 hour') AS justified; raw | justified
-------------+-----------------
1 mon -01:00:00 | 29 days 23:00:00
1 mon -1 hour被规整为29 days 23:00:00:先把 1 月(按 30 天)借出 1 天抵消负小时,得到 29 天 23 小时。这种「同号」表示便于比较大小与参与后续运算。
三个函数的选型与配合
你要解决什么问题?
├─ 「距今多久」/「相差几年几个月」给人看 → age()
├─ 精确天数 / 秒数(计费、超期、SLA 判定) → 直接相减 或 EXTRACT(EPOCH FROM ...)
├─ interval 显示成「X 天」不好看,想变「X 月」 → justify_days()
├─ interval 小时数超过 24,想变「X 天」 → justify_hours()
└─ interval 各字段正负混杂,要统一符号 → justify_interval()关键约束:不要在「精确计算」中混入 justify_*。它们的 30 天/24 小时换算比是展示用的近似,一旦参与二次计算(尤其是跨夏令时、跨月),误差会累积:
-- 错误!用 justify 后的 interval 再做日期加法,结果会漂移
SELECT DATE '2026-01-31' + justify_days(INTERVAL '30 days');
-- 结果:2026-03-03(1 mon 加到 1-31 上,落到 2-28,再加 3 天)-- 正确:需要「加一个月」就直接用 interval '1 month'
SELECT DATE '2026-01-31' + INTERVAL '1 month';
-- 结果:2026-02-28同时注意 age() 的结果不能直接加回原日期得到另一个日期之外的错误——它只是差值表示:
SELECT DATE '2026-01-01' + age(DATE '2026-09-24', DATE '2026-01-01');
-- 结果:2026-09-24(在多数月份下成立,但跨 2 月时会偏移)实战示例
-- 示例库:demo
DROP TABLE IF EXISTS subscriptions;
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
plan TEXT NOT NULL, -- monthly / yearly
started_at DATE NOT NULL,
ended_at DATE, -- NULL 表示仍在订阅
status TEXT NOT NULL -- active / canceled / expired
);
-- 造数:覆盖各种订阅时长
INSERT INTO subscriptions (user_id, plan, started_at, ended_at, status)
SELECT
1000 + i,
CASE WHEN i % 3 = 0 THEN 'yearly' ELSE 'monthly' END,
DATE '2024-01-01' + (i * 7),
CASE WHEN i % 5 = 0 THEN NULL
ELSE DATE '2024-01-01' + (i * 7) + (i % 400 + 30) END,
CASE WHEN i % 5 = 0 THEN 'active'
WHEN i % 4 = 0 THEN 'canceled'
ELSE 'expired' END
FROM generate_series(1, 240) AS i;
ANALYZE subscriptions;步骤 1:计算每个用户的订阅时长(符号差)
SELECT
id,
started_at,
ended_at,
age(COALESCE(ended_at, CURRENT_DATE), started_at) AS duration
FROM subscriptions
WHERE status = 'expired'
ORDER BY id
LIMIT 5;预期输出:
id | started_at | ended_at | duration
----+------------+------------+------------------
2 | 2024-01-15 | 2024-12-10 | 10 mons 25 days
3 | 2024-01-22 | 2025-08-31 | 1 year 7 mons 9 days
4 | 2024-01-29 | 2026-05-21 | 2 years 3 mons 22 days步骤 2:把时长拆成「年 / 月 / 日」三列便于排序与筛选
SELECT
EXTRACT(YEAR FROM age(COALESCE(ended_at, CURRENT_DATE), started_at))::INTEGER AS years,
EXTRACT(MONTH FROM age(COALESCE(ended_at, CURRENT_DATE), started_at))::INTEGER AS months,
EXTRACT(DAY FROM age(COALESCE(ended_at, CURRENT_DATE), started_at))::INTEGER AS days,
count(*) AS cnt
FROM subscriptions
WHERE status = 'expired'
GROUP BY 1, 2, 3
ORDER BY 1 DESC, 2 DESC, 3 DESC
LIMIT 5; years | months | days | cnt
-------+--------+------+-----
2 | 9 | 22 | 1
2 | 7 | 27 | 1
2 | 6 | 18 | 1
2 | 5 | 12 | 1
2 | 4 | 25 | 1步骤 3:用精确天数做超期判定(不要用 age)
-- 精确统计「已结束且时长超过 365 天」的订阅
SELECT count(*) FILTER (WHERE ended_at - started_at > 365) AS over_1y,
count(*) FILTER (WHERE ended_at - started_at > 730) AS over_2y
FROM subscriptions
WHERE ended_at IS NOT NULL; over_1y | over_2y
---------+---------
69 | 14超期/计费类判定必须用整数天数差,
age()的月份长度不定,用它做阈值判断会不可复现。
步骤 4:把累计天数规整成易读的 interval
SELECT
INTERVAL '45 days' AS raw,
justify_days(INTERVAL '45 days') AS as_month,
INTERVAL '50 hours' AS raw_hours,
justify_hours(INTERVAL '50 hours') AS as_days,
justify_interval(INTERVAL '45 days 50 hours') AS both; raw | as_month | raw_hours | as_days | both
-----------+-----------+-----------+-------------+---------------
45 days | 1 mon 15 days | 50:00:00 | 2 days 02:00:00 | 1 mon 17 days 02:00:00步骤 5:处理负数差(续费提前 / 时间倒挂)
-- 找出「结束时间早于开始时间」的脏数据
SELECT
id,
started_at,
ended_at,
age(ended_at, started_at) AS negative_duration
FROM subscriptions
WHERE ended_at IS NOT NULL
AND ended_at < started_at
LIMIT 5; id | started_at | ended_at | negative_duration
----+------------+----------+-------------------
(无行)-- 正确比较 interval 的最小值:用 justify_interval 统一符号后再比较
SELECT min(justify_interval(age(COALESCE(ended_at, CURRENT_DATE), started_at))) AS shortest
FROM subscriptions
WHERE status = 'expired'; shortest
--------------------------
1 mon 2 days 00:00:00注意事项
age()返回的是「符号差」,不是精确天数。要精确天数用date1 - date2(返回integer),要精确秒数用EXTRACT(EPOCH FROM (ts1 - ts2))。第一个参数更早时结果为负,且各字段都带负号:
sqlSELECT age(DATE '2026-01-01', DATE '2026-09-24'); -- 结果:-8 mons -23 daysage(timestamp)单参数版以current_date(当天 0 点)为基准,会丢掉时分秒:sql-- 不推荐:丢精度 SELECT age(created_at) FROM orders; -- 推荐:明确基准 SELECT age(now(), created_at) FROM orders;justify_days的换算比固定为 30 天 = 1 月,与真实月份无关,规整后的结果不再等价于原interval:sql-- 错误!规整改变了语义 WHERE justify_days(interval_col) > INTERVAL '1 mon' -- 正确:直接与天数比较 WHERE interval_col > INTERVAL '30 days'不要把
justify_*的结果用于日期加减,会造成非预期漂移:sql-- 不推荐 SELECT DATE '2026-01-31' + justify_days(INTERVAL '30 days'); -- 2026-03-03justify_hours不会进位到「月」。它只做「小时 → 天」,INTERVAL '720 hours'会变成30 days而不是1 mon:sqlSELECT justify_hours(INTERVAL '720 hours'); -- 30 days SELECT justify_days(justify_hours(INTERVAL '720 hours')); -- 1 mon(需要再调一次)justify_interval已包含justify_days+justify_hours的效果,无需再手工嵌套:interval的比较会先折算成统一刻度(1 月 = 30 天、1 日 = 24 小时),因此「看起来不等」的两个 interval 可能相等:sqlSELECT INTERVAL '30 days' = INTERVAL '1 mon' AS eq, -- true(比较时统一折算) INTERVAL '30 days' > INTERVAL '1 mon' AS gt; -- false但它们在日期加法中仍按各自字段处理,结果可能不同。涉及语义比较时建议统一成天数或秒数再比。
EXTRACT(DAY FROM age(...))取出的是「符号差里的日部分」,不是总天数。总天数要用减法:sqlSELECT EXTRACT(DAY FROM age(DATE '2026-09-24', DATE '2026-01-01')); -- 23(不是 266)age与date_part/EXTRACT对「月」字段的口径一致,但对interval抽epoch时按 30 天/月折算,不是真实月长:sqlSELECT EXTRACT(EPOCH FROM INTERVAL '1 mon'); -- 2592000(30 天)夏令时切换日
justify_hours可能与真实墙上时间不符:INTERVAL '24 hours'规整为1 day,但在夏令时切换当天,1 day与24 hours加到时间戳上结果差 1 小时。age的性能很好(IMMUTABLE),但它作用在列上会让普通索引失效。按「距今多久」筛选请改写成日期范围条件。
最佳实践
- 展示用
age(),计算用减法:这是最核心的分工,写清楚注释避免后人混用。 - 算年龄/工龄/订阅时长统一用
age(current_date, birth_date),它天然给出「X 岁 Y 月 Z 天」:sqlSELECT age(current_date, DATE '1990-05-20') AS exact_age; - 要整数岁数就取年份部分,不要用天数除以 365:sql
SELECT EXTRACT(YEAR FROM age(current_date, birth_date))::INTEGER AS age_years; - 年龄计算基准用
current_date而非now(),避免同一事务内因跨零点产生不同结果(生日精度只到天)。 - 超期、账期、SLA 一律用整数天数或秒数,把
age()限制在展示层:sqlWHERE ended_at - started_at > 365 -- 好 WHERE EXTRACT(YEAR FROM age(ended_at, started_at)) >= 1 -- 不好 - 把「年/月/日」三部分拆成独立列输出,方便报表排序和前端自行拼接文案:sql
EXTRACT(YEAR FROM age(...)) AS years, EXTRACT(MONTH FROM age(...)) AS months, EXTRACT(DAY FROM age(...)) AS days - 只在最终展示时调用一次
justify_*,不要把规整后的interval存回数据库或继续参与运算。 - 需要「规整成月」时用
justify_days(justify_hours(x))或直接justify_interval(x),后者还会顺带统一符号,通常是你真正想要的那个。 - 存储
interval时保持原始三段结构,不要预先规整,让消费方按自己的展示需求处理。 - 对可能为
NULL的结束时间用COALESCE(ended_at, CURRENT_DATE),避免整个表达式变成NULL:sqlage(COALESCE(ended_at, CURRENT_DATE), started_at) - 写测试覆盖月末(1-31 → 2-28)与闰年(2-29)两个边界,
age在这两处最容易与预期不一致。 - 业务口径写进注释:明确「一个月按 30 天还是按自然月」,这是
age与justify_days结果分歧的根源,也是团队间最容易扯皮的地方。
下一节
继续学习:INTERVAL 运算