PostgreSQL 的 now() 在事务里是"冻结"的——你踩过这个坑吗?

2026-06-02 32 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:7 分钟

now() 是 PostgreSQL 里最常被随手调用的函数之一,取当前时间、写日志、算过期……几乎无处不在。但如果你在事务里用它,它返回的并不是"此刻",而是事务开始的那一刻。一个跑了两分钟的事务,从头到尾每次 now() 拿到的都是同一个时间戳。这个行为符合 SQL 标准,却和大多数人的直觉相悖,最近 Emmett 库的作者 Marcin 就因此踩了一个实打实的 bug。

事务级时间冻结:到底发生了什么

PostgreSQL 的时间函数分两类:

函数 时间基准 同一事务内是否变化
now() / current_timestamp 事务开始 ❌ 冻结
clock_timestamp() 真实墙钟时间 ✅ 每次调用都更新

下面直接跑一段 SQL 就能看到差异:

-- 开启事务
BEGIN;

-- 第一次调用
SELECT now() AS txn_now_1, clock_timestamp() AS wall_1;

-- 等待 3 秒(在 psql 里可以用 \sleep,这里用 pg_sleep)
SELECT pg_sleep(3);

-- 第二次调用
SELECT now() AS txn_now_2, clock_timestamp() AS wall_2;

COMMIT;

运行结果大致如下:

 txn_now_1          | wall_1
 2025-06-15 10:00:00 | 2025-06-15 10:00:00

 txn_now_2          | wall_2
 2025-06-15 10:00:00 | 2025-06-15 10:00:03

now() 两次返回完全一致,而 clock_timestamp() 正确推进了 3 秒。这不是 bug,是 SQL 标准定义的行为——current_timestamp 取的是语句开始的时间,PostgreSQL 把"语句"升级成了"事务",让同一事务内所有操作共享一个时间基准,保证一致性。

这个"一致性"什么时候会变成坑

想象一个典型场景:批量处理订单,每条订单记录自己的处理时间。

import psycopg

conn = psycopg.connect("dbname=shop")
with conn.transaction():
    for order_id in order_ids:
        # 处理逻辑……可能每条耗时几十毫秒
        process_order(order_id)
        conn.execute(
            "UPDATE orders SET processed_at = now() WHERE id = %s",
            [order_id]
        )

如果处理了 1000 条订单,总耗时 30 秒,那么所有订单的 processed_at 都是同一秒。你事后想按处理顺序排查问题,时间字段完全帮不上忙。

更隐蔽的坑出现在过期判断里:

BEGIN;

-- 检查会话是否过期(假设超时 5 分钟)
SELECT * FROM sessions
WHERE expires_at > now();  -- 事务开始时的时间

-- ……这里做了一堆耗时操作,实际已经过了 5 分钟……

-- 再次检查,now() 还是事务开始时的时间
-- 本该过期的会话仍然"看起来有效"
SELECT * FROM sessions
WHERE expires_at > now();

COMMIT;

事务越长,这个偏差越大。几毫秒的短事务基本无感,但涉及外部 API 调用、批量计算的长事务就可能产生实质性的逻辑错误。

怎么取到"真正的现在"

方案一:换函数——clock_timestamp()

最直接的办法,把 now() 替换为 clock_timestamp()

-- 需要真实时间的地方
UPDATE orders SET processed_at = clock_timestamp() WHERE id = 123;

但要注意:如果你确实需要事务内时间一致性(比如同一笔事务的所有日志记录同一个时间戳以便关联),就不要盲目替换。两种函数各有适用场景,关键是知道区别后按需选择。

方案二:把时间戳从应用层传进去

在 Python 侧生成时间,作为参数传入 SQL,彻底绕开数据库的时间基准问题:

from datetime import datetime, timezone

with conn.transaction():
    for order_id in order_ids:
        process_order(order_id)
        # 每条记录取自己的真实时间
        processed_at = datetime.now(timezone.utc)
        conn.execute(
            "UPDATE orders SET processed_at = %s WHERE id = %s",
            [processed_at, order_id]
        )

好处是时间来源完全可控,不受事务长短影响,也不依赖特定 PostgreSQL 函数。缺点是应用层时间和数据库服务器时间可能有微小偏差,对大多数业务场景可以忽略,但对强一致性要求高的系统(比如金融对账)需要确保应用和数据库使用同一个时间源(NTP 同步或数据库时间函数)。

方案三:缩短事务

很多时间冻结问题本质上是事务太长。把批量操作拆成多个短事务,每个事务几秒内完成,now() 的偏差就小到可以忽略:

for order_id in order_ids:
    with conn.transaction():  # 每条订单一个事务
        process_order(order_id)
        conn.execute(
            "UPDATE orders SET processed_at = now() WHERE id = %s",
            [order_id]
        )

代价是失去了跨订单的原子性——如果第 500 条失败了,前 499 条已经提交。需要根据业务决定是否可以接受部分成功,或者引入补偿机制。

一份自查清单

在用到 now() / current_timestamp 的项目里,可以快速排查:

  1. 事务内是否有耗时操作(外部调用、批量循环、大查询)?如果有,检查依赖 now() 的逻辑是否需要真实时间。
  2. 时间字段是否用于排序或过期判断?如果是,确认 now() 的冻结行为是否会导致误判。
  3. 是否需要事务内时间一致性?如果同一事务的多条记录必须共享同一时间戳(审计日志关联),保留 now();如果每条记录需要独立时间,换 clock_timestamp() 或应用层传入。
  4. 短事务场景可以直接用 now(),偏差在毫秒级,基本无感。

PostgreSQL 的这个行为不是 bug,而是标准定义的特性。问题出在我们把 now() 当成了"墙钟时间"的快捷方式,却忘了它在事务里是一把冻结的秒表。知道区别之后,选哪个函数就不再是盲猜,而是根据场景做出明确决定。


相关推荐