货币类型(MONEY)
本节讲解 PostgreSQL 的专用货币类型 MONEY。你将了解它的输入输出格式、固定标度规则、locale 依赖带来的坑,以及为什么生产环境普遍不推荐直接使用它。
前置知识
阅读本节前,建议先了解:浮点类型(REAL / DOUBLE PRECISION)
基础概念
货币类型(Money Type)是 PostgreSQL 提供的专用类型 MONEY,用于存放带固定小数位的金额。它以 8 字节整数存储,并按数据库的 lc_monetary 设置决定显示格式。
| 类型名 | 别名 | 存储空间 | 取值范围 | 小数位 | 说明 |
|---|---|---|---|---|---|
MONEY | CASH | 8 字节 | -92233720368547758.08 ~ +92233720368547758.07 | 固定 2 位(受 locale 影响) | 平台相关,等价于 int64 除以 100 |
关键术语:
- 货币类型(Money Type):
MONEY,PostgreSQL 特有类型,非 SQL 标准。 - 区域设置(Locale):反映操作系统与数据库的语言区域配置,
MONEY的输出格式(货币符号、千分位分隔符、小数分隔符)由lc_monetary决定。 - 标度(Scale):
MONEY的小数位固定为 2,由lc_monetary决定;en_US.UTF-8下为 2 位。
MONEY 与 NUMERIC 最本质的区别在于:NUMERIC 只存数字,货币符号和格式完全由客户端决定;MONEY 把"格式"也交给服务端管理。
-- 数据库当前的货币区域设置
SHOW lc_monetary; lc_monetary
-----------------
en_US.UTF-8
(1 row)MONEY 的输出带货币符号和千分位分隔符:
SELECT '1234.56'::MONEY AS m1, 12345.678::MONEY AS m2, '-0.005'::MONEY AS m3; m1 | m2 | m3
------------+------------+----------
$1,234.56 | $12,345.68 | -$0.01
(1 row)可以看到 12345.678 被四舍五入到 2 位小数,-0.005 被舍入为 -$0.01。
语法与用法
列名 MONEY | CASH-- 最简可运行示例
CREATE TABLE wallets (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner TEXT NOT NULL,
balance MONEY NOT NULL DEFAULT 0
);
INSERT INTO wallets (owner, balance) VALUES
('alice', 1234.56),
('bob', '$2,000.00'),
('carol', '500.00');
SELECT id, owner, balance FROM wallets ORDER BY id; id | owner | balance
----+-------+------------
1 | alice | $1,234.56
2 | bob | $2,000.00
3 | carol | $500.00
(3 rows)注意 alice 用裸数字、bob 用带 $ 和千分位的字符串,两者都能插入成功——输入的货币符号与分隔符同样由 locale 解释,输出符号也由 locale 决定,这是最容易踩的坑。
详细说明
存储与整数换算
MONEY 在内部就是 int64(8 字节),存储的是"分值"(乘以 100 后的整数)。可以用一个"影子列"验证这一点:
CREATE TABLE money_probe (amount MONEY);
INSERT INTO money_probe VALUES (1234.56), ('$1.23'), (-0.01);
-- money 与 numeric 可直接比较/换算
SELECT amount,
amount::NUMERIC AS as_numeric,
(amount::NUMERIC * 100)::BIGINT AS as_cents,
pg_column_size(amount) AS bytes
FROM money_probe; amount | as_numeric | as_cents | bytes
----------+------------+----------+-------
$1,234.56| 1234.56 | 123456 | 8
$1.23 | 1.23 | 123 | 8
-$0.01 | -0.01 | -1 | 8
(3 rows)无论数值大小,pg_column_size 永远是 8 字节(行内无需额外对齐时),这是 MONEY 相对 NUMERIC 的体积优势。
转成其他类型
| 转换方向 | 写法 | 结果 |
|---|---|---|
MONEY → NUMERIC | amount::NUMERIC | 保留 2 位小数,去掉符号 |
MONEY → BIGINT | amount::BIGINT | 四舍五入到整数元,丢掉分值 |
MONEY → DOUBLE PRECISION | amount::FLOAT8 | 得浮点数,引入误差风险 |
NUMERIC → MONEY | 123.456::MONEY | 四舍五入到 2 位 |
TEXT → MONEY | '$1,234.56'::MONEY | 解析字符串,符号/分隔符按 locale |
MONEY → TEXT | amount::TEXT | 带符号的显示串 |
SELECT
1234.56::MONEY::NUMERIC AS to_numeric,
1234.56::MONEY::BIGINT AS to_bigint, -- 四舍五入,丢分值
1234.56::MONEY::FLOAT8 AS to_float8,
123.456::NUMERIC::MONEY AS from_numeric,
1234.56::MONEY::TEXT AS to_text; to_numeric | to_bigint | to_float8 | from_numeric | to_text
------------+-----------+-----------+--------------+------------
1234.56 | 1235 | 1234.56 | $123.46 | $1,234.56
(1 row)MONEY::BIGINT 会直接丢掉分值,把它当成"元"用是常见的精度事故来源。
运算符
除常规算术外,MONEY 支持乘以/除以数值:
SELECT
100.00::MONEY + 50.50::MONEY AS add,
100.00::MONEY - 150.00::MONEY AS sub,
100.00::MONEY * 1.13 AS mul, -- 乘数值
100.00::MONEY / 4 AS div; -- 除数值 add | sub | mul | div
----------+-----------+----------+----------
$150.50 | -$50.00 | $113.00 | $25.00
(1 row)MONEY * MONEY 和 MONEY / MONEY 均不被支持,会报类型错误:
SELECT 100.00::MONEY * 2.00::MONEY;ERROR: operator does not exist: money * money
LINE 1: SELECT 100.00::MONEY * 2.00::MONEY;
^
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.这一限制迫使你在表达式里混用 numeric,进一步削弱了 MONEY 的可用性。
聚合函数
sum / avg / min / max 都支持 MONEY:
CREATE TABLE money_agg (amount MONEY);
INSERT INTO money_agg VALUES (10.00), (20.00), (30.50), (40.25);
SELECT
sum(amount) AS total,
avg(amount) AS average,
min(amount) AS min_amount,
max(amount) AS max_amount
FROM money_agg; total | average | min_amount | max_amount
---------+----------+------------+------------
$100.75 | $25.19 | $10.00 | $40.25
(1 row)注意 sum 返回 money(8 字节整数累加,不损失精度),而 avg 返回 money 并四舍五入到 2 位,因此 $25.1875 变成 $25.19。如果后续要基于平均值继续计算,这种中间舍入会造成累计偏差。
格式化输出
MONEY 的默认输出受 lc_monetary 控制。可以临时改区域设置观察差异:
SHOW lc_monetary;
SET lc_monetary = 'en_US.UTF-8';
SELECT 1234.56::MONEY AS us_format; us_format
---------------
$1,234.56
(1 row)SET lc_monetary = 'de_DE.UTF-8'; -- 需要系统安装该区域
SELECT 1234.56::MONEY AS de_format; de_format
--------------
1.234,56 €
(1 row)同一份数据在不同 locale 下产生完全不同的字符串。这意味着 MONEY 的输出不可当作稳定协议:备份脚本、导出 CSV、跨环境同步都可能因 locale 差异而产生不一致文本。
为何生产环境普遍不推荐 MONEY
| 问题 | 说明 | 影响 |
|---|---|---|
| 输出依赖 locale | 同一值在不同 lc_monetary 下显示不同 | 导出/同步结果不可预测 |
| 输入同样依赖 locale | '1.234,56' 在 en_US 下解析失败或错解 | 客户端必须与服务端 locale 一致 |
| 只支持一种货币 | 类型里不含货币代码 | 无法做多币种表 |
| 标度固定为 2 位 | 无法表示 4 位甚至 8 位小数的币种 | 日元尚可,加密货币完全不行 |
无 MONEY * MONEY | 表达式必须显式转型 | 代码可读性差、易出错 |
::BIGINT 静默丢精度 | 丢分值而非报错 | 隐蔽的金额错误 |
| 客户端支持差 | ORM / 驱动常把它映射成字符串 | 需要额外解析 |
| 非 SQL 标准 | 迁移到 MySQL / Oracle 无对应类型 | 迁移成本 |
与 numeric 混算需显式转型 | 无隐式提升 | 表达式冗长 |
MONEY 唯一真正占优的场景是:单币种、只需 2 位小数、且需要极致紧凑存储的批量计算。除此之外没有使用理由。
替代方案:NUMERIC + 货币代码列
主流做法是用 NUMERIC(p, s) 存金额,另设一列存 ISO 4217 货币代码:
CREATE TABLE orders_v2 (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL,
currency CHAR(3) NOT NULL, -- ISO 4217 代码
amount NUMERIC(18,4) NOT NULL CHECK (amount >= 0), -- 4 位小数兼容更多币种
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT orders_v2_currency_iso CHECK (currency IN ('CNY','USD','EUR','JPY','GBP'))
);
INSERT INTO orders_v2 (order_no, currency, amount) VALUES
('A1001', 'CNY', 1234.5600),
('A1002', 'USD', 1234.5600),
('A1003', 'JPY', 1234.0000);
SELECT order_no, currency, amount, pg_column_size(amount) AS amount_bytes
FROM orders_v2 ORDER BY id; order_no | currency | amount | amount_bytes
----------+----------+----------+--------------
A1001 | CNY | 1234.5600| 8
A1002 | USD | 1234.5600| 8
A1003 | JPY | 1234.0000| 7
(3 rows)这种设计的好处:
- 金额与货币解耦:同一张表能装多币种订单。
- 小数位可调:4 位小数兼容外汇、加密货币(如需 8 位可放宽到
NUMERIC(24,8))。 - 输入输出与 locale 无关:
1234.5600在任何环境都是1234.5600。 - 客户端支持好:所有驱动都能原生映射成高精度十进制类型(Java
BigDecimal、PythonDecimal、Goshopspring/decimal)。 - 可直接参与任意算术,无需转型。
代价是 NUMERIC(18,4) 每值约占 8~10 字节,比 MONEY 的固定 8 字节略多,但换来的可移植性完全值得。
实战示例
下面用一个完整流程对比 MONEY 与 NUMERIC 在真实业务中的行为差异。
第 1 步:建表准备
DROP TABLE IF EXISTS pay_money, pay_numeric;
CREATE TABLE pay_money (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
item TEXT NOT NULL,
price MONEY NOT NULL
);
CREATE TABLE pay_numeric (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
item TEXT NOT NULL,
currency CHAR(3) NOT NULL,
price NUMERIC(18,4) NOT NULL
);第 2 步:插入相同数据
INSERT INTO pay_money (item, price) VALUES
('键盘', 399.00),
('显示器', 1299.90),
('主机', 8999.00);
INSERT INTO pay_numeric (item, currency, price) VALUES
('键盘', 'CNY', 399.0000),
('显示器', 'CNY', 1299.9000),
('主机', 'CNY', 8999.0000);第 3 步:计算含税总价(税率 6.5%)
SELECT
'MONEY' AS kind,
sum(price * 1.065) AS total
FROM pay_money
UNION ALL
SELECT
'NUMERIC',
sum(price * 1.065)
FROM pay_numeric; kind | total
---------+-------------
MONEY | $11,414.43
NUMERIC | 11414.4350
(1 row)两类都算出了结果,但差别在中间精度:MONEY 在每次乘法时都四舍五入到分,NUMERIC 保留 4 位小数到最终才处理。单条差异是分级别,乘以几十万行订单就会放大成可观的账目偏差。
第 4 步:验证 MONEY 的中间舍入
SELECT
399.00::MONEY * 1.065 AS money_step,
399.0000::NUMERIC(18,4) * 1.065 AS numeric_step; money_step | numeric_step
------------+--------------
$424.94 | 424.9350
(1 row)399.00 * 1.065 = 424.935,MONEY 立刻舍入为 $424.94(多了 0.005),而 NUMERIC 保留完整值等最终再处理。
第 5 步:locale 变更导致导出结果不同
SET lc_monetary = 'en_US.UTF-8';
SELECT price::TEXT AS us_view FROM pay_money WHERE id = 2; us_view
--------------
$1,299.90
(1 row)SET lc_monetary = 'de_DE.UTF-8';
SELECT price::TEXT AS de_view FROM pay_money WHERE id = 2; de_view
-------------
1.299,90 €
(1 row)同一行数据的文本表示完全不同。而 NUMERIC 无论如何都是 1299.9000:
SELECT price::TEXT AS numeric_view FROM pay_numeric WHERE id = 2; numeric_view
--------------
1299.9000
(1 row)第 6 步:验证多币种场景 MONEY 无法胜任
-- 试图在 MONEY 表里放美元
INSERT INTO pay_money (item, price) VALUES ('US 键盘', '$399.00');
SELECT id, item, price FROM pay_money ORDER BY id; id | item | price
----+---------+------------
1 | 键盘 | $399.00
2 | 显示器 | $1,299.90
3 | 主机 | $8,999.00
4 | US 键盘 | $399.00
(4 rows)美元金额被当成本地货币存进去,货币信息彻底丢失。而 NUMERIC 方案里 currency 列忠实保留了币种。
第 7 步:清理环境
SET lc_monetary = 'en_US.UTF-8'; -- 恢复默认,避免影响其他会话测试
DROP TABLE IF EXISTS pay_money, pay_numeric, wallets, money_probe, money_agg;注意事项
MONEY的输出不是稳定协议。任何依赖文本比对的地方(导出 CSV、备份 diff、跨环境同步)都会因 locale 差异而失败。
-- 不推荐:直接把 money 塞进 JSON 供前端消费
SELECT jsonb_build_object('total', 1234.56::MONEY); jsonb_build_object
-------------------------------------
{"total": "$1,234.56"}前端拿到的是带 $ 和逗号的字符串,还得自己解析。
- 输入解析同样依赖 locale,而且失败时可能静默错解而非报错:
-- 在 de_DE locale 下,'1,234.56' 会被解析成 1234.56 还是 1.23456?
-- 结果取决于 locale,跨环境结果不同,是极隐蔽的 bug
SELECT '1,234.56'::MONEY; money
-------------
$1,234.56
(1 row)MONEY到整数是四舍五入,不是截断,且不报错:
-- 错误!以为取的是"元",实际被四舍五入
SELECT 9.99::MONEY::BIGINT; -- 10,不是 9MONEY不能直接与NUMERIC相加,需要显式转型,容易在长表达式里漏写:
SELECT 10.00::MONEY + 5.00; -- 可以(5.00 是 numeric,能隐式转 money 吗?)ERROR: operator does not exist: money + numeric
LINE 1: SELECT 10.00::MONEY + 5.00;
^
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.必须写成 10.00::MONEY + 5.00::NUMERIC::MONEY 或 (10.00::MONEY::NUMERIC + 5.00)::MONEY。
MONEY不支持MONEY * MONEY/MONEY / MONEY,汇率、折扣率必须存成NUMERIC。lc_monetary是会话/数据库级设置,不同会话可能不同,导致同一查询在应用连接池的不同连接上输出不一致。MONEY的 2 位标度无法覆盖所有币种。JPY 无小数位、KWD 有 3 位、BHD 有 3 位、部分加密货币 8 位。MONEY与NUMERIC之间的CHECK约束写法别扭,且MONEY列上的CHECK无法表达"金额必须是 4 位小数"之类约束。ORM 与驱动支持参差。部分驱动把
MONEY映射为string,部分映射为Decimal(可能丢精度),同一段代码在不同驱动下行为不同。
最佳实践
- 新项目一律使用
NUMERIC(p, s) + currency CHAR(3),不要用MONEY。这是唯一在可移植性、精度、可维护性上都不妥协的方案。 NUMERIC标度按币种需求取:人民币/美元用NUMERIC(18, 2)~NUMERIC(18, 4),多币种统一用NUMERIC(20, 8)一步到位,避免后期改标度迁移。- 金额列必须有
CHECK约束:CHECK (amount >= 0)、CHECK (currency IN (...)),把非法数据挡在入口。 - 计算链路全程用
NUMERIC,只在最终展示时用to_char(amount, 'FM999,999,990.00')加千分位与固定符号。
SELECT to_char(1234.56::NUMERIC(18,4), 'FM999,999,990.00') AS formatted; formatted
--------------
1,234.56
(1 row)- 展示符号交给前端或
to_char,不要指望数据库类型携带货币符号。 - 多币种场景把"币种"当维度,不要当格式。所有汇总必须先按币种分组,再做跨币种折算。
- 如果历史系统已经在用
MONEY,迁移路径是:新增NUMERIC列 → 双写 → 回填 → 校验一致 → 切读 → 删旧列。迁移期间固定lc_monetary保证回填结果可复现。 - 导出
MONEY数据前显式SET lc_monetary,或直接amount::NUMERIC后再导出。
-- 导出前统一转 numeric,消除 locale 影响
COPY (SELECT id, amount::NUMERIC FROM t) TO STDOUT WITH (FORMAT csv);- 只在极端存储敏感的单币种批处理场景考虑
MONEY,且必须写清楚注释说明理由与限制。 - 不要用浮点存金额——既不用
MONEY,也不用REAL/DOUBLE PRECISION,用NUMERIC。
下一节
继续学习:数值类型选择指南