PostgreSQL 时区与日期时间处理完全指南

彻底讲清 PostgreSQL 的时区与日期时间:timestamp 与 timestamptz 的内部存储差异、TimeZone 参数与会话时区、AT TIME ZONE 的两种用法、now 与 clock_timestamp 等函数区别、date_trunc 与 date_bin 与 extract 与 to_char 技巧、夏令时不存在与重复时刻的陷阱、interval 与月份边界、generate_series 时间轴、与 JDBC 和 Prisma 的协同配置。

时区是数据库里最容易埋雷的领域之一。timestamp 与 timestamptz 只差三个字母,行为却完全不同;AT TIME ZONE 用在不同的类型上含义相反;夏令时会产生「不存在的时刻」和「重复的时刻」。这些问题在生产上表现为跨时区用户看到的订单时间不对、日统计少了一小时、定时任务在切换日跑两次。本文把这些坑一次讲透。

核心认知:timestamptz 存的是 UTC 绝对时刻,不带时区;timestamp 存的是「墙上时钟读数」,不带任何时区语义。二者都不存时区名。


一、timestamp 与 timestamptz 的本质差异

1.1 内部存储

timestamp    (timestamp without time zone)  8 字节,存本地日期时间读数
timestamptz  (timestamp with time zone)     8 字节,存自 2000-01-01 UTC 起的微秒数

两者都是 8 字节的整数,都不存储时区信息。区别在于:timestamptz 在写入时把输入按当前会话时区转换成 UTC 存起来,读取时再按会话时区转回来;timestamp 则原样存取,不做任何转换。

SELECT pg_column_size(timestamptz '2026-10-06 15:00:00+08') AS tstz_bytes,   -- 8
       pg_column_size(timestamp   '2026-10-06 15:00:00')    AS ts_bytes;     -- 8

1.2 一个实验看清差异

SET TimeZone = 'Asia/Shanghai';
SELECT '2026-10-06 15:00:00'::timestamptz AS a;   -- 2026-10-06 15:00:00+08
SET TimeZone = 'UTC';
SELECT '2026-10-06 15:00:00'::timestamptz AS b;   -- 2026-10-06 07:00:00+00(同一绝对时刻)
SELECT '2026-10-06 15:00:00'::timestamp   AS c;   -- 2026-10-06 15:00:00(读数不变)

关键结论:timestamptz 的输出依赖会话时区,timestamp 的输出不依赖。前者是绝对时刻,后者是相对读数。

1.3 选型结论

timestamptz   记录「事件发生的绝对时刻」:订单创建、日志时间、审计时间
timestamp     记录「与地点无关的日历读数」:营业日、生日、排班表上的 09:00
date          只需要日期精度时

绝大多数业务字段都应该用 timestamptz。只有「这个时刻的时区语义由业务上下文决定」时才用 timestamp,例如「每天 09:00 开门」——这个 09:00 是当地墙上时间。


二、TimeZone 参数与会话时区

SHOW TimeZone;                       -- 当前会话时区
SET TimeZone = 'Asia/Shanghai';      -- 会话级
SET LOCAL TimeZone = 'UTC';          -- 事务级,事务结束即失效
ALTER DATABASE mydb SET TimeZone = 'Asia/Shanghai';   -- 库级默认

-- 查看可用的时区名(来自系统 tzdata)
SELECT name FROM pg_timezone_names WHERE name LIKE 'Asia/%' ORDER BY name LIMIT 10;
SELECT abbrev, utc_offset, is_dst FROM pg_timezone_names WHERE name = 'Asia/Shanghai';

时区名推荐用规范写法(Asia/Shanghai 而非 PRC)。缩写(如 CST)歧义极大——它可能是中国标准时间也可能是美国中部时间,永远不要用缩写。可用 SELECT * FROM pg_timezone_abbrevs WHERE abbrev = 'CST'; 查看系统认可的缩写及其偏移。


三、AT TIME ZONE 两种用法辨析

这是最容易搞混的地方:AT TIME ZONE 作用在 timestamptz 与 timestamp 上,含义相反。

-- 用法一:timestamptz -> timestamp,把绝对时刻按指定时区展开成墙上时间
SELECT timestamptz '2026-10-06 15:00:00+08' AT TIME ZONE 'UTC';
-- 2026-10-06 07:00:00      (结果类型是 timestamp)

-- 用法二:timestamp -> timestamptz,把墙上时间按指定时区解释成绝对时刻
SELECT timestamp '2026-10-06 15:00:00' AT TIME ZONE 'Asia/Shanghai';
-- 2026-10-06 15:00:00+08   (结果类型是 timestamptz)

记忆方法:

timestamptz AT TIME ZONE tz  ->  timestamp   得到「在该时区看是几点」
timestamp   AT TIME ZONE tz  ->  timestamptz 得到「该时区的这个时刻对应的绝对时刻」
-- 典型用途:按用户所在时区做日统计
SELECT (created_at AT TIME ZONE 'America/New_York')::date AS local_day,
       count(*)
FROM orders GROUP BY 1 ORDER BY 1;

四、now 家族函数

SELECT now();                  -- 事务开始时刻,返回 timestamptz
SELECT current_timestamp;      -- 同 now()
SELECT statement_timestamp();  -- 当前语句开始时刻
SELECT clock_timestamp();      -- 调用瞬间的真实时刻,每次调用都变
SELECT transaction_timestamp();-- 同 now()
SELECT timeofday();            -- text 类型,含时区缩写
now / current_timestamp / transaction_timestamp   事务内固定,同一事务多次调用值相同
statement_timestamp                               语句内固定,同一语句多次调用值相同
clock_timestamp                                   实时变化,循环里每轮都不同

对比实验:先 SELECT now() AS t1, clock_timestamp() AS c1;,SELECT pg_sleep(0.5); 后再查一次,会发现 now() 两次相同而 clock_timestamp() 不同。

在长事务里用 now() 记录「处理时间」是错的——它会等于事务开始时刻。要记录真实处理时刻必须用 clock_timestamp()。


五、日期时间函数实用技巧

5.1 date_trunc

SELECT date_trunc('hour',  timestamptz '2026-10-06 15:37:42+08');  -- 15:00:00
SELECT date_trunc('day',   timestamptz '2026-10-06 15:37:42+08');  -- 00:00:00
SELECT date_trunc('month', timestamptz '2026-10-06 15:37:42+08');  -- 10-01 00:00:00
SELECT date_trunc('week',  timestamptz '2026-10-06 15:37:42+08');  -- 周一 00:00:00

date_trunc 对 timestamptz 会先按会话时区截断再转换,因此时区不同结果不同:同是 timestamptz '2026-10-06 07:30:00+00',在 Asia/Shanghai 下 date_trunc('day', ...) 得到 2026-10-06 00:00:00+08,在 UTC 下得到 2026-10-06 00:00:00+00。做日统计时要先 AT TIME ZONE 固定到业务时区,避免结果随会话时区漂移。

5.2 date_bin

date_bin 把时间按任意间隔对齐,是 date_trunc 的泛化(PG14+)。

-- 按 15 分钟对齐
SELECT date_bin('15 minutes', timestamptz '2026-10-06 15:37:42+08',
                timestamptz '2000-01-01 00:00:00+08');
-- 2026-10-06 15:30:00+08

-- 按 7 天对齐,起点可控
SELECT date_bin('7 days', now(), timestamptz '2026-01-05 00:00:00+08');

第三个参数是「原点」,决定对齐的基准。对于非标准间隔(如 10 分钟、45 秒)只能用 date_bin。

5.3 extract

SELECT extract(year   FROM timestamptz '2026-10-06 15:37:42+08');  -- 2026
SELECT extract(epoch  FROM timestamptz '2026-10-06 15:37:42+08');  -- 秒级 Unix 时间戳
SELECT extract(dow    FROM timestamptz '2026-10-06 15:37:42+08');  -- 1(周一,0 是周日)
SELECT extract(isodow FROM timestamptz '2026-10-06 15:37:42+08');  -- 1
SELECT extract(week   FROM timestamptz '2026-10-06 15:37:42+08');  -- ISO 周数
SELECT extract(timezone_hour FROM now());                          -- 会话时区偏移小时

extract(epoch from ...) 是取 Unix 时间戳的标准方式;epoch from interval 则返回总秒数,可用于把 interval 转成秒。

5.4 to_char

SELECT to_char(timestamptz '2026-10-06 15:37:42+08', 'YYYY-MM-DD HH24:MI:SS');
SELECT to_char(timestamptz '2026-10-06 15:37:42+08', 'IYYY-"W"IW');   -- ISO 年与周
SELECT to_char(interval '3 days 04:05:06', 'DD HH24:MI:SS');

常用格式符:YYYY/MM/DD(年月日)、HH24/HH12/MI/SS(时分秒)、MS/US(毫秒/微秒)、TZ/OF(时区缩写/偏移)、IW/IYYY(ISO 周/ISO 年)。to_char 的输出同样受会话时区影响,且 TZ 取的是会话时区的缩写,跨库比较字符串时可能不一致。


六、夏令时陷阱

夏令时(DST)会造成两类异常时刻。

-- 类型一:不存在的时刻(春季前跳,时钟从 02:00 直接到 03:00)
SET TimeZone = 'America/New_York';
SELECT timestamp '2026-03-08 02:30:00' AT TIME ZONE 'America/New_York';
-- 2026-03-08 03:30:00-04   <- 02:30 不存在,被推后一小时

-- 类型二:重复的时刻(秋季后跳,01:00 到 02:00 出现两次)
SELECT timestamptz '2026-11-01 05:30:00+00' AT TIME ZONE 'America/New_York';  -- 01:30
SELECT timestamptz '2026-11-01 06:30:00+00' AT TIME ZONE 'America/New_York';  -- 01:30
-- 两次都显示 01:30,但绝对时刻不同

后果与对策:

1. 定时任务在切换日可能跑两次或漏跑 -> 用 UTC 定义调度时间
2. 按天聚合在切换日出现 23 或 25 小时 -> 用 date_trunc 而非固定 24 小时
3. 唯一约束在重复时刻冲突 -> 存 timestamptz 而非 timestamp
4. 显示层出现重复的 01:30 -> 用 UTC 排序,展示层再本地化

七、interval 运算与月份边界

SELECT timestamp '2026-01-31' + interval '1 month';    -- 2026-02-28
SELECT timestamp '2026-03-31' - interval '1 month';    -- 2026-02-28
SELECT timestamp '2026-02-28' + interval '1 month';    -- 2026-03-28

月份运算会钳制到月末,因此不满足结合律:(timestamp '2026-01-31' + interval '1 month') + interval '1 month' 得到 03-28,而 timestamp '2026-01-31' + interval '2 months' 得到 03-31。

-- interval 的内部结构:月、日、微秒三个独立字段
SELECT justify_interval(interval '1 month 35 days');   -- 2 mons 5 days
SELECT extract(epoch FROM interval '1 day');           -- 86400
SELECT interval '1 day' = interval '24 hours';         -- false(1 天不总是 24 小时)

关键:1 day 与 24 hours 在夏令时切换日不等价。跨时区做「加一天」运算时,语义必须明确是要「日历天」还是「24 小时」。


八、generate_series 生成时间轴

-- 按天生成时间轴
SELECT generate_series(timestamptz '2026-10-01 00:00:00+08',
                       timestamptz '2026-10-07 00:00:00+08',
                       interval '1 day') AS d;

-- 按小时生成,用于补齐缺口
SELECT generate_series(date_trunc('hour', now()) - interval '23 hours',
                       date_trunc('hour', now()),
                       interval '1 hour') AS h;
-- 补零:按天统计订单,缺失的日期填 0,且能走 created_at 索引
WITH days AS (
    SELECT generate_series(date_trunc('day', now()) - interval '6 days',
                           date_trunc('day', now()), interval '1 day') AS d
)
SELECT d::date AS day, count(o.id) AS orders
FROM days
LEFT JOIN orders o ON o.created_at >= d AND o.created_at < d + interval '1 day'
GROUP BY d ORDER BY d;

上例用 >= d AND < d + 1 day 而不是 date_trunc(o.created_at) = d,前者能走 created_at 上的索引,后者会让索引失效。


九、与 JDBC 与 Prisma 与 ORM 的协同

JDBC       连接参数加 ?TimeZone=UTC 让驱动按 UTC 解析 timestamptz;
           ResultSet.getObject(col, OffsetDateTime.class) 可拿到带偏移的对象
Prisma     schema 用 DateTime(映射到 timestamptz);
           连接串加 ?options=-c%20timezone%3DUTC 固定会话时区
Hibernate  hibernate.jdbc.time_zone=UTC 全局固定,避免 JVM 默认时区影响
-- 验证应用连接实际使用的时区
SELECT current_setting('TimeZone'), now();

最佳实践:应用与数据库统一用 UTC 存取,只在展示层做本地化。这样跨服务、跨地区、跨语言时都不会因默认时区不同而产生偏差。


十、跨时区存储与展示最佳实践

1. 存储:事件时刻一律用 timestamptz,写入时明确带偏移或统一按 UTC 写入
2. 会话:应用连接固定 TimeZone = UTC,消除隐式转换
3. 展示:把 UTC 时刻按用户所在时区转换,只在前端或报表层做
4. 统计:按业务时区聚合时显式 AT TIME ZONE,不要依赖会话默认
5. 调度:定时任务的时间用 UTC 定义,避免夏令时切换日异常
6. 记录:把用户时区名(如 Asia/Shanghai)作为独立字段存储供展示层使用
-- 完整的存储与展示示例
CREATE TABLE events (
    id          bigserial PRIMARY KEY,
    occurred_at timestamptz NOT NULL,           -- 绝对时刻
    user_tz     text NOT NULL DEFAULT 'UTC',    -- 展示用时区名
    payload     jsonb
);
INSERT INTO events (occurred_at, user_tz, payload)
VALUES (now(), 'Asia/Shanghai', '{"type":"login"}');

-- 按用户本地时区展示
SELECT id, to_char(occurred_at AT TIME ZONE user_tz, 'YYYY-MM-DD HH24:MI:SS') AS local_time
FROM events ORDER BY occurred_at DESC LIMIT 10;

十一、常见错误清单

1. 用 timestamp 存事件时刻,跨时区部署后时间全错
2. 用 PRC / CST 这类缩写做时区,歧义且不跨平台
3. 长事务里用 now() 记录处理时间,得到事务开始时刻
4. 按 24 小时切分自然日,夏令时切换日统计错乱
5. 用 interval '1 day' 与 interval '24 hours' 互换,跨 DST 不等价
6. 应用与数据库时区不一致,导致写入读出相差数小时
7. 直接比较 timestamp 与 timestamptz,隐式转换产生意外结果
8. 在索引列上套 to_char 或 AT TIME ZONE 或 date_trunc,导致无法走索引
-- 错误:索引失效
SELECT * FROM orders WHERE date_trunc('day', created_at) = date '2026-10-06';

-- 正确:半开区间,可走索引
SELECT * FROM orders WHERE created_at >= timestamptz '2026-10-06 00:00:00+08'
                       AND created_at <  timestamptz '2026-10-07 00:00:00+08';

常见问题(FAQ)

到底该用 timestamp 还是 timestamptz

默认用 timestamptz。它是绝对时刻,不受服务器或会话时区影响,跨时区部署时行为一致。只有「这个读数本身就是当地墙上时间、且语义与地点绑定」的场景才用 timestamp,例如营业时间表里的 09:00 到 18:00。

timestamptz 会存储时区名吗

不会。它只存 UTC 的微秒数。输入时按会话时区转换成 UTC,输出时再按会话时区转换回来。所谓「带时区」只是转换行为,不是存储内容。若要保留用户原始时区,必须另存一个字段。

AT TIME ZONE 为什么结果类型会变

因为它的语义随输入类型翻转。作用在 timestamptz 上是「展开成某时区的墙上时间」,返回 timestamp;作用在 timestamp 上是「把该墙上时间解释为某时区的绝对时刻」,返回 timestamptz。类型变了,后续运算的语义也跟着变。

夏令时切换日的日统计为什么不准

因为那天可能只有 23 小时或有 25 小时。若用固定 24 小时切分,边界会错位。正确做法是用 date_trunc('day', ts AT TIME ZONE '业务时区') 按日历天聚合,或用半开区间 >= d AND < d + interval '1 day',让 interval '1 day' 自己处理夏令时。

如何在索引上做时区相关的查询

把转换放在常量侧而不是列侧。要查「北京时间某天的数据」,把边界先算成 timestamptz 常量,再用 >= 与 < 比较原始列,这样 created_at 上的索引可用。任何在索引列上套函数(AT TIME ZONE、to_char、date_trunc)的写法都会导致全表扫描。


相关阅读

延伸阅读


完整示例(一键复制)

-- ========== 1. 时区基础确认 ==========
SHOW TimeZone;
SET TimeZone = 'UTC';                       -- 会话固定为 UTC
SELECT now(), clock_timestamp(), statement_timestamp();

-- ========== 2. 两种类型的差异 ==========
SET TimeZone = 'Asia/Shanghai';
SELECT '2026-10-06 15:00:00'::timestamptz AS tstz_show;
SET TimeZone = 'UTC';
SELECT '2026-10-06 15:00:00'::timestamptz AS tstz_show_utc;

-- ========== 3. AT TIME ZONE 两种用法 ==========
SELECT timestamptz '2026-10-06 15:00:00+08' AT TIME ZONE 'UTC'          AS to_wall,
       timestamp   '2026-10-06 15:00:00'    AT TIME ZONE 'Asia/Shanghai' AS to_instant;

-- ========== 4. 按业务时区做日统计 ==========
SELECT (created_at AT TIME ZONE 'Asia/Shanghai')::date AS local_day,
       count(*) AS orders
FROM orders
WHERE created_at >= now() - interval '7 days'
GROUP BY 1 ORDER BY 1;

-- ========== 5. 补零时间轴(可走索引的写法) ==========
WITH days AS (
    SELECT generate_series(date_trunc('day', now()) - interval '6 days',
                           date_trunc('day', now()), interval '1 day') AS d
)
SELECT d::date AS day, count(o.id) AS orders
FROM days
LEFT JOIN orders o ON o.created_at >= d AND o.created_at < d + interval '1 day'
GROUP BY d ORDER BY d;

-- ========== 6. 任意间隔对齐与格式化 ==========
SELECT date_bin('15 minutes', now(), timestamptz '2000-01-01 00:00:00+00') AS bucket,
       to_char(now() AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI:SS') AS local_str,
       extract(epoch FROM now()) AS unix_seconds;

-- ========== 7. 夏令时观察 ==========
SET TimeZone = 'America/New_York';
SELECT timestamp '2026-03-08 02:30:00' AT TIME ZONE 'America/New_York' AS nonexistent,
       timestamptz '2026-11-01 06:30:00+00' AT TIME ZONE 'America/New_York' AS repeated;
-- ========== 8. 应用连接串建议 ==========
JDBC        jdbc:postgresql://host:5432/db?TimeZone=UTC
Prisma      postgresql://user:pw@host:5432/db?options=-c%20timezone%3DUTC
Hibernate   hibernate.jdbc.time_zone=UTC

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询