KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
00 · 类型与建模 — keel 龙骨
## 现场:差 0.03 元的对账
现场:差 0.03 元的对账
财务把差异贴到群里的时候,数字是 0.03。
订单量一百万出头,每笔金额两位小数。代码做的是最朴素的 sum(amount),没有并发、没有丢单、没有重复消费。差异来自一个建表时随手做的决定:金额列的类型写成了 double precision。
这个类型在别的语言里叫 double 或 float,在 PostgreSQL 里叫 float8,它们是同一件东西:二进制浮点数。而二进制的世界里没有 0.1 这个数,只有一个足够接近它的近似值。
先别往下读。猜一下:把 0.1 用 float8 累加 1000 次,和用 numeric 累加 1000 次,结果会差多少?
两种数字:十进制算术与二进制浮点
直觉上,numeric 和 float8 的区别是「精度不同」。这个说法不算错,但会让你做错选择——你会以为它是「稍微准一点」和「稍微快一点」之间的取舍,而实际上它们是两套不同的算术。
float8 实现的是 IEEE 754 双精度二进制浮点,它擅长表示形如 1/2^k 的数,不擅长表示十进制小数。0.1 在二进制里是无限循环小数,存进去的是截断后的近似值。这个近似值本身很小(大约 1e-17 量级),但累加会放大误差,因为每一次加法都在近似值上再近似一次。
numeric 是任意精度的十进制数,它按十进制的位来存,0.1 就是 0.1,没有近似。代价是它的运算走的是软件实现的十进制算术,比 CPU 硬件直接算浮点慢。
一次完整运行
在一台 PostgreSQL 16.15 实例上,把 1000 个 0.1 分别累加:
CREATE TABLE t_money(f float8, n numeric(14,2));
INSERT INTO t_money SELECT 0.1, 0.1::numeric(14,2) FROM generate_series(1,1000);
SELECT sum(f) AS float_sum, sum(n) AS numeric_sum FROM t_money;
float_sum | numeric_sum
------------------+-------------
99.9999999999986 | 100.00
float8 少的那部分大约是 1.4e-12。这个量级在大多数业务里无所谓,问题在于它不稳定:换个数据分布、换个累加顺序,误差的符号和大小都会变。同样一批数据,两次对账可能给出两个不同的差异值,而这正是对账最难排查的形态——你没法用一个固定规则解释它。
把规模放大到一百万个 0.01(差不多就是一年百万单的量级):
INSERT INTO t_money2 SELECT 0.01, 0.01::numeric(14,2) FROM generate_series(1,1000000);
SELECT sum(f) AS float_sum, sum(n) AS numeric_sum FROM t_money2;
float_sum | numeric_sum
--------------------+-------------
10000.000000171856 | 10000.00
到了这个量级,误差是 0.00000017 元。看起来还是很小。但真实的金额不会整齐如 0.01:会有 19.99、88.35、0.07 这类值,每个都带自己的表示误差;会有折扣、分摊、汇率折算,每一步都在放大的基数上再乘一次。误差在链路上滚两圈之后是 0.03 还是 300 元,取决于你的业务链路有多长,而不是取决于 PG 有多准。
顺带看一个经典输入:
SELECT 0.1::float8 + 0.2::float8 AS float_sum, 0.1::numeric + 0.2::numeric AS numeric_sum;
float_sum | numeric_sum
---------------------+-------------
0.30000000000000004 | 0.3
numeric 不是零代价
既然 numeric 精确,是不是该把所有数字列都改成 numeric?
先看空间。同样是 100 万行,一列 float8 一列 numeric(14,2),值域都是 0~10000 之间带两位小数:
total | float8_column | numeric_column
-------+---------------+----------------
42 MB | 7813 kB | 6836 kB
numeric 那一列反而更小。原因在单值上看得更清楚:
float8_bytes | float4_bytes | numeric_small_bytes | numeric_14_2_bytes
--------------+--------------+---------------------+-------------------
8 | 4 | 10 | 14
numeric 是变长的:值小的时候用 10 字节,位数多的时候用 14 字节。上面的数据里大部分值只有四五位有效数字,所以总占用比固定 8 字节的 float8 还少。如果你把值域换成几十亿的金额,numeric 会涨到十几字节,那时它才比 float8 大。
所以「numeric 更占空间」这句话要加条件才成立。真正稳定的代价在运算上——同样的 100 万行各聚合一次:
sum
------------
4999750000
Time: 475.406 ms
sum
---------------
4999750000.00
Time: 503.059 ms
差了约 6%,在这个规模上不算大。注意这一次两个结果是相等的,因为这批数据的值域很小、都是整数加 0.25,float8 恰好能精确表示。这也提醒一件事:拿小值域的数据去验证浮点误差,很可能验证不出来,误差必须在真实分布和真实链路上才会浮现。
结论可以写死:金额、数量、任何要与另一个系统对账的数字,用 numeric。 只有当你在做的是统计、指标、模型特征这类「结果本身允许有误差」的计算时,float8 的精度才够用。这个判断不依赖数据量级,依赖的是这个数字会不会被拿去和别人的数字比对。
时间戳的两种意思
PG 里有两个看起来几乎一样的时间类型:
timestamptz(timestamp with time zone)timestamp(timestamp without time zone)
它们的名字有误导性。timestamptz 并不存时区,它存的是一个绝对时刻(内部的 UTC 微秒数),显示时按当前会话的时区渲染。timestamp 存的是一个墙上时间,没有绝对位置,你在哪里读它,它都是那串数字。
同一行数据在两个时区下读出来:
CREATE TABLE t_ts(id int, tz timestamptz, nt timestamp);
SET TIME ZONE 'Asia/Shanghai';
INSERT INTO t_ts VALUES (1, '2026-03-15 02:30:00', '2026-03-15 02:30:00');
SELECT tz, nt FROM t_ts;
tz | nt
------------------------+---------------------
2026-03-15 02:30:00+08 | 2026-03-15 02:30:00
换到 UTC 再读同一行,一个变了,一个没变:
SET TIME ZONE 'UTC';
SELECT tz, nt FROM t_ts;
tz | nt
------------------------+---------------------
2026-03-14 18:30:00+00 | 2026-03-15 02:30:00
tz 从 02:30+08 变成 18:30+00,是同一个时刻的两种写法。nt 纹丝不动,因为它压根没记录这是哪个时区的 02:30。
这个差别决定了选择:
- 记录事件发生的时刻(订单创建、日志时间、任务开始)用
timestamptz。它是绝对位置,跨时区、跨夏令时都不会错位。 - 记录一个「本地墙上时间」的约定(门店每天 09:00 开门、每周一 00:00 结算、用户设置的闹钟 07:30)用
timestamp。这类值的含义就是「当地钟表上的那个时间」,加上时区反而会带来跨时区偏移的麻烦。
有一个组合要特别注意:timestamptz 不存时区,所以「这个订单是用户在当地几点下的」这类信息,timestamptz 给不了你。需要的话得额外存一列原始时区或者本地时间。这不是 PG 的缺陷,是所有「绝对时刻」类型共同的取舍。
NULL 不是「没有值」,是「不知道」
NULL 在 SQL 里不是空字符串、不是 0,它是「未知」。这个定位听起来抽象,但它在比较运算上的后果非常具体。
CREATE TABLE t_nulls(id int, status text);
INSERT INTO t_nulls VALUES (1,'ok'),(2,'bad'),(3,NULL);
SELECT count(*) FROM t_nulls; -- 3
SELECT count(*) FROM t_nulls WHERE status <> 'bad'; -- 1
SELECT count(*) FROM t_nulls WHERE status <> 'bad' OR status IS NULL; -- 2
total_rows
------------
3
matched_by_ne_bad
-------------------
1
with_is_null_added
--------------------
2
第三行的 status 是 NULL,NULL <> 'bad' 的结果不是「真」,也不是「假」,而是 NULL——未知。WHERE 只保留结果为「真」的行,未知一律丢弃。所以「待处理列表」少掉的那些记录,从来不是被谁删了,是它们从来没通过筛选条件。
直接看这三个比较表达式的结果:
SELECT NULL <> 'bad' AS null_ne_bad, NULL = NULL AS null_eq_null, NULL IS NULL AS null_is_null;
null_ne_bad | null_eq_null | null_is_null
-------------+--------------+--------------
| | t
前两列是空白——那是 NULL 在 psql 里的显示方式。NULL = NULL 不是真,因为「未知等于未知」仍然是未知。只有 IS NULL 这种专门的操作符才能拿到确定的布尔值。
NOT IN 的塌缩
比 <> 更隐蔽的是 NOT IN。假设有一条「找出没有被引用的记录」的查询:
CREATE TABLE t_a(code int);
INSERT INTO t_a VALUES (1),(2),(NULL);
CREATE TABLE t_b(id int, ref int);
INSERT INTO t_b VALUES (10,1),(11,2),(12,3),(13,9);
SELECT * FROM t_b WHERE ref NOT IN (SELECT code FROM t_a);
id | ref
----+-----
(0 rows)
一行都没返回。目标是「ref 不在 (1, 2, NULL) 里」,按直觉应该返回 ref=3 和 ref=9 两行。但 x NOT IN (1,2,NULL) 会被展开成 x <> 1 AND x <> 2 AND x <> NULL,最后一个式子的结果永远是 NULL,整个 AND 链无论前面多真都塌成未知。
换成 NOT EXISTS 就正常:
SELECT * FROM t_b b WHERE NOT EXISTS (SELECT 1 FROM t_a a WHERE a.code = b.ref);
id | ref
----+-----
12 | 3
13 | 9
这个坑的恶劣之处在于它不报错。子查询里插进一个 NULL 的时机可能是几个月后某个功能上线,而受影响的查询在别的地方,表现为「某个报表某一天开始空了」。
工程上的处理方式有两个层次。表层是把 NOT IN 换成 NOT EXISTS,这两个写法在 PG 里的执行计划通常等价,语义上 NOT EXISTS 才是你真正想要的那个。更根本的一层是:如果一个列的业务含义不允许未知,就把它设成 NOT NULL。status 这种列,与其让它允许 NULL 然后在每个查询里补 IS NULL 分支,不如在建模时就定一个明确的初始值。这一条会在第 01 章展开。
长度的三种写法
char(n)、varchar(n)、text 三者在 PG 里的差别比多数人以为的小,也比多数人以为的怪。
SELECT length('ab'::char(5)) AS char5_len,
length('ab'::varchar(5)) AS varchar5_len,
'ab'::char(5) = 'ab '::text AS char_pads_equal;
char5_len | varchar5_len | char_pads_equal
-----------+--------------+-----------------
2 | 2 | f
两个 length 都是 2——char(n) 不在存储时补齐(它是变长的),只在语义上贴着「定长」的标签。但它确实带着补齐语义:char(5) 的值在与 text 比较时会先按定长规则处理,所以 'ab'::char(5) 和 'ab '(两个空格)不相等。这类比较差异在字符串拼接、GROUP BY、跨表 JOIN 时会突然冒出来,而且极难排查。
在 PostgreSQL 的语境下结论很干脆:
- 默认用
text。 它的性能和varchar完全一样,没有长度检查开销,也没有补齐语义。 - 只在长度本身是业务约束时才用
varchar(n),比如「这个字段对应上游系统的 50 字符字段」。而且要知道,改这个长度会触发一次表重写(除非新长度不小于旧长度且只放宽上限),在千万级表上是个需要计划的操作。 - 不要用
char(n)。 它是从定长存储时代留下来的东西,在 PG 里没有任何一项优势。
从这里也能看出一个更大的模式:varchar(255) 这种写法之所以流行,是 MySQL 早期习惯的迁移痕迹,而不是一个技术判断。类型里的每一段声明,都该对应一个真实存在的约束,否则它只是下一个人的困惑。
建模决策表
| 需求 | 选择 | 判断依据 |
|---|---|---|
| 金额、数量、对账口径的数字 | numeric(p,s) |
会被拿去和别人的数字逐位比对,不能有表示误差 |
| 统计指标、模型特征、大致量级 | float8 / float4 |
结果本身允许误差,且需要硬件浮点速度 |
| 记录事件发生的绝对时刻 | timestamptz |
跨时区、跨夏令时必须指向同一时刻 |
| 记录当地钟表上的约定时间 | timestamp |
含义就是那个本地时间,加时区反而错位 |
| 普通的字符串 | text |
与 varchar 性能相同,无补齐语义 |
| 有真实长度约束的字符串 | varchar(n) |
约束来自外部契约,不是习惯 |
| 业务上不允许未知的列 | NOT NULL + 默认值 |
把「未知」的可能性在设计阶段消掉 |
生产边界
本课实验跑在一台本地 PostgreSQL 16.15(Windows,--locale=C,shared_buffers 128MB)上。正文里的毫秒数和字节数只用来读形状,不要抄绝对值:换一台机器、换一个 shared_buffers、换一批数据,数字都会变。稳定的是因果关系——numeric 误差为零而 float8 不是,timestamptz 随会话时区变而 timestamp 不变。
替换到生产环境时,有三件事要跟着改:
- 造数方式:本课用
generate_series现造数据,生产上做同类验证要用影子表或从只读备库抽样,别在业务表上做全表聚合。 - 类型变更的成本:本课不涉及存量表的类型迁移。真实系统里把
double precision改成numeric会重写整张表并持有ACCESS EXCLUSIVE锁,必须走在线变更流程(加新列、双写回填、切换、删旧列),这一步的规划比选择类型本身更难。 NOT NULL的补加:ALTER TABLE ... SET NOT NULL需要对全表做一次校验扫描。大表上同样要走「先加CHECK (col IS NOT NULL) NOT VALID、再VALIDATE」的分步流程。
上线后该盯的指标:pg_stat_user_tables 里目标表的 seq_scan 与 idx_scan 比例、pg_stat_activity 里长时间运行的 ALTER TABLE、以及应用侧的对账差异看板。类型问题一旦上线,症状通常不在数据库的监控里,而在业务的对账报表里——所以对账口径本身要留一条从差异值反查到列的路径。
动手
- 复现本课全部输出。建库后依次跑通
t_money、t_money2、t_ts、t_nulls、t_a/t_b五组语句。判断标准:sum(f)与sum(n)的输出格式不同(一个不带小数点,一个带),且NOT IN那一条返回 0 行。 - 加一组自己的数据:把
t_money2的0.01换成19.99,行数改成 50 万,跑一次两个sum。判断标准:float_sum与numeric_sum的差异量级变大或变小,但numeric_sum始终精确等于19.99 × 500000。 - 找出你手头任何一张表的金额列,用
\d 表名看它的类型。判断标准:你能说出它为什么是现在这个类型,以及改成numeric需要付出什么。 - 在
t_ts上插入一个跨越夏令时的时间点(例如某个采用夏令时的时区的 03:00),分别在两个时区下读它。判断标准:你能解释tz列的输出为什么在两个时区下相差的正是时区偏移。
自测
- 为什么
float8累加 1000 次0.1的误差,和累加 1000 次0.25的误差不一样?请从二进制表示的角度解释。 - 上面
t_vol的实验里numeric列比float8列占空间更小。在什么条件下这个结论会反过来?为什么? timestamptz不存时区,那么「用户在当地时间 09:00 下的单」这个事实,用timestamptz能不能还原出来?如果不能,建模时该加什么?WHERE status <> 'bad'和WHERE status IS DISTINCT FROM 'bad'在含NULL的表上结果不同,请说出原因,并判断哪种更接近「找出现在还是 bad 的记录」这个业务意图。- 一个列是
varchar(50),业务上从未出现过 50 字符以上的值。此时把它改成text有什么好处?有什么风险?
↓ 下一步:01 章 · 约束是数据库兜底