精确数值(NUMERIC / DECIMAL)
本节讲解 PostgreSQL 的任意精度数值类型 NUMERIC(别名 DECIMAL)。你将掌握精度与标度的含义、四舍五入规则、存储开销,以及金额建模的最佳实践。
前置知识
阅读本节前,建议先了解:自增类型(SERIAL / IDENTITY)
基础概念
精确数值类型(Exact Numeric Type)指 NUMERIC,它能精确表示十进制小数,不做二进制浮点近似。DECIMAL 是 SQL 标准名,在 PostgreSQL 中与 NUMERIC 完全等价(DECIMAL 只是别名,不是独立类型)。
| 类型名 | 别名 | 存储空间 | 取值范围 | 说明 |
|---|---|---|---|---|
NUMERIC(p, s) | DECIMAL(p, s) | 变长,见下文换算 | 小数点前最多 p - s 位,小数最多 s 位 | 指定精度与标度 |
NUMERIC(p) | DECIMAL(p) | 同上 | 等价于 NUMERIC(p, 0),只存整数 | 整数语义 |
NUMERIC | DECIMAL | 同上 | 最多 131072 位小数点前 + 16383 位小数点后 | 不限制精度 |
两个关键术语:
- 精度(Precision):有效数字的总位数,即
p。 - 标度(Scale):小数点后的位数,即
s。
NUMERIC(10, 2) 表示:总共 10 位数字,其中小数点后 2 位,因此整数部分最多 8 位,可表示的最大值是 99999999.99。
术语补充:
- 数值字面量(Numeric Literal):形如
1.23的不加引号常量,PostgreSQL 会将其判定为numeric类型而非float8。 - 特殊值
NaN(Not a Number):NUMERIC支持的"非数字"值,在排序中比任何数字都大。 - 任意精度(Arbitrary Precision):精度上限只受 PostgreSQL 内部限制约束,可达小数点前 131072 位。
语法与用法
列名 NUMERIC [ (精度 [, 标度]) ]-- 最简可运行示例
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0) -- 金额:精确到分
);
INSERT INTO products (name, price) VALUES ('机械键盘', 299.00), ('无线鼠标', 59.90);
SELECT name, price, price * 3 AS price_x3 FROM products ORDER BY price DESC; name | price | price_x3
----------+--------+----------
机械键盘 | 299.00 | 897.00
无线鼠标 | 59.90 | 179.70
(2 rows)字面量的类型判定值得注意:
SELECT
pg_typeof(1.23) AS literal_type, -- numeric
pg_typeof(1.23e2) AS e_notation_type, -- numeric(指数写法仍解析为 numeric)
pg_typeof(1.23::DOUBLE PRECISION) AS float_type; -- double precision literal_type | e_notation_type | float_type
--------------+-----------------+--------------------
numeric | numeric | double precision
(3 rows)不加类型前缀的小数字面量默认是 numeric,这是 PostgreSQL 的设计选择(SQL 标准要求如此),也是为什么 SELECT 0.1 + 0.2 会精确返回 0.3 而不是浮点误差值。
详细说明
精度与标度的含义
用一个实验看清楚 p 和 s 分别约束什么:
CREATE TABLE numeric_demo (
n_10_2 NUMERIC(10, 2), -- 整数最多 8 位,小数正好 2 位
n_5_0 NUMERIC(5, 0), -- 只存整数,最多 5 位
n_5_3 NUMERIC(5, 3), -- 整数最多 2 位,小数正好 3 位
n_free NUMERIC -- 无限制
);按各列上限插入:
INSERT INTO numeric_demo (n_10_2, n_5_0, n_5_3) VALUES (99999999.99, 99999, 99.999);
SELECT n_10_2, n_5_0, n_5_3 FROM numeric_demo; n_10_2 | n_5_0 | n_5_3
------------+-------+--------
99999999.99 | 99999 | 99.999
(1 row)超出精度会报错:
-- 错误!整数部分超过 8 位(p - s = 8)
INSERT INTO numeric_demo (n_10_2) VALUES (100000000.00);ERROR: numeric field overflow
DETAIL: A field with precision 10, scale 2 must round to an absolute value less than 10^8.-- 错误!整数部分超过 2 位(p - s = 2)
INSERT INTO numeric_demo (n_5_3) VALUES (100.000);ERROR: numeric field overflow
DETAIL: A field with precision 5, scale 3 must round to an absolute value less than 10^2.小数位会被四舍五入,不是报错:
-- 小数超过 2 位:自动四舍五入到 2 位
INSERT INTO numeric_demo (n_10_2) VALUES (1.005) RETURNING n_10_2; n_10_2
--------
1.01
(1 row)-- 注意 1.005 的结果:原因是 numeric 在入库前就已按目标标度舍入
INSERT INTO numeric_demo (n_10_2) VALUES (1.004) RETURNING n_10_2; n_10_2
--------
1.00
(1 row)各列的有效范围:
| 声明 | 整数部分位数 | 最大绝对值 | 最小非零绝对值(step) |
|---|---|---|---|
NUMERIC(10, 2) | 8 | 99999999.99 | 0.01 |
NUMERIC(5, 0) | 5 | 99999 | 1 |
NUMERIC(5, 3) | 2 | 99.999 | 0.001 |
NUMERIC(18, 6) | 12 | 999999999999.999999 | 0.000001 |
NUMERIC | 131072 | 10^131072 | 无固定步长 |
四舍五入规则
PostgreSQL 的 numeric 舍入是四舍五入(Round Half Away From Zero),不是银行家舍入(Banker's Rounding):
SELECT
round(2.5) AS r_2_5, -- 3
round(3.5) AS r_3_5, -- 4
round(-2.5) AS r_n2_5, -- -3(远离零)
round(2.4) AS r_2_4, -- 2
round(0.125, 2) AS r_2dp; -- 0.13 r_2_5 | r_3_5 | r_n2_5 | r_2_4 | r_2dp
-------+-------+--------+-------+-------
3 | 4 | -3 | 2 | 0.13
(1 row)注意 numeric 字面量在解析阶段就已确定标度,所以 2.5 是"精确的 2.5",舍入方向明确。这与 float8 不同——float8 的 2.5 恰好可精确表示,但 0.1 不行。
round() 的两种形态:
SELECT
round(123.456) AS no_scale, -- 123,返回 numeric
round(123.456, 1) AS scale_1, -- 123.5
round(123.456, 0) AS scale_0, -- 123
round(123.456, -1) AS scale_neg, -- 120(负数标度向整数位舍入)
round(123.456, -2) AS scale_neg2; -- 100 no_scale | scale_1 | scale_0 | scale_neg | scale_neg2
----------+---------+---------+-----------+-----------
123 | 123.5 | 123 | 120 | 100
(1 row)其他舍入函数:
SELECT
trunc(123.456) AS truncated, -- 123 截断
trunc(123.456, 2) AS trunc_2dp, -- 123.45
ceil(123.001) AS ceiled, -- 124 向上取整
floor(123.999) AS floored, -- 123 向下取整
sign(-42.5) AS sign_val, -- -1
abs(-42.5) AS abs_val; -- 42.5 truncated | trunc_2dp | ceiled | floored | sign_val | abs_val
-----------+-----------+--------+---------+----------+---------
123 | 123.45 | 124 | 123 | -1 | 42.5
(1 row)一个必须警惕的陷阱:numeric 列在存储时已经按列标度舍入,所以"分位以下的小数"永远不存在于表中。但计算中间结果可能产生更精细的小数:
-- 单价 100.00,数量 3,折扣 0.333 → 若直接相乘再存入 NUMERIC(10,2),结果被舍入
SELECT (100.00 * 3 * (1 - 0.333))::NUMERIC(10, 2) AS rounded_total; rounded_total
---------------
200.10
(1 row)-- 若分两步舍入(先算单价折扣,再乘数量),结果可能不同
SELECT (round(100.00 * (1 - 0.333), 2) * 3) AS step_rounded; step_rounded
--------------
200.31
(1 row)业务含义:金额计算的舍入顺序会影响最终结果,必须由业务规则明确规定,并在测试用例中固化。会计上通常采用"先算明细再汇总"而非"先汇总再舍入"。
存储开销
NUMERIC 是变长类型,存储开销与位数成正比,这是它与整数类型最大的工程差异。
规则如下:
| 组成部分 | 字节数 |
|---|---|
| 变长头(varlena header) | 1 字节(短值)或 4 字节(长值) |
| 符号位 + 分组数(weight、dscale 等) | 4 字节 |
| 有效数字分组 | 每 4 位十进制数字占 2 字节(即每 2 字节存 4 位) |
估算表(忽略 4 字节头,只算数字部分):
| 声明 / 值 | 有效位数 | 数字存储估算 | 含头总开销估算 |
|---|---|---|---|
NUMERIC(5, 2) 存 123.45 | 5 | 2 字节 + 2 字节 = 4 字节 | 约 8~9 字节 |
NUMERIC(10, 2) 存 12345678.90 | 10 | 3 组 × 2 = 6 字节 | 约 11 字节 |
NUMERIC(10, 2) 存 1.00 | 3 | 2 字节 | 约 7 字节 |
NUMERIC(18, 6) 存 123456789012.123456 | 18 | 5 组 × 2 = 10 字节 | 约 15 字节 |
NUMERIC(38, 10) 存 38 位 | 38 | 10 组 × 2 = 20 字节 | 约 25 字节 |
实测:
CREATE TABLE size_demo (
n_5_2 NUMERIC(5, 2),
n_10_2 NUMERIC(10, 2),
n_18_6 NUMERIC(18, 6),
i_val BIGINT,
f_val DOUBLE PRECISION
);
INSERT INTO size_demo
SELECT 123.45, 12345678.90, 123456789012.123456, 1234567890123456789, 1234567.89
FROM generate_series(1, 10000);INSERT 0 10000-- 用 pg_column_size 看单行的各列实际占用
SELECT
pg_column_size(n_5_2) AS bytes_n_5_2,
pg_column_size(n_10_2) AS bytes_n_10_2,
pg_column_size(n_18_6) AS bytes_n_18_6,
pg_column_size(i_val) AS bytes_bigint,
pg_column_size(f_val) AS bytes_float8
FROM size_demo LIMIT 1; bytes_n_5_2 | bytes_n_10_2 | bytes_n_18_6 | bytes_bigint | bytes_float8
-------------+--------------+--------------+--------------+--------------
7 | 10 | 15 | 8 | 8
(1 row)结论很清晰:BIGINT 恒定 8 字节,DOUBLE PRECISION 恒定 8 字节,而 NUMERIC 随位数增长。在需要存海量数值的场景(时序数据、指标表)里,这个差异会直接体现在表体积与 IO 上。
对比一个具体案例:存 1 亿行金额数据。
| 方案 | 单列开销 | 1 亿行总开销 |
|---|---|---|
NUMERIC(10, 2) | 约 10 字节 | 约 1 GB |
BIGINT(存"分") | 8 字节 | 约 800 MB |
DOUBLE PRECISION | 8 字节 | 约 800 MB(但有精度风险) |
省下 200 MB 换来"必须记住单位是分"的心智负担,多数场景并不划算;但如果表有 10 亿行,就是 2 GB 的差异。
与浮点类型的取舍
-- 浮点误差演示:为什么金额不能用 float
SELECT
0.1::DOUBLE PRECISION + 0.2::DOUBLE PRECISION AS float_sum,
0.1::NUMERIC + 0.2::NUMERIC AS numeric_sum,
(0.1::DOUBLE PRECISION + 0.2::DOUBLE PRECISION) = 0.3::DOUBLE PRECISION AS float_equal,
(0.1::NUMERIC + 0.2::NUMERIC) = 0.3::NUMERIC AS numeric_equal; float_sum | numeric_sum | float_equal | numeric_equal
---------------------+-------------+-------------+---------------
0.30000000000000004 | 0.30 | f | t
(1 row)float8 的相等比较失败,这在金额对账、库存扣减场景里是致命的。
累加 10000 次 0.01 的对比:
-- 生成 10000 个 0.01 并分别用两种类型求和
SELECT
sum(0.01::DOUBLE PRECISION) AS float_total,
sum(0.01::NUMERIC) AS numeric_total
FROM generate_series(1, 10000); float_total | numeric_total
-------------+---------------
100.0 | 100.00
(1 row)本例中 float8 恰好收敛到 100.0,但换一组数就会暴露问题:
-- 0.07 累加 100 次
SELECT
sum(0.07::DOUBLE PRECISION) AS float_total,
sum(0.07::NUMERIC) AS numeric_total
FROM generate_series(1, 100); float_total | numeric_total
-------------+---------------
7.02 | 7.00
(1 row)float8 给出 7.02,与 NUMERIC 的 7.00 相差 0.02——在金额场景等同于账目错误。
| 对比维度 | NUMERIC | DOUBLE PRECISION |
|---|---|---|
| 精度 | 精确十进制 | 二进制近似 |
| 相等比较 | 可靠 | 不可靠 |
| 存储 | 变长(约 5~25 字节) | 固定 8 字节 |
| 运算速度 | 较慢(软件实现) | 快(硬件 FPU) |
| 范围 | 极大(10^131072) | 约 ±1.8 × 10^308 |
| 特殊值 | NaN | Infinity、-Infinity、NaN |
| 适用 | 金额、计数、比率 | 科学计算、统计、几何 |
特殊值 NaN
NUMERIC 支持 NaN,它的行为非常特殊:
SELECT
'NaN'::NUMERIC AS nan_value,
'NaN'::NUMERIC = 'NaN'::NUMERIC AS nan_equals_nan,
'NaN'::NUMERIC > 999999::NUMERIC AS nan_greater_than_all,
'NaN'::NUMERIC + 1 AS nan_plus_one; nan_value | nan_equals_nan | nan_greater_than_all | nan_plus_one
-----------+----------------+----------------------+--------------
NaN | t | t | NaN
(1 row)三个关键点:
NaN = NaN返回 true(与 IEEE 754 浮点规范相反)。NaN比任何数字都大,排序时排在最后。NaN参与运算结果仍是NaN(不像NULL那样传播为NULL)。
-- 排序验证:NaN 排在最后
CREATE TABLE nan_demo (v NUMERIC);
INSERT INTO nan_demo (v) VALUES (3), ('NaN'), (1), (2), (-999);
SELECT v FROM nan_demo ORDER BY v; v
--------
-999
1
2
3
NaN
(5 rows)-- ARRAY 排序中 NaN 也是最大
SELECT unnest(ARRAY[1, 'NaN'::NUMERIC, -5]) AS v ORDER BY v; v
-----
-5
1
NaN
(3 rows)NUMERIC 的 NaN 与 float8 的 NaN 语义不同,混用时要注意:
SELECT
('NaN'::NUMERIC = 'NaN'::NUMERIC) AS numeric_nan_eq, -- true
('NaN'::FLOAT8 = 'NaN'::FLOAT8) AS float_nan_eq, -- false!
('NaN'::FLOAT8 > 1e308::FLOAT8) AS float_nan_gt; -- false! numeric_nan_eq | float_nan_eq | float_nan_gt
----------------+--------------+--------------
t | f | f
(1 row)结论:float8 的 NaN 遵循 IEEE 754(不等于任何值,包括自己);NUMERIC 的 NaN 遵循 SQL 标准(等于自己且最大)。检测 NaN 时用标准函数:
SELECT
isfinite(1.5::NUMERIC) AS finite_num, -- true
isfinite('NaN'::NUMERIC) AS finite_nan, -- false
isfinite('Infinity'::FLOAT8) AS finite_inf; -- false finite_num | finite_nan | finite_inf
------------+------------+------------
t | f | f
(1 row)NUMERIC 不支持 Infinity(与 float8 不同):
-- 错误!NUMERIC 无法表示无穷
SELECT 'Infinity'::NUMERIC;ERROR: invalid input syntax for type numeric: "Infinity"常用数值函数
与金额、比率相关的函数集合:
SELECT
mod(17.5, 5) AS modulo, -- 取余
power(2, 10) AS power_val, -- 幂
sqrt(16) AS sqrt_val, -- 平方根,返回 numeric
ln(2.718281828) AS ln_val, -- 自然对数
log(100) AS log10_val, -- 常用对数
exp(1) AS exp_val, -- e 的幂
div(17, 5) AS int_div, -- 整数商(PostgreSQL 14+)
scale(123.456) AS scale_val, -- 标度 = 3
min_scale(123.450) AS min_scale_val; -- 最简标度 = 2 modulo | power_val | sqrt_val | ln_val | log10_val | exp_val | int_div | scale_val | min_scale_val
--------+-----------+----------+---------------------+-----------+-------------------+---------+-----------+---------------
2.5 | 1024 | 4 | 0.9999999998311266 | 2 | 2.718281828459045 | 3 | 3 | 2
(1 row)scale() 与 min_scale() 是 numeric 专有函数:
SELECT
scale(1.5::NUMERIC) AS s1, -- 1
scale(1.50::NUMERIC) AS s2, -- 2(保留尾随零)
scale(1.500::NUMERIC) AS s3, -- 3
scale(100::NUMERIC) AS s4; -- 0 s1 | s2 | s3 | s4
----+----+----+----
1 | 2 | 3 | 0
(1 row)注意 NUMERIC 会保留输入的字面标度,1.50 与 1.5 的 scale() 不同。这在金额展示上有时有用,有时会带来困扰:
-- 金额求和会采用输入中的最大标度
SELECT sum(v) AS total, scale(sum(v)) AS result_scale
FROM (VALUES (1.5::NUMERIC), (2.25::NUMERIC)) AS t(v); total | result_scale
-------+--------------
3.75 | 2
(1 row)金额建模最佳实践
方案对比:
| 方案 | 类型 | 优点 | 缺点 |
|---|---|---|---|
A. NUMERIC(10, 2) | 精确小数 | 直观、精确、无需换算 | 变长、运算稍慢 |
B. BIGINT 存"分" | 整数 | 定长、快、无歧义 | 单位需约定、展示要除以 100、比例计算麻烦 |
C. DOUBLE PRECISION | 浮点 | 快 | 不可用于金额 |
D. MONEY | 货币 | 自带格式 | locale 依赖,不推荐(见下节) |
推荐方案 A 的完整写法:
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL UNIQUE,
currency CHAR(3) NOT NULL DEFAULT 'CNY' -- ISO 4217 货币代码
CHECK (currency ~ '^[A-Z]{3}$'),
subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0),
discount NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (discount >= 0),
shipping NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (shipping >= 0),
total NUMERIC(12, 2) GENERATED ALWAYS AS (subtotal - discount + shipping) STORED,
-- 税率用 4 位小数,避免精度损失
tax_rate NUMERIC(6, 4) NOT NULL DEFAULT 0 CHECK (tax_rate BETWEEN 0 AND 1),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_orders_discount_le_subtotal CHECK (discount <= subtotal)
);为什么金额列用 NUMERIC(p, 2) 而不是 NUMERIC:
- 有明确的溢出上限,能提前发现"金额异常巨大"的数据错误。
- 有明确的标度,存储开销固定可预测。
- 不带标度的
NUMERIC会保留输入标度,导致同一个"金额"列里出现1.5和1.500混存,比较与展示都别扭。
-- 对比:无标度的 NUMERIC 会保留输入的尾随零
SELECT v, scale(v) AS s FROM (VALUES (1.5::NUMERIC), (1.500::NUMERIC), (1.50000::NUMERIC)) AS t(v); v | s
--------+---
1.5 | 1
1.500 | 3
1.50000 | 5
(3 rows)多币种建模要注意:不要用一张表的同一个 NUMERIC(10,2) 列存所有币种。JPY 无小数(标度 0),而某些加密货币需要 8 位小数。做法是加一列货币代码,并把标度统一到最精细的币种:
CREATE TABLE multi_currency_amounts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
currency CHAR(3) NOT NULL CHECK (currency IN ('CNY', 'USD', 'JPY', 'BTC')),
amount NUMERIC(20, 8) NOT NULL, -- 统一用 8 位标度,覆盖 BTC
CHECK (
(currency = 'JPY' AND scale(amount) <= 0) OR
(currency = 'CNY' AND scale(amount) <= 2) OR
(currency = 'USD' AND scale(amount) <= 2) OR
(currency = 'BTC' AND scale(amount) <= 8)
)
);比率的精度也很关键。折扣率用 NUMERIC(6, 4) 表示 0.0000 ~ 1.0000,能表达 0.01% 的精度:
-- 折扣 8.75% → 0.0875,用 4 位小数足够
SELECT 1000.00::NUMERIC(10,2) * 0.0875::NUMERIC(6,4) AS discount_amount; discount_amount
-----------------
87.500000
(1 row)注意结果是 87.500000,标度是 6(2 + 4)。存回 NUMERIC(12,2) 列时会舍入为 87.50。
实战示例
场景:为一个电商系统的订单结算设计金额模型,演示精度规划、舍入策略、以及完整的结算计算。
第 1 步:建订单与明细表
CREATE TABLE IF NOT EXISTS orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL UNIQUE,
currency CHAR(3) NOT NULL DEFAULT 'CNY',
subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (subtotal >= 0),
tax_rate NUMERIC(6, 4) NOT NULL DEFAULT 0.1300 CHECK (tax_rate BETWEEN 0 AND 1),
tax_amount NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0),
total NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (total >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS order_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
sku TEXT NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price NUMERIC(10, 2) NOT NULL CHECK (unit_price >= 0),
discount NUMERIC(6, 4) NOT NULL DEFAULT 0 CHECK (discount BETWEEN 0 AND 1),
-- 行小计:存储型生成列,公式固化在数据库层
line_total NUMERIC(12, 2) GENERATED ALWAYS AS
(round(qty * unit_price * (1 - discount), 2)) STORED
);CREATE TABLE
CREATE TABLE注意 line_total 的表达式里显式调用了 round(..., 2):这样"行小计保留 2 位"这条业务规则就被固化在数据库层,而不是散落在各个应用的代码里。
第 2 步:插入订单与明细
INSERT INTO orders (order_no, currency, tax_rate) VALUES ('SO-20260922-001', 'CNY', 0.1300)
RETURNING id, order_no; id | order_no
----+-------------------
1 | SO-20260922-001
(1 row)INSERT INTO order_items (order_id, sku, qty, unit_price, discount) VALUES
(1, 'SKU-1001', 2, 299.00, 0.0000),
(1, 'SKU-1002', 5, 59.90, 0.1000), -- 9 折
(1, 'SKU-1003', 1, 1299.00, 0.0500); -- 95 折
SELECT sku, qty, unit_price, discount, line_total FROM order_items ORDER BY id; sku | qty | unit_price | discount | line_total
----------+-----+------------+----------+------------
SKU-1001 | 2 | 299.00 | 0.0000 | 598.00
SKU-1002 | 5 | 59.90 | 0.1000 | 269.55
SKU-1003 | 1 | 1299.00 | 0.0500 | 1234.05
(3 rows)逐笔验算:5 × 59.90 × (1 - 0.1) = 299.50 × 0.9 = 269.55 ✓;1 × 1299.00 × 0.95 = 1234.05 ✓。
第 3 步:汇总小计并计算税额(关键的舍入策略演示)
-- 推荐做法:先汇总明细,再按汇总值算税(会计口径统一)
UPDATE orders o
SET subtotal = agg.line_sum
FROM (
SELECT order_id, sum(line_total) AS line_sum
FROM order_items
WHERE order_id = 1
GROUP BY order_id
) agg
WHERE o.id = agg.order_id
RETURNING o.order_no, o.subtotal; order_no | subtotal
-------------------+----------
SO-20260922-001 | 2101.60
(1 row)-- 计算税额并更新总额:税额保留 2 位(四舍五入)
UPDATE orders
SET tax_amount = round(subtotal * tax_rate, 2),
total = subtotal + round(subtotal * tax_rate, 2)
WHERE id = 1
RETURNING order_no, subtotal, tax_rate, tax_amount, total; order_no | subtotal | tax_rate | tax_amount | total
-------------------+----------+----------+------------+---------
SO-20260922-001 | 2101.60 | 0.1300 | 273.21 | 2374.81
(1 row)验算:2101.60 × 0.13 = 273.208 → 273.21 ✓;2101.60 + 273.21 = 2374.81 ✓。
第 4 步:对比"先算税再汇总"的错误结果
-- 不推荐:逐行算税再汇总,会产生"分"级误差累积
SELECT
sum(round(line_total * 0.1300, 2)) AS tax_per_line, -- 逐行算税
round(sum(line_total) * 0.1300, 2) AS tax_on_total -- 汇总后算税(推荐)
FROM order_items WHERE order_id = 1; tax_per_line | tax_on_total
--------------+--------------
273.21 | 273.21
(2 rows / 1 row)本例中两种口径恰好一致,但换成更多明细行或不同税率就会分叉。下面的极端例子能清楚暴露问题:
-- 10 行每行 0.05 元,税率 13%
SELECT
sum(round(0.05::NUMERIC(10,2) * 0.13, 2)) AS per_line_tax,
round(sum(0.05::NUMERIC(10,2)) * 0.13, 2) AS total_tax
FROM generate_series(1, 10); per_line_tax | total_tax
--------------+-----------
0.10 | 0.07
(1 row)逐行算税得到 0.10(每行 0.05 × 0.13 = 0.0065 → 0.01,10 行 = 0.10),汇总算税得到 0.07(0.50 × 0.13 = 0.065 → 0.07)。同一笔业务,两种算法相差近 43%。这就是为什么舍入策略必须显式约定并写成测试用例。
第 5 步:验证精度约束生效
-- 单价格式化:小数超过 2 位会被舍入,不报错
INSERT INTO order_items (order_id, sku, qty, unit_price) VALUES (1, 'SKU-9999', 1, 12.345)
RETURNING sku, unit_price; sku | unit_price
----------+------------
SKU-9999 | 12.35
(1 row)-- 但整数部分溢出会报错
INSERT INTO order_items (order_id, sku, qty, unit_price) VALUES (1, 'SKU-9998', 1, 100000000.00);ERROR: numeric field overflow
DETAIL: A field with precision 10, scale 2 must round to an absolute value less than 10^8.-- 折扣率超出 0~1 也会被 CHECK 拦住
INSERT INTO order_items (order_id, sku, qty, unit_price, discount) VALUES (1, 'SKU-9997', 1, 10.00, 1.5);ERROR: new row for relation "order_items" violates check constraint "order_items_discount_check"
DETAIL: Failing row contains (5, 1, SKU-9997, 1, 10.00, 1.5000, 10.00).第 6 步:格式化输出与对账校验
SELECT
o.order_no,
o.subtotal,
o.tax_amount,
o.total,
to_char(o.total, 'FM999,999,990.00') AS total_display,
-- 校验:小计 + 税额 = 总额(精确比较,无需容差)
(o.subtotal + o.tax_amount = o.total) AS math_check
FROM orders o WHERE o.order_no = 'SO-20260922-001'; order_no | subtotal | tax_amount | total | total_display | math_check
-------------------+----------+------------+---------+---------------+------------
SO-20260922-001 | 2101.60 | 273.21 | 2374.81 | 2,374.81 | t
(1 row)注意 math_check 是 true。如果金额列是 DOUBLE PRECISION,这个校验会经常失败——这就是 NUMERIC 的核心价值。
第 7 步:清理
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS numeric_demo;
DROP TABLE IF EXISTS nan_demo;
DROP TABLE IF EXISTS size_demo;
DROP TABLE IF EXISTS multi_currency_amounts;注意事项
DECIMAL与NUMERIC完全等价,选择哪个只影响可读性,不影响行为。
SELECT
pg_typeof(1.0::DECIMAL) AS decimal_type,
pg_typeof(1.0::NUMERIC) AS numeric_type;- 不写标度的
NUMERIC会保留输入的尾随零,导致比较与展示异常。
-- 不推荐:无标度,1.5 与 1.50 会以不同标度存储
CREATE TABLE t_v1 (amount NUMERIC);
-- 推荐:金额明确标度
CREATE TABLE t_v2 (amount NUMERIC(12, 2) NOT NULL DEFAULT 0);NUMERIC整数部分溢出报错,小数部分溢出静默舍入,这两种行为要在测试里覆盖。
-- 报错
-- INSERT INTO t_v2 (amount) VALUES (1000000000000.00);
-- 静默舍入为 1.23
INSERT INTO t_v2 (amount) VALUES (1.234) RETURNING amount;- 别用
NUMERIC做全表sum()的唯一手段就以为安全。sum(numeric)返回numeric,精度安全,但大数据量下比整数求和慢。
-- 若精度要求不高、数据量极大,可考虑大整数存"分"
SELECT sum(x) FROM (VALUES (1),(2),(3)) AS t(x);NaN与NULL语义完全不同,聚合时NaN会污染结果。
-- NaN 参与任何运算结果都是 NaN,sum 也会变 NaN
SELECT sum(v) AS total FROM (VALUES (1::NUMERIC), ('NaN'::NUMERIC)) AS t(v); total
-------
NaN
(1 row)-- 过滤 NaN 才能得到有意义的总和
SELECT sum(v) AS clean_total FROM (VALUES (1::NUMERIC), ('NaN'::NUMERIC)) AS t(v) WHERE v <> 'NaN'::NUMERIC; clean_total
-------------
1
(1 row)NUMERIC的NaN与FLOAT8的NaN比较语义相反,跨类型比较会让人困惑。
-- NUMERIC 的 NaN 等于自己
SELECT 'NaN'::NUMERIC = 'NaN'::NUMERIC; -- true
-- FLOAT8 的 NaN 不等于自己
SELECT 'NaN'::FLOAT8 = 'NaN'::FLOAT8; -- falseround()不指定标度时返回numeric且标度为 0,直接用于除法会得到"整数化"的结果。
SELECT round(5.0 / 2) AS result; -- 3,注意是 numeric 而非 integer- 整数除法陷阱:两个整数相除会截断,
NUMERIC参与才会保留小数。
-- 错误!结果是 0,不是 0.67
SELECT 2 / 3 AS int_division;
-- 正确
SELECT 2::NUMERIC / 3 AS numeric_division; int_division
--------------
0
(1 row)
numeric_division
------------------
0.6666666666666666
(1 row)- 比较
NUMERIC与FLOAT8会触发隐式转换,可能引入浮点误差。
-- 不推荐:混用类型比较
SELECT 0.1::NUMERIC = 0.1::FLOAT8 AS mixed_compare;
-- 推荐:统一类型再比较
SELECT 0.1::NUMERIC = 0.1::NUMERIC AS clean_compare;NUMERIC索引比整数索引大,高基数NUMERIC列的 B-tree 会明显更大、更深。
-- 若某列只用于等值查找且能接受整数化,考虑另存整数版本
-- ALTER TABLE t ADD COLUMN amount_cents BIGINT GENERATED ALWAYS AS ((amount * 100)::BIGINT) STORED;ALTER TABLE ... TYPE NUMERIC(p, s)会重写全表并校验所有行,大表上是危险操作。
-- 危险:既重写表,又可能因数据溢出而失败
-- ALTER TABLE big_orders ALTER COLUMN amount TYPE NUMERIC(16, 4);- 永远不要用
NUMERIC存时间戳或日期。它有专门的类型,且日期运算函数只对日期类型生效。
最佳实践
- 所有金额列统一用
NUMERIC(p, 2),绝不用REAL/DOUBLE PRECISION/MONEY。 - 金额列的精度按业务上限留足余量:一般账户用
NUMERIC(12, 2),企业级金额用NUMERIC(18, 2);p - s至少要能覆盖未来 5 年的量级增长。 - 比率、税率、折扣率用
NUMERIC(p, 4)或NUMERIC(p, 6),不要用 2 位小数——0.13与0.1325的差异在金额规模放大后会显现。 - 多币种场景加
currency CHAR(3)列并校验 ISO 4217 格式,标度统一取最精细的币种。 - 舍入策略必须显式写在 SQL 里并固化为测试用例:明确"先算明细再汇总"还是"先汇总再算",避免应用层各自实现。
- 用生成列固化舍入与汇总规则(如
line_total),避免同一规则在多个服务里重复实现。 - 对不变量加
CHECK约束:amount >= 0、discount BETWEEN 0 AND 1、discount <= subtotal。 - 金额对账校验用精确相等(
subtotal + tax = total),这是NUMERIC相对浮点的最大优势,应充分利用。 - 禁止在业务数据里使用
NaN;如果确实需要"未知数值",用NULL,因为NULL的语义更标准且不污染聚合。 - 不要为了省存储把金额改成
BIGINT存分,除非表规模确实达到数亿行且经过基准测试验证收益。 - 大表改精度/标度前先跑一遍
SUM范围检查与scale()分布检查,确认不会溢出。 - 输出层用
to_char统一格式化金额,避免不同客户端(psql / JDBC / PDO)对numeric的默认展示不一致导致的对账困惑。
下一节