PostgreSQL 提供多种“当前时间”。事务时间标记事务开始,语句时间标记客户端命令到达,墙钟读数则在表达式求值时读取当前时钟。选择不同函数,会改变审计字段、过期检查和长任务中间观测的含义。应先明确要记录哪个事件,再决定函数,不能因为名称都有时间含义就随意互换。
一个事务只有一个开始时刻
now()、transaction_timestamp() 与不带精度参数的 CURRENT_TIMESTAMP,返回同一个事务开始瞬间。在显式事务里,它们始终固定,即使后续语句已经晚了几秒或几分钟。CURRENT_TIMESTAMP 可以指定精度来舍入结果;小数位数的处理与采样时刻是两回事,不能因为显示精度不同就推断时钟来源改变。
这种稳定性适合让相关行共享同一个逻辑事务时间。它不记录每行真正插入的时刻,也不是最终提交时间。事务可能先等待锁或执行大量计算,随后才插入数据,最后还有可能回滚。把列命名为 created_at,并不能自动说明默认值到底代表哪个事件,需要在数据契约里写清楚。
普通自动提交模式下,连续语句通常各自处于不同事务,因此 now 看起来会随每条语句前进。但这种观察没有改变函数契约。后来为了原子性加上显式事务时,原本被误认为实时变化的时间,就可能暴露出早已固定的问题;验证时应覆盖真实事务边界,而不只在交互终端连续敲两条查询。
语句标记与实时读数
statement_timestamp() 返回服务器收到最近一条客户端命令消息的时间,在该命令执行期间保持稳定。事务中后续命令的语句时间可能与事务时间不同。同一协议消息里发送的多条 SQL 命令,也可能共享这个标记,所以它不一定代表等待结束后,每个内部操作真正开始的那一刻。
clock_timestamp() 在求值时读取当前墙钟,即使同一条语句内部也可能变化。由于时钟精度有限,两次调用并不保证得到不同值。它也不是单调计时器,时钟校正可能影响前后读数的差值。需要保证耗时测量不受墙钟调整影响时,应使用合适的单调时钟,而不是把数据库墙钟当成无条件递增的计数器。
这些值单独使用,都不能证明并发事务的提交顺序,也不能提供唯一事件身份。相关字段需要一致逻辑时间时选事务标记,观测命令到达时选语句标记,需要读取当前墙钟时才选实时观测。身份与顺序要求应另外明确,不能让时间戳自动承担它未承诺的语义。
坏掉的样子
长事务中的过期检查如果使用 now,就会持续与事务开始时刻比较。一个在事务开始时成立的条件,即使真实墙钟已经越过截止时间,也可能继续成立。即便使用 clock_timestamp,也只检查求值瞬间,不能保证稍后的提交仍在截止时间之前。要求提交时有效的业务规则,需要另外设计并发与时间边界。反过来,把全部默认值换成 clock_timestamp,又可能让本该共享逻辑创建时间的相关行各不相同。修改函数前,应先确定列代表的是业务批次、命令到达还是实际观测。
列默认值中的特殊字面量 TIMESTAMPTZ 'now' 也是一个陷阱。PostgreSQL 在解析常量时就把它转换成时间,所以后续插入可能一直拿到建表时刻。DEFAULT now() 是函数调用,会在真正使用默认值时求值。两种写法看起来相似,并不代表求值生命周期相同;只检查默认表达式包含 now 这个词,会漏掉这个差别。
这些函数返回 timestamptz。切换会话时区会改变瞬间的显示形式,而不是改变已存储的瞬间本身。timestamptz 不保留最初传入的时区名称;应用需要记住原始时区时,应单独建模,不能从后来显示的偏移量反推原始地区。相同偏移量也不足以唯一确定某个时区名称。
快速验证
把下面内容保存为 timestamps.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f timestamps.sql。显式事务之外,应保持 psql 默认的自动提交行为;第二段需要让两次插入分别处在不同事务里。所有表都是临时表。
\set ON_ERROR_STOP on
BEGIN;
SET LOCAL timezone='UTC';
CREATE TEMP TABLE moments (step integer,tx timestamptz,statement timestamptz,wall timestamptz);
INSERT INTO moments VALUES (1,now(),statement_timestamp(),clock_timestamp());
SELECT pg_sleep(0.03);
INSERT INTO moments VALUES (2,CURRENT_TIMESTAMP,statement_timestamp(),clock_timestamp());
SELECT step,tx,statement,wall FROM moments ORDER BY step;
SELECT min(tx)=max(tx) AS one_transaction_time,
count(DISTINCT statement)=2 AS two_statement_times FROM moments;
SELECT now()=transaction_timestamp() AND now()=CURRENT_TIMESTAMP AS aliases;
SELECT extract(epoch FROM now()) AS instant_utc;
SET LOCAL timezone='Asia/Shanghai';
SELECT now() AS same_instant_different_display,extract(epoch FROM now()) AS instant_shanghai;
DO $$ BEGIN
IF (SELECT min(tx)=max(tx) FROM moments) IS DISTINCT FROM true THEN
RAISE EXCEPTION 'transaction time changed';
END IF;
IF now() IS DISTINCT FROM transaction_timestamp() THEN RAISE EXCEPTION 'aliases differ'; END IF;
IF (SELECT tx FROM moments WHERE step=1) IS DISTINCT FROM now() THEN
RAISE EXCEPTION 'time zone changed the instant';
END IF;
END $$;
ROLLBACK;
CREATE TEMP TABLE defaults_demo (
id integer,
parsed timestamptz DEFAULT TIMESTAMPTZ 'now',
called timestamptz DEFAULT now()
);
INSERT INTO defaults_demo (id) VALUES (1);
SELECT pg_sleep(0.03);
INSERT INTO defaults_demo (id) VALUES (2);
SELECT id,parsed,called FROM defaults_demo ORDER BY id;
SELECT min(parsed)=max(parsed) AS one_parsed_constant,
count(DISTINCT called)=2 AS two_insert_transaction_times FROM defaults_demo;
DO $$ BEGIN
IF (SELECT min(parsed)=max(parsed) FROM defaults_demo) IS DISTINCT FROM true THEN
RAISE EXCEPTION 'parsed default was evaluated again';
END IF;
END $$;
DROP TABLE defaults_demo;
本轮 PostgreSQL 18.6 单用户重跑将命令分别输入,前两行共享相同 tx,但 statement 和 wall 读数不同;未测试 psql 客户端协议。别名比较返回真。从 UTC 切换到 Asia/Shanghai 后,显示偏移变成东八区,但两次 epoch 值完全一致。断言检查事务时间稳定,并确认时区切换没有改变同一个瞬间,而不是仅凭显示字符串判断。
回滚第一段后,默认值实验在两个独立事务中分别插入一行。两行 parsed 保留相同的解析期常量,而 called 随插入事务变化。短暂休眠是为了让本次差别容易观察,并不保证时钟分辨率或调度延迟。最后的 DROP 清除第二张临时表;如果断言提前终止,连接结束也会清理临时对象,实验不会留下持久业务数据。
来源
- PostgreSQL 日期时间函数,事务、语句、墙钟和默认值求值语义,已核验 2026-10-15。
- PostgreSQL 日期时间类型,timestamptz 存储、显示和特殊输入字面量,已核验 2026-10-15。