时区是数据库里最容易埋雷的领域之一。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)的写法都会导致全表扫描。
相关阅读
- PostgreSQL 数据类型深入 — 类型体系与选型
- PostgreSQL 高级 SQL 查询实战 — 窗口函数与时间序列分析
- PostgreSQL 时序数据工作负载 — 时间分区与 BRIN 索引
- PostgreSQL Prisma 实战 — ORM 层的类型映射
- PostgreSQL 查询优化实战 — 避免函数包裹索引列
- PostgreSQL 专题导航
延伸阅读
- PostgreSQL schema 设计规范 — 字段类型与约束设计
- PostgreSQL 统计信息与 ANALYZE — 时间列的选择性与统计
完整示例(一键复制)
-- ========== 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。