TIMESTAMPTZ 详解
本节深入讲解 PostgreSQL 的 TIMESTAMPTZ(TIMESTAMP WITH TIME ZONE)类型。你将理解它的内部存储模型、显示换算规则、与 TIMESTAMP 的本质区别,以及跨时区业务中正确使用它的方法。
前置知识
阅读本节前,建议先了解:DATE / TIME / TIMESTAMP
基础概念
TIMESTAMPTZ 是 PostgreSQL 最容易被误解的类型之一,根源在于它的名字。
| 类型名 | 存储空间 | 内部表示 | 显示依据 | 说明 |
|---|---|---|---|---|
TIMESTAMP | 8 字节 | 本地日期时间(无时区) | 原样输出 | 墙上时钟时间 |
TIMESTAMPTZ | 8 字节 | UTC 微秒数(自 2000-01-01) | 会话 TimeZone | 绝对时间点 |
TIMETZ | 12 字节 | 时间 + 固定偏移 | 原样输出 | 不推荐使用 |
关键术语:
- 时刻(Instant):时间轴上一个绝对点,与观察者所在时区无关。
- 墙上时钟(Wall Clock):某个时区里挂钟显示的时间。
- 会话时区(Session TimeZone):
TimeZone参数,决定TIMESTAMPTZ输入解释与输出显示。 - UTC 偏移(UTC Offset):本地时间与 UTC 的差值,如
+08。 - 时区缩写(Time Zone Abbreviation):如
CST,含义歧义(中国标准时间/美国中部时间),不应依赖。
核心事实:TIMESTAMPTZ 不存储时区。它只存一个 UTC 时刻,输出时按会话时区换算成带偏移的文本。
-- 存入同一时刻的两种写法,结果完全相同
SELECT
TIMESTAMPTZ '2026-09-22 10:30:00+08' AS from_beijing,
TIMESTAMPTZ '2026-09-22 02:30:00+00' AS from_utc,
TIMESTAMPTZ '2026-09-22 10:30:00+08' = TIMESTAMPTZ '2026-09-22 02:30:00+00' AS equal; from_beijing | from_utc | equal
-----------------------+---------------------+-------
2026-09-22 10:30:00+08 | 2026-09-22 10:30:00+08 | t两条不同的字面量指向同一时刻,因此相等,且都按当前会话时区(+08)显示。
语法与用法
-- 类型声明
列名 TIMESTAMPTZ [(p)]
列名 TIMESTAMP WITH TIME ZONE [(p)]
-- 字面量(推荐:带显式偏移)
TIMESTAMPTZ '2026-09-22 10:30:00+08'
TIMESTAMPTZ '2026-09-22 10:30:00+08:00'
TIMESTAMPTZ '2026-09-22 10:30:00 Asia/Shanghai'
-- 字面量(依赖会话时区解释)
TIMESTAMPTZ '2026-09-22 10:30:00'
-- 当前时刻
now() -- TIMESTAMPTZ,事务开始时刻
CURRENT_TIMESTAMP -- 同 now()
-- 时区换算
ts AT TIME ZONE 'zone' -- TIMESTAMPTZ → TIMESTAMP(按 zone 换算)
ts AT TIME ZONE 'zone' -- TIMESTAMP → TIMESTAMPTZ(按 zone 解释)
-- 会话时区
SHOW TimeZone;
SET TimeZone TO 'Asia/Shanghai';
SET TIME ZONE 'UTC';
SET TIME ZONE INTERVAL '+08:00' HOUR TO MINUTE;
-- 常用函数
timezone(zone, ts) -- 等价于 ts AT TIME ZONE zone
to_char(ts, fmt) -- 格式化输出
date_trunc(field, ts) -- 截断(按会话时区)最简可运行示例:
SET TimeZone TO 'Asia/Shanghai';
SELECT
now() AS tstz_now,
now() AT TIME ZONE 'UTC' AS as_utc_wallclock,
now() AT TIME ZONE 'Asia/Shanghai' AS as_cn_wallclock; tstz_now | as_utc_wallclock | as_cn_wallclock
------------------------------+------------------------+---------------------
2026-09-22 10:30:00.123456+08 | 2026-09-22 02:30:00.123456 | 2026-09-22 10:30:00.123456详细说明
内部存储:UTC 微秒数
TIMESTAMPTZ 与 TIMESTAMP 都是 8 字节整数,但语义完全不同:
| 类型 | 8 字节表示 | 基准点 |
|---|---|---|
TIMESTAMP | 微秒数(无时区含义) | 2000-01-01 00:00:00 |
TIMESTAMPTZ | UTC 微秒数 | 2000-01-01 00:00:00 UTC |
用 epoch 验证两者在同一时刻下的差异:
SET TimeZone TO 'Asia/Shanghai';
SELECT
extract(epoch FROM TIMESTAMP '2026-09-22 10:30:00') AS ts_epoch,
extract(epoch FROM TIMESTAMPTZ '2026-09-22 10:30:00+08') AS tstz_epoch,
extract(epoch FROM TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'Asia/Shanghai') AS ts_equiv; ts_epoch | tstz_epoch | ts_equiv
--------------+--------------+------------
1789967400 | 1789967400 | 1789967400关键差异是:把 TIMESTAMPTZ 转为 TIMESTAMP 时需要指定用哪个时区解释:
SET TimeZone TO 'Asia/Shanghai';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00+08'::timestamp AS cast_to_ts; cast_to_ts
---------------------
2026-09-22 10:30:00SET TimeZone TO 'UTC';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00+08'::timestamp AS cast_to_ts; cast_to_ts
---------------------
2026-09-22 02:30:00同一个 TIMESTAMPTZ 值,在不同的会话时区下 ::timestamp 得到不同的墙上时钟。这正是 TIMESTAMPTZ::timestamp 转换必须谨慎的原因。
输入解释:会话时区决定"裸字面量"含义
不带偏移的字面量会按会话时区解释:
SET TimeZone TO 'Asia/Shanghai';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00' AS cn_session; cn_session
-----------------------
2026-09-22 10:30:00+08SET TimeZone TO 'UTC';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00' AS utc_session; utc_session
-----------------------
2026-09-22 10:30:00+00同一条 SQL 在不同会话时区下存入了不同的时刻(相差 8 小时)。这解释了为什么 TIMESTAMPTZ 的裸字面量在生产代码中应当避免:
-- 危险:含义取决于会话时区
INSERT INTO events (occurred_at) VALUES ('2026-09-22 10:30:00');
-- 安全:显式声明偏移或时区名
INSERT INTO events (occurred_at) VALUES ('2026-09-22 10:30:00+08');
INSERT INTO events (occurred_at) VALUES ('2026-09-22 10:30:00 Asia/Shanghai');输出显示:会话时区决定显示文本
SELECT now() AS t;SET TimeZone TO 'Asia/Shanghai';
SELECT now() AS cn_view; cn_view
------------------------------
2026-09-22 10:30:00.123456+08SET TimeZone TO 'America/New_York';
SELECT now() AS ny_view; ny_view
------------------------------
2026-09-21 22:30:00.123456-04同一行数据,在纽约会话下显示为前一天 22:30。日期部分都变了,这是跨时区报表最常见的问题来源。
时区名与固定偏移的区别:
SET TimeZone TO 'America/New_York';
SELECT
TIMESTAMPTZ '2026-01-15 12:00:00+00' AS winter,
TIMESTAMPTZ '2026-07-15 12:00:00+00' AS summer; winter | summer
-------------------------+-------------------------
2026-01-15 07:00:00-05 | 2026-07-15 08:00:00-04同一个时区名,冬季偏移 -05、夏季偏移 -04(夏令时)。用固定偏移 -05 会在夏季出错,因此生产环境应使用 IANA 时区名而非固定偏移:
| 写法 | 是否处理夏令时 | 推荐度 |
|---|---|---|
'Asia/Shanghai' | 是(中国无夏令时) | 推荐 |
'America/New_York' | 是 | 推荐 |
'+08:00' | 否 | 仅表达固定偏移 |
'CST' | 歧义 | 禁止 |
查看可用时区名:
SELECT name, abbrev, utc_offset, is_dst
FROM pg_timezone_names
WHERE name IN ('Asia/Shanghai', 'America/New_York', 'Europe/London')
ORDER BY name; name | abbrev | utc_offset | is_dst
-----------------+--------+------------+--------
America/New_York | EDT | -04:00:00 | t
Asia/Shanghai | CST | 08:00:00 | f
Europe/London | BST | 01:00:00 | tAT TIME ZONE:双向转换的核心操作符
AT TIME ZONE 的行为取决于操作数类型,这是理解它的关键:
| 左侧类型 | 右侧 | 结果类型 | 语义 |
|---|---|---|---|
TIMESTAMPTZ | 时区 | TIMESTAMP | 按该时区换算成墙上时钟 |
TIMESTAMP | 时区 | TIMESTAMPTZ | 把该墙上时钟按该时区解释为时刻 |
TIMETZ | 时区 | TIMETZ | 转换时区 |
SET TimeZone TO 'Asia/Shanghai';
SELECT
TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'UTC' AS tstz_to_ts,
TIMESTAMP '2026-09-22 10:30:00' AT TIME ZONE 'Asia/Shanghai' AS ts_to_tstz,
TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'America/New_York' AS to_ny; tstz_to_ts | ts_to_tstz | to_ny
-----------------------+------------------------+----------------------
2026-09-22 02:30:00 | 2026-09-22 10:30:00+08 | 2026-09-21 22:30:00观察结果类型:
SELECT
pg_typeof(TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'UTC') AS left_is_tstz,
pg_typeof(TIMESTAMP '2026-09-22 10:30:00' AT TIME ZONE 'UTC') AS left_is_ts; left_is_tstz | left_is_ts
----------------+------------------------
timestamp | timestamp with time zone同一条表达式,左侧类型不同,结果类型相反。这是使用 AT TIME ZONE 时最容易踩的坑。
由于 PostgreSQL 的时区名大小写不敏感,AT TIME ZONE 'UTC' 与 AT TIME ZONE 'utc' 等价:
SELECT
TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'UTC' AS a,
TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'utc' AS b; a | b
--------------------+--------------------
2026-09-22 02:30:00 | 2026-09-22 02:30:00汇总统计的时区陷阱
date_trunc('day', ...) 与 extract 都按会话时区计算:
SET TimeZone TO 'UTC';
DROP TABLE IF EXISTS tz_orders;
CREATE TABLE tz_orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- 中国时间 2026-09-22 00:30 与 23:30 的两笔订单
INSERT INTO tz_orders (order_no, created_at) VALUES
('A-001', '2026-09-22 00:30:00+08'),
('A-002', '2026-09-22 23:30:00+08');-- 在 UTC 会话下按天分组
SELECT date_trunc('day', created_at)::date AS day, count(*) AS cnt
FROM tz_orders
GROUP BY 1 ORDER BY 1; day | cnt
------------+-----
2026-09-21 | 1
2026-09-22 | 1-- 切到中国时区后按天分组
SET TimeZone TO 'Asia/Shanghai';
SELECT date_trunc('day', created_at)::date AS day, count(*) AS cnt
FROM tz_orders
GROUP BY 1 ORDER BY 1; day | cnt
------------+-----
2026-09-22 | 2同一天在中国是 9 月 22 日的两笔订单,在 UTC 会话下被拆成了两天。这是"日报数字对不上"的典型原因。
正确做法是显式指定业务时区,不依赖会话设定:
SELECT
(created_at AT TIME ZONE 'Asia/Shanghai')::date AS business_day,
count(*) AS cnt
FROM tz_orders
GROUP BY 1 ORDER BY 1; business_day | cnt
--------------+-----
2026-09-22 | 2同一时刻的多种表示
同一时刻在不同时区的显示,验证 TIMESTAMPTZ 的"只存时刻"特性:
DROP TABLE IF EXISTS world_clock;
CREATE TABLE world_clock (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
city TEXT NOT NULL,
zone TEXT NOT NULL,
local_time TIMESTAMPTZ NOT NULL
);
-- 三个城市同一时刻的"当地时间"表示
INSERT INTO world_clock (city, zone, local_time) VALUES
('北京', 'Asia/Shanghai', '2026-09-22 10:30:00+08'),
('纽约', 'America/New_York', '2026-09-21 22:30:00-04'),
('伦敦', 'Europe/London', '2026-09-22 03:30:00+01');
SELECT city, zone, local_time,
local_time AT TIME ZONE zone AS local_wallclock
FROM world_clock ORDER BY id; city | zone | local_time | local_wallclock
------+------------------+------------------------+---------------------
北京 | Asia/Shanghai | 2026-09-22 10:30:00+08 | 2026-09-22 10:30:00
纽约 | America/New_York | 2026-09-21 22:30:00-04 | 2026-09-21 22:30:00
伦敦 | Europe/London | 2026-09-22 03:30:00+01 | 2026-09-22 03:30:00三行的 local_time 实际上是同一个时刻:
SELECT
count(DISTINCT local_time) AS distinct_instants,
min(local_time) = max(local_time) AS all_same,
count(*) AS rows
FROM world_clock; distinct_instants | all_same | rows
-------------------+----------+------
1 | t | 3排序与索引
TIMESTAMPTZ 的比较基于 UTC 时刻,因此排序结果与显示时区无关:
SET TimeZone TO 'UTC';
SELECT order_no, created_at FROM tz_orders ORDER BY created_at; order_no | created_at
----------+------------------------
A-001 | 2026-09-21 16:30:00+00
A-002 | 2026-09-22 15:30:00+00SET TimeZone TO 'Asia/Shanghai';
SELECT order_no, created_at FROM tz_orders ORDER BY created_at; order_no | created_at
----------+------------------------
A-001 | 2026-09-22 00:30:00+08
A-002 | 2026-09-22 23:30:00+08排序顺序完全一致,只是显示文本不同。这意味着 TIMESTAMPTZ 列上的 B-tree 索引在任何会话时区下都有效——这是它相对 TIMESTAMP 的一大优势(TIMESTAMP 的语义随会话时区解读而变,排序结果却固定,容易被误读)。
TIMETZ:为什么不推荐
TIME WITH TIME ZONE 存储"时间 + 固定偏移",但它没有日期,因此无法处理夏令时切换:
SELECT
TIMETZ '10:30:00+08',
TIMETZ '10:30:00+08' + INTERVAL '2 hours' AS plus_2h; timetz | plus_2h
------------+------------
10:30:00+08 | 12:30:00+08问题在于"某地 10:30 加 2 小时"在夏令时切换日可能不是 12:30。由于缺少日期信息,PostgreSQL 无法判断。
-- 对比:TIMESTAMPTZ 能正确处理夏令时
SET TimeZone TO 'America/New_York';
SELECT
TIMESTAMPTZ '2026-03-08 01:30:00-05' + INTERVAL '1 hour' AS after_dst; after_dst
-----------------------
2026-03-08 03:30:00-04美国 2026-03-08 是夏令时切换日,01:30 加了 1 小时后变成 03:30(跳过 02:00~03:00 区间)。TIMESTAMPTZ 处理正确,TIMETZ 做不到。
结论:TIMETZ 几乎不应使用。需要"每天某时刻"的语义时,用 TIME + TEXT 时区名 两列,或用 TIMESTAMP 加时区列。
时区数据的更新
PostgreSQL 的时区数据来自操作系统的 IANA 数据库,也可能内置。查看版本:
SELECT * FROM pg_timezone_abbrevs LIMIT 3; abbrev | utc_offset | is_dst
--------+------------+--------
ACST | 09:30:00 | f
ACT | -05:00:00 | f
ACWST | 08:45:00 | f某些国家会修改时区规则(如取消夏令时),需要更新 PostgreSQL 的时区数据:
# 从 IANA 下载最新 tzdata 后(PostgreSQL 内置副本)
# 编译安装时会自动更新,或使用系统 tzdata检查当前时区库版本:
SELECT version(); version
---------------------------------------------------------------------------------------------------------
PostgreSQL 18.0 on x86_64-pc-linux-gnu, compiled by gcc 13.2.0, 64-bit实战示例
以下示例在 demo 库中按顺序执行。
第 1 步:建事件表
SET TimeZone TO 'Asia/Shanghai';
DROP TABLE IF EXISTS user_events;
CREATE TABLE user_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
local_note TIMESTAMP, -- 对照:不带时区的"本地记录"
source_zone TEXT NOT NULL DEFAULT 'Asia/Shanghai'
);
INSERT INTO user_events (user_id, event_type, occurred_at, local_note, source_zone) VALUES
(1001, 'login', '2026-09-22 00:30:00+08', '2026-09-22 00:30:00', 'Asia/Shanghai'),
(1001, 'purchase','2026-09-22 23:30:00+08', '2026-09-22 23:30:00', 'Asia/Shanghai'),
(1002, 'login', '2026-09-21 22:30:00-04', '2026-09-21 22:30:00', 'America/New_York'),
(1002, 'logout', '2026-09-22 03:30:00+01', '2026-09-22 03:30:00', 'Europe/London');第 2 步:观察两种类型的存储与显示差异
SELECT
id,
event_type,
occurred_at,
local_note,
occurred_at AT TIME ZONE source_zone AS restored_local
FROM user_events
ORDER BY id; id | event_type | occurred_at | local_note | restored_local
----+------------+------------------------+---------------------+---------------------
1 | login | 2026-09-22 00:30:00+08 | 2026-09-22 00:30:00 | 2026-09-22 00:30:00
2 | purchase | 2026-09-22 23:30:00+08 | 2026-09-22 23:30:00 | 2026-09-22 23:30:00
3 | login | 2026-09-22 10:30:00+08 | 2026-09-21 22:30:00 | 2026-09-21 22:30:00
4 | logout | 2026-09-22 10:30:00+08 | 2026-09-22 03:30:00 | 2026-09-22 03:30:00关键观察:occurred_at 的第 3、4 行显示为 10:30:00+08,因为它们在 UTC 上等于北京 10:30。而 local_note 保留了各自的墙上时钟。AT TIME ZONE source_zone 能把 TIMESTAMPTZ 还原为原始墙上时钟。
第 3 步:验证同一时刻
-- UTC 上第 3、4 行与第 1、2 行的关系
SELECT
id,
occurred_at AT TIME ZONE 'UTC' AS utc_wallclock,
extract(epoch FROM occurred_at) AS epoch
FROM user_events
ORDER BY epoch; id | utc_wallclock | epoch
----+---------------------+--------------
1 | 2026-09-21 16:30:00 | 1789921800
3 | 2026-09-22 02:30:00 | 1789957800
4 | 2026-09-22 02:30:00 | 1789957800
2 | 2026-09-22 15:30:00 | 1789995000第 3、4 行的 epoch 完全相同(1789957800),确认是同一时刻。
第 4 步:跨时区"按业务日"聚合
SELECT
(occurred_at AT TIME ZONE 'Asia/Shanghai')::date AS cn_day,
count(*) AS cnt
FROM user_events
GROUP BY 1 ORDER BY 1; cn_day | cnt
------------+-----
2026-09-22 | 4SELECT
(occurred_at AT TIME ZONE 'America/New_York')::date AS ny_day,
count(*) AS cnt
FROM user_events
GROUP BY 1 ORDER BY 1; ny_day | cnt
------------+-----
2026-09-21 | 1
2026-09-22 | 3同一份数据,按上海"业务日"聚合得 1 天,按纽约得 2 天。业务日报必须绑定固定时区。
第 5 步:显示给不同时区的用户
SELECT
event_type,
to_char(occurred_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI:SS') AS cn_time,
to_char(occurred_at AT TIME ZONE 'America/New_York', 'YYYY-MM-DD HH24:MI:SS') AS ny_time,
to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS utc_time
FROM user_events
ORDER BY epoch OFFSET 0
LIMIT 2; event_type | cn_time | ny_time | utc_time
------------+--------------------+--------------------+--------------------
login | 2026-09-22 00:30:00 | 2026-09-21 12:30:00 | 2026-09-21 16:30:00
purchase | 2026-09-22 23:30:00 | 2026-09-22 11:30:00 | 2026-09-22 15:30:00第 6 步:时区敏感的范围查询
-- 查询"北京时间 2026-09-22 全天"的数据(半开区间,避免索引失效)
SELECT count(*) AS cnt
FROM user_events
WHERE occurred_at >= TIMESTAMPTZ '2026-09-22 00:00:00+08'
AND occurred_at < TIMESTAMPTZ '2026-09-23 00:00:00+08'; cnt
-----
4对比用 AT TIME ZONE 包裹列的错误写法:
-- 无法使用 occurred_at 上的索引
SELECT count(*) AS cnt
FROM user_events
WHERE (occurred_at AT TIME ZONE 'Asia/Shanghai')::date = '2026-09-22';第 7 步:date_trunc 的时区敏感性验证
SET TimeZone TO 'UTC';
SELECT
date_trunc('day', TIMESTAMPTZ '2026-09-22 00:30:00+08') AS utc_trunc; utc_trunc
-----------------------
2026-09-21 00:00:00+00SET TimeZone TO 'Asia/Shanghai';
SELECT
date_trunc('day', TIMESTAMPTZ '2026-09-22 00:30:00+08') AS cn_trunc; cn_trunc
-----------------------
2026-09-22 00:00:00+08同一个值,截断到不同的"天"。这再次说明聚合逻辑必须显式绑定时区。
第 8 步:用 AT TIME ZONE 后再截断(推荐模式)
SET TimeZone TO 'UTC'; -- 即使会话在 UTC,结果也正确
SELECT
(date_trunc('day', occurred_at AT TIME ZONE 'Asia/Shanghai'))::date AS cn_day,
count(*) AS cnt
FROM user_events
GROUP BY 1
ORDER BY 1; cn_day | cnt
------------+-----
2026-09-22 | 4occurred_at AT TIME ZONE 'Asia/Shanghai' 先把 TIMESTAMPTZ 转成"上海墙上时钟"(TIMESTAMP),再 date_trunc 就与会话时区无关了。
第 9 步:清理
DROP TABLE IF EXISTS user_events;
DROP TABLE IF EXISTS world_clock;
DROP TABLE IF EXISTS tz_orders;
RESET TimeZone;注意事项
1. TIMESTAMPTZ 不存储时区,只存 UTC 时刻
CREATE TABLE t (ts TIMESTAMPTZ);
INSERT INTO t VALUES ('2026-09-22 10:30:00+08');
SET TimeZone TO 'UTC';
SELECT ts FROM t; -- 显示 2026-09-22 02:30:00+00原始偏移 +08 没有被保存。若要保留"用户提交时的本地时间与时区",必须额外存储:
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
local_time TIMESTAMP NOT NULL, -- 用户本地墙上时钟
time_zone TEXT NOT NULL -- IANA 时区名
);2. 裸字面量依赖会话时区,跨环境结果不同
INSERT INTO t VALUES ('2026-09-22 10:30:00'); -- 危险应改为:
INSERT INTO t VALUES ('2026-09-22 10:30:00+08'); -- 显式偏移
INSERT INTO t VALUES ('2026-09-22 10:30:00 Asia/Shanghai'); -- 显式时区名3. AT TIME ZONE 的结果类型随左侧类型翻转
SELECT
pg_typeof(TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'UTC') AS a,
pg_typeof(TIMESTAMP '2026-09-22 10:30:00' AT TIME ZONE 'UTC') AS b; a | b
----------------+---------------------------
timestamp | timestamp with time zone4. 不要用固定偏移替代时区名
-- 在夏令时国家会出错
SET TimeZone TO 'America/New_York';
SELECT TIMESTAMPTZ '2026-01-15 12:00:00+00' AS winter,
TIMESTAMPTZ '2026-07-15 12:00:00+00' AS summer; winter | summer
-------------------------+-------------------------
2026-01-15 07:00:00-05 | 2026-07-15 08:00:00-04若把时区写成 '-05:00',夏季会差 1 小时。
5. date_trunc 与 extract 依赖会话时区
SET TimeZone TO 'UTC';
SELECT extract(day FROM TIMESTAMPTZ '2026-09-22 00:30:00+08') AS utc_day;
SET TimeZone TO 'Asia/Shanghai';
SELECT extract(day FROM TIMESTAMPTZ '2026-09-22 00:30:00+08') AS cn_day; utc_day
---------
21
cn_day
--------
22聚合、报表、分区裁剪都受此影响。显式 AT TIME ZONE 是唯一可靠做法。
6. CURRENT_DATE 依赖会话时区
SET TimeZone TO 'UTC';
SELECT CURRENT_DATE AS utc_date;
SET TimeZone TO 'America/New_York';
SELECT CURRENT_DATE AS ny_date; utc_date
------------
2026-09-22
ny_date
------------
2026-09-21跨零点时段相差一天。
7. TIMESTAMPTZ::date 与 ::timestamp::date 结果不同
SET TimeZone TO 'Asia/Shanghai';
SELECT
TIMESTAMPTZ '2026-09-22 00:30:00+08'::date AS direct_cast,
(TIMESTAMPTZ '2026-09-22 00:30:00+08' AT TIME ZONE 'UTC')::date AS via_utc; direct_cast | via_utc
-------------+---------
2026-09-22 | 2026-09-21::date 按会话时区换算后再取日期,AT TIME ZONE 'UTC' 则先固定到 UTC 墙上时钟。
8. TIMETZ 无法处理夏令时,应避免使用
SET TimeZone TO 'America/New_York';
SELECT TIMETZ '01:30:00-05' + INTERVAL '1 hour' AS tz_plus; tz_plus
-------------
02:30:00-05没有日期信息,无法判断是否跨越夏令时切换。用 TIMESTAMPTZ 得到正确结果 03:30:00-04。
9. 时区缩写有歧义
SELECT
'CST'::timetz AS cst,
TIMESTAMPTZ '2026-09-22 10:30:00 CST' AS ambiguous; cst | ambiguous
----------+-----------------------
10:30:00-06 | 2026-09-22 10:30:00-06PostgreSQL 把 CST 解释为 -06:00(美国中部标准时间),而非中国标准时间 +08:00。永远不要使用缩写。
10. SET TIME ZONE 与 SET TimeZone 不等价于 SET TIMEZONE
三种写法都有效,但推荐用 SQL 标准的 SET TIME ZONE:
SET TIME ZONE 'UTC';
SET TIME ZONE 'Asia/Shanghai';
SET TIME ZONE INTERVAL '+08:00' HOUR TO MINUTE;在存储过程中使用 SET LOCAL 可限定在当前事务:
BEGIN;
SET LOCAL TIME ZONE 'Asia/Shanghai';
SELECT CURRENT_DATE;
COMMIT; -- 事务结束后自动恢复最佳实践
1. 所有"时间点"字段统一用 TIMESTAMPTZ
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
paid_at TIMESTAMPTZ,
shipped_at TIMESTAMPTZ
);2. 在数据库或角色级固定时区,避免依赖客户端
ALTER DATABASE demo SET TIME ZONE 'Asia/Shanghai';
ALTER ROLE app_user SET TIME ZONE 'Asia/Shanghai';或在连接串中指定:
jdbc:postgresql://host:5432/demo?TimeZone=Asia/Shanghai3. 业务日聚合显式 AT TIME ZONE,不要依赖会话
-- 推荐:与部署环境无关
SELECT
(created_at AT TIME ZONE 'Asia/Shanghai')::date AS business_day,
count(*) AS cnt
FROM orders
GROUP BY 1 ORDER BY 1;
-- 不推荐:结果随会话时区变化
SELECT created_at::date AS day, count(*) FROM orders GROUP BY 1;4. 范围查询用带偏移的字面量 + 半开区间
SELECT * FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-09-01 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-10-01 00:00:00+08';5. 让客户端库处理时区,数据库只存 UTC 时刻
多数应用层框架(Java 的 OffsetDateTime、Python 的 datetime with tzinfo、JavaScript 的 Date)能正确处理 TIMESTAMPTZ:
# JDBC:用 OffsetDateTime 读取 TIMESTAMPTZ
OffsetDateTime ts = rs.getObject("created_at", OffsetDateTime.class);不要用 java.sql.Timestamp(按 JVM 默认时区解释,容易出错)。
6. 审计日志额外保留原始本地时间与时区
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
action VARCHAR(32) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
user_time TIMESTAMP NOT NULL,
user_tz TEXT NOT NULL,
CONSTRAINT chk_user_tz CHECK (user_tz ~ '^[A-Za-z_]+/[A-Za-z_]+$')
);7. 分区键用 TIMESTAMPTZ 时必须注意分区边界的时区语义
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY,
created_at TIMESTAMPTZ NOT NULL,
payload JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_09 PARTITION OF events
FOR VALUES FROM ('2026-09-01 00:00:00+08') TO ('2026-10-01 00:00:00+08');分区边界必须带显式偏移,否则会随会话时区漂移。
8. date_bin 处理固定长度时间桶(PostgreSQL 14+)
-- PostgreSQL 14+ 新增,按 15 分钟对齐
SET TimeZone TO 'Asia/Shanghai';
SELECT
date_bin('15 minutes', TIMESTAMPTZ '2026-09-22 10:37:00+08', TIMESTAMPTZ '2000-01-01 00:00:00+08') AS bucket,
date_bin('1 hour', TIMESTAMPTZ '2026-09-22 10:37:00+08', TIMESTAMPTZ '2000-01-01 00:00:00+08') AS hour_bucket; bucket | hour_bucket
---------------------+---------------------
2026-09-22 10:30:00+08 | 2026-09-22 10:00:00+08date_bin 与 date_trunc 的区别:date_bin 支持任意步长(如 15 分钟、7 天),date_trunc 只支持固定单位(y/m/d/h...)。注意 date_bin 的第三个参数是锚点,决定桶的起始对齐位置。
9. 展示给用户时一次性转换,不要在 SQL 里散落 AT TIME ZONE
-- 推荐:一列一次转换
SELECT
order_no,
to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI:SS') AS cn_created
FROM orders;
-- 不推荐:多次转换同一列,容易前后不一致
SELECT
to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS d,
to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'HH24:MI:SS') AS t,
date_trunc('day', created_at AT TIME ZONE 'Asia/Shanghai') AS bucket
FROM orders;10. 时区数据变更时的兼容性检查
当 IANA 时区库更新(如某国取消夏令时),历史数据的显示会变化:
-- 用固定偏移的快照表避免历史数据受规则变更影响
CREATE TABLE tz_snapshot (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
zone_name TEXT NOT NULL,
at_instant TIMESTAMPTZ NOT NULL,
utc_offset INTERVAL NOT NULL, -- 当时的实际偏移
PRIMARY KEY (zone_name, at_instant)
);
INSERT INTO tz_snapshot (zone_name, at_instant, utc_offset)
SELECT
'Asia/Shanghai',
now(),
(TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'UTC') - (TIMESTAMPTZ '2026-09-22 10:30:00+08' AT TIME ZONE 'Asia/Shanghai');下一节
继续学习:INTERVAL 时间间隔