☰
PostgreSQL numeric类型读入内存的精度与内存开销解析
2026/10/1 22:35:53 网站建设 项目流程

做后端这些年,凡是跟钱、跟重量、跟比例打交道的字段,我一律默认选 PostgreSQL 的 numeric 类型。原因很简单:它不像 float4/float8 那样在二进制转换里偷偷丢精度,也不像 int 那样容不下小数。但最近好几个人问我同一个很基础的问题:PG 库里的 numeric 数据读到内存中到底是什么格式?有人说拿到的是字符串,有人说变成了 BigDecimal 或 Decimal,还有人发现同一个 SQL 在 JVM 和数据分析引擎里跑,内存占用和精度表现完全不一样。

这篇文章就是把 numeric 从磁盘到客户端内存的完整路径拆开讲,包括 PG 内部存储、wire protocol、JDBC/psycopg/Npgsql 的映射差异,以及我在实际项目中踩过的精度和内存坑。适合正在做金融结算、报表引擎、数据同步、数据中台的同学,也适合那些已经用了 BigDecimal 但始终没搞清楚“为什么一个数字这么吃内存”的后端开发。

1. numeric 的磁盘真相:它不是浮点数,而是“十进制纸条”

1.1 内部其实是基数为 10000 的变长数组

很多人对 numeric 的第一个误解,是觉得它和 float8 一样,在 PG 内部就是一个 C 语言的 double。实际上 numeric 是变长类型,存储格式非常像“人手工分节的十进制纸条”。

PostgreSQL 内部把 decimal 数字按每 4 位一组切开,每一组用一个 int16 保存,这 4 位十进制数字叠加起来,就构成了这个数的绝对值。除了 digits 数组,它还需要记录四个元数据:ndigits(有多少组)、weight(最高组的位置)、sign(正负号)、dscale(小数位数)。以 123456789.12345 为例,它会像 1|2345|6789|1234|5 这样分组存储,然后通过 weight 和 dscale 告诉解析方小数点该放在哪里。

这里的关键词是“十进制纸条”。float8 是用符号位、指数、尾数拼出来的二进制科学计数法,很多十进制小数在二进制下根本没有尽头,比如 0.1 存进 float8 后其实是一个无限循环小数截断后的近似值。而 numeric 每个 digit 都是货真价实的十进制位,没有二进制舍入误差。代价就是它比定长类型更复杂:需要额外的组数信息、符号、小数位信息,读取时要解析,运算时要做十进制规约,CPU 开销和内存占用都比 float8 大好几个量级。

1.2 传输协议决定了你看到的是字符串还是二进制块

数据库里存得明白,不等于客户端能看得明白。PG 的客户端协议支持两种传输格式:text 和 binary。

text 模式下,任何 numeric 都会被序列化成普通十进制字符串,比如'123456789.12345'。这个字符串对人类友好,但到语言运行时手里还得再解析一次。binary 模式下,PG 不会把它转成浮点数,也不会贴上“字符串”标签,而是按照一套 internal representation 传出去:先发 ndigits、weight、sign、dscale 四个元数据字段,再跟一段 digits 数组。客户端驱动拿到这串二进制结构,才能真正还原成 numeric 的值。

所以“读到内存中是什么格式”这个问题,本质上是由驱动决定,而不是由 PG 决定。PG 只是给你两条路:要文本字符串,要原生数字组。驱动选哪种协议、映射成哪种对象,直接关系到精度和内存表现。

2. 读进内存后的三种正经形态和一种事故形态

2.1 字符串形态:最直观,但后面全是坑

如果直接用 JDBC 的 getString()、Python 里 raw SQL 的 fetchone() 拿到裸结果,或者中间经过了数据同步工具,numeric 在内存里就是字符串。

字符串形态的好处是完整保留你看到的字符,不会凭空多出小数位。但坏处有三层:第一,字符串本身不能参与加减乘除,后续计算必须再次解析;第二,很多同学解析时顺手调了Double.parseDouble或float(),这一下就把精度送走了;第三,字符串在内存里并不便宜。Java 的 String 是 UTF-16 编码,每个字符占 2 字节,一个 22 位的金额数字,光内容就是 44 字节,再加对象头、哈希缓存,60 字节起步。如果这个值又会进一步转成 BigDecimal,内存直接双份。

我见过不少报表项目,中间层 DTO 里定义的是 String,写的时候想着“反正是展示给前端看的”,结果等要二次聚合时,系统里飘满了原始字符串和临时生成的 BigDecimal,GC 压力巨大。

2.2 BigDecimal/Decimal 形态:精度安全,内存天然放大

当 JDBC 驱动按标准接口返回java.math.BigDecimal,或者 psycopg2 默认返回decimal.Decimal时,numeric 就变成了一种任意精度十进制对象。

以 Java 为例,BigDecimal 内部由两个部分组成:一个BigInteger保存未缩放的值,一个int保存 scale(小数位数)。BigInteger 又由 int 数组和符号位组成。也就是说,磁盘上一个 14 字节的 numeric 值,到堆上可能变成一个七八十字节的对象。这个放大不是驱动实现偷懒,而是语言运行时只能通过对象结构来表达任意精度。

这种形态是安全的,scale 和值都能对上,适合计算、比较、序列化到高精度协议。但代价是对象开销、分配频率、GC 压力同时上升。在大批量读取时,BigDecimal 往往是内存瓶颈的第一来源。

2.3 double/float 形态:往往是映射错误的产物

有些 ORM 或者报表工具在读取 numeric 列时,会默认映射成 double。比如 Java 里有人图省事调rs.getDouble(),Python 里有人对 Decimal 做float(),C# 里有人直接Convert.ToDouble(decimal)。

这个操作瞬间把任意精度十进制值压进 IEEE-754 双精度结构:1 位符号位、11 位指数位、52 位尾数位。能表达的有效精度大约只有 15 到 17 位十进制数字。超过这个范围,看似正常显示,实际内存里已经是另一串二进制近似值。典型的例子是 0.01 在 double 里真实值是0.01000000000000000020816...,单独打印会被舍入掩盖,几千次累加后差异就会显形。

更隐蔽的是大整数。PG 里允许numeric表示几十位甚至上百位的整数,double 直接读出来就是一个“差不多对”的近似值,排序、去重都能错。大家一定要把这条记牢:numeric 的约束是十进制精度,double 的极限是二进制精度,两者不存在无损等价关系。

2.4 JSON 形态:协议层面最容易丢精度的隐藏通道

另一个经常被忽视的场景是 JSON。PG 的jsonb、row_to_json、to_json在处理 numeric 时,默认会输出成 JSON 数字字面量,而不是字符串。这个设计在跨语言传输时会惹出大事。

原因在于 JSON 标准里的数字是十进制字面量,但很多语言解析 JSON 时会把它转成二进制浮点数。以 JavaScript 最典型:JSON.parse("9007199254740993")会得到9007199254740992,因为超过 2^53 的整数无法被 double 精确表示。所以在 JSON 通道里,严谨的做法是让 PG 侧把 numeric 先转成文本:amount::text,或者应用层收到后再用字符串 to BigDecimal,绝不直接依赖 JSON 解析器自动转数字。

3. 主流驱动与语言的内存映射对比:同样的 numeric 不一样的下场

3.1 JDBC 的 getBigDecimal 为什么是“第一选择”

Java 生态里,PG JDBC 驱动最标准的读取方式是rs.getBigDecimal(),它无论走 text 还是 binary 协议,都会尝试还原成 BigDecimal。但如果有人图省事写rs.getObject(),情况就微妙了:驱动可能根据返回值和列类型做优化,有时返回 Long、有时返回 Double、有时返回 BigDecimal。我不建议赌这个自动判断,因为不同版本、不同列 typmod 下的行为不完全一致。

比较稳妥的写法是显式判类型:

BigDecimal amount = rs.getBigDecimal("amount"); if (amount == null) { // 处理 NULL }

同样,PreparedStatement 绑定参数时也尽量用setBigDecimal,不要先转成 String 再让 PG 做隐式 cast。转 string 不仅多一次解析,还容易因为小数位处理产生额外的 scale 变化。

3.2 Python 生态:psycopg2 与 asyncpg 各有各的脾气

psycopg2 的行为相对固定,numeric 列默认返回decimal.Decimal,这符合“任意精度十进制”的直觉。但有些人为了性能会主动float(row[0]),这是非常危险的偷懒。

asyncpg 的情况更值得注意:它默认返回的不是标准 Decimal,而是一个asyncpg.pgproto.pgproto.Numeric容器,底层同样保精度,只是类型不同。如果你直接塞给 pandas 或者转成 JSON,可能需要显式处理:

value = record["amount"] # asyncpg.Numeric decimal_value = value.to_decimal()

在异步高并发场景下,尽量只 select 真正需要的 numeric 列。因为每多一个 Numeric/Decimal 对象,就多一次对象分配和 GC 压力,这不是 Python 的强项。

3.3 .NET 的 Npgsql:decimal 的精度边界你要心里有数

Npgsql 默认会把 PG numeric 映射为 C# 的decimal,这是最自然的映射,因为两者都是十进制。但 C# decimal 是 128 位十进制浮点,有效数字最多大约 28 到 29 位。一旦 PG 数值超过这个范围,Npgsql 的默认映射就会出问题,要么抛异常,要么要求你改用NpgsqlTypes.NpgsqlNumeric这个任意精度类型。

在实际项目里,这个问题比很多人想得更常见。比如产品表存了一个 40 位的流水号,或者统计引擎里出现了天文数字,你以为驱动会“自动扩容”,实际上语言类型决定了天花板。选型阶段就要在 DTO 里明确:使用 decimal 还是 NpgsqlNumeric,或者干脆用 string 承接,不然后期换类型会伤筋动骨。

3.4 其它语言:与其交给运行时猜,不如显式声明类型

Go、Rust 这类强调类型显式的语言里,驱动一般不会默认把 numeric 塞进 float64。比如 Go 的 pgx 有专门的pgtype.Numeric,内部使用big.Int加上缩放因子来表达,语义非常明确。Rust 的 sqlx 也有对应的 PG numeric 类型。

我认为这里反映的是同一个道理:跨语言处理 numeric,最怕的是“类型隐式坍塌”。凡是自动转成 float/double 的地方,就是精度事故的温床。与其事后排查,不如一开始就在映射层显式指定高精度类型或字符串。

语言/驱动默认内存类型精度边界备注
Java JDBCBigDecimal任意精度使用 getBigDecimal,不要依赖 getObject
Python psycopg2decimal.Decimal任意精度避免手动转 float
Python asyncpgasyncpg.Numeric任意精度可转 Decimal 或自定义容器
.NET Npgsqldecimal / NpgsqlNumeric28~29 位有效数字超出时需切换 NpgsqlNumeric
Go pgxpgtype.Numeric任意精度底层 big.Int 组合,避免 float64

4. 内存账本:从 PG 磁盘到 JVM 堆,一个 numeric 被放大了多少倍

4.1 字节级别的对比表

很多 JVM 内存模型优化的文章都会强调对象头、对齐填充,但真正看进去的人不多。这里我把一个 numeric(20,5) 在不同阶段的大概体积摆出来:

阶段形态预估字节数
PG 磁盘/共享缓冲变长 numeric 内部结构约 12~14 字节
text 协议传输22 位十进制字符串约 30 字节左右(不含协议头)
Java String 内存UTF-16 字符串 + 对象头约 50~60 字节
Java BigDecimalBigInteger int[] + scale + 对象头约 70~100 字节
double 基本类型IEEE-754 双精度8 字节
Double 包装对象对象头 + 8 字节约 24 字节

同一个值,在 PG 里十几字节,进了 JVM 堆可能膨胀到七八十字节,放大倍数接近 5 到 6 倍。如果读取时再同时保留 String 和 BigDecimal,那一个值可能占掉 150 字节以上。这个放大倍数,就是很多数据同步和批处理任务 OOM 的根本原因。

4.2 千万行数据查询为什么容易把堆和内存池压垮

我们用最简单的方式估算:一张千万行的表,有一个 numeric(18,6) 列,PG 端的数据量大约 140 到 160MB。但用 JDBC 逐行封装成 BigDecimal 后,不算其它列,仅这一列就是10_000_000 × 80B ≈ 800MB。如果再把行对象、ResultSet 内部缓冲、字段名映射也算上,堆直接奔着 1.5GB 去了。这在 16G 的容器里,配合 JVM 默认堆大小,说崩就崩。

这类问题常和热词里“jvm内存模型”“spark内存”“内存溢出”绑在一起出现。尤其在 Spark 里读取 PG numeric 数据,如果你让 Catalyst 把它当作非标准 DecimalType,它可能先把每行的值做成面向对象的 Decimal,再在 UnsafeRow 里压实成内部字节数组,中间这一来一回,内存放大是肉眼可见的。

我的建议是分三步缓解:

  • 先确认这个 numeric 列是否真的需要任意精度。如果业务固定 2 位小数,可以直接在 PG 侧乘 100 或 10000,落成 bigint,这样磁盘从 14 字节变 8 字节,内存里变成 long,性能好一个数量级。
  • 如果必须保留 numeric,尽量把 SUM、AVG、ROUND 这类聚合下推到 PG,让内存里只存结果,而不是存几百万元数据回来自己做。
  • 批量读取时不要一次fetch全量,JDBC 设fetchSize,并用流式读取,或者直接分页,减少单次驻留对象数。

4.3 堆外内存与对象池的取舍

热词里“堆外内存”“内存池”“大内存架构”这些场景,在 numeric 上也有特殊讲究。

如果你用 Netty 的堆外 ByteBuf,或者自建了一个 native 内存池来缓存结果,那么 numeric 一般会被序列化成 byte[] 或字符串。这会把原本 14 字节的 PG 内部值变成 20 多字节的文本。看似不多,但如果缓存命中率不高、释放不及时,堆外内存会缓慢上涨,最终看起来是“内存池泄漏”,其实只是字符串对象生命周期管理失控。

BigDecimal 这类的不可变对象也不适合做对象池:每次运算都会产生新对象,池化不但没法复用,反而增加管理开销。真在大内存数据管道里,优先选择整型替代方案,其次才是高精度对象。

5. 一次“差一分钱”事故的完整复盘:从 double 解析到 BigDecimal 修复

5.1 事故现场:报表汇总差 0.01

某结算系统的日终报表,金额全部来自 PG numeric 列。某天核对时发现本月累计和上月累计之间差了 0.01,数据库里单笔 SUM 却完全正确。一开始怀疑是分页漏数据,查了一圈不是;又怀疑是时区问题,排除;最后定位到一段内存计算代码:

double amount = rs.getDouble("amount"); total += amount;

粗看没毛病,单笔金额都只有两位小数。但问题在于,double无法精确表示 0.01,更无法精确表示它和一堆两位小数的累加。只要有一批订单在数据库和内存两条路径上分别做过聚合,最后比较 BigDecimal 与 double 结果,误差就会暴露。

5.2 排查链路:库、驱动、协议、内存逐层取证

我按这个顺序把现场从头捋了一遍:

第一步,用 psql 直接查数据库的 SUM 结果,正确。这排除了脏数据问题。

第二步,在 Java 里同时用getDouble和getBigDecimal读取同一行,打印各自的值。视觉上几乎一致,但用compareTo比较 BigDecimal 时,发现 double 转换出来的 BigDecimal 和数据库原值的最后几位不一样。这定位到读取层。

第三步,看驱动日志,确认走的是 text 协议,PG 侧给出的字符串没有精度问题。这排除了传输层被截断的可能。

第四步,检查整条链路的类型映射。发现有人为了“统一格式”,在写入汇总对象时把 BigDecimal 又转成了 double,等于在内存里二次损伤精度。

5.3 修复方案和后续性能处理

修复方向非常明确:链路里不允许出现 double。读取用getBigDecimal,运算用 BigDecimal,最后输出时用setScale(2, RoundingMode.HALF_UP)。注意实际业务里如果涉及除法和汇率,舍入规则要提前定好,不然后面对账又会出现新一类的“几分钱差异”。

性能上,把大部分聚合操作下推到 PG,只取最终结果;明细查询只展示时用字符串格式化。这样既保住精度,又避免在 JVM 里跑千万行 BigDecimal 加法。

6. numeric 列没写精度怎么办:typmod=-1 带来的读取不确定性

6.1 未定义精度的真实行为

建表时写amount numeric,和写amount numeric(18,2),在 PG 内部对应不同的 typmod。前者是未定义精度和小数位,任何数字都能存,读取时 dscale 完全由实际值决定。

这带来一个很隐蔽的问题:应用层无法从列元数据得知“该列到底应该保留多少位小数”。相同的列,这一行可能是 1.1,下一行可能是 1.100000,到了内存里,不同驱动会保留不同的 scale。比如文本协议下1.1和1.10会传出不同长度的字符串,BigDecimal 也会拿到不同 scale,但equals比较时它们是“不相等”的。

所以生产环境我强烈建议建表时显式声明 numeric(p,s)。这不仅是存储规范,更是给下游读取方的尺子。没有这把尺子,内存格式就会跟着数据漂移。

6.2 用 information_schema 摸清列的尺子

如果表已经存在,不确定某个 numeric 列的精度和小数位,可以这样查:

SELECT column_name, numeric_precision, numeric_scale FROM information_schema.columns WHERE table_name = 'your_table' AND data_type = 'numeric';

numeric_precision和numeric_scale会返回 NULL,说明这一列没有定义 typmod,下游就要格外小心。这里额外提醒一句:不要只看 ORM 映射文件里的 Java 类型,比如Double或者BigDecimal,那不代表数据库实际精度。以 information_schema 为准。

6.3 应用层需要统一约定的几个建议

面对 typmod=-1 的列,我的实践经验是三层约定一起上:

  • 应用层 DTO 统一用字符串承接 numeric,不做隐式转换,避免“第一人称视角”里的类型漂移。
  • 需要计算的字段,在入口处统一 parse 成 BigDecimal,并指定 scale,不做第二次猜测。
  • 数据同步和中间表尽量落地成 numeric(38,10) 或类似有界定义,防止下游引擎因为精度和 scale 的随机性产生解析失败。

这一套约定多花不了多少代码,但能挡掉大量“明明数据库是对的,到了内存里就变了个样”的疑难杂症。

我个人现在的习惯是:numeric 一旦进入代码,就把它当成“不可变的高精度值对象”,不随便转 double,不随便依赖隐式映射。凡是看到金额、比例、计量值这类字段,第一反应就是查一查表定义里的 numeric_precision 和 numeric_scale,再定内存里的承接类型。这条路走多了之后,线上关于“差一分钱”和“内存溢出”的告警真的会少很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询