
如果你的表里用double precision存了商品单价19.9显示出来却变成19.899999...财务对账怎么都对不上——这时候最先要怀疑的不是数据库出了 bug而是你把类型选错了。很多人第一次接触 PostgreSQL 时看到文档里real、double precision、numeric、float4、float8一堆名字直接懵了到底哪个是浮点哪个是精确小数精度问题到底出在哪里这篇文章我就基于 PostgreSQL 17把浮点类型的完整用法、区别、适用场景、精度问题和最佳实践一次讲透适合刚上手 PG 的开发者也适合被精度问题坑过、想彻底搞明白根因的老手。1. 先弄清楚 PostgreSQL 里的浮点类型家族1.1 real、float8、numeric 到底谁是谁PostgreSQL 里真正意义上的浮点类型只有两种real也叫float4和double precision也叫float8。另外还有一款绕不开的numeric虽然严格来说它不是浮点类型但几乎所有“小数存什么类型”的讨论最后都会把它拉进来对比所以这里一起说清楚。类型别名存储大小十进制有效位数本质realfloat44 字节约 6-7 位单精度二进制浮点double precisionfloat88 字节约 15-16 位双精度二进制浮点numericdecimal变长最高可达 1000 位任意精度十进制数我建一张表来直观感受一下CREATE TABLE float_demo ( price_real real, price_double double precision, price_numeric numeric(10,2) ); INSERT INTO float_demo VALUES (19.9, 19.9, 19.9); SELECT price_real, price_double, price_numeric FROM float_demo;实际查询结果往往让你意外price_double显示成19.9看起来没事但如果你把price_double精确输出就会看到19.899999999999999。numeric却忠实地显示19.90。这就是二进制浮点和十进制精确小数在“表示方式”上的根本差异也是整篇文章需要围绕的核心。记住一个关键点real和float4是同一个东西double precision和float8也是同一个东西只是不同写法。你在\d 表名里看到的列类型通常显示为real或double precision但在函数签名、源码注释、数据类型名称里float4、float8这种叫法更常见。1.2 float(n) 这种兼容写法会映射成什么PostgreSQL 还保留了一套 SQL 标准风格的float(n)写法这个写法是根据 n 的大小自动映射到real或double precision的SELECT pg_typeof(1.23::float(24)); -- 结果是 real SELECT pg_typeof(1.23::float(25)); -- 结果是 double precision规则是float(1)到float(24)映射到realfloat(25)到float(53)映射到double precision。这套东西主要是为了兼容从其他数据库迁移过来的历史 SQL。我在实际项目里不太建议用float(n)因为它隐藏了真实的存储类型看代码的人还得心算一下 n 的范围远不如直接写real或double precision来得清楚。1.3 numeric 不算浮点但它是所有人对“小数”的默认预期numeric也叫decimal在 PostgreSQL 里两者完全等价都是“任意精度十进制数”。它内部不是用二进制存储的而是对每位十进制数字单独编码所以它不会出现 0.1 0.2 0.30000000000000004 这种二进制浮点误差。你可以指定numeric(10,2)来限定总共 10 位、小数 2 位超出精度直接报错。这么一对比你就明白为什么“小数类型选择”里绕不开 numeric 了大多数业务需求心口一致的“小数”其实是十进制小数能精确表示、能精确计算、计算完还能精确四舍五入。而二进制浮点类型给的是“近似值”精度好不好用要看场景。2. 精度之谜0.1 0.2 为什么会算出 0.300000000000000042.1 二进制浮点不是“精确小数”是“最近可表示值”所有现代编程语言和数据库里的单精度/双精度浮点都遵循 IEEE 754 标准。一个float8由三部分组成符号位、指数位、尾数位。它本质上是用“尾数 × 2^指数”来逼近一个实数。十进制里我们很习惯 0.5、0.25 这种有限小数因为 10 2 × 5所以分母只含 2 和 5 因子的十进制小数才能写尽。但二进制小数只认 2 这个因子所以像 0.1 这种十进制有限小数在二进制里是无限循环小数0.1十进制 0.00011001100110011001100110011…二进制无限循环无法塞进有限的 53 位尾数只能四舍五入截断于是double precision里存的并不是真正的 0.1而是离 0.1 最近的、可以表示的二进制浮点数。用 PostgreSQL 17 实测一下SELECT 0.1::float8::numeric(20,20); -- 输出0.10000000000000000555看到没有你以为存进去的是 0.1实际上存的是 0.10000000000000000555。这就是“表示误差”的来源。2.2 实测三个典型精度崩溃场景第一个场景是最经典的加法SELECT 0.1::float8 0.2::float8; -- 输出0.30000000000000004 SELECT (0.1::float8 0.2::float8) 0.3::float8; -- 输出false第二个场景是乘法。单价 19.9 元买 39 件SELECT 19.9::float8 * 39::float8; -- 输出776.1000000000001没错应付金额是 776.1float8 算出来变成了 776.1000000000001。单独看误差极小但乘以订单量、再求和最后对账就差出几分钱。第三个场景是编写 SQL 时忍不住用round兜底SELECT round(0.1::float8 0.2::float8, 2); -- 输出0.3问题在于你不能假设每一条计算路径、每一次累加之后round都能把误差完全收拾干净。如果误差发生在小数点后第 3 位而业务要求精确到分小数点后 2 位那 round 看起来能救如果误差在累加过程中不断放大最后落到小数点后第 2 位账就对不上了。为了把问题说透我举个日常类比你用三位小数去表示 1/3写 0.333然后 0.333 × 3 0.999而不是 1。float8 对 0.1 做的事情和这个本质上一模一样——它不是算错了而是在存储那一刻就已经“失真”了。2.3 误差不是 bug但业务对误差的容忍度才是关键从我多年经验看很多初级开发者第一次发现这个问题时第一反应是“数据库坏了”或者“我写错了”。其实二进制浮点有误差是数学上的必然不是 PostgreSQL 的缺陷更不是 17 版本的新 bug。真正需要判断的是你的业务能不能容忍这个误差如果场景是“记录温度 25.6 度”float8 存成 25.600000000000001显示时格式化一下没有任何业务影响。如果场景是“计算订单总金额 776.1 元”float8 算成 776.1000000000001客户投诉、财务对不平这就是大事故。所以问题的核心不在于“浮点有没有误差”而在于“这个误差对你的业务模型是否致命”。3. 什么业务适合放浮点float8 的用武之地与实际选型3.1 传感器数据、科学计算、坐标值为什么适合浮点类型并不是一无是处它在很多真实场景里就是最优解。我梳理几个高频使用场景传感器和物联网数据。温度、湿度、气压、振动频率这类数据传感器本身的精度通常只有 ±0.1 甚至 ±0.5你存 float8 的那点误差远远小于测量误差完全不影响分析。这时用 float4 还能省一半存储空间。科学计算和算法输出。机器学习模型的置信度、归一化后的特征向量、推荐系统打分这些值本身来自浮点运算它们就活在“近似”的世界里你强行把它们转成 numeric 反而损失性能、增加复杂度。这类场景直接用 float8 最自然。坐标类数据。GPS 经纬度常用numeric(10,6)存但如果你只是做地图打点和轨迹记录float8 也完全够用。经纬度小数点后 6 位大约对应 0.1 米的物理精度float8 能提供约 15 位有效数字富余量非常大。还有一类是游戏或实时仿真里的位置、速度、加速度它们每帧都在变化对精度的要求是“看起来合理”而不是“精确到分”float8 是最合适的选择。3.2 一张决策表什么字段该用什么数型每次设计表结构时我习惯先过一遍这个表业务字段类型推荐 PG 类型理由金额、价格、折扣率、税率numeric财务对账必须是精确十进制积分、件数、库存integer / bigint整数量根本没有小数百分比展示值numeric(5,2)展示需要固定精度温度、湿度、压力等物理量real / float4测量误差远大于浮点误差存储小算法打分、置信度、特征值double precision本身是浮点运算产物近似可接受经纬度numeric(10,6) 或 float8看是否要参与精确网格计算订单号、身份证等长数字text / bigint数字只是“看起来像数字”的标识符这个表是我经历过几个项目后沉淀下来的。重点不是死记硬背而是理解背后的逻辑凡是“人眼要能精确读出来且必须对上账”的数据用 numeric凡是“机器算出来且误差可控”的数据用浮点。3.3 你还能怎么利用 double 的性能优势PostgreSQL 对 float8 的运算有很好的优化在大量数值计算场景下float8 的速度明显优于 numeric因为 numeric 的十进制计算涉及更多中间处理。比如你对一亿行传感器数据做均值、方差、标准差用 float8 能在可接受时间内跑完而 numeric 可能慢到让人抓狂。但“性能好”绝不能成为你在金额字段上用 float8 的借口。性能问题可以用分区、索引、物化视图去解决精度问题却可能让整个业务系统失去信任。性价比完全不同。4. 什么业务绝对不能碰浮点金额、代码与等值查询4.1 把金额存成 float8 后会发生什么直接看实测结果SELECT 19.9::float8 * 3::float8; -- 输出59.699999999999996一件商品 19.9 元买 3 件客户应该在支付页看到 59.7 元float8 算出来却是 59.699999999999996。如果这块数据只是给人看前端格式化一下还能勉强掩盖但如果后端直接拿这个值做优惠判断、库存扣减、账单生成早晚出问题。更危险的是累加场景。假设三笔订单金额分别是 0.1、0.2、0.3用 float8 求和SELECT 0.1::float8 0.2::float8 0.3::float8; -- 输出0.6000000000000001浮点误差在累加过程中不会相互抵消反而可能不断累积。大促期间一天几十万笔订单每笔错几分钱最后对账系统报出来的差异就是天价数字——这种问题在出现的那一秒几乎无法追溯是哪一笔订单引入的误差。还有更隐蔽的坑数据库里 float8 字段做DISTINCT去重两笔金额明明都是 19.9但因为计算路径不同实际存储的二进制位不同去重结果保留了两行。这种“看起来一模一样、实际不相等”的数据会让报表和聚合结果非常诡异。4.2 numeric 为什么能避免误差一次原理级的解释numeric的存储方式和 float8 有本质区别。它把数字拆成十进制的“数字组”来存每一位的有效数字都是明确的。你可以把它理解为字符串式的存储加上算术运算规则13.70 就是 13.70计算过程中每一步都按十进制规则舍入所以不会出现“0.1 实际上存成 0.10000000000000000555”的问题。用 numeric 时可以限定精度CREATE TABLE orders ( order_id bigint PRIMARY KEY, amount numeric(18,2) NOT NULL, tax_rate numeric(6,4) DEFAULT 0.0000 );插入超过精度范围的值PG 直接报错这反而是一种保护。比如amount numeric(18,2)只能存最多 16 位整数和 2 位小数一旦有人往里塞了一个超过范围的数据PG 会拒绝写入防止脏数据进入财务链路。4.3 订单号、身份证这类长数字也别交给 float8很多人以为“数字就该用数字类型”但像订单号这种本质上是“标识符”的数据一旦超过 float8 的精确整数范围就会悄悄丢精度。float8 能精确表示的最大整数是 2^53也就是 9007199254740992。超过这个数的整数float8 就无法保证精确了。实测SELECT 9007199254740993::bigint::float8::bigint; -- 输出9007199254740992看明白了吗存进去 9007199254740993再读出来变成 9007199254740992直接在存储环节丢了一位。订单号、流水号、身份证这类数据要么用bigint要么干脆用text千万别图省事塞进 float8。5. 浮点数组比较一个容易在 PG 17 里翻车的隐藏坑5.1 一次“数据明明存在却匹配不上”的真实排查有一次我在做推荐系统项目算法每天把用户特征向量存成double precision[]数组用来做用户相似度去重。某天线上反馈“同一个用户向量出现了多行”我排查了很久最后定位到一个匪夷所思的细节两行的特征向量在客户端打印出来都是[0.1, 0.3]但数据库里用等值查询却匹配不到。后来我把两条数据的二进制值拉出来对比才发现一个是通过 JSON 文本解析进来的一个是算法内部实时计算的两者在二进制上根本不一样。复现这个问题的 SQL 很简单SELECT ARRAY[0.1::float8, 0.3::float8] ARRAY[0.1::float8, 0.1::float8 0.2::float8]; -- 输出false第一行的第二个元素是直接写死的 0.3第二行的第二个元素是 0.1 0.2 算出来的 0.30000000000000004。看起来都是[0.1, 0.3]但 PostgreSQL 做数组等值比较时会逐个元素比较用的是浮点的精确等值判断结果自然就是 false。5.2 数组等值比较在浮点语境下的含义PostgreSQL 从较早版本就支持数组的、比较操作符PG 17 里依然如此。数组比较的语义是先比长度再从左到右逐元素比较。对 float8 数组来说元素比较使用的是“浮点数是否严格相等”也就是按二进制位判断。这就意味着数组等值比较对浮点数极度敏感。就算你SELECT出来的值格式化后一模一样只要二进制尾数不同比较就是 false。这不是数据库 bug而是数组等值比较本身固有的“精确匹配”语义。如果你真要拿 float8 数组做去重、关联建议先对数值做统一格式化比如都round(值, 6)之后转成文本数组再比较或者干脆用md5之类的哈希函数对格式化后的文本做聚合键。但要注意round之后再比较也有潜在风险关键在于你必须把规则定成“全链路一致”而不是依赖直觉。5.3 升级到 PG 17 前后数组查询的变化与应对很多团队从旧版本升到 PG 17并行查询、分区裁剪的默认行为有变化一些原来“碰巧能跑通”的 SQL 突然开始出错。浮点数组比较不是 17 才引入的机制但 PG 17 的并行及执行计划优化会让这类问题更容易暴露出来以前单线程执行时数据量小、没触发比较升到 17 后执行计划变了把原本应该比较的路径提前执行问题就浮出水面。我的建议很简单不要在业务逻辑里用 float8 数组做等值关联或去重。数组适合存“展示用”或“整体导出用”的数据不适合当关联键。如果一定要用把关联键改成 round 后的文本字段或者拆成子表逐行关联。6. NaN 与 Infinity浮点最容易被忽略的三条尾巴6.1 浮点除零不报错NaN 会悄悄污染整列数据正整数除以 0 在 PostgreSQL 的普通整数类型里会直接报错但浮点数有特殊值机制。PG 17 里执行SELECT 1.0::float8 / 0.0::float8; -- 输出Infinity SELECT 0.0::float8 / 0.0::float8; -- 输出NaNInfinity和NaN是两个特殊的浮点位型。NaN代表“不是一个数”它最大的问题是具有传染性任何包含 NaN 的算术运算结果几乎都是 NaN。当你对一列数据做sum时只要这一列里藏着一条 NaN整个求和结果就变成 NaN。实际场景里 NaN 怎么来的大多是应用层计算了非法表达式后直接写入数据库或者从外部导入文件时解析失败默认成了NaN。这类脏数据很难通过普通查询发现因为SELECT NaN::float8 NaN::float8; -- 输出falseNaN 不等于自身如果你用WHERE value NaN去过滤一条都查不出来。正确写法是SELECT COUNT(*) FROM measurements WHERE value IS DISTINCT FROM value;value IS DISTINCT FROM value对 NaN 会返回 true这才能把 NaN 揪出来。更推荐的做法是在建表时就加约束把 NaN 挡在外面CREATE TABLE measurements ( sensor_id int, value double precision CHECK (isfinite(value)) );isfinite()是 PostgreSQL 内置函数只接受有限浮点数NaN 和 Infinity 都会被拒绝。这个约束配合应用层清洗基本能把特殊值问题根治。6.2 排序、分组、过滤时 NaN 的奇怪表现在 PG 17 里ORDER BY value ASC时NaN 会排在所有普通数值的后面ORDER BY value DESC时NaN 反而排在最前面。这和你业务想要的“最大最小值”很可能不一致。分组时另有一个坑多个 NaN 在GROUP BY里会落进同一组因为哈希聚合时它们被当作同一个值但如果你用WHERE value NaN去筛又筛不出来。过滤和分组对 NaN 的处理逻辑不一致很容易让统计结果产生“幽灵行”。典型表现是你按传感器分组算平均值某个传感器的读数全是 NaNAVG结果不是 NULL 而是 NaN再把结果导出到报表系统前端图表直接画不出线。6.3 用这三步把特殊值挡在业务层门外我现在每个项目都会在数据接入层做三道防线应用层校验所有浮点字段写入前判断Math.isFinite(value)Java或math.isfinite(value)Python把 NaN 和 Infinity 提前拦截。数据库约束建表时对关键浮点列加CHECK (isfinite(column))防止漏网之鱼。查询兜底汇总计算时用NULLIF(value, NaN)之类的表达式把 NaN 转成 NULL避免污染聚合结果。三道防线听起来繁琐但一旦上线跑业务你会发现省下的排查时间远大于前期成本。7. 落到工程上的最佳实践从建表到迁移的完整约束7.1 字段定义规范能写多明确就写多明确PostgreSQL 17 中最推荐的实践不是“记住浮点规则”而是把规则固化到建表语句里。我这里给出一套我常用的建表规范可以直接抄CREATE TABLE finance_orders ( order_id bigint PRIMARY KEY, amount numeric(18,2) NOT NULL, tax_rate numeric(6,4) DEFAULT 0.0000, exchange_rate numeric(20,10), quantity integer NOT NULL, score double precision );我为什么这样定amount用numeric(18,2)绝大多数公司的财务系统只精确到分18 位总长足够容纳亿元级订单。tax_rate用numeric(6,4)税率经常出现 0.0625 这种四位的十进制小数不用浮点避免税率计算引入误差。exchange_rate用numeric(20,10)跨境订单汇率换算必须保留足够多的十进制位数否则多币种对账会差钱。score用double precision算法分数本来就是浮点运算产物不需要精确十进制语义用 float8 性能更好。建表时把约束和注释写清楚比事后培训团队成员“哪个场景用哪种类型”有效得多。7.2 MySQL 迁到 PG 17 时的浮点映射从 MySQL 迁到 PostgreSQL 17 是很多团队必经之路。两边数据类型的映射直接影响线上行为MySQL 类型PostgreSQL 17 类型注意事项FLOATreal 或 double precision按原精度需求选DOUBLEdouble precision直接映射DECIMAL(M,D)numeric(M,D)原样迁移别改成 doubleTINYINT(1)booleanMySQL 习惯用 0/1PG 直接用 booleanBIGINTbigint保持不动最容易踩的坑是 MySQL 的DECIMAL迁到 PG 后被顺手改成了double precision。因为 MySQL 的DECIMAL底层实现也有性能问题很多人在 MySQL 期就用 float/double 存金额迁到 PG 之后“惯性不改”结果把精度问题原封不动带了过来。正确的做法是迁移过程中把DECIMAL全部映射为numeric顺手把历史上那些不合理的 float 金额字段也一并修正。7.3 累计求和的校验别用绝对误差用相对误差如果历史表里已经存在 float8 的金额字段没办法立刻改表结构你可以先做一轮完整性检查找出误差可能超标的行SELECT order_id, sum(amount) AS float_sum FROM orders GROUP BY order_id HAVING round(sum(amount), 2) round(sum(amount_numeric), 2);在实际项目里金额字段的校验我建议用“相对误差”而不是“绝对误差”。绝对误差 0.01 对一笔 100 万的订单来说是九牛一毛但逻辑上还是不等对一笔 0.1 元的订单来说0.01 就是 10% 的误差完全不可接受。所以更好的校验条件是HAVING abs(sum(amount) - sum(amount_numeric)) 0.01 AND abs(sum(amount) - sum(amount_numeric)) / NULLIF(sum(amount_numeric), 0) 0.0001;先看绝对误差是否超过 0.01再看相对误差是否超过万分之一。两步同时满足才说明这笔订单真的需要人工复核。至于已经发现的浮点金额字段最彻底的方案还是ALTER TABLE ... ALTER COLUMN ... TYPE numeric(18,2) USING amount::numeric(18,2)。PG 在没有特殊约束的情况下允许 float8 平滑转 numeric但要在停机窗口做并对历史数据做全量校验。8. 一分钟决策接到字段需求时我的判断顺序8.1 四个问题帮你锁定类型每次接到新需求看到“这个字段要存小数”我会按顺序问自己四个问题这个值是人输的、财务要看的、对外展示的如果是直接用 numeric。这个值会不会参与等值查询、去重、分组关联如果是放弃 float8因为浮点比较的不可控性会让 SQL 行为变得诡异。这个值是否来自物理测量、科学计算、算法推断如果是float8 是很自然的选择。这个值是否需要多次累加、累计求平均如果是浮点误差会随运算次数放大能用 numeric 就不要犹豫。其中第 4 条经常被忽略。很多数据单独存储时看不出问题但一旦反复聚合误差就“滚雪球”。8.2 我个人用了三年的一套浮点自查清单最后分享一份我在代码评审、表结构评审时反复使用的自查清单也送给你金额、费用、账务相关一律 numeric不接受任何浮动。税率、汇率、折扣率至少 numeric(6,4)按需加长。积分、余额、库存能用 bigint 就用 bigint别想着小数。坐标、角度、比例看用途记录用 float8计算网格或等值匹配用 numeric。传感器读数real 足够float4 省空间。算法分、特征向量float8但禁止做等值匹配。所有浮点字段加CHECK (isfinite(col))防止 NaN 和 Infinity 入库。所有需要展示的小数字段在应用层定义好格式化函数不要依赖数据库隐式转换。我个人在实际项目里长期执行“凡是带钱和税的类型一律 numeric凡是传感器和算法输出的向量一律 float8”这条铁律三年里因为数值精度引发的线上故障几乎为零。这个经验一开始看起来机械但它是踩过账目对不上、数组匹配不上、NaN 污染聚合结果这些坑之后总结出来最简单可靠的方案。你在 PG 17 里设计类型时多花两分钟按这套思路走一遍就能把大部分精度问题消灭在表结构设计阶段。