numeric 与时间:两类最容易选错的类型
数值和时间是两个「选错了要还债很久」的类型族。这一节只讲会真正咬人的部分。
金额永远不能用浮点
Section titled “金额永远不能用浮点”SELECT 0.1::float8 + 0.2::float8 = 0.3::float8 AS 浮点相等吗,
(0.1::float8 + 0.2::float8)::text AS 浮点的实际结果,
0.1::numeric + 0.2::numeric = 0.3::numeric AS numeric相等吗;float8 是 IEEE 754 二进制浮点,0.1 在二进制里是无限循环小数,存进去就已经不精确了。这不是 Postgres 的问题,是所有语言共有的。
误差会累积。把一分钱加一千次:
SELECT sum(x)::text AS 浮点累加一千次 FROM (SELECT 0.01::float8 AS x FROM generate_series(1, 1000)) t;
结果是 9.999999999999831,不是 10。在对账系统里这就是事故。
大整数同样会被吃掉精度:
SELECT 123456789012345678::float8::text AS 浮点表示,
123456789012345678::numeric::text AS numeric表示;float8 只有约 15~17 位有效数字,后面的位直接丢了。
numeric 的精度与舍入
Section titled “numeric 的精度与舍入”numeric(10, 2) 的意思是「总共 10 位有效数字,其中 2 位在小数点后」。超出小数位会四舍五入,超出整数位会报错:
SELECT 1.005::numeric(10,2) AS 舍入到两位,
12.345::numeric(4,1) AS 舍入到一位;SELECT 12345.67::numeric(5,2) AS 整数位溢出会报错;
不写精度的裸 numeric 可以存任意精度,但别在表定义里这么干——它会掩盖数据质量问题,而且失去了「这一列最多多少钱」这个文档价值。
timestamptz 存的不是时区
Section titled “timestamptz 存的不是时区”这是时间类型最大的误解。名字叫 “timestamp with time zone”,但它不存储时区:
SELECT pg_column_size(timestamptz '2024-06-01 12:00:00+00') AS timestamptz字节数,
pg_column_size(timestamp '2024-06-01 12:00:00') AS timestamp字节数;两者都是 8 字节。timestamptz 存的是一个绝对时刻(相对于 UTC 纪元的微秒数)。「时区」只在两个地方起作用:输入时用来换算成 UTC,输出时用来渲染成本地墙钟时间。
同一个值,在不同会话时区下显示不同:
SET TIME ZONE 'Asia/Shanghai';
SELECT (timestamptz '2024-06-01 12:00:00+00')::text AS 上海时区下显示,
(timestamptz '2024-06-01 20:00:00+08')::text AS 同一时刻的另一种写法;SET TIME ZONE 'UTC'; SELECT (timestamptz '2024-06-01 12:00:00+00')::text AS UTC时区下显示;
是同一个值,只是渲染方式不同。
而 timestamp(不带 tz)存的是一个没有绝对含义的墙钟读数——它不知道自己是哪个时区的 12 点:
SET TIME ZONE 'Asia/Shanghai';
SELECT (timestamp '2024-06-01 12:00:00')::text AS 无时区_上海会话,
(timestamptz '2024-06-01 12:00:00+00')::text AS 有时区_上海会话;AT TIME ZONE 是两者之间的转换器,方向由输入类型决定:
SET TIME ZONE 'UTC';
SELECT (timestamptz '2024-06-01 12:00:00+00' AT TIME ZONE 'Asia/Shanghai')::text AS "tstz → 上海墙钟(timestamp)",
(timestamp '2024-06-01 12:00:00' AT TIME ZONE 'Asia/Shanghai')::text AS "上海墙钟 → 绝对时刻(tstz)";interval 的夏令时陷阱
Section titled “interval 的夏令时陷阱”interval '1 day' 和 interval '24 hours' 不是一回事。前者是「日历上的一天」,后者是「绝对的 24 小时」。平时相等,在夏令时切换日会分道扬镳:
SET TIME ZONE 'America/New_York';
SELECT (timestamptz '2024-03-09 12:00:00-05' + interval '1 day')::text AS 加1天,
(timestamptz '2024-03-09 12:00:00-05' + interval '24 hours')::text AS 加24小时;2024-03-10 是美东夏令时开始的日子,那天只有 23 小时。
+ 1 day→ 还是 12:00,墙钟时间不变(这通常是用户期望的「明天同一时间」)。+ 24 hours→ 变成 13:00,因为它老老实实加了 86400 秒。
常用的时间工具
Section titled “常用的时间工具”SET TIME ZONE 'UTC';
SELECT date_trunc('month', timestamptz '2024-06-15 13:45:00+00')::text AS 截断到月,
date_trunc('hour', timestamptz '2024-06-15 13:45:00+00')::text AS 截断到小时,
age(timestamptz '2025-01-01', timestamptz '2024-06-15')::text AS 人类可读的年龄,
justify_interval(interval '400 days')::text AS 规整成年月日;date_trunc 是做时间序列聚合的主力。配合 generate_series 可以补齐没有数据的时间桶:
数据集里的订单从 2024-06-01 开始,所以先看直接 GROUP BY 的结果:
SET TIME ZONE 'UTC';
SELECT date_trunc('day', created_at)::date AS 日期, count(*) AS 订单数
FROM orders WHERE created_at < '2024-06-03'
GROUP BY 1 ORDER BY 1;只有 2 行。5 月 29–31 日没有订单,这三个日期整行消失了。再看 LEFT JOIN 到生成序列上:
SET TIME ZONE 'UTC';
SELECT d::date AS 日期, count(o.id) AS 订单数
FROM generate_series(
timestamptz '2024-05-29', timestamptz '2024-06-03', interval '1 day'
) d
LEFT JOIN orders o ON date_trunc('day', o.created_at) = d
GROUP BY d ORDER BY d;6 行,空桶补成了 0。做时间序列报表时这是必须的——否则前端画出来的折线图会把没有数据的日子直接跳过,趋势就失真了。
注意 count(o.id) 而不是 count(*):LEFT JOIN 不匹配时右表全是 NULL,count(*) 会数成 1。
先自己回答,再点开对照。
为什么存金额不能用 double precision?给出一个会出错的具体数值。
因为 float8 是 IEEE 754 二进制浮点,0.1 在二进制里是无限循环小数,存进去就已经不精确了。
本节的三个数值都可以直接引用:
0.1::float8 + 0.2::float8 = 0.3::float8返回 false;- 把
0.01累加一千次得到 9.999999999999831,不是 10; 123456789012345678::float8后面几位直接丢了——float8只有约 15~17 位有效数字。
这不是 Postgres 的问题,是所有语言共有的。
常见错误:以为问题只出在显示上,加个 round(x, 2) 就干净了。出错的是比较和累加,跟怎么显示无关:0.1 + 0.2 = 0.3 返回 false,这个 false 写在对账脚本的等值判断里,或者写在 WHERE balance = 0 里,是一条静默返回错误结果的语句——没有报错,没有异常日志,只是账对不平。而且误差是累积的,最后一步舍入救不回前面一千次加法已经丢掉的部分。
numeric 相比浮点的代价是什么?什么数据反而该用浮点?
代价是 numeric 是变长的软件实现,算术比浮点慢一个数量级左右——浮点运算是一条 CPU 指令,numeric 是一段代码。
反而该用浮点的是本身就带测量误差的数据:科学计算、机器学习特征、传感器读数。那里精度不是需求,速度才是。
常见错误一:「numeric 更准,那全库统一用 numeric 最安全」。对一个第 3 位就不准的传感器读数,你付出了一个数量级的算术代价,买来的精度在数据源头根本不存在。类型选择要匹配数据的性质,不是越精确越好。
常见错误二:既然 Postgres 有一个叫 money 的类型,那存钱当然用它。不要用——它的小数位数由全局参数 lc_monetary 决定,改一次配置全库的语义就变了,而且它不记录币种。这个坑的特点是:在单一币种、从不改配置的环境里它一直工作正常,直到某天要做多币种,或者某次迁移带了不同的 locale 过来。正确写法是 numeric(p,s) 加一个单独的 currency 列。
timestamptz 到底存了什么?它和 timestamp 都是 8 字节,差别在哪?
timestamptz 存的是一个绝对时刻——相对于 UTC 纪元的微秒数。时区只在两个地方起作用:输入时用来换算成 UTC,输出时用来渲染成本地墙钟时间。
timestamp 存的是一个没有绝对含义的墙钟读数,它不知道自己是哪个时区的 12 点。
所以差别不在字节数(两者都是 8 字节),而在这 8 字节有没有一个约定好的原点。有原点,2024-06-01 12:00:00+00 和 2024-06-01 20:00:00+08 就是同一个值,只是渲染方式不同;没有原点,它就只是一串数字。
常见错误:望文生义,以为 “with time zone” 意味着时区跟着值一起存下来了,于是用 timestamptz 来记录「这条记录是在哪个时区产生的」。8 字节里装不下时区——本节的 pg_column_size 就是证据,带 tz 和不带 tz 一样大。写入时的那个偏移量被换算掉之后就永久丢失了,你只知道那一刻是什么时候,不知道当事人当时看的是哪块表。真要保留这个信息,得另开一列存时区名。
什么样的数据才适合用不带时区的 timestamp?
本身就与时区无关的东西:生日、酒店的「入住时间 14:00」、闹钟设定。这些语义就是「墙钟上的那个读数」,换到哪个时区都不该被换算。
反过来,「记录某件事发生的时刻」一律用 timestamptz。
常见错误:「我们的服务器统一设成 UTC,所以用 timestamp 存 UTC 时间效果完全一样。」在单机、单时区、永不迁移的前提下,这确实成立——而这正是它危险的地方:这个前提写在运维配置里,不写在数据里。换一台机器、加一个不同 TimeZone 的只读副本、有人在会话里 SET TIME ZONE,同一列的语义就变了,而且不会有任何报错。你无法从一个裸 timestamp 反推它当初指的是哪一刻,历史数据是不可修复的。timestamptz 把这个约定放进类型系统,代价为零——两者都是 8 字节。
+ interval '1 day' 和 + interval '24 hours' 什么时候会给出不同结果?
夏令时切换日。 1 day 是「日历上的一天」,24 hours 是「绝对的 86400 秒」,平时相等,在那一天分道扬镳。
本节的实测:美东时区下,2024-03-09 12:00:00-05 加 1 day 还是 12:00(墙钟不变),加 24 hours 变成 13:00(老老实实加满 86400 秒)——因为 2024-03-10 是夏令时开始的日子,那天只有 23 小时。
选择规则是看需求说的是哪一种:「订阅到期日」「每天早上 9 点提醒」用日历单位;「超时 24 小时后作废」「限速窗口」用绝对单位。
常见错误:以为「我们不在有夏令时的地区,所以随便用」。夏令时只是日历单位不等长的一种表现,month 同样不等长,而且和时区完全无关——interval '1 month' 的长度取决于它加在哪个月上,'2024-01-31' + 1 month 会被截断到 2 月 29 日。按月续期的场景里,一旦某次落到月末被截断,后面每期的日子就跟着变了,这在任何时区都会发生。
(回到上面「interval 的夏令时陷阱」一节)