尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

PostgreSQL numeric类型全解析:存储格式、内存表示与精度实践

发布时间:2026/9/26 17:25:43

资讯中心
01
ARTICLE

PostgreSQL numeric类型全解析:存储格式、内存表示与精度实践

PostgreSQL numeric类型全解析:存储格式、内存表示与精度实践
先说明一下这篇文章不是给你讲“numeric怎么存进内存”这种教科书定义而是把我在实际项目里和 PostgreSQL 的 numeric 搏斗过几轮之后积累下来的完整链路梳理。从数据库磁盘上的存储格式到进程内存里的表示再到客户端 Java/Python 读进来的对象形态一次讲透。如果你正好在处理这类问题PG 导出数据后出现精度丢失、numeric 字段在 Java 里变成科学计数法、或者批量读取 numeric 时内存飙高那这篇文章基本就是照着你的痛点写的。1. 先说清楚这个问题到底在问什么1.1 从一张报错截图说起前阵子有个同事跑数据同步源端 PG 表里有个字段是numeric(18, 4)他拿 JDBC 读出来以后直接doubleValue()转成 Double 再塞进目标库。跑完一看几千万金额对不上账小数点后面第二位就开始飘。这其实是所有跟 numeric 打过交道的人都会遇到的经典问题numeric 不是浮点数它是一套精心设计的十进制变长编码。如果你不知道它在不同环节的表示方式就很容易在“数据库存的值”和“应用内存里的值”之间被精度坑一下。所以这里讲的“PG库数据numeric格式数据读到内存中格式”本质上是三条链路磁盘上的存储格式tuple 里的 varlena 字节序列PostgreSQL 进程内存里的datum / NumericVar 结构应用客户端内存里接收到的对象格式Java BigDecimal、Python Decimal 等三者形态完全不同但很多资料把它们混在一起讲导致新手以为 numeric 在内存里就是字符串或者就是 BigDecimal其实都不准确。1.2 为什么 numeric 比 float 更值得认真对待PostgreSQL 里浮点类型float4/float8用的是 IEEE 754 二进制表示快但存 0.1 这种十进制小数会有无限循环二进制尾巴只是显示层截断了。numeric 则完全绕开二进制浮点直接用十进制的“数字数组”来存所以它能精确表示所有十进制小数。代价是慢、占空间、结构复杂。一个numeric(18,4)表面上只占 9 字节左右整数小数位都能装下实际上因为要存符号、小数位、每组四位十进制数它的磁盘占用和内存开销会比 float8 高不少。更重要的是它在内存里也不是一个 64 位的数而是一个结构体加一组 int16 数组。理解了这一点你就知道为什么很多 PG 内核开发者建议能用整数搞定的事别轻易上 numeric。但业务里金额、税率、汇率这种必须精确保存的字段又绕不开它。所以搞懂它的内存格式不是闲得没事是真能救命的知识点。2. 磁盘与网络上的 numeric那套古老的十进制打包格式2.1 base 10000numeric 真正的“数字单元”很多人第一次看 PG 源码里的 numeric 相关注释都会懵为什么好好的二进制不用要用 10000 做基数先看源码里最核心的注释src/backend/utils/adt/numeric.c/* * Numeric data is stored in decimal digits, with base 10000. * ... * The value represented by the digit array is * sign * 0.digits[0] * 10000^weight ... */这里 base 10000 的意思是每四位十进制数打包成一个 int16。比如12345678.90会被拆成123456789000对应的 weight 是 1因为1234 * 10000^1才是最高位的量级dscale 是 2保留两位小数。所以这个数值在 PG 内部不是“一个数”而是[1234, 5678, 9000]三个 int16 元素加一堆元数据。为什么用 10000 而不是 10用 10000 可以把四位十进制压进一个 16 位整数既保留了十进制精确性又比逐位存储节省空间。为什么不直接上更大的基数比如 10^9因为 int16 的符号范围是 -32768 到 32767四位十进制数最大 9999落在安全区间内做乘法运算时不容易溢出。如果换 10^9 就得用 int32中间运算还要防溢出麻烦得多。这是 PostgreSQL 在“精度”和“运算效率”之间做出的老练取舍。2.2 存储布局逐个字节拆在磁盘的元组里numeric 是变长类型走了 varlena 那一套。完整布局如下typedef struct NumericData { int32 vl_len_; /* varlena header */ int16 n_weight; /* weight of first digit */ uint16 n_sign; /* sign (see NUMERIC_SIGN_*) */ int16 n_dscale; /* display scale */ int16 n_data[FLEXIBLE_ARRAY_MEMBER]; /* decimal digits */ } NumericData;逐个字段解释一下vl_len_变长头4 字节。如果数值很短PG 还会用 1 字节的短 varlena 头做压缩这是很多人在内存分析时漏掉的一个细节。n_weight第一个 digit 的权重也就是10000^weight的指数。比如12345678.90的 weight 是 1。n_sign符号位。不是简单的 0/1而是按位掩码设计0x0000 正数0x4000 负数0xC000 NaN0xD000 正无穷0xF000 负无穷n_dscale显示小数位也就是用户用numeric(10,2)指定的那个 2或者运算后保留的小数位。n_data[]实际数字数组每个元素是一个 0 到 9999 的 int16。举个例子12345678.90的存储字节序列大致是header(4B) | weight1(2B) | sign0(2B) | dscale2(2B) | 1234(2B) | 5678(2B) | 9000(2B)总共 4 2 2 2 2 2 2 16 字节没算短头部优化的情况。而float8才 8 字节。这就是为什么我在 1.2 里说它又慢又占地方——真实对比下来numeric 的存储开销通常是 float8 的两倍起步数据量一大内存和磁盘压力都会上来。顺带提一句PostgreSQL 15 之前 numeric 的小数位最多 16383整体精度上限是 131072 位准确说 PG15 前精度上限是 1000 位PG15 把数值上限扩展到了numeric最大精度 131072 位、最大小数位 16383。但这个改动没有动存储格式只是放宽了 dscale 和 digit 数量的限制。2.3 浮点转 numeric 的精度陷阱在 PG 里执行numeric 0.1和0.1::float8::numeric结果看起来一样但内部字节完全不一样。前者从字符串解析直接把 “1” 放进 digit 数组精确无误差。后者是先转成 IEEE 754 的二进制浮点再把这个二进制近似值转成十进制 digit得到的是0.1000000000000000055511151231257827021181583404541015625这种一长串尾巴。这个问题在应用层同样存在。你用rs.getBigDecimal()没问题但如果手贱调了getDouble()再BigDecimal.valueOf(d)精度就废了。后面实操部分我再细讲。3. 读进内存之后几种“内存格式”要分清3.1 PostgreSQL 进程内的 datum 与 NumericVar当 PG 执行查询、把 numeric 字段读进进程内存时它首先拿到的是上面说的那个NumericData指针也就是一个 varlena datum。要注意这个 datum 不是直接用于计算的。因为 packed 格式的 digit 数组是按四位十进制打包的做加减乘除时进位借位很麻烦。所以 PG 在执行运算前会把它“展开”成NumericVartypedef struct NumericVar { int ndigits; /* number of digits in digits[] */ int weight; /* weight of first digit */ int sign; /* NUMERIC_POS, NUMERIC_NEG, etc */ int dscale; /* display scale */ NumericDigit *buf; /* allocated buffer */ NumericDigit *digits; /* decimal digits */ } NumericVar;这里的NumericDigit其实还是 int16但digits[]是连续分配的一段内存。执行SELECT col 1这类操作时PG 会把 datum 转成 NumericVar算完再转回 NumericData 存盘或发往客户端。所以“读进内存的格式”严格来说有两层原始 datum只读快适合直接输出、序列化NumericVar可计算但占用更多内存因为 buf 是可变的通常会按最大精度先分配一段缓冲区如果你在做 PG 内核开发或者 C 扩展这两者必须分清。如果你只是用 JDBC 读数据那上面这些属于“知道有这回事就好”重点是下面这一层。3.2 客户端 Java 内存中的表现Java 这边JDBC 驱动拿到 PG 返回的数据后会根据协议决定给你什么对象。PostgreSQL JDBC 驱动默认走文本协议除非你设置了prepareThreshold触发了二进制协议。文本协议下numeric 传回来的就是一个字符串比如12345.6700。PGJDBC 拿到这个字符串后如果你调getBigDecimal()它就用new BigDecimal(String)准确构造一个java.math.BigDecimal对象。重点来在 Java 内存里numeric 最终几乎总是表现为 BigDecimal。BigDecimal 内部的结构是final int[] intCompact; // 紧凑表示 final int precision; // 有效数字个数 final int scale; // 小数位它同样是“十进制 digit 数组”的思想跟 PG 的 base 10000 异曲同工只是 Java 直接用了 int 数组。所以 numeric - BigDecimal 是精度无损的天然映射。但有一个非常隐蔽的 FAQ如果你调的是getObject()某些旧版本 PGJDBC 会返回PGobject而PGobject.getValue()返回的是字符串。看起来差不多但如果你直接把这个字符串当“内存里的 numeric”后续做 JSON 序列化时可能被转成字符串而不是数字。我之前就见过一个接口别人拿PGobject的字符串塞进 JSON结果前端拿到的是12345.67带引号的字符串前端求和时变成字符串拼接直接炸了。3.3 二进制协议里的 numeric送进网络前的形态如果客户端和 PG 之间走了二进制协议比如 PGJDBC 的binaryTransfernumeric 在网络上的载荷格式是int16 ndigits int16 weight uint16 sign int16 dscale int16 digits[ndigits]这个格式和磁盘上的NumericData几乎一样只少了 varlena 头。PGJDBC 在 42.x 版本里对 numeric 的二进制解析支持一直不算完善很多版本遇到非默认 scale 的 numeric 时干脆退回文本协议。所以你现在用 JDBC 连 PG绝大多数场景下驱动都是走文本协议返回字符串再由驱动解析成 BigDecimal。这里我想强调一个容易踩的坑如果你自己写网络协议层去接 PG 的二进制 numeric别想当然按 float 的字节序解析。必须按上面那个顺序先读 ndigits再读 weight/sign/dscale最后读 digits 数组。字节序是网络序大端digits 每个元素是带符号的 int16 还是无符号严格说 digit 本身按无符号处理但 weight/sign 都是带符号的sign 是 uint16。我在做数据同步工具时踩过这个坑按小端解析了 weight结果所有大数的量级全部错乱查了半天才发现协议里 int16 是大端。4. 实操从 PG 到应用内存的完整链路4.1 用 JDBC 把 numeric 接进 Java 内存最常见的场景就是 Java 服务查 PG把 numeric 读进内存。直接给一套标准写法try (PreparedStatement ps conn.prepareStatement( SELECT id, amount, tax_rate FROM orders WHERE id ?)) { ps.setLong(1, orderId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { long id rs.getLong(id); BigDecimal amount rs.getBigDecimal(amount); BigDecimal taxRate rs.getBigDecimal(tax_rate); // 不要这么干 // double amountDouble rs.getDouble(amount); } } }几个经验点优先用getBigDecimal()别用getDouble()。getDouble()内部做了Double.parseDouble(rs.getString())或二进制转换一旦 numeric 的小数位超过 double 的精确范围大约 15-17 位有效数字精度就丢了。金额字段丢精度对账必挂。如果需要数值运算直接用 BigDecimal 的方法比如amount.multiply(taxRate).setScale(4, RoundingMode.HALF_UP)。不要转成 Double 算完再转回 BigDecimal这是很多“线上金额差一分钱”事故的根源。setScale 的时机要后置。PG 返回的 dscale 可能是 2但中间运算结果的小数位会变长。你可以在最终落库或返回前端前统一 setScale中间环节保精度。大量读取时的内存优化。BigDecimal 对象比较重每个对象有 intCompact、精度、scale 等字段加上对象头一个小数可能占 40-80 字节。如果你一次查 10 万行 numeric那就是几 MB 到十几 MB 的内存。如果只是展示直接用字符串反而更省如果要做 map/reduce 聚合可以用BigDecimal但尽量避免中间产生大量中间态对象。JDBC 还有一个隐藏机制setFetchSize()。PG 默认会把查询结果全部缓冲到客户端内存对于大结果集必须手动setFetchSize(1000)或setFetchSize(ResultSet.TYPE_FORWARD_ONLY)配CONCUR_READ_ONLY才能走游标模式逐批拉取。否则一次读 500 万行 numericJVM 内存直接给你颜色看。这个跟 numeric 本身关系不大但跟“读到内存”的体量强相关我后面排查部分还会提。4.2 高性能场景如何减少转换开销如果你是在做数据同步、ETL、导入导出这种需要把大量 numeric 从 PG 读出来再写走的任务那么“读到内存”这一步的代价就很肉疼了。我实测过一个场景单表 2000 万行、8 个 numeric 字段用 JDBC 默认方式全量读JVM 老年代涨了接近 2GBGC 频繁。后来做了三件事内存直接降了一个量级改用流式读取stmt.setFetchSize(5000)让 PG 分批次返回不要一股脑塞进 ResultSet 缓冲区。能少建对象就少建如果目标端也是 PG读出来直接用COPY的文本格式流式转发numeric 就用字符串传递不转 BigDecimal。字符串是 immutable 的生命周期短GC 压力小很多。关闭二进制传输如果你显式开过prepareThreshold-1或直接不设置让驱动走文本协议省去二进制解析逻辑的开销。这不是绝对真理但 numeric 的二进制解析在 PGJDBC 里效率并不高文本协议加字符串切割反而更稳。下面是流式读取的参考写法try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement( SELECT id, amount FROM big_table, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(1000); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { long id rs.getLong(id); BigDecimal amount rs.getBigDecimal(amount); // 消费处理写入下游... } } } conn.commit(); }注意TYPE_FORWARD_ONLY和CONCUR_READ_ONLY缺一不可否则 PGJDBC 不会启用服务端游标fetchSize 直接失效。4.3 Python / C 侧验证除了 JavaPython 这边读 PG 的 numeric 也是同理。psycopg2默认会把 numeric 映射成decimal.Decimal这是无损的。import psycopg2 import psycopg2.extras conn psycopg2.connect(dbnametest userpostgres) cur conn.cursor() cur.execute(SELECT 12345678.90::numeric(18,2)) row cur.fetchone() print(type(row[0])) # class decimal.Decimal print(row[0]) # 12345678.90但有一个容易忽略的点如果你用的是psycopg2.extras.RealDictCursor等自定义游标或者某些 ORM 的默认类型映射没注册 Decimalnumeric 可能被映射成str。SQLAlchemy psycopg2 的经典组合一般没问题但如果你用的是pg8000或者手写协议库注意看一下类型映射别让数字变字符串又变回来精度和类型都会出幺蛾子。C 客户端libpq这边更原始PQgetvalue 返回的就是字符串指针内存里就是char[]需要你自己解析成long long或者任意精度库。这也是为什么“内存格式”在不同语言里差别这么大——本质上 PG 给到客户端的不管文本还是二进制最终都需要客户端解析一次而解析出来的对象形态完全取决于语言和驱动。5. 常见问题与排查实录5.1 经典事故numeric 变成科学计数法现象Java 里读 numeric然后 JSON 序列化返回前端前端显示1.2345678E7或者莫名其妙变成1.23E7字符串。原因numeric 读成 BigDecimal 后如果调用了BigDecimal.toString()当数值绝对值大于等于 1E7 或小于 1E-3 时Java 会用科学计数法表示。某些 JSON 库比如比较老的 fastjson 版本会直接调用 toString于是数字就变成科学计数法了。排查和解决BigDecimal amount rs.getBigDecimal(amount); // 想要普通十进制字符串 String plain amount.toPlainString();同时序列化时可以用JsonFormat(pattern 0.########)之类的注解或者自定义序列化器保证 BigDecimal 输出为普通数字格式。这个坑特别常见因为 PG 里很正常的12345678.90到了 Java 里 toString 就成了1.234567890E7前端看到直接懵。5.2 内存暴涨numeric 是否被误存成 text有一类内存问题是 schema 设计挖的坑应用层为了省事把金额字段直接定义成text或varchar写入时String.valueOf(amount)读出来再new BigDecimal(str)。这种做法的内存代价非常阴险text 在 PG 进程里是 varlena按字节存占的空间可能比 numeric 还大。到了 Java 内存里它是 String包含 char[]UTF-16一个数字字符占 2 字节再转 BigDecimal 又要创建第二个对象。你等于一份数据占了 double 的内存。更麻烦的是text 无法保证“数值格式统一”同一个值可能存出1.23、1.230、01.23三种形态读出来再转 BigDecimal 时scale 不一致直接导致对账不平。所以排查内存问题时要先看 schemainformation_schema.columns里查一下如果金额字段是character varying那问题根源就是设计错了改成 numeric 不仅是省内存更是保正确。5.3 排序、分组、去重中 numeric 的隐藏成本numeric 的排序和比较不是 CPU 一条指令能搞定的它要走numeric_cmp函数逐个 digit 比较。比 int8 的排序慢一到两个数量级。在 GROUP BY 或 DISTINCT 时PG 要拿 numeric 做 hash而 numeric 没有天然的 int hash得先转成一种规范化表示再算这里也有不小的开销。我之前排查过一个慢查询一张 5 亿行的流水表按account_id, amount做去重amount 是 numeric(18,2)。去重跑了 40 分钟把 amount 改成 bigint金额*100用分存储后只跑了 6 分钟。除了算法和索引的影响数据类型带来的比较成本真的不可忽视。这里给个建议能用整数表达精确金额的地方优先用整数int8 最小货币单位。numeric 留给真正需要任意精度和小数位动态变化的场景。实在要用 numeric尽量避免在高频 join/group by 的 key 上使用哪怕是二级索引也会受影响。5.4 快速排查工具如果你怀疑数据或内存里有异常的 numeric有几个小技巧-- 查看字段的数据类型 SELECT column_name, data_type, numeric_precision, numeric_scale FROM information_schema.columns WHERE table_name your_table; -- 找出非标准格式的 numeric会报错或返回0 SELECT count(*) FROM your_table WHERE amount::text ~ [^0-9.\-];在 Java 侧如果你怀疑getObject()返回的类型不对可以打印一下Object v rs.getObject(amount); System.out.println(v.getClass().getName()); System.out.println(v);正常情况下应该是java.math.BigDecimal。如果出来的是org.postgresql.util.PGobject检查一下驱动版本和连接参数。6. 我踩过的几个坑最后说几点个人体会全是从线上事故里摔出来的。第一个坑不要相信getString()拿到的 numeric 字符串可以直接往文件里写。PG 输出的文本格式可能包含号、指数形式比如1e20这种很大或很小的数值会以科学计数法输出准确说 PG 的 numeric 文本输出在 scale 极大或极小时也会用指数形式如果你做数据文件迁移最好统一用numeric::text配合to_char()做格式化或者直接用 COPY 导出。用 COPY 导出时numeric 默认就是纯文本分隔符注意转义即可这个格式对大批量迁移非常友好。第二个坑批量写入 numeric 时PreparedStatement 的 setBigDecimal 一定要带上 scale。ps.setBigDecimal(1, amount)有些驱动会把你 BigDecimal 的 scale 原样传过去但有些老版本驱动要求手动ps.setBigDecimal(1, amount.setScale(2, RoundingMode.HALF_UP))不然目标列是 numeric(10,2)你写个 scale0 的值进去会报数值溢出或者被静默截断。我在 Maven 里用了一个老版本的 PGJDBC就吃过这个亏批量 insert 几万条后才发现小数位全丢了。第三个坑JVM 内存调优时别只盯堆内。如果你用 JDBC 读大结果集PGJDBC 在流式读取时会在堆外开 DirectByteBuffer堆外内存堆满也会抛 OOM。用-XX:MaxDirectMemorySize限制一下同时调小 fetchSize能避免诡异崩溃。这和“numeric 读到内存”直接相关——numeric 本身对象重量大流的批量缓冲又占堆外空间两相叠加特别容易爆。第四个坑如果你在做 PG 扩展或者用 PL/pgSQL 处理 numeric别直接用numeric类型做循环变量。循环一百万次精度计算性能和内存都会让你崩溃。能先转成整数处理就转成整数最后再转回 numeric。PL/pgSQL 里 numeric 的中间结果往往保留 16383 位小数你压根不需要那么高精度记得::numeric(18,4)手动收一下 scale能省巨量内存。总结成一句话就是numeric 在 PG 里是一套“十进制四位一打包”的变长结构到了内存里一定要用精确十进制对象去接Java 用 BigDecimalPython 用 DecimalC 端自己换任意精度库。中间任何转成 float/double 的动作都是在拿你的业务正确性开玩笑。如果你能把这套链路理清楚后面再遇到“PG numeric 读到内存格式不对”这类问题基本十分钟就能定位到是协议解析、驱动版本、类型映射还是代码转换的问题。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。