Skip to content

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 Length28~31 天不等,是「月」不能与「天」互相精确换算的根本原因

interval 的内部结构(这是理解全部 justify_* 的钥匙):

text
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'266integer需要精确天数(计费、超期判定)
age(DATE '2026-09-24', DATE '2026-01-01')8 mons 23 daysinterval展示「多久以前」,给人看
TIMESTAMPTZ '2026-09-24 12:00+08' - TIMESTAMPTZ '2026-01-01 00:00+08'266 days 12:00:00interval需要精确到秒的耗时
EXTRACT(EPOCH FROM (...))23025600numeric需要数值化的秒数

语法与用法 ​

sql
-- 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

最小可运行示例:

sql
-- 官方文档示例:两个日期的符号差
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 是按字段逐级借位计算的,借位时用的是较早那个日期所在月的天数。

sql
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;
text
 official_example |   this_year
------------------+--------------
 1 mon 1 day      | 8 mons 23 days

上例说明 age 的结果依赖具体月份的天数,与直接相减得到的固定天数有本质区别。

更重要的是:age 并不等于「先算天数差再做 justify」。用官方例子对照可以看出两者结果不同:

sql
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;
text
   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 点处理:

sql
-- 当前日期为 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
text
  with_time  | same_day
-------------+----------
 15:00:00    | 00:00:00

单参数版内部使用 current_date(当天 0 点),而不是 now()。想精确到秒必须显式传两个参数:

sql
SELECT 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
sql
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;
text
  d60  |   d29    |  h24   |  h23     |   h1500
-------+----------+--------+----------+-----------
 2 mons| 29 days  | 1 day  | 23:00:00 | 1 day 01:00:00

justify_days 的 30 天固定换算意味着它会改变时间差的实际含义,只在「展示层规整」时才安全:

sql
-- 危险:规整后语义变了
SELECT
  INTERVAL '30 days' = INTERVAL '1 mon'                       AS raw_equal,   -- false
  justify_days(INTERVAL '30 days') = INTERVAL '1 mon'         AS after_justify; -- true
text
 raw_equal | after_justify
-----------+---------------
 f         | t

justify_interval 的独特之处在于统一符号:当各字段正负混杂时,它会把负值向更高位借位,使结果的所有字段同号。

sql
SELECT
  INTERVAL '1 mon -1 hour'                     AS raw,
  justify_interval(INTERVAL '1 mon -1 hour')   AS justified;
text
    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 小时。这种「同号」表示便于比较大小与参与后续运算。

三个函数的选型与配合 ​

text
你要解决什么问题?
  ├─ 「距今多久」/「相差几年几个月」给人看     → age()
  ├─ 精确天数 / 秒数(计费、超期、SLA 判定)    → 直接相减 或 EXTRACT(EPOCH FROM ...)
  ├─ interval 显示成「X 天」不好看,想变「X 月」 → justify_days()
  ├─ interval 小时数超过 24,想变「X 天」        → justify_hours()
  └─ interval 各字段正负混杂,要统一符号         → justify_interval()

关键约束:不要在「精确计算」中混入 justify_*。它们的 30 天/24 小时换算比是展示用的近似,一旦参与二次计算(尤其是跨夏令时、跨月),误差会累积:

sql
-- 错误!用 justify 后的 interval 再做日期加法,结果会漂移
SELECT DATE '2026-01-31' + justify_days(INTERVAL '30 days');
-- 结果:2026-03-03(1 mon 加到 1-31 上,落到 2-28,再加 3 天)
sql
-- 正确:需要「加一个月」就直接用 interval '1 month'
SELECT DATE '2026-01-31' + INTERVAL '1 month';
-- 结果:2026-02-28

同时注意 age() 的结果不能直接加回原日期得到另一个日期之外的错误——它只是差值表示:

sql
SELECT DATE '2026-01-01' + age(DATE '2026-09-24', DATE '2026-01-01');
-- 结果:2026-09-24(在多数月份下成立,但跨 2 月时会偏移)

实战示例 ​

sql
-- 示例库: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:计算每个用户的订阅时长(符号差)

sql
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;

预期输出:

text
 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:把时长拆成「年 / 月 / 日」三列便于排序与筛选

sql
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;
text
 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)

sql
-- 精确统计「已结束且时长超过 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;
text
 over_1y | over_2y
---------+---------
      69 |      14

超期/计费类判定必须用整数天数差,age() 的月份长度不定,用它做阈值判断会不可复现。

步骤 4:把累计天数规整成易读的 interval

sql
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;
text
    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:处理负数差(续费提前 / 时间倒挂)

sql
-- 找出「结束时间早于开始时间」的脏数据
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;
text
 id | started_at | ended_at | negative_duration
----+------------+----------+-------------------
(无行)
sql
-- 正确比较 interval 的最小值:用 justify_interval 统一符号后再比较
SELECT min(justify_interval(age(COALESCE(ended_at, CURRENT_DATE), started_at))) AS shortest
FROM subscriptions
WHERE status = 'expired';
text
         shortest
--------------------------
 1 mon 2 days 00:00:00

注意事项 ​

  1. age() 返回的是「符号差」,不是精确天数。要精确天数用 date1 - date2(返回 integer),要精确秒数用 EXTRACT(EPOCH FROM (ts1 - ts2))。

  2. 第一个参数更早时结果为负,且各字段都带负号:

    sql
    SELECT age(DATE '2026-01-01', DATE '2026-09-24');
    -- 结果:-8 mons -23 days
  3. age(timestamp) 单参数版以 current_date(当天 0 点)为基准,会丢掉时分秒:

    sql
    -- 不推荐:丢精度
    SELECT age(created_at) FROM orders;
    -- 推荐:明确基准
    SELECT age(now(), created_at) FROM orders;
  4. justify_days 的换算比固定为 30 天 = 1 月,与真实月份无关,规整后的结果不再等价于原 interval:

    sql
    -- 错误!规整改变了语义
    WHERE justify_days(interval_col) > INTERVAL '1 mon'
    -- 正确:直接与天数比较
    WHERE interval_col > INTERVAL '30 days'
  5. 不要把 justify_* 的结果用于日期加减,会造成非预期漂移:

    sql
    -- 不推荐
    SELECT DATE '2026-01-31' + justify_days(INTERVAL '30 days');   -- 2026-03-03
  6. justify_hours 不会进位到「月」。它只做「小时 → 天」,INTERVAL '720 hours' 会变成 30 days 而不是 1 mon:

    sql
    SELECT justify_hours(INTERVAL '720 hours');   -- 30 days
    SELECT justify_days(justify_hours(INTERVAL '720 hours'));  -- 1 mon(需要再调一次)
  7. justify_interval 已包含 justify_days + justify_hours 的效果,无需再手工嵌套:

  8. interval 的比较会先折算成统一刻度(1 月 = 30 天、1 日 = 24 小时),因此「看起来不等」的两个 interval 可能相等:

    sql
    SELECT INTERVAL '30 days' = INTERVAL '1 mon' AS eq,     -- true(比较时统一折算)
           INTERVAL '30 days' > INTERVAL '1 mon' AS gt;     -- false

    但它们在日期加法中仍按各自字段处理,结果可能不同。涉及语义比较时建议统一成天数或秒数再比。

  9. EXTRACT(DAY FROM age(...)) 取出的是「符号差里的日部分」,不是总天数。总天数要用减法:

    sql
    SELECT EXTRACT(DAY FROM age(DATE '2026-09-24', DATE '2026-01-01'));   -- 23(不是 266)
  10. age 与 date_part/EXTRACT 对「月」字段的口径一致,但对 interval 抽 epoch 时按 30 天/月折算,不是真实月长:

    sql
    SELECT EXTRACT(EPOCH FROM INTERVAL '1 mon');   -- 2592000(30 天)
  11. 夏令时切换日 justify_hours 可能与真实墙上时间不符:INTERVAL '24 hours' 规整为 1 day,但在夏令时切换当天,1 day 与 24 hours 加到时间戳上结果差 1 小时。

  12. age 的性能很好(IMMUTABLE),但它作用在列上会让普通索引失效。按「距今多久」筛选请改写成日期范围条件。

最佳实践 ​

  1. 展示用 age(),计算用减法:这是最核心的分工,写清楚注释避免后人混用。
  2. 算年龄/工龄/订阅时长统一用 age(current_date, birth_date),它天然给出「X 岁 Y 月 Z 天」:
    sql
    SELECT age(current_date, DATE '1990-05-20') AS exact_age;
  3. 要整数岁数就取年份部分,不要用天数除以 365:
    sql
    SELECT EXTRACT(YEAR FROM age(current_date, birth_date))::INTEGER AS age_years;
  4. 年龄计算基准用 current_date 而非 now(),避免同一事务内因跨零点产生不同结果(生日精度只到天)。
  5. 超期、账期、SLA 一律用整数天数或秒数,把 age() 限制在展示层:
    sql
    WHERE ended_at - started_at > 365          -- 好
    WHERE EXTRACT(YEAR FROM age(ended_at, started_at)) >= 1   -- 不好
  6. 把「年/月/日」三部分拆成独立列输出,方便报表排序和前端自行拼接文案:
    sql
    EXTRACT(YEAR  FROM age(...)) AS years,
    EXTRACT(MONTH FROM age(...)) AS months,
    EXTRACT(DAY   FROM age(...)) AS days
  7. 只在最终展示时调用一次 justify_*,不要把规整后的 interval 存回数据库或继续参与运算。
  8. 需要「规整成月」时用 justify_days(justify_hours(x)) 或直接 justify_interval(x),后者还会顺带统一符号,通常是你真正想要的那个。
  9. 存储 interval 时保持原始三段结构,不要预先规整,让消费方按自己的展示需求处理。
  10. 对可能为 NULL 的结束时间用 COALESCE(ended_at, CURRENT_DATE),避免整个表达式变成 NULL:
    sql
    age(COALESCE(ended_at, CURRENT_DATE), started_at)
  11. 写测试覆盖月末(1-31 → 2-28)与闰年(2-29)两个边界,age 在这两处最容易与预期不一致。
  12. 业务口径写进注释:明确「一个月按 30 天还是按自然月」,这是 age 与 justify_days 结果分歧的根源,也是团队间最容易扯皮的地方。

下一节

继续学习:INTERVAL 运算

参考链接 ​