PosygreSQL TIMESTAMP 和 TIMESTAMPTZ
在 PostgreSQL 中,TIMESTAMP 和 TIMESTAMPTZ 的区别是一个非常容易踩坑、但又非常重要的问题。
一句话结论(先记住)
PostgreSQL 只有 TIMESTAMPTZ 真正“懂时区”,TIMESTAMP 只是“带日期的字符串”。
| 类型 | 全名 | 是否存时区 | 本质 |
|---|---|---|---|
| TIMESTAMP | timestamp without time zone | ❌ 不存 | 纯日期 + 时间 |
| TIMESTAMPTZ | timestamp with time zone | ✅ 逻辑上有 | 存 UTC 时间点 |
⚠️ 关键点:
-
TIMESTAMPTZ 并不会存“+08:00”这样的时区字符串
-
它会:
- 输入时 -> 转成 UTC 存储
- 查询时 -> 按会话时区转回来显示
CREATE TABLE t1 (
ts TIMESTAMP
);
SET TIME ZONE 'Asia/Shanghai';
INSERT INTO t1 VALUES ('2025-01-01 12:00:00');
SET TIME ZONE 'UTC';
SELECT ts FROM t1;
结果仍然是:
2025-01-01 12:00:00
👉 原样存、原样取,不做任何转换
CREATE TABLE t2 (
ts TIMESTAMPTZ
);
SET TIME ZONE 'Asia/Shanghai';
INSERT INTO t2 VALUES ('2025-01-01 12:00:00');
SET TIME ZONE 'UTC';
SELECT ts FROM t2;
结果是:
2025-01-01 04:00:00+00
👉 插入时假定是上海时间 -> 转成 UTC -> 存储
SET TIME ZONE 'Asia/Shanghai';
SELECT now(); -- 2025-01-01 12:00:00+08
SET TIME ZONE 'UTC';
SELECT now(); -- 2025-01-01 04:00:00+00
- 同一瞬间
- 不同显示
- 底层 UTC 时间点一致
| 维度 | TIMESTAMP | TIMESTAMPTZ |
|---|---|---|
| 是否时区感知 | ❌ | ✅ |
| 存储内容 | 字面时间 | UTC 时间点 |
| 自动转换 | ❌ | ✅ |
| 跨时区安全 | ❌ | ✅ |
| 推荐程度 | ⚠️ 谨慎 | ✅ 强烈推荐 |
凡是“某个真实发生的时间点”
- 创建时间 / 更新时间
- 日志时间
- 订单时间
- 用户行为时间
- API 请求时间
- 事件发生时间
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
✅ 这是 PostgreSQL 的最佳实践
仅表示“日历时间,不代表某一瞬间”
- 每天 09:00 上班
- 生日(不关心时区)
- 定时任务配置时间
- 课程开始时间(固定本地时间)
work_start_time TIMESTAMP
- 多时区用户
- 数据对不上
- 跨时区统计全乱
👉 必错
SELECT ts FROM table;
看到 +00 就慌了?
👉 这是显示时区,不是丢了信息
INSERT INTO t VALUES ('2025-01-01T04:00:00Z');
但会话时区是 Asia/Shanghai
👉 会被当成“本地时间”再转一次
✅ 正确方式:
- 前端发 UTC
- 后端设置 SET TIME ZONE ‘UTC’
- 或使用 TIMESTAMPTZ + 明确时区
| 数据库 | 类型 | 行为 |
|---|---|---|
| MySQL | TIMESTAMP | 会自动转时区 |
| MySQL | DATETIME | 不转 |
| PostgreSQL | TIMESTAMP | 不转 |
| PostgreSQL | TIMESTAMPTZ | 自动转(推荐) |
👉 PostgreSQL 把“是否带时区”区分得更清晰
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
SET TIME ZONE 'UTC';
- 数据库存 UTC
- 前端按用户时区展示
这是一个非常关键、也是日志系统最容易踩坑的问题。结论先行:
日志系统如果不用 TIMESTAMPTZ,迟早一定出事故。
日志记录的不是「一个时间字符串」,而是:
某一个“真实发生的时间点(Instant)”
这个时间点具有 3 个特征:
- 全世界唯一
- 与地理位置无关
- 可排序、可对齐
👉 这正是 TIMESTAMPTZ 的语义
- 北京服务:2025-01-01 10:00:00
- 纽约服务:2025-01-01 10:00:00
如果是 TIMESTAMP:
2025-01-01 10:00:00 北京
2025-01-01 10:00:00 纽约
- 👉 这两条日志根本不是同一时刻
- 👉 却被当成同一时间
排序、聚合、因果分析全部失真
你做:
ORDER BY created_at;
但:
-
北京机器是 UTC+8
-
服务器是 UTC
-
容器是 UTC
-
某台机器时区被人改过
-
👉 时间线是乱的
-
👉 排出来的“因果顺序是假的”
纽约时间:
2025-11-02 01:30
这一天:
- 01:00 ~ 02:00 出现两次
用 TIMESTAMP:
01:30 是哪一次?
- 👉 根本无法区分
- 👉 日志出现“时间倒流”
TIMESTAMPTZ:
- ✅ 自动转成 UTC
- ✅ 不存在二义性
SELECT extract(epoch FROM now());
-> 本质是一个全球唯一的时间轴位置
SET TIME ZONE 'Asia/Shanghai';
SELECT created_at FROM logs;
SET TIME ZONE 'UTC';
SELECT created_at FROM logs;
👉 同一条日志,不同视角
| 系统 | 时间 |
|---|---|
| Nginx | UTC |
| Kubernetes | UTC |
| PostgreSQL | UTC |
| Prometheus | UTC |
| OpenTelemetry | UTC |
👉 天然对齐
SELECT *
FROM logs
WHERE created_at BETWEEN
'2025-01-01 00:00:00+00'
AND
'2025-01-01 01:00:00+00';
- ✅ 不受服务器时区影响
- ✅ 不受客户端时区影响
SELECT date_trunc('hour', created_at), count(*)
FROM logs
GROUP BY 1;
- TIMESTAMPTZ:按真实时间轴
- TIMESTAMP:按“机器心情”
A 服务发请求 -> B 服务处理 -> C 服务写库
👉 只有 UTC 时间线 才能正确串起来
所有大型日志系统,最终都会统一成 UTC + TIMESTAMPTZ
包括但不限于:
- ELK
- Loki
- ClickHouse
- Datadog
- New Relic
- OpenTelemetry
不统一 -> 一定会踩坑
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
ALTER DATABASE yourdb SET TIME ZONE 'UTC';
- 所有日志时间 = UTC
- 不做本地时间计算
- 展示时才转时区
{
"timestamp": "2025-01-01T04:00:00Z"
}
日志 = 时间轴上的事件
时间轴只能有一个:UTC
PostgreSQL 里,只有 TIMESTAMPTZ 做得到
下面这份是真正可落地、能扛高并发、可长期运维的 PostgreSQL 日志表结构设计方案。
按 设计目标 -> 表结构 -> 索引 -> 分区 -> 写入优化 -> 查询优化 -> 常见坑 的顺序来讲。
场景假设:
- 每秒 1 万 ~ 10 万 日志写入
- 日志只追加(Append Only)
- 强查询:按时间 / 服务 / 关键字
- 日志量:亿级
- PostgreSQL 17+
日志表设计的核心目标只有 5 个:
- 写入快(最重要)
- 时间有序
- 冷热数据可管理
- 查询可控
- 可自动清理
👉 任何影响写入性能的设计,都要慎用。
✅ 推荐字段(最小可用集)
CREATE TABLE logs (
id BIGINT GENERATED ALWAYS AS IDENTITY,
ts TIMESTAMPTZ NOT NULL, -- 真实发生时间(UTC)
level SMALLINT NOT NULL, -- 日志级别(枚举化)
service TEXT NOT NULL, -- 服务名
host TEXT, -- 主机 / Pod
trace_id TEXT, -- 链路追踪
message TEXT NOT NULL, -- 日志正文
extra JSONB, -- 扩展字段
PRIMARY KEY (id, ts)
) PARTITION BY RANGE (ts);
为什么这样设计?
| 字段 | 原因 |
|---|---|
| BIGINT id | 顺序写,减少索引分裂 |
| TIMESTAMPTZ ts | 时间轴唯一 |
| SMALLINT level | 比 TEXT 快、可排序 |
| JSONB extra | 灵活但可控 |
| 主键包含 ts | 分区键必须在主键中 |
level TEXT -- INFO / ERROR / DEBUG
✅ 正确
-- 应用层枚举
0 = DEBUG
1 = INFO
2 = WARN
3 = ERROR
4 = FATAL
level SMALLINT NOT NULL
- 更快
- 更小
- 更好索引
分区粒度怎么选?
| 写入量 | 推荐 |
|---|---|
| < 1000/s | 按天 |
| 1000~1万/s | 按天 |
| 1万~10万/s | 按小时 |
| >10万/s | 小时 + 分库 |
示例:按天分区
CREATE TABLE logs (
...
) PARTITION BY RANGE (ts);
CREATE TABLE logs_2025_01_01
PARTITION OF logs
FOR VALUES FROM ('2025-01-01') TO ('2025-01-02');
优点
- INSERT 命中单一分区
- VACUUM / DELETE 不锁全表
- 可快速 DROP 历史数据
❗ 高并发写入时的索引原则
索引越少越好
-- 时间范围查询
CREATE INDEX ON logs USING BRIN (ts);
-- 服务 + 时间
CREATE INDEX ON logs (service, ts DESC);
-- 错误日志
CREATE INDEX ON logs (level, ts DESC);
| 行为 | 原因 |
|---|---|
| 全字段 BTree | 写入慢 |
| message LIKE 索引 | 爆炸 |
| GIN(JSONB) 全索引 | 写入极慢 |
WHERE message LIKE '%error%'
ALTER TABLE logs
ADD COLUMN message_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', message)) STORED;
CREATE INDEX ON logs USING GIN (message_tsv);
查询:
WHERE message_tsv @@ to_tsquery('error')
INSERT INTO logs (...) VALUES
(...),
(...),
(...);
✅ 比单条快 10~100 倍
ALTER SYSTEM SET synchronous_commit = off;
👉 允许极小概率丢日志,换吞吐
COPY logs (ts, level, service, message)
FROM STDIN;
ALTER SYSTEM SET wal_compression = on;
DELETE FROM logs WHERE ts < now() - interval '30 days';
DROP TABLE logs_2024_11_01;
- 无 WAL
- 无锁
- 瞬间完成
应用
↓
日志 SDK(批量)
↓
PostgreSQL(热数据 7 天)
↓
定期导出
↓
ClickHouse / S3(冷数据)
日志表 = 只写 + 时间分区 + 少索引
TIMESTAMPTZ + RANGE 分区 是底线
DELETE 日志 = 自杀
DROP 分区 = 正道
结合 写入性能、查询模式、分区裁剪(partition pruning) 来讲,而不是只列索引。
日志表的索引设计原则只有一句话:
每个分区只保留“时间相关 + 高频过滤”的最少索引
时间分区 ≠ 多建索引
👉 分区解决的是「扫描范围」,索引只解决「分区内过滤」。
CREATE TABLE logs (
id BIGINT GENERATED ALWAYS AS IDENTITY,
ts TIMESTAMPTZ NOT NULL,
level SMALLINT NOT NULL,
service TEXT NOT NULL,
host TEXT,
trace_id TEXT,
message TEXT,
extra JSONB,
PRIMARY KEY (id, ts)
) PARTITION BY RANGE (ts);
索引优先级排序(从高到低)
- 1️⃣ 时间范围查询
- 2️⃣ 服务 / 系统 / 模块 + 时间
- 3️⃣ 错误级别 + 时间
- 4️⃣ 全文搜索(可选)
- 5️⃣ 追踪 ID(trace_id)
CREATE INDEX ON logs USING BRIN (ts);
为什么用 BRIN?
| 原因 | 说明 |
|---|---|
| 极小 | MB 级 |
| 写入快 | 几乎无维护成本 |
| 时间有序 | 日志天然顺序写 |
✅ 分区 + BRIN = 亿级日志标配
CREATE INDEX ON logs (service, ts DESC);
适用查询
WHERE service = 'order'
AND ts >= now() - interval '1 hour'
为什么 ts 放后面?
- service 先过滤
- ts 用于排序 + 范围扫描
CREATE INDEX ON logs (level, ts DESC);
典型查询
WHERE level >= 3
AND ts >= now() - interval '10 minutes'
CREATE INDEX ON logs (message);
原因:
- TEXT 超长
- 索引巨大
- 几乎没法命中
CREATE INDEX ON logs USING GIN (extra);
原因:
- GIN 写入慢
- WAL 暴增
- 只在少量字段查询才值得
ALTER TABLE logs
ADD COLUMN message_tsv tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', message)
) STORED;
CREATE INDEX ON logs USING GIN (message_tsv);
查询:
WHERE message_tsv @@ to_tsquery('timeout')
- ✅ 比 LIKE 快几个数量级
- ✅ 可控
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX ON logs USING GIN (message gin_trgm_ops);
⚠️ 只建议:
- 小分区
- 少量字段
- 中文模糊搜索
CREATE INDEX ON logs (trace_id);
仅在你确定 trace_id 会被频繁查时才建。
关键点:索引要建在父表上
CREATE INDEX idx_logs_service_ts
ON logs (service, ts DESC);
PostgreSQL 会:
- 自动在 新分区 上创建索引
- 避免你手动维护
每个分区最终只保留 3~5 个索引
-- 时间
BRIN (ts)
-- 业务维度
BTREE (service, ts DESC)
BTREE (level, ts DESC)
-- 可选
GIN (message_tsv)
BTREE (trace_id)
正确姿势
Append
-> Bitmap Heap Scan on logs_2025_01_01
Index Cond: (service = 'api')
❌ 错误信号
Seq Scan on logs
👉 表示:
- 分区没裁剪
- 索引设计有问题
- 查询条件写错
时间分区解决“扫哪里”,索引只解决“怎么扫”
BRIN 管时间,BTREE 管业务
索引宁少勿多
DELETE 日志是原罪,DROP 分区是正道
下面这部分内容非常关键,也是很多人「用了 TimescaleDB 却没真正用对」的地方。
TimescaleDB 的时间列必须用 TIMESTAMPTZ,不是建议,是事实标准
如果你用:
- TIMESTAMP -> ❌ 风险极高
- TIMESTAMPTZ -> ✅ 完全契合 TimescaleDB 设计
TimescaleDB 的核心不是“表”,而是:
基于时间轴(Time Axis)的数据引擎
它内部做了 4 件事:
- 时间分区(time chunk)
- 时间裁剪(chunk pruning)
- 时间压缩(ordered by time)
- 时间聚合(time_bucket)
👉 这些都假设:
时间是单调、连续、无歧义的
这正是 TIMESTAMPTZ 的特性。
TIMESTAMP '2025-01-01 10:00:00'
- 不是 UTC
- 不是本地时间
- 是“悬空时间”
TimescaleDB 不知道这个时间点在全球时间轴上的位置
SELECT create_hypertable('metrics', 'ts');
TimescaleDB 按 ts 划 chunk。
如果 ts 是 TIMESTAMP:
- 不同时区写入
- chunk 边界混乱
- chunk overlap
- chunk pruning 失效
👉 性能直接下降一个数量级
TimescaleDB 压缩默认:
ORDER BY ts
如果 ts 不是 UTC:
- 顺序错误
- 压缩比下降
- 解压查询变慢
time_bucket('5 min', ts)
如果 ts 是 TIMESTAMP:
- 跨时区 bucket 不对齐
- DST 切换直接错位
内部存储方式(关键)
TIMESTAMPTZ = UTC 时间戳 + 时区转换规则
- 存储:UTC
- 显示:会话时区
- 排序:绝对正确
👉 TimescaleDB 需要的正是「UTC 时间轴」
2025-01-01T00:00:00Z
2025-01-01T01:00:00Z
2025-01-01T02:00:00Z
无歧义、无回退、无重复。
CREATE TABLE metrics (
ts TIMESTAMPTZ NOT NULL,
device_id TEXT NOT NULL,
cpu DOUBLE PRECISION,
mem DOUBLE PRECISION
);
SELECT create_hypertable(
'metrics',
'ts',
chunk_time_interval => INTERVAL '1 day'
);
推荐写法(UTC)
INSERT INTO metrics (ts, device_id, cpu)
VALUES (now(), 'node-1', 0.82);
或:
INSERT INTO metrics (ts, device_id, cpu)
VALUES ('2025-01-01T08:00:00Z', 'node-1', 0.82);
INSERT INTO metrics (ts)
VALUES ('2025-01-01 08:00:00');
- 没时区
- 会按 session timezone 猜
- 不可控
SET TIME ZONE 'Asia/Shanghai';
SELECT ts FROM metrics LIMIT 1;
SET TIME ZONE 'UTC';
SELECT ts FROM metrics LIMIT 1;
👉 同一条数据,不同时区视角。
| 项目 | 官方默认 |
|---|---|
| 时间列 | TIMESTAMPTZ |
| 存储 | UTC |
| chunk | UTC 对齐 |
| time_bucket | UTC 计算 |
你可以不用,但你踩的坑,官方不会兜底
| 场景 | 时间类型 |
|---|---|
| 日志 | TIMESTAMPTZ |
| 监控 | TIMESTAMPTZ |
| 事件 | TIMESTAMPTZ |
| 聚合 | TIMESTAMPTZ |
👉 永远不要混用
TimescaleDB 是“时间轴数据库”
TIMESTAMPTZ 是“唯一合法时间类型”
不用 = 自毁性能 + 埋雷