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 并不存时区,它存的是一个绝对时刻(内部的 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 不存时区,所以「这个订单是用户在当地几点下的」这类信息,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 的语境下结论很干脆:

从这里也能看出一个更大的模式: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 不变。

替换到生产环境时,有三件事要跟着改:

  1. 造数方式:本课用 generate_series 现造数据,生产上做同类验证要用影子表或从只读备库抽样,别在业务表上做全表聚合。
  2. 类型变更的成本:本课不涉及存量表的类型迁移。真实系统里把 double precision 改成 numeric 会重写整张表并持有 ACCESS EXCLUSIVE 锁,必须走在线变更流程(加新列、双写回填、切换、删旧列),这一步的规划比选择类型本身更难。
  3. 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、以及应用侧的对账差异看板。类型问题一旦上线,症状通常不在数据库的监控里,而在业务的对账报表里——所以对账口径本身要留一条从差异值反查到列的路径。

动手

  1. 复现本课全部输出。建库后依次跑通 t_money、t_money2、t_ts、t_nulls、t_a/t_b 五组语句。判断标准:sum(f) 与 sum(n) 的输出格式不同(一个不带小数点,一个带),且 NOT IN 那一条返回 0 行。
  2. 加一组自己的数据:把 t_money2 的 0.01 换成 19.99,行数改成 50 万,跑一次两个 sum。判断标准:float_sum 与 numeric_sum 的差异量级变大或变小,但 numeric_sum 始终精确等于 19.99 × 500000。
  3. 找出你手头任何一张表的金额列,用 \d 表名 看它的类型。判断标准:你能说出它为什么是现在这个类型,以及改成 numeric 需要付出什么。
  4. 在 t_ts 上插入一个跨越夏令时的时间点(例如某个采用夏令时的时区的 03:00),分别在两个时区下读它。判断标准:你能解释 tz 列的输出为什么在两个时区下相差的正是时区偏移。

自测

  1. 为什么 float8 累加 1000 次 0.1 的误差,和累加 1000 次 0.25 的误差不一样?请从二进制表示的角度解释。
  2. 上面 t_vol 的实验里 numeric 列比 float8 列占空间更小。在什么条件下这个结论会反过来?为什么?
  3. timestamptz 不存时区,那么「用户在当地时间 09:00 下的单」这个事实,用 timestamptz 能不能还原出来?如果不能,建模时该加什么?
  4. WHERE status <> 'bad' 和 WHERE status IS DISTINCT FROM 'bad' 在含 NULL 的表上结果不同,请说出原因,并判断哪种更接近「找出现在还是 bad 的记录」这个业务意图。
  5. 一个列是 varchar(50),业务上从未出现过 50 字符以上的值。此时把它改成 text 有什么好处?有什么风险?

↓ 下一步:01 章 · 约束是数据库兜底

进入 keel 阅读