上周帮朋友看一条统计SQL,数据怎么都对不上。他写的条件类似dateadd(day, -1, create_time),我一看就明白了——这是把MySQL或SQL Server的写法直接搬到了PostgreSQL里。PG里根本没有dateadd,正确写法是create_time - interval '1 day'。这类事碰多了之后我发现,PostgreSQL的时间函数看着简单,真要用起来,处处都是暗坑。now()和clock_timestamp()有什么区别?timestamp和timestamptz到底该选哪个?为什么按天分组统计有时会少一天?为什么时间字段明明建了索引却不用?这篇文章就把PG里和时间相关的函数按场景完整梳理一遍,从类型体系、当前时刻获取、格式化转换、截断对齐、间隔运算到时区处理,最后再说说索引和性能。适合刚接触PG的读者,也适合写了几年SQL但从没认真体会过时间类型的开发者。搞清楚这些,能帮你少走很多弯路。1. 动手写SQL之前,先搞懂PG的四类时间类型很多人栽跟头,不是因为函数不会用,而是因为类型没搞懂。PG里的时间类型不算多,但每个都有自己的脾气。1.1 timestamp和timestamptz:差的不只是名字timestamp全称是timestamp without time zone,不带时区;timestamptz全称是timestamp with time zone,带时区。这两个名字极具迷惑性,因为timestamptz其实并不在存储时保存你的时区偏移量,它保存的是一个绝对的UTC时刻,只是在展示的时候会根据会话的TimeZone设置转换成你所在的时区。可以这么理解:timestamp就像一张照片,上面印着钟表显示的时间,你说它是几点就是几点;timestamptz则更像一个全球通用的绝对时间点,它在内部永远记住“这是地球上的哪个瞬间”,至于显示成北京几点还是伦敦几点,只是换个皮肤的事。-- 当前会话是东八区 SET timezone TO 'Asia/Shanghai'; SELECT '2024-06-01 12:00:00'::timestamp, '2024-06-01 12:00:00'::timestamptz; -- 结果: -- 2024-06-01 12:00:00 | 2024-06-01 12:00:00+08当你切换会话时区再查一次:SET timezone TO 'UTC'; SELECT '2024-06-01 12:00:00'::timestamp, '2024-06-01 12:00:00'::timestamptz; -- 结果: -- 2024-06-01 12:00:00 | 2024-06-01 04:00:00+00看到了吗?timestamp类型不管你怎么切时区,显示都是12点;而timestamptz会随着时区设置重新换算。国内开发环境大多默认东八区,感觉不出差别,一旦你把数据库迁移到云上或者把连接串里的时区改成UTC,之前写的timestamp字段会瞬间感觉“时间穿越了”。这就是大多数时间错乱问题的根源。我在实际项目里的建议是:业务时间字段统一使用timestamptz,让数据库存绝对时刻,展示和换算交给会话时区。别自己在一个timestamp字段里存“东八区时间”,因为过了两年没人记得这个字段到底存的是哪个时区的钟表时间。1.2 date、time和interval:各管一段这四个类型的分工必须时刻清楚:类型含义典型值适合场景date只存日期,无时刻2024-06-01生日、交易日期time只存时刻,无日期12:30:45营业时间、班次timestamp/timestamptz日期加时刻2024-06-01 12:30:45业务发生时间interval时间段/时长1 day、3 hours时间加减、计算时长特别要说interval,它和时间点完全不是一个维度。可以把timestamp想成尺子上的刻度,interval就是一段长度。刻度加长度得到另一个刻度,这是时间加减的基础。interval的写法也很有讲究:interval '1 day'和interval '24 hours'在大多数情况等值,但在遇到夏令时切换的地区就不一样了。国内没有夏令时,所以这个坑体会不深,但如果你做的业务面向海外用户,interval '1 day'是“按日历上的一天走”,interval '24 hours'是“按固定的小时数走”,这两种时的语义差异会在跨时区的应用里把人折磨疯。2. 取当前时间:now()、current_timestamp与clock_timestamp()的选择题PG里取当前时间的函数多到让人眼花,常用的有这些:函数返回类型特点now()timestamptz事务开始时刻,事务内保持不变transaction_timestamp()timestamptz等价于now()current_timestamptimestamptz等价于now(),SQL标准写法statement_timestamp()timestamptz当前语句开始时刻,同一事务内不同语句会变化clock_timestamp()timestamptz真实时钟时间,每次调用都变化timeofday()textclock_timestamp()的文本形式current_datedate当前会话时区的日期2.1 事务稳定时间与实时时钟的差异now()的全称语义是“事务开始的时间”。一个显式事务里,不管你执行多少条语句、执行多久,now()返回的都是那个事务最开始的值。这个特性非常有用,比如批量插入一批订单,希望所有订单都带上同一个“入库批次时间”,那用now()就完全正确。clock_timestamp()则完全不同,它是真正去读系统时钟,每调用一次都会变。即使在同一条SQL里,写两个clock_timestamp(),得到的值都可能差几个微秒。看个直观的例子:BEGIN; SELECT now(), clock_timestamp(), pg_sleep(2) IS NOT NULL; SELECT now(), clock_timestamp(), pg_sleep(2) IS NOT NULL; SELECT now(), clock_timestamp(); COMMIT; -- 第二个now()和第一个完全一样 -- 而clock_timestamp()两次差了大约4秒另一个容易混淆的是statement_timestamp()。它返回当前这条SQL开始执行的时刻,在一个事务里执行多条SQL,每条SQL拿到的statement_timestamp()都不相同。如果你想记录“这条语句什么时候跑的”,用它;如果你想记录“这笔业务什么时候发生”,用now()。2.2 实际开发中应该选哪个我的选择逻辑非常简单:写审计字段、创建时间、更新时间,一律用now()。一个事务内多条记录时间戳一致,排查问题时不会出现“同一笔订单两个时间”的困惑。做数据同步、CDC采集、日志记录,需要精确到操作发生的真实时刻,用clock_timestamp()。比如你在一个循环里逐行更新数据并记录每行被处理的精确时间,now()会让所有行都变成同一个时间,这明显不对。取“今天的日期”做业务判断,写current_date,不要写cast(now() as date),前者语义更清晰,也少了隐式转换。注意:now()在PG里既可以在SQL中用,也可以在存储过程、触发器函数里用,效果一致。很多人以为now()就是“当前时间”而已,其实它背后是“事务快照时间”,理解这一点,很多诡异的用法就明白了。3. 时间与字符串互转:to_char、to_date、to_timestamp的正确姿势时间函数另一个大板块是格式化和解析。PG提供了一整套模板模式,灵活但也很容易写错。3.1 to_char格式化参数速查to_char把时间转成字符串,常用模板如下:模板含义示例输出YYYY4位年份2024YY2位年份24MM2位月份06DD2位日期01HH2424小时制小时14HH1212小时制小时02MI分钟30SS秒45D星期几(1-7,周日为1)5IDISO星期几(1-7,周一为1)4ddd一年中的第几天153WW一年中的第几周22IWISO周22Q季度2TZ时区缩写CST最容易被坑的是D和ID。D返回的数字里,周日是1,周一是2,一直到周六是7,这跟国内习惯的“周一=1”完全相反。要做每周报表的分组排序,用ID更符合直觉。SELECT to_char('2024-06-01'::date, 'D'), -- 周六,返回7 to_char('2024-06-01'::date, 'ID'); -- 周六,返回63.2 to_date与to_timestamp解析时的隐藏规则to_date('20240601', 'YYYYMMDD')这样的解析很直观,但有几个容易踩的细节:第一,格式串写错会静默“合理化”。比如to_date('2024-06-31', 'YYYY-MM-DD')并不会报错,PG会把6月31日自动滚成7月1日。做数据校验时别指望它给你抛异常。第二,FX修饰符可以关掉“宽松匹配”。FX表示精确匹配,比如:SELECT to_date('20240601', 'FXYYYYMMDD'); -- 正常 SELECT to_date('2024-06-01', 'FXYYYYMMDD'); -- 报错,因为中间多了横杠第三,to_timestamp返回的是timestamptz,不是timestamp。这个很容易忽略。如果你从字符串解析出来的时间要跟一个timestamp类型的列去比较,PG会先把类型对齐,期间时区转换可能让结果出乎意料。3.3 从一个慢查询说起:避免在条件里写转换说一个我常见到的性能反例。业务里想按天查订单,有人这么写:SELECT * FROM orders WHERE to_char(create_time, 'YYYY-MM-DD') = '2024-06-01';这个写法在功能上没错,但它对create_time做了函数处理后,普通索引完全用不上,只能全表扫描。数据量上百万之后,查询基本就是灾难。正确做法是用范围条件:SELECT * FROM orders WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';这样既能走索引,语义也更精确。这个只是索引问题的冰山一角,后面专门讲。4. extract、date_part与date_trunc:按天/周/月统计的基本功做报表、做漏斗、做留存分析,本质上都是“把时间对齐到某个周期,再分组聚合”。PG提供了三个核心工具:extract、date_part和date_trunc。4.1 取时间字段的某个部分extract(field FROM source)是标准SQL写法,date_part('field', source)是PG的老写法,作用完全相同。常用字段有:字段含义注意year年month月1-12day月中的日1-31dow星期几周日为0,周六为6isodowISO星期几周一为1,周日为7week一年的第几周按ISO标准quarter季度epoch距离Unix纪元的秒数含小数,可当时间戳用epoch是一个被低估的字段。它可以把timestamptz转成数值型,方便在Redis、缓存、外部系统里传递时间。SELECT extract(epoch FROM now()), extract(epoch FROM now() AT TIME ZONE 'UTC');这里有个附加的坑:now()是timestamptz,直接extract(epoch FROM now())没问题;但如果你的列是timestamp,PG会先把它当作会话时区的本地时间再转成UTC计算epoch,前后结果可能对不上。最稳妥的做法是统一走timestamptz。4.2 date_trunc:按自然周期对齐,报表分组利器date_trunc的单位很丰富:microseconds、milliseconds、second、minute、hour、day、week、month、quarter、year、decade、century、millennium。它的作用是把时间截断到指定周期的起点:SELECT date_trunc('day', '2024-06-15 23:59:59'::timestamp); -- 2024-06-15 00:00:00 SELECT date_trunc('week', '2024-06-15 23:59:59'::timestamp); -- 2024-06-10 00:00:00,周一 SELECT date_trunc('month', '2024-06-15 23:59:59'::timestamp); -- 2024-06-01 00:00:00 SELECT date_trunc('quarter', '2024-06-15 23:59:59'::timestamp); -- 2024-04-01 00:00:00注意week对齐的是周一。如果你希望“周”从周日开始,就需要自己偏移处理。实际报表场景最能体现它的价值。比如统计某订单表最近7天每天的订单数:SELECT date_trunc('day', created_at) AS day, count(*) AS order_cnt, sum(total_amount) AS gmv FROM orders WHERE created_at >= now() - interval '7 days' GROUP BY date_trunc('day', created_at) ORDER BY day;按月统计、按季度统计,只需要把day换成对应单位,SQL骨架完全不用动。这就是date_trunc相比to_char(x, 'YYYY-MM')做分组的优势:返回的仍然是时间类型,排序和后续处理都比字符串方便。经验:GROUP BY date_trunc(...)时,SELECT里也最好写同样的date_trunc(...),不要只在GROUP BY里写而SELECT里写别的别名表达式,否则语义容易混乱,也容易被其他同事误解。5. 时间加减与间隔计算:interval和age()的实战组合时间加减是业务里永远躲不掉的需求:往前推30天、计算两个时间点相隔多久、判断用户是否连续登录。5.1 时间点直接加减:别把数字当intervalPG里时间加减遵循一个原则:时间点只能加减长度,不能加减普通数字。写create_time + 7是错的,必须写成create_time + interval '7 days'。不过有个例外:date类型可以直接加减整数,整数表示天数。SELECT '2024-06-01'::date + 7; -- 2024-06-08 SELECT '2024-06-01'::date + interval '7 days'; -- 2024-06-08 00:00:00date + 7返回date,date + interval返回timestamp。两个写法结果都正确,但返回类型不同,后续如果还要精确到时分秒,就要考虑类型匹配。5.2 两个时间相减:结果不是数字很多人会把两个时间相减后直接当“天数”用,这是PG里最容易出错的点之一。timestamp - timestamp的结果是interval,不是数值。比如:SELECT '2024-06-10 12:00:00'::timestamp - '2024-06-01 00:00:00'::timestamp; -- 结果是 9 days 12:00:00,是interval如果你想要纯粹的天数,该用什么?常见的办法是extract(day FROM ...),但要注意interval里的“天”和“小时”是分开存储的字段,直接extract(day FROM interval)只会把days部分取出来,剩下的时间部分会被丢弃。比如上面那个例子,extract(day FROM result)返回9,但你其实丢掉了12小时。更稳妥的取“两个时间点之间的总天数”方法:SELECT extract(epoch FROM ('2024-06-10 12:00:00'::timestamp - '2024-06-01 00:00:00'::timestamp)) / 86400; -- 9.5 天先把interval换算成秒,再除以一天的秒数,这才是真正完整的天数。如果是date类型相减,结果就是整数,不存在这个问题:SELECT '2024-06-10'::date - '2024-06-01'::date; -- 95.3 age():算年龄、算工龄的正确打开方式age()函数按年月日结构返回间隔,适合人类可读的时长表达:SELECT age('2024-06-10'::date, '2000-01-01'::date); -- 24 years 5 mons 9 days如果你用普通的timestamp相减去算年龄,得到可能是几千天,可读性太差。age()会自动借位换算成年月日,用来算用户年龄非常合适。5.4 连续签到类场景的时间窗口判断判断“最近7天是否有活跃”这类需求,关键的SQL写法是:SELECT user_id, count(DISTINCT date_trunc('day', active_at)) AS active_days FROM user_actions WHERE active_at >= now() - interval '7 days' AND active_at < now() GROUP BY user_id;这里的两个细节值得注意:第一,用count(DISTINCT date_trunc(...))而不是直接count(*),因为用户一天可能有多条行为记录,直接count(*)会把同一天算多次;第二,时间窗口用左闭右开>=和<,避免同一时刻的数据被两个窗口重复统计。顺带一提,判断“用户是否连续登录N天”时,很多人想当然用last_login + interval '1 day' = today这种逐日判断,其实效率很差。更合理的方式是把登录日期序列用窗口函数拉出相邻差值,再判断差值为1的连续段长度。时间函数和窗口函数配合,能做到很多看起来复杂的分析。6. 时区一致性问题:一次线上统计错位的排错全过程时区是PG时间函数里最隐蔽的坑,我单独用一节来讲,因为它值得。6.1 问题现象:凌晨的订单跑去了前一天有一回做订单报表,按天统计某地区成交额,发现每天早上8点前出的报表,“今日订单”数量总是比前一天的“凌晨订单”少。把明细拉出来一看,明明是当天凌晨0点到8点的订单,统计结果却被算到了前一天。最诡异的是,数据本身看起来没毛病。6.2 从数据到连接串再到会话时区的逐层排查排查过程大致分了三步。第一步,检查数据本身。直接查表,看订单的created_at原始值。发现表里存的值确实是今天凌晨的时间,没有问题。第二步,检查查询会话的时区。执行SHOW timezone;,结果发现连接串建立后,会话时区是UTC。而业务同事在应用层往里写数据时,没有指定时区,直接用的字符串或者本地时间插入。第三步,分析问题根因。这个表的created_at是timestamptz类型。应用层用“东八区本地时间”字符串写入时,PG会把这个字符串当成会话时区(UTC)的时间来理解,再转换成UTC绝对时刻存入。于是,本地8点的数据,换算成UTC就是当天0点,而到了次日凌晨0到8点,换算成UTC就成了前一日的16点到23点59分。等到出报表时,数据库又按UTC会话时区读出并展示,看起来自然就差了一天。6.3 修复方案与长期建议修复分两层。短期修复,把应用连接串里的时区参数显式指定为东八区,让写入的本地时间字符串能被正确理解;同时把报表查询会话的时区统一设置为业务所属时区。长期建议有三条:数据库连接串里显式写options=-c timezone=Asia/Shanghai这类参数,杜绝依赖数据库服务端的默认时区。应用层写入timestamptz字段时,优先传入带时区信息的对象,比如Java里用OffsetDateTime,Python里用带tzinfo的datetime,不要用纯字符串。所有报表、统计、定时任务,执行前先SET timezone或者统一在连接层约定好,避免某条SQL在错误的时区下运行。时区问题之所以麻烦,是因为它往往不报错、只出看似“差一点”的数据。查错时如果只盯着SQL本身,很难定位到根因。经验:遇到时间错位问题,第一件事永远是SHOW timezone;和SELECT * FROM pg_settings WHERE name = 'TimeZone';,把会话时区、数据库默认时区、连接串时区三层全部对齐,再谈别的。7. 别让函数坑了索引:时间条件查询的性能红线最后说性能。前面简单提过函数包裹列会导致索引失效,这里展开讲透。7.1 函数包裹列导致索引失效的案例假设订单表orders的created_at上有普通B-tree索引,下面这些写法都会让索引失效:-- 错误写法1:格式化后比较 SELECT * FROM orders WHERE to_char(created_at, 'YYYY-MM-DD') = '2024-06-01'; -- 错误写法2:截断到天再比较 SELECT * FROM orders WHERE date_trunc('day', created_at) = '2024-06-01'::timestamp; -- 错误写法3:只按日期部分比较 SELECT * FROM orders WHERE created_at::date = '2024-06-01'::date;原因很简单:B-tree索引是按原始列值排序的,当你用函数把列的值变换之后,索引里存的原始顺序就不再适用了,数据库只能全表扫描,逐行计算函数值再比较。有人问:为什么PG这么“笨”,不能自动识别出date_trunc('day', created_at) = 某天等价于一个范围条件?确实,PG的优化器没聪明到做这种等价变换。这不是BUG,是设计选择。7.2 日期范围条件的推荐写法正确的写法永远是范围条件:SELECT * FROM orders WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00';如果只查当天,动态写法可以这样:SELECT * FROM orders WHERE created_at >= current_date AND created_at < current_date + 1;这样的查询可以直接走created_at上的B-tree索引。我在某次实际优化中,把一条用了to_char(created_at, 'YYYY-MM-DD')的统计查询改成范围条件后,执行时间直接从几百毫秒降到几十毫秒,数据量大约百万级,效果立竿见影。7.3 如果确实需要在函数上建索引当然,有些业务实在绕不开函数条件。比如你经常要按date_trunc('day', created_at)来分组或过滤,并且数据量大到必须用索引,PG也支持表达式索引:CREATE INDEX idx_orders_created_day ON orders (date_trunc('day', created_at));建了表达式索引后,WHERE date_trunc('day', created_at) = ...就能走这个索引。代价是每次插入、更新都要多算一次表达式并维护索引,写入性能会略降。所以我的建议是:能改写法走普通索引,就别建表达式索引;实在要建,也要先确认查询频率远高于写入频率。7.4 时间范围查询的边界细节范围查询有一个必须养成的习惯:永远用“左闭右开”。-- 推荐:包含起始,排除结束 WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00'; -- 不推荐:用between WHERE created_at BETWEEN '2024-06-01 00:00:00' AND '2024-06-01 23:59:59';BETWEEN是包含两个端点的闭区间。如果数据里有一条2024-06-01 23:59:59.500,它就不在BETWEEN范围里,但业务上它很可能属于当天。虽然可以写成BETWEEN ... AND '2024-06-01 23:59:59.999999'来补救,但不如直接养成左闭右开的习惯干净。另外,当created_at是timestamptz而对比值是字符串时,字符串会被隐式转换成timestamptz,转换规则取决于会话时区。这又绕回了时区问题:保证会话时区正确,再写范围条件,否则边界上一样会错。写到最后,分享一个我现在的固定习惯:任何涉及时间字段的SQL,先问三件事——字段类型是timestamp还是timestamptz、会话时区是什么、条件是否能用上索引。这三件事确认完,大部分和PG时间函数有关的坑都能避开。如果你过去也遇到过“时间错一天”“索引没走”“统计数字对不上”之类的问题,回头看看这些点,基本都能找到答案。
上周帮朋友看一条统计SQL,数据怎么都对不上。他写的条件类似dateadd(day, -1, create_time),我一看就明白了——这是把MySQL或SQL Server的写法直接搬到了PostgreSQL里。PG里根本没有dateadd,正确写法是create_time - interval '1 day'。这类事碰多了之后我发现,PostgreSQL的时间函数看着简单,真要用起来,处处都是暗坑。now()和clock_timestamp()有什么区别?timestamp和timestamptz到底该选哪个?为什么按天分组统计有时会少一天?为什么时间字段明明建了索引却不用?这篇文章就把PG里和时间相关的函数按场景完整梳理一遍,从类型体系、当前时刻获取、格式化转换、截断对齐、间隔运算到时区处理,最后再说说索引和性能。适合刚接触PG的读者,也适合写了几年SQL但从没认真体会过时间类型的开发者。搞清楚这些,能帮你少走很多弯路。1. 动手写SQL之前,先搞懂PG的四类时间类型很多人栽跟头,不是因为函数不会用,而是因为类型没搞懂。PG里的时间类型不算多,但每个都有自己的脾气。1.1 timestamp和timestamptz:差的不只是名字timestamp全称是timestamp without time zone,不带时区;timestamptz全称是timestamp with time zone,带时区。这两个名字极具迷惑性,因为timestamptz其实并不在存储时保存你的时区偏移量,它保存的是一个绝对的UTC时刻,只是在展示的时候会根据会话的TimeZone设置转换成你所在的时区。可以这么理解:timestamp就像一张照片,上面印着钟表显示的时间,你说它是几点就是几点;timestamptz则更像一个全球通用的绝对时间点,它在内部永远记住“这是地球上的哪个瞬间”,至于显示成北京几点还是伦敦几点,只是换个皮肤的事。-- 当前会话是东八区 SET timezone TO 'Asia/Shanghai'; SELECT '2024-06-01 12:00:00'::timestamp, '2024-06-01 12:00:00'::timestamptz; -- 结果: -- 2024-06-01 12:00:00 | 2024-06-01 12:00:00+08当你切换会话时区再查一次:SET timezone TO 'UTC'; SELECT '2024-06-01 12:00:00'::timestamp, '2024-06-01 12:00:00'::timestamptz; -- 结果: -- 2024-06-01 12:00:00 | 2024-06-01 04:00:00+00看到了吗?timestamp类型不管你怎么切时区,显示都是12点;而timestamptz会随着时区设置重新换算。国内开发环境大多默认东八区,感觉不出差别,一旦你把数据库迁移到云上或者把连接串里的时区改成UTC,之前写的timestamp字段会瞬间感觉“时间穿越了”。这就是大多数时间错乱问题的根源。我在实际项目里的建议是:业务时间字段统一使用timestamptz,让数据库存绝对时刻,展示和换算交给会话时区。别自己在一个timestamp字段里存“东八区时间”,因为过了两年没人记得这个字段到底存的是哪个时区的钟表时间。1.2 date、time和interval:各管一段这四个类型的分工必须时刻清楚:类型含义典型值适合场景date只存日期,无时刻2024-06-01生日、交易日期time只存时刻,无日期12:30:45营业时间、班次timestamp/timestamptz日期加时刻2024-06-01 12:30:45业务发生时间interval时间段/时长1 day、3 hours时间加减、计算时长特别要说interval,它和时间点完全不是一个维度。可以把timestamp想成尺子上的刻度,interval就是一段长度。刻度加长度得到另一个刻度,这是时间加减的基础。interval的写法也很有讲究:interval '1 day'和interval '24 hours'在大多数情况等值,但在遇到夏令时切换的地区就不一样了。国内没有夏令时,所以这个坑体会不深,但如果你做的业务面向海外用户,interval '1 day'是“按日历上的一天走”,interval '24 hours'是“按固定的小时数走”,这两种时的语义差异会在跨时区的应用里把人折磨疯。2. 取当前时间:now()、current_timestamp与clock_timestamp()的选择题PG里取当前时间的函数多到让人眼花,常用的有这些:函数返回类型特点now()timestamptz事务开始时刻,事务内保持不变transaction_timestamp()timestamptz等价于now()current_timestamptimestamptz等价于now(),SQL标准写法statement_timestamp()timestamptz当前语句开始时刻,同一事务内不同语句会变化clock_timestamp()timestamptz真实时钟时间,每次调用都变化timeofday()textclock_timestamp()的文本形式current_datedate当前会话时区的日期2.1 事务稳定时间与实时时钟的差异now()的全称语义是“事务开始的时间”。一个显式事务里,不管你执行多少条语句、执行多久,now()返回的都是那个事务最开始的值。这个特性非常有用,比如批量插入一批订单,希望所有订单都带上同一个“入库批次时间”,那用now()就完全正确。clock_timestamp()则完全不同,它是真正去读系统时钟,每调用一次都会变。即使在同一条SQL里,写两个clock_timestamp(),得到的值都可能差几个微秒。看个直观的例子:BEGIN; SELECT now(), clock_timestamp(), pg_sleep(2) IS NOT NULL; SELECT now(), clock_timestamp(), pg_sleep(2) IS NOT NULL; SELECT now(), clock_timestamp(); COMMIT; -- 第二个now()和第一个完全一样 -- 而clock_timestamp()两次差了大约4秒另一个容易混淆的是statement_timestamp()。它返回当前这条SQL开始执行的时刻,在一个事务里执行多条SQL,每条SQL拿到的statement_timestamp()都不相同。如果你想记录“这条语句什么时候跑的”,用它;如果你想记录“这笔业务什么时候发生”,用now()。2.2 实际开发中应该选哪个我的选择逻辑非常简单:写审计字段、创建时间、更新时间,一律用now()。一个事务内多条记录时间戳一致,排查问题时不会出现“同一笔订单两个时间”的困惑。做数据同步、CDC采集、日志记录,需要精确到操作发生的真实时刻,用clock_timestamp()。比如你在一个循环里逐行更新数据并记录每行被处理的精确时间,now()会让所有行都变成同一个时间,这明显不对。取“今天的日期”做业务判断,写current_date,不要写cast(now() as date),前者语义更清晰,也少了隐式转换。注意:now()在PG里既可以在SQL中用,也可以在存储过程、触发器函数里用,效果一致。很多人以为now()就是“当前时间”而已,其实它背后是“事务快照时间”,理解这一点,很多诡异的用法就明白了。3. 时间与字符串互转:to_char、to_date、to_timestamp的正确姿势时间函数另一个大板块是格式化和解析。PG提供了一整套模板模式,灵活但也很容易写错。3.1 to_char格式化参数速查to_char把时间转成字符串,常用模板如下:模板含义示例输出YYYY4位年份2024YY2位年份24MM2位月份06DD2位日期01HH2424小时制小时14HH1212小时制小时02MI分钟30SS秒45D星期几(1-7,周日为1)5IDISO星期几(1-7,周一为1)4ddd一年中的第几天153WW一年中的第几周22IWISO周22Q季度2TZ时区缩写CST最容易被坑的是D和ID。D返回的数字里,周日是1,周一是2,一直到周六是7,这跟国内习惯的“周一=1”完全相反。要做每周报表的分组排序,用ID更符合直觉。SELECT to_char('2024-06-01'::date, 'D'), -- 周六,返回7 to_char('2024-06-01'::date, 'ID'); -- 周六,返回63.2 to_date与to_timestamp解析时的隐藏规则to_date('20240601', 'YYYYMMDD')这样的解析很直观,但有几个容易踩的细节:第一,格式串写错会静默“合理化”。比如to_date('2024-06-31', 'YYYY-MM-DD')并不会报错,PG会把6月31日自动滚成7月1日。做数据校验时别指望它给你抛异常。第二,FX修饰符可以关掉“宽松匹配”。FX表示精确匹配,比如:SELECT to_date('20240601', 'FXYYYYMMDD'); -- 正常 SELECT to_date('2024-06-01', 'FXYYYYMMDD'); -- 报错,因为中间多了横杠第三,to_timestamp返回的是timestamptz,不是timestamp。这个很容易忽略。如果你从字符串解析出来的时间要跟一个timestamp类型的列去比较,PG会先把类型对齐,期间时区转换可能让结果出乎意料。3.3 从一个慢查询说起:避免在条件里写转换说一个我常见到的性能反例。业务里想按天查订单,有人这么写:SELECT * FROM orders WHERE to_char(create_time, 'YYYY-MM-DD') = '2024-06-01';这个写法在功能上没错,但它对create_time做了函数处理后,普通索引完全用不上,只能全表扫描。数据量上百万之后,查询基本就是灾难。正确做法是用范围条件:SELECT * FROM orders WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';这样既能走索引,语义也更精确。这个只是索引问题的冰山一角,后面专门讲。4. extract、date_part与date_trunc:按天/周/月统计的基本功做报表、做漏斗、做留存分析,本质上都是“把时间对齐到某个周期,再分组聚合”。PG提供了三个核心工具:extract、date_part和date_trunc。4.1 取时间字段的某个部分extract(field FROM source)是标准SQL写法,date_part('field', source)是PG的老写法,作用完全相同。常用字段有:字段含义注意year年month月1-12day月中的日1-31dow星期几周日为0,周六为6isodowISO星期几周一为1,周日为7week一年的第几周按ISO标准quarter季度epoch距离Unix纪元的秒数含小数,可当时间戳用epoch是一个被低估的字段。它可以把timestamptz转成数值型,方便在Redis、缓存、外部系统里传递时间。SELECT extract(epoch FROM now()), extract(epoch FROM now() AT TIME ZONE 'UTC');这里有个附加的坑:now()是timestamptz,直接extract(epoch FROM now())没问题;但如果你的列是timestamp,PG会先把它当作会话时区的本地时间再转成UTC计算epoch,前后结果可能对不上。最稳妥的做法是统一走timestamptz。4.2 date_trunc:按自然周期对齐,报表分组利器date_trunc的单位很丰富:microseconds、milliseconds、second、minute、hour、day、week、month、quarter、year、decade、century、millennium。它的作用是把时间截断到指定周期的起点:SELECT date_trunc('day', '2024-06-15 23:59:59'::timestamp); -- 2024-06-15 00:00:00 SELECT date_trunc('week', '2024-06-15 23:59:59'::timestamp); -- 2024-06-10 00:00:00,周一 SELECT date_trunc('month', '2024-06-15 23:59:59'::timestamp); -- 2024-06-01 00:00:00 SELECT date_trunc('quarter', '2024-06-15 23:59:59'::timestamp); -- 2024-04-01 00:00:00注意week对齐的是周一。如果你希望“周”从周日开始,就需要自己偏移处理。实际报表场景最能体现它的价值。比如统计某订单表最近7天每天的订单数:SELECT date_trunc('day', created_at) AS day, count(*) AS order_cnt, sum(total_amount) AS gmv FROM orders WHERE created_at >= now() - interval '7 days' GROUP BY date_trunc('day', created_at) ORDER BY day;按月统计、按季度统计,只需要把day换成对应单位,SQL骨架完全不用动。这就是date_trunc相比to_char(x, 'YYYY-MM')做分组的优势:返回的仍然是时间类型,排序和后续处理都比字符串方便。经验:GROUP BY date_trunc(...)时,SELECT里也最好写同样的date_trunc(...),不要只在GROUP BY里写而SELECT里写别的别名表达式,否则语义容易混乱,也容易被其他同事误解。5. 时间加减与间隔计算:interval和age()的实战组合时间加减是业务里永远躲不掉的需求:往前推30天、计算两个时间点相隔多久、判断用户是否连续登录。5.1 时间点直接加减:别把数字当intervalPG里时间加减遵循一个原则:时间点只能加减长度,不能加减普通数字。写create_time + 7是错的,必须写成create_time + interval '7 days'。不过有个例外:date类型可以直接加减整数,整数表示天数。SELECT '2024-06-01'::date + 7; -- 2024-06-08 SELECT '2024-06-01'::date + interval '7 days'; -- 2024-06-08 00:00:00date + 7返回date,date + interval返回timestamp。两个写法结果都正确,但返回类型不同,后续如果还要精确到时分秒,就要考虑类型匹配。5.2 两个时间相减:结果不是数字很多人会把两个时间相减后直接当“天数”用,这是PG里最容易出错的点之一。timestamp - timestamp的结果是interval,不是数值。比如:SELECT '2024-06-10 12:00:00'::timestamp - '2024-06-01 00:00:00'::timestamp; -- 结果是 9 days 12:00:00,是interval如果你想要纯粹的天数,该用什么?常见的办法是extract(day FROM ...),但要注意interval里的“天”和“小时”是分开存储的字段,直接extract(day FROM interval)只会把days部分取出来,剩下的时间部分会被丢弃。比如上面那个例子,extract(day FROM result)返回9,但你其实丢掉了12小时。更稳妥的取“两个时间点之间的总天数”方法:SELECT extract(epoch FROM ('2024-06-10 12:00:00'::timestamp - '2024-06-01 00:00:00'::timestamp)) / 86400; -- 9.5 天先把interval换算成秒,再除以一天的秒数,这才是真正完整的天数。如果是date类型相减,结果就是整数,不存在这个问题:SELECT '2024-06-10'::date - '2024-06-01'::date; -- 95.3 age():算年龄、算工龄的正确打开方式age()函数按年月日结构返回间隔,适合人类可读的时长表达:SELECT age('2024-06-10'::date, '2000-01-01'::date); -- 24 years 5 mons 9 days如果你用普通的timestamp相减去算年龄,得到可能是几千天,可读性太差。age()会自动借位换算成年月日,用来算用户年龄非常合适。5.4 连续签到类场景的时间窗口判断判断“最近7天是否有活跃”这类需求,关键的SQL写法是:SELECT user_id, count(DISTINCT date_trunc('day', active_at)) AS active_days FROM user_actions WHERE active_at >= now() - interval '7 days' AND active_at < now() GROUP BY user_id;这里的两个细节值得注意:第一,用count(DISTINCT date_trunc(...))而不是直接count(*),因为用户一天可能有多条行为记录,直接count(*)会把同一天算多次;第二,时间窗口用左闭右开>=和<,避免同一时刻的数据被两个窗口重复统计。顺带一提,判断“用户是否连续登录N天”时,很多人想当然用last_login + interval '1 day' = today这种逐日判断,其实效率很差。更合理的方式是把登录日期序列用窗口函数拉出相邻差值,再判断差值为1的连续段长度。时间函数和窗口函数配合,能做到很多看起来复杂的分析。6. 时区一致性问题:一次线上统计错位的排错全过程时区是PG时间函数里最隐蔽的坑,我单独用一节来讲,因为它值得。6.1 问题现象:凌晨的订单跑去了前一天有一回做订单报表,按天统计某地区成交额,发现每天早上8点前出的报表,“今日订单”数量总是比前一天的“凌晨订单”少。把明细拉出来一看,明明是当天凌晨0点到8点的订单,统计结果却被算到了前一天。最诡异的是,数据本身看起来没毛病。6.2 从数据到连接串再到会话时区的逐层排查排查过程大致分了三步。第一步,检查数据本身。直接查表,看订单的created_at原始值。发现表里存的值确实是今天凌晨的时间,没有问题。第二步,检查查询会话的时区。执行SHOW timezone;,结果发现连接串建立后,会话时区是UTC。而业务同事在应用层往里写数据时,没有指定时区,直接用的字符串或者本地时间插入。第三步,分析问题根因。这个表的created_at是timestamptz类型。应用层用“东八区本地时间”字符串写入时,PG会把这个字符串当成会话时区(UTC)的时间来理解,再转换成UTC绝对时刻存入。于是,本地8点的数据,换算成UTC就是当天0点,而到了次日凌晨0到8点,换算成UTC就成了前一日的16点到23点59分。等到出报表时,数据库又按UTC会话时区读出并展示,看起来自然就差了一天。6.3 修复方案与长期建议修复分两层。短期修复,把应用连接串里的时区参数显式指定为东八区,让写入的本地时间字符串能被正确理解;同时把报表查询会话的时区统一设置为业务所属时区。长期建议有三条:数据库连接串里显式写options=-c timezone=Asia/Shanghai这类参数,杜绝依赖数据库服务端的默认时区。应用层写入timestamptz字段时,优先传入带时区信息的对象,比如Java里用OffsetDateTime,Python里用带tzinfo的datetime,不要用纯字符串。所有报表、统计、定时任务,执行前先SET timezone或者统一在连接层约定好,避免某条SQL在错误的时区下运行。时区问题之所以麻烦,是因为它往往不报错、只出看似“差一点”的数据。查错时如果只盯着SQL本身,很难定位到根因。经验:遇到时间错位问题,第一件事永远是SHOW timezone;和SELECT * FROM pg_settings WHERE name = 'TimeZone';,把会话时区、数据库默认时区、连接串时区三层全部对齐,再谈别的。7. 别让函数坑了索引:时间条件查询的性能红线最后说性能。前面简单提过函数包裹列会导致索引失效,这里展开讲透。7.1 函数包裹列导致索引失效的案例假设订单表orders的created_at上有普通B-tree索引,下面这些写法都会让索引失效:-- 错误写法1:格式化后比较 SELECT * FROM orders WHERE to_char(created_at, 'YYYY-MM-DD') = '2024-06-01'; -- 错误写法2:截断到天再比较 SELECT * FROM orders WHERE date_trunc('day', created_at) = '2024-06-01'::timestamp; -- 错误写法3:只按日期部分比较 SELECT * FROM orders WHERE created_at::date = '2024-06-01'::date;原因很简单:B-tree索引是按原始列值排序的,当你用函数把列的值变换之后,索引里存的原始顺序就不再适用了,数据库只能全表扫描,逐行计算函数值再比较。有人问:为什么PG这么“笨”,不能自动识别出date_trunc('day', created_at) = 某天等价于一个范围条件?确实,PG的优化器没聪明到做这种等价变换。这不是BUG,是设计选择。7.2 日期范围条件的推荐写法正确的写法永远是范围条件:SELECT * FROM orders WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00';如果只查当天,动态写法可以这样:SELECT * FROM orders WHERE created_at >= current_date AND created_at < current_date + 1;这样的查询可以直接走created_at上的B-tree索引。我在某次实际优化中,把一条用了to_char(created_at, 'YYYY-MM-DD')的统计查询改成范围条件后,执行时间直接从几百毫秒降到几十毫秒,数据量大约百万级,效果立竿见影。7.3 如果确实需要在函数上建索引当然,有些业务实在绕不开函数条件。比如你经常要按date_trunc('day', created_at)来分组或过滤,并且数据量大到必须用索引,PG也支持表达式索引:CREATE INDEX idx_orders_created_day ON orders (date_trunc('day', created_at));建了表达式索引后,WHERE date_trunc('day', created_at) = ...就能走这个索引。代价是每次插入、更新都要多算一次表达式并维护索引,写入性能会略降。所以我的建议是:能改写法走普通索引,就别建表达式索引;实在要建,也要先确认查询频率远高于写入频率。7.4 时间范围查询的边界细节范围查询有一个必须养成的习惯:永远用“左闭右开”。-- 推荐:包含起始,排除结束 WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00'; -- 不推荐:用between WHERE created_at BETWEEN '2024-06-01 00:00:00' AND '2024-06-01 23:59:59';BETWEEN是包含两个端点的闭区间。如果数据里有一条2024-06-01 23:59:59.500,它就不在BETWEEN范围里,但业务上它很可能属于当天。虽然可以写成BETWEEN ... AND '2024-06-01 23:59:59.999999'来补救,但不如直接养成左闭右开的习惯干净。另外,当created_at是timestamptz而对比值是字符串时,字符串会被隐式转换成timestamptz,转换规则取决于会话时区。这又绕回了时区问题:保证会话时区正确,再写范围条件,否则边界上一样会错。写到最后,分享一个我现在的固定习惯:任何涉及时间字段的SQL,先问三件事——字段类型是timestamp还是timestamptz、会话时区是什么、条件是否能用上索引。这三件事确认完,大部分和PG时间函数有关的坑都能避开。如果你过去也遇到过“时间错一天”“索引没走”“统计数字对不上”之类的问题,回头看看这些点,基本都能找到答案。