Skip to content

TIMESTAMPTZ 详解 ​

本节深入讲解 PostgreSQL 的 TIMESTAMPTZ(TIMESTAMP WITH TIME ZONE)类型。你将理解它的内部存储模型、显示换算规则、与 TIMESTAMP 的本质区别,以及跨时区业务中正确使用它的方法。

前置知识

阅读本节前,建议先了解:DATE / TIME / TIMESTAMP

基础概念 ​

TIMESTAMPTZ 是 PostgreSQL 最容易被误解的类型之一,根源在于它的名字。

类型名存储空间内部表示显示依据说明
TIMESTAMP8 字节本地日期时间(无时区)原样输出墙上时钟时间
TIMESTAMPTZ8 字节UTC 微秒数(自 2000-01-01)会话 TimeZone绝对时间点
TIMETZ12 字节时间 + 固定偏移原样输出不推荐使用

关键术语:

  • 时刻(Instant):时间轴上一个绝对点,与观察者所在时区无关。
  • 墙上时钟(Wall Clock):某个时区里挂钟显示的时间。
  • 会话时区(Session TimeZone):TimeZone 参数,决定 TIMESTAMPTZ 输入解释与输出显示。
  • UTC 偏移(UTC Offset):本地时间与 UTC 的差值,如 +08。
  • 时区缩写(Time Zone Abbreviation):如 CST,含义歧义(中国标准时间/美国中部时间),不应依赖。

核心事实:TIMESTAMPTZ 不存储时区。它只存一个 UTC 时刻,输出时按会话时区换算成带偏移的文本。

sql
-- 存入同一时刻的两种写法,结果完全相同
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;
text
     from_beijing      |      from_utc       | equal
-----------------------+---------------------+-------
 2026-09-22 10:30:00+08 | 2026-09-22 10:30:00+08 | t

两条不同的字面量指向同一时刻,因此相等,且都按当前会话时区(+08)显示。

语法与用法 ​

text
-- 类型声明
列名 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)       -- 截断(按会话时区)

最简可运行示例:

sql
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;
text
           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
TIMESTAMPTZUTC 微秒数2000-01-01 00:00:00 UTC

用 epoch 验证两者在同一时刻下的差异:

sql
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;
text
   ts_epoch   |  tstz_epoch  |  ts_equiv
--------------+--------------+------------
 1789967400   | 1789967400   | 1789967400

关键差异是:把 TIMESTAMPTZ 转为 TIMESTAMP 时需要指定用哪个时区解释:

sql
SET TimeZone TO 'Asia/Shanghai';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00+08'::timestamp AS cast_to_ts;
text
     cast_to_ts
---------------------
 2026-09-22 10:30:00
sql
SET TimeZone TO 'UTC';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00+08'::timestamp AS cast_to_ts;
text
     cast_to_ts
---------------------
 2026-09-22 02:30:00

同一个 TIMESTAMPTZ 值,在不同的会话时区下 ::timestamp 得到不同的墙上时钟。这正是 TIMESTAMPTZ::timestamp 转换必须谨慎的原因。

输入解释:会话时区决定"裸字面量"含义 ​

不带偏移的字面量会按会话时区解释:

sql
SET TimeZone TO 'Asia/Shanghai';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00' AS cn_session;
text
     cn_session
-----------------------
 2026-09-22 10:30:00+08
sql
SET TimeZone TO 'UTC';
SELECT TIMESTAMPTZ '2026-09-22 10:30:00' AS utc_session;
text
     utc_session
-----------------------
 2026-09-22 10:30:00+00

同一条 SQL 在不同会话时区下存入了不同的时刻(相差 8 小时)。这解释了为什么 TIMESTAMPTZ 的裸字面量在生产代码中应当避免:

sql
-- 危险:含义取决于会话时区
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');

输出显示:会话时区决定显示文本 ​

sql
SELECT now() AS t;
sql
SET TimeZone TO 'Asia/Shanghai';
SELECT now() AS cn_view;
text
           cn_view
------------------------------
 2026-09-22 10:30:00.123456+08
sql
SET TimeZone TO 'America/New_York';
SELECT now() AS ny_view;
text
           ny_view
------------------------------
 2026-09-21 22:30:00.123456-04

同一行数据,在纽约会话下显示为前一天 22:30。日期部分都变了,这是跨时区报表最常见的问题来源。

时区名与固定偏移的区别:

sql
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;
text
         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'歧义禁止

查看可用时区名:

sql
SELECT name, abbrev, utc_offset, is_dst
FROM pg_timezone_names
WHERE name IN ('Asia/Shanghai', 'America/New_York', 'Europe/London')
ORDER BY name;
text
      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   | t

AT TIME ZONE:双向转换的核心操作符 ​

AT TIME ZONE 的行为取决于操作数类型,这是理解它的关键:

左侧类型右侧结果类型语义
TIMESTAMPTZ时区TIMESTAMP按该时区换算成墙上时钟
TIMESTAMP时区TIMESTAMPTZ把该墙上时钟按该时区解释为时刻
TIMETZ时区TIMETZ转换时区
sql
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;
text
      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

观察结果类型:

sql
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;
text
  left_is_tstz  |   left_is_ts
----------------+------------------------
 timestamp      | timestamp with time zone

同一条表达式,左侧类型不同,结果类型相反。这是使用 AT TIME ZONE 时最容易踩的坑。

由于 PostgreSQL 的时区名大小写不敏感,AT TIME ZONE 'UTC' 与 AT TIME ZONE 'utc' 等价:

sql
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;
text
        a           |        b
--------------------+--------------------
 2026-09-22 02:30:00 | 2026-09-22 02:30:00

汇总统计的时区陷阱 ​

date_trunc('day', ...) 与 extract 都按会话时区计算:

sql
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');
sql
-- 在 UTC 会话下按天分组
SELECT date_trunc('day', created_at)::date AS day, count(*) AS cnt
FROM tz_orders
GROUP BY 1 ORDER BY 1;
text
    day     | cnt
------------+-----
 2026-09-21 |   1
 2026-09-22 |   1
sql
-- 切到中国时区后按天分组
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;
text
    day     | cnt
------------+-----
 2026-09-22 |   2

同一天在中国是 9 月 22 日的两笔订单,在 UTC 会话下被拆成了两天。这是"日报数字对不上"的典型原因。

正确做法是显式指定业务时区,不依赖会话设定:

sql
SELECT
  (created_at AT TIME ZONE 'Asia/Shanghai')::date AS business_day,
  count(*)                                          AS cnt
FROM tz_orders
GROUP BY 1 ORDER BY 1;
text
 business_day | cnt
--------------+-----
 2026-09-22   |   2

同一时刻的多种表示 ​

同一时刻在不同时区的显示,验证 TIMESTAMPTZ 的"只存时刻"特性:

sql
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;
text
 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 实际上是同一个时刻:

sql
SELECT
  count(DISTINCT local_time)                          AS distinct_instants,
  min(local_time) = max(local_time)                   AS all_same,
  count(*)                                            AS rows
FROM world_clock;
text
 distinct_instants | all_same | rows
-------------------+----------+------
                 1 | t        |    3

排序与索引 ​

TIMESTAMPTZ 的比较基于 UTC 时刻,因此排序结果与显示时区无关:

sql
SET TimeZone TO 'UTC';
SELECT order_no, created_at FROM tz_orders ORDER BY created_at;
text
 order_no |       created_at
----------+------------------------
 A-001    | 2026-09-21 16:30:00+00
 A-002    | 2026-09-22 15:30:00+00
sql
SET TimeZone TO 'Asia/Shanghai';
SELECT order_no, created_at FROM tz_orders ORDER BY created_at;
text
 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 存储"时间 + 固定偏移",但它没有日期,因此无法处理夏令时切换:

sql
SELECT
  TIMETZ '10:30:00+08',
  TIMETZ '10:30:00+08' + INTERVAL '2 hours' AS plus_2h;
text
   timetz   |  plus_2h
------------+------------
 10:30:00+08 | 12:30:00+08

问题在于"某地 10:30 加 2 小时"在夏令时切换日可能不是 12:30。由于缺少日期信息,PostgreSQL 无法判断。

sql
-- 对比:TIMESTAMPTZ 能正确处理夏令时
SET TimeZone TO 'America/New_York';
SELECT
  TIMESTAMPTZ '2026-03-08 01:30:00-05' + INTERVAL '1 hour' AS after_dst;
text
      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 数据库,也可能内置。查看版本:

sql
SELECT * FROM pg_timezone_abbrevs LIMIT 3;
text
 abbrev | utc_offset | is_dst
--------+------------+--------
 ACST   | 09:30:00   | f
 ACT    | -05:00:00  | f
 ACWST  | 08:45:00   | f

某些国家会修改时区规则(如取消夏令时),需要更新 PostgreSQL 的时区数据:

bash
# 从 IANA 下载最新 tzdata 后(PostgreSQL 内置副本)
# 编译安装时会自动更新,或使用系统 tzdata

检查当前时区库版本:

sql
SELECT version();
text
                                                 version
---------------------------------------------------------------------------------------------------------
 PostgreSQL 18.0 on x86_64-pc-linux-gnu, compiled by gcc 13.2.0, 64-bit

实战示例 ​

以下示例在 demo 库中按顺序执行。

第 1 步:建事件表

sql
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 步:观察两种类型的存储与显示差异

sql
SELECT
  id,
  event_type,
  occurred_at,
  local_note,
  occurred_at AT TIME ZONE source_zone AS restored_local
FROM user_events
ORDER BY id;
text
 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 步:验证同一时刻

sql
-- 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;
text
 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 步:跨时区"按业务日"聚合

sql
SELECT
  (occurred_at AT TIME ZONE 'Asia/Shanghai')::date AS cn_day,
  count(*)                                          AS cnt
FROM user_events
GROUP BY 1 ORDER BY 1;
text
   cn_day   | cnt
------------+-----
 2026-09-22 |   4
sql
SELECT
  (occurred_at AT TIME ZONE 'America/New_York')::date AS ny_day,
  count(*)                                            AS cnt
FROM user_events
GROUP BY 1 ORDER BY 1;
text
   ny_day   | cnt
------------+-----
 2026-09-21 |   1
 2026-09-22 |   3

同一份数据,按上海"业务日"聚合得 1 天,按纽约得 2 天。业务日报必须绑定固定时区。

第 5 步:显示给不同时区的用户

sql
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;
text
 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 步:时区敏感的范围查询

sql
-- 查询"北京时间 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';
text
 cnt
-----
   4

对比用 AT TIME ZONE 包裹列的错误写法:

sql
-- 无法使用 occurred_at 上的索引
SELECT count(*) AS cnt
FROM user_events
WHERE (occurred_at AT TIME ZONE 'Asia/Shanghai')::date = '2026-09-22';

第 7 步:date_trunc 的时区敏感性验证

sql
SET TimeZone TO 'UTC';
SELECT
  date_trunc('day', TIMESTAMPTZ '2026-09-22 00:30:00+08') AS utc_trunc;
text
      utc_trunc
-----------------------
 2026-09-21 00:00:00+00
sql
SET TimeZone TO 'Asia/Shanghai';
SELECT
  date_trunc('day', TIMESTAMPTZ '2026-09-22 00:30:00+08') AS cn_trunc;
text
      cn_trunc
-----------------------
 2026-09-22 00:00:00+08

同一个值,截断到不同的"天"。这再次说明聚合逻辑必须显式绑定时区。

第 8 步:用 AT TIME ZONE 后再截断(推荐模式)

sql
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;
text
   cn_day   | cnt
------------+-----
 2026-09-22 |   4

occurred_at AT TIME ZONE 'Asia/Shanghai' 先把 TIMESTAMPTZ 转成"上海墙上时钟"(TIMESTAMP),再 date_trunc 就与会话时区无关了。

第 9 步:清理

sql
DROP TABLE IF EXISTS user_events;
DROP TABLE IF EXISTS world_clock;
DROP TABLE IF EXISTS tz_orders;
RESET TimeZone;

注意事项 ​

1. TIMESTAMPTZ 不存储时区,只存 UTC 时刻

sql
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 没有被保存。若要保留"用户提交时的本地时间与时区",必须额外存储:

sql
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. 裸字面量依赖会话时区,跨环境结果不同

sql
INSERT INTO t VALUES ('2026-09-22 10:30:00');   -- 危险

应改为:

sql
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 的结果类型随左侧类型翻转

sql
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;
text
       a        |             b
----------------+---------------------------
 timestamp      | timestamp with time zone

4. 不要用固定偏移替代时区名

sql
-- 在夏令时国家会出错
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;
text
         winter          |         summer
-------------------------+-------------------------
 2026-01-15 07:00:00-05  | 2026-07-15 08:00:00-04

若把时区写成 '-05:00',夏季会差 1 小时。

5. date_trunc 与 extract 依赖会话时区

sql
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;
text
 utc_day
---------
      21

 cn_day
--------
      22

聚合、报表、分区裁剪都受此影响。显式 AT TIME ZONE 是唯一可靠做法。

6. CURRENT_DATE 依赖会话时区

sql
SET TimeZone TO 'UTC';
SELECT CURRENT_DATE AS utc_date;
SET TimeZone TO 'America/New_York';
SELECT CURRENT_DATE AS ny_date;
text
 utc_date
------------
 2026-09-22

 ny_date
------------
 2026-09-21

跨零点时段相差一天。

7. TIMESTAMPTZ::date 与 ::timestamp::date 结果不同

sql
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;
text
 direct_cast | via_utc
-------------+---------
 2026-09-22  | 2026-09-21

::date 按会话时区换算后再取日期,AT TIME ZONE 'UTC' 则先固定到 UTC 墙上时钟。

8. TIMETZ 无法处理夏令时,应避免使用

sql
SET TimeZone TO 'America/New_York';
SELECT TIMETZ '01:30:00-05' + INTERVAL '1 hour' AS tz_plus;
text
  tz_plus
-------------
 02:30:00-05

没有日期信息,无法判断是否跨越夏令时切换。用 TIMESTAMPTZ 得到正确结果 03:30:00-04。

9. 时区缩写有歧义

sql
SELECT
  'CST'::timetz AS cst,
  TIMESTAMPTZ '2026-09-22 10:30:00 CST' AS ambiguous;
text
   cst    |      ambiguous
----------+-----------------------
 10:30:00-06 | 2026-09-22 10:30:00-06

PostgreSQL 把 CST 解释为 -06:00(美国中部标准时间),而非中国标准时间 +08:00。永远不要使用缩写。

10. SET TIME ZONE 与 SET TimeZone 不等价于 SET TIMEZONE

三种写法都有效,但推荐用 SQL 标准的 SET TIME ZONE:

sql
SET TIME ZONE 'UTC';
SET TIME ZONE 'Asia/Shanghai';
SET TIME ZONE INTERVAL '+08:00' HOUR TO MINUTE;

在存储过程中使用 SET LOCAL 可限定在当前事务:

sql
BEGIN;
SET LOCAL TIME ZONE 'Asia/Shanghai';
SELECT CURRENT_DATE;
COMMIT;   -- 事务结束后自动恢复

最佳实践 ​

1. 所有"时间点"字段统一用 TIMESTAMPTZ

sql
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. 在数据库或角色级固定时区,避免依赖客户端

sql
ALTER DATABASE demo SET TIME ZONE 'Asia/Shanghai';
ALTER ROLE app_user SET TIME ZONE 'Asia/Shanghai';

或在连接串中指定:

text
jdbc:postgresql://host:5432/demo?TimeZone=Asia/Shanghai

3. 业务日聚合显式 AT TIME ZONE,不要依赖会话

sql
-- 推荐:与部署环境无关
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. 范围查询用带偏移的字面量 + 半开区间

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

text
# JDBC:用 OffsetDateTime 读取 TIMESTAMPTZ
OffsetDateTime ts = rs.getObject("created_at", OffsetDateTime.class);

不要用 java.sql.Timestamp(按 JVM 默认时区解释,容易出错)。

6. 审计日志额外保留原始本地时间与时区

sql
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 时必须注意分区边界的时区语义

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

sql
-- 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;
text
       bucket        |     hour_bucket
---------------------+---------------------
 2026-09-22 10:30:00+08 | 2026-09-22 10:00:00+08

date_bin 与 date_trunc 的区别:date_bin 支持任意步长(如 15 分钟、7 天),date_trunc 只支持固定单位(y/m/d/h...)。注意 date_bin 的第三个参数是锚点,决定桶的起始对齐位置。

9. 展示给用户时一次性转换,不要在 SQL 里散落 AT TIME ZONE

sql
-- 推荐:一列一次转换
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 时区库更新(如某国取消夏令时),历史数据的显示会变化:

sql
-- 用固定偏移的快照表避免历史数据受规则变更影响
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 时间间隔

参考链接 ​