导语
金融系统里最朴素也最容易被低估的问题,是”一笔钱”怎么在代码里表示。从注册界面上用户输入的
99.9,到数据库里沉淀的一条账务分录,再到次日早上风控人员打开的
Excel
报表,这个数字要经过若干语言、若干进程、若干存储介质、若干种币种与汇率变换。每一步都可能丢掉一分钱,或者凭空多出一分钱。
这不是危言耸听。IEEE 754 的默认行为决定了
0.1 + 0.2 ≠ 0.3;JSON
规范没有规定数字的精度;Excel 在打开 CSV 时会把
16 位银行卡号变成科学记数法;MySQL 的
FLOAT
字段聚合之后和你用计算器算的不一样;阿拉伯语环境下负号和货币符号的方向会让前端显示看似”合理”但实际错误;交易所里一个
satoshi
的误差乘以高频次数就是真金白银的盈亏。金融工程师做的第一件事,不是写撮合、不是写清算,而是先把”钱”这个类型在系统里钉死。
本文是【金融科技工程】系列第 02 篇,面向对象是准备写账务、支付、计费、交易所核心代码的工程师。读完之后你应该能回答这几个问题:
- 什么时候允许用
double,什么时候必须用Decimal,什么时候必须用int64的最小单位? - ISO 4217 的小数位数(
minor unit)对你的 schema 意味着什么?日元、第纳尔、黄金、比特币该怎么统一? - 从前端输入到账务落库再到对账报表,这笔钱要经过多少次”单位转换”?哪些是安全的、哪些是有损的?
- 汇率换算时,点差、方向、基准货币、三角套算的精度如何控制?
- 数据库里
NUMERIC(20,4)、BIGINT、字符串三种方案各适合什么场景?
一、为什么 float / double 不是”钱”
1.1 IEEE 754 的本质
IEEE 754 双精度浮点数(binary64)用 64
位表达一个近似实数:1 位符号、11 位指数、52 位尾数。它的底是
2,而人类日常使用的小数是十进制。0.1
在二进制里是一个无限循环:
0.1(10) = 0.0001100110011001100110011001100110011...(2)
CPU 只能存 52 位尾数,所以 0.1
被截断为最接近的可表达值,真实值约为
0.1000000000000000055511151231257827021181583404541015625。把两个”看起来是
0.1”的数相加,误差不会抵消,而是累积。
在 Python 里验证:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> sum([0.1] * 10) == 1.0
False
>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')在 Go 里同样:
package main
import "fmt"
func main() {
a := 0.1
b := 0.2
fmt.Println(a + b) // 0.30000000000000004
fmt.Println(a+b == 0.3) // false
sum := 0.0
for i := 0; i < 10; i++ {
sum += 0.1
}
fmt.Println(sum == 1.0) // false
fmt.Printf("%.20f\n", sum) // 0.99999999999999988898
}这不是语言 bug,是 IEEE 754 的正常行为。CPU、编译器、运行时都没错。错的是把”十进制金额”交给”二进制浮点”去表达这件事本身。
1.2 累加误差与聚合黑洞
单次误差是 10^-16
量级,肉眼不可见。问题发生在大批量聚合:一张日终报表要把几千万条流水累加,float64
的累积误差可能达到分、甚至元级别。不同顺序相加结果还不同——浮点加法不满足结合律:
>>> (1e20 + 1) - 1e20
0.0
>>> 1e20 + (1 - 1e20)
0.0
>>> (1e20 - 1e20) + 1
1.0这就意味着:
- 分布式账务聚合时,不同 shard 的顺序不同,最终对账永远对不平;
- 同一套 SQL 在 MySQL 与 PostgreSQL 的
SUM(FLOAT)结果不一定相等; - 单元测试里能过,线上数据量放大后开始飘。
1.3 历史警示
金融系统”单位 / 精度 / 类型”类的事故反复出现,几乎每次都伴随巨额损失:
- Knight Capital 2012 年 8 月 1
日事件:因老代码路径(SMARS 的
Power Peg路由逻辑)误被激活,在约 45 分钟内对 150 多只股票下达了数百万笔错误订单,造成约 4.6 亿美元税前亏损,公司几乎因此破产,后被 Getco 收购。这不是单纯的浮点问题,但根因之一是”变量含义”与”数值类型”在一次部署中被污染:同一个标志位在新旧系统里含义不同,既说明了类型语义需要强约束,也说明了金融里任何”隐含约定”都是炸弹。SEC 后续发布的 Release No. 70694 详细复盘了这一过程。 - TJX 2007 年数据事件:虽然主要是安全与加密问题,但在后续清算赔付的会计处理中,跨币种、跨法域的计提金额一度因舍入规则不一致出现口径差异,公开的 10-K 中曾多次调整计提数字。这提醒我们:会计口径的金额绝不能依赖最终显示层的舍入。
- 温哥华证交所 1982
年指数事件:指数被设计为保留 3
位小数、每次变动截断而非四舍五入,22 个月后从
1000被”磨”到520,真实值却在1098。重新计算后指数瞬间跳回。这是舍入方向系统性偏置的经典案例。 - 1991 年 Patriot 导弹时钟漂移:虽然不是金融场景,但同样是浮点累积误差引发的灾难——以 0.1 秒为单位的计时器连续运行 100 小时后,累计偏差约 0.34 秒,直接导致拦截失败。金融清算系统里 24×7 运行的时间窗口累加,同源。
这些事件的共同点:数值类型与业务单位被默认为”差不多就行”,直到某次扩大量级的场景把误差放大到可见。工程师的责任是在扩大之前就把它钉死。
1.4 什么时候可以用浮点?
不是所有金融相关字段都不能用浮点。以下场景浮点可接受:
- 统计可视化:K
线图显示用的价格、涨跌幅百分比,显示层
float32足够; - 机器学习特征:模型输入的标准化金额、对数收益率;
- 风险指标:VaR、Sharpe、波动率等不进账务的派生量;
- 中间态展示:后端用 Decimal
算清楚之后,仅在返回给前端渲染时转
float做图。
红线只有一条:任何要落账、要对账、要结算、要对用户可见金额的字段,必须不是 float / double。
1.5 一个可复现的演示
下面是一个完整的可复现脚本,模拟”一百万笔小额交易累加”在不同类型下的偏差:
import random
from decimal import Decimal
random.seed(42)
txns_cent = [random.randint(1, 9999) for _ in range(1_000_000)] # 分
txns_float = [c / 100.0 for c in txns_cent] # 元(浮点)
txns_dec = [Decimal(c) / Decimal(100) for c in txns_cent] # 元(Decimal)
s_int = sum(txns_cent) # 真值
s_float = sum(txns_float)
s_dec = sum(txns_dec)
print("truth (cent):", s_int)
print("float sum :", s_float, "diff cent:", round(s_float * 100) - s_int)
print("decimal sum:", s_dec, "diff cent:", int(s_dec * 100) - s_int)在我的一次运行里,float 版本比真值差
61 分,Decimal 版本差
0。把样本量提到一亿,float
偏差可以轻松到几十元。这是一个不可以在生产上”抽样测试”的问题——量级不到的时候永远看起来对。
1.6 为什么
float 在风控、统计可以用
因为那些场景的输出本身就是”估计量”,±0.01%
的相对误差不影响决策。账务不同,账务的输出是”账本”,必须逐分精确、逐日对平、逐月审计。区别不是”重不重要”,而是”容差模型不同”。
二、三种金额建模方案
2.1 Decimal(任意精度十进制)
把金额用十进制存储,精度由运行时决定。几乎每个语言都有实现:
| 语言 | 类型 | 说明 |
|---|---|---|
| Python | decimal.Decimal |
标准库,支持上下文精度 |
| Java | java.math.BigDecimal |
不可变,支持 8 种 RoundingMode |
| C# / .NET | System.Decimal |
128 位,约 28–29 位有效数字 |
| Go | github.com/shopspring/decimal |
社区标准,无标准库实现 |
| Rust | rust_decimal |
96 位尾数 + 定标 |
| PostgreSQL | NUMERIC(p, s) |
任意精度,运算不丢精度 |
| MySQL | DECIMAL(p, s) |
最高 DECIMAL(65, 30) |
优点:直观,Decimal("0.1") + Decimal("0.2") = Decimal("0.3");支持任意小数位(汇率
8 位、利率 12 位都不怕)。
缺点:
- 运算比
int64慢 10–100 倍,撮合引擎这类延迟敏感场景用不起; - 跨语言序列化需要字符串(不能直接塞 JSON number,见 §九);
- 不同实现的舍入默认值不同(Python 默认
ROUND_HALF_EVEN,Java 的BigDecimal没有默认、必须显式指定)。
2.2 定点整数(Fixed-point,minor units)
把所有金额按”最小单位”存成整数。人民币和美元就是”分”;比特币是
satoshi(1 BTC = 10^8 satoshi);以太坊是
wei(1 ETH = 10^18 wei)。
优点:
- 运算是原生整数,一条
ADD指令,零误差; - 存储紧凑,
BIGINT8 字节覆盖±9.22 × 10^18; - 跨语言、跨协议友好——JSON 里就是个整数。
缺点:
- 必须永远记得”这个数的单位是什么”——单位一旦错位(把分当元),就是 100 倍灾难;
- 乘除(利息、税、分账比例)必须先放大再收缩,容易漏舍入;
- 小数位数异构币种(JPY 0 位 vs BHD 3 位 vs ETH 18 位)在同一个表里不好对齐。
工程上普遍的做法:账务主存用 BIGINT
最小单位 + 币种码;对外展示和汇率计算临时转成
Decimal。
2.3 缩放定点(Scaled integer with fixed scale)
介于两者之间:所有字段统一放大 10^N 倍(N
常取 4、6、8),用 BIGINT
存。相当于你选定了一个”系统内部最小单位”,比币种的最小单位更细。
例如某交易所定义”系统精度 8 位”,则:
- USD 1 元 =
100_000_000 - JPY 1 元 =
100_000_000(即使 JPY 标准只有 0 位,多出来的始终是 0) - BTC 1 枚 =
100_000_000(恰好就是 satoshi)
这样账务层所有金额都是同一数量级的
BIGINT,聚合、对账、分片都简单。代价是:与外部系统(银行渠道、卡组织)交互时必须按对方的
minor unit 再转一次,一旦漏转就是精度事故。
2.4 选型矩阵
| 场景 | 推荐 |
|---|---|
| 撮合引擎订单价量 | 缩放定点 BIGINT(纳秒级延迟) |
账务分录 amount |
minor unit BIGINT +
currency |
| 汇率、利率、费率 | Decimal(20, 12) 或
NUMERIC |
| API 对外金额 | 字符串(带币种码) |
| 展示层 | 按 locale 格式化后的字符串 |
| 统计报表、BI | Decimal;不可用 FLOAT |
| 风险模型、特征工程 | float64 可接受 |
选型的第一原则:越靠近真金白银越整数、越靠近人眼越字符串、中间的派生计算可以 Decimal。
三、ISO 4217 与币种模型
3.1 小数位数差异
ISO 4217
是联合国货币代码标准,定义三位字母码(USD、CNY、JPY)和三位数字码,并规定每种货币的
minor unit 小数位。常见值:
| 代码 | 名称 | 小数位 | 最小单位 |
|---|---|---|---|
| USD | 美元 | 2 | cent |
| EUR | 欧元 | 2 | cent |
| CNY | 人民币 | 2 | 分 |
| GBP | 英镑 | 2 | penny |
| JPY | 日元 | 0 | 日元 |
| KRW | 韩元 | 0 | 원 |
| VND | 越南盾 | 0 | đồng |
| BHD | 巴林第纳尔 | 3 | fils |
| KWD | 科威特第纳尔 | 3 | fils |
| JOD | 约旦第纳尔 | 3 | qirsh |
| CLF | 智利 UF | 4 | 计价单位 |
| XAU | 黄金(盎司) | — | 非流通币,不定义 |
编码时不要硬编码”2 位小数”。下面是一个最小的币种表:
CREATE TABLE currency (
code CHAR(3) PRIMARY KEY, -- ISO 4217 字母码
numeric_code SMALLINT NOT NULL UNIQUE, -- ISO 4217 数字码
name VARCHAR(64) NOT NULL,
minor_unit SMALLINT NOT NULL, -- 小数位数
symbol VARCHAR(8),
is_fiat BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO currency VALUES
('USD', 840, 'US Dollar', 2, '$', true, true),
('EUR', 978, 'Euro', 2, '€', true, true),
('CNY', 156, 'Chinese Yuan Renminbi',2, '¥', true, true),
('JPY', 392, 'Japanese Yen', 0, '¥', true, true),
('BHD', 48, 'Bahraini Dinar', 3, 'BD', true, true),
('XAU', 959, 'Gold (troy ounce)', 6, 'Au', false, true),
('BTC', 0, 'Bitcoin', 8, '₿', false, true),
('ETH', 0, 'Ether', 18, 'Ξ', false, true),
('USDT', 0, 'Tether', 6, '₮', false, true);3.2 非 ISO 币种:金属、加密货币、虚拟资产
ISO 4217 给贵金属定义了
XAU / XAG / XPT / XPD(盎司),但加密货币没有官方位置。通行做法:
- 自建扩展:数字码用
0或私有区段(ISO 规定950–999、000–099部分区段保留),字母码沿用市场惯例(BTC、ETH、SOL、USDT、USDC)。 - 最小单位显式声明:
BTC定 8 位(satoshi),ETH实际支持到 18 位(wei),但账务展示层普遍截到 8 位;稳定币USDT在不同链上精度不同(Ethereum 6 位、Tron 6 位、Solana 6 位,以合约decimals()为准)。 - 链感知的 “asset”:同一个
USDT在ERC20 / TRC20 / BEP20上是三种互不兑换的资产,严肃的交易所会把它建模成(symbol, chain, contract_address)三元组,而不仅是symbol。
3.3 单位换算的一条铁律
任何跨币种运算之前,必须先统一到”base unit ×
10^minor_unit”的整数形态,计算完再反向展开。不要在
Decimal
状态下直接做跨币种加法——那样加出来的数,数学上有值,业务上毫无意义。
# 错误:不同币种直接相加
total = Decimal("100.00") + Decimal("10000") # USD 100 + JPY 10000 = ???
# 正确:先换算到同一币种的 minor unit
usd_cents = 100_00
jpy_yen = 10_000
# 必须经过汇率
rate_jpy_per_usd = Decimal("151.23")
total_jpy = jpy_yen + round(usd_cents / 100 * rate_jpy_per_usd)四、多语言金额显示
4.1 格式化的四个正交维度
一个金额字符串的展示,至少有四个独立变量:
- 货币符号:
¥、$、€、¥(全角 vs 半角)、US$、CA$; - 千分位分隔符:英语
1,234.56、法语1 234,56、德语1.234,56、印度1,23,456.78(lakh 分组); - 负数表示:
-1234.56、(1234.56)(会计括号法)、1234.56-(尾置负号,某些欧洲银行); - 符号位置:符号前置(
$100)、符号后置(100 €,法国)、符号与数字之间插空格(CHF 100.00)。
手工拼接永远拼不对,必须交给本地化库:
>>> import babel.numbers as bn
>>> bn.format_currency(1234.56, 'USD', locale='en_US')
'$1,234.56'
>>> bn.format_currency(1234.56, 'EUR', locale='fr_FR')
'1\u202f234,56\u00a0€'
>>> bn.format_currency(1234.56, 'INR', locale='hi_IN')
'₹1,234.56'
>>> bn.format_currency(1234567.89, 'INR', locale='en_IN')
'₹12,34,567.89'
>>> bn.format_currency(-1234.56, 'USD', locale='en_US', format_type='accounting')
'($1,234.56)'Java 用
NumberFormat.getCurrencyInstance(Locale);Go 用
golang.org/x/text/currency +
message.Printer;前端用
Intl.NumberFormat。CLDR(Unicode Common Locale Data
Repository)是所有这些库的共同数据源。
4.2 BIDI:阿拉伯语与希伯来语
阿拉伯语、希伯来语是从右向左(RTL)书写的,但数字是左到右(LTR)的。一个包含”金额 + 货币 + 描述”的字符串混合了两种方向,浏览器按 Unicode BIDI 算法(UAX #9)渲染,很容易出现”负号跑到数字右边”、“币种符号与数字分开”等错位。
工程上:
- 一律使用 CLDR 的 currency pattern,不要自己拼字符串;
- 必要时在金额前后插入
U+202A(LRE)、U+202B(RLE)、U+202C(PDF)等方向控制符; - 测试用例必须覆盖
ar-SA、he-IL、fa-IR,并且覆盖负金额。
4.3 货币符号的坑
¥ 既可以是 CNY 也可以是 JPY。Unicode 里
U+00A5 (¥)
是通用日元/元符号,U+FFE5 (¥) 是全角版本,CNY
官方更推荐 CN¥ 或直接 RMB /
¥
配币种码消歧。不要在不带币种上下文的场景下只显示符号——大额跨境场景里显示¥100既可能是
14 美元也可能是 100 美元。规范做法:
- 日志、API、对外通知永远带三字母码;
- UI 里当页面 locale 无歧义时可只显示符号,否则用 ISO 码兜底。
4.4 千分位与本地化数据
CLDR 数据每半年更新一次,印度 lakh / crore
分组、瑞士用 '
作为千分位、南非用空格——这些都是由数据驱动的,代码不要硬编码。生产环境定期跟随
CLDR 升级;版本差异会导致同一金额不同时间渲染不一致,建议把
CLDR 版本号写进前后端构建元信息,便于追溯。
五、会计单位与显示单位分离
这是本文最想强调的一条原则。系统里存在至少三种”单位”:
- 账务单位(ledger unit):最小整数单位,永不变化,是事实来源;
- 业务单位(business unit):和币种
minor_unit对齐,如”元”; - 显示单位(display unit):按 locale 渲染后的字符串,含符号、分组、负数形式。
三者之间的转换必须显式:
[User Input "99.90 USD"]
│ parse + validate (locale-aware)
▼
[Decimal(99.90) USD]
│ to_minor_units: × 10^minor_unit(USD)=100
▼
[int64 9990 USD_CENTS] <-- 存储与传输的唯一真相
│
│ for display:
│ ÷ 100 → Decimal(99.90)
│ format(locale=xx_YY) → "$99.90" / "99,90 $US" / "US$99.90"
▼
[Rendered string]
5.1 API 层的纪律
对外 API 返回金额时,推荐同时带三个字段:
{
"amount_minor": 9990,
"currency": "USD",
"amount_decimal": "99.90"
}amount_minor用于机器消费,无歧义;currency必须存在;amount_decimal是字符串,不是 JSON number,避免客户端 JS 隐式转成Number丢精度;- 显示字符串由客户端按自己 locale 渲染,不要由后端塞一个
formatted: "$99.90"字段(会和客户端语言冲突)。
5.2 禁止在存储层做汇总时跨单位
报表层如果要聚合不同币种,必须:
- 先按币种分别汇总(
GROUP BY currency),得到每币种的BIGINT总和; - 再用某个”报表基准币种 + 当日快照汇率”换算;
- 换算结果标记为”派生量”(
is_derived = true),不进账务、不可回写。
把”USD 100 + JPY 10000”直接相加成一个数字,是典型的会计错误,也是审计红线。
六、汇率建模
6.1 直接报价 vs 间接报价
汇率(FX rate)表达”1 单位 base currency = X 单位 quote currency”。习惯有两种:
- 直接报价(direct quote):以本国货币为
quote。中国境内
USD/CNY = 7.1923表示 1 USD = 7.1923 CNY; - 间接报价(indirect
quote):以本国货币为 base。英国
GBP/USD = 1.2640。
工程上不要依赖习惯,永远存
(base, quote, rate) 三元组:
CREATE TABLE fx_rate (
base_ccy CHAR(3) NOT NULL,
quote_ccy CHAR(3) NOT NULL,
rate NUMERIC(24,12) NOT NULL, -- 1 base = rate quote
rate_type VARCHAR(16) NOT NULL, -- mid / bid / ask / settle
source VARCHAR(32) NOT NULL, -- ECB / PBOC / Reuters / internal
ts TIMESTAMPTZ NOT NULL,
PRIMARY KEY (base_ccy, quote_ccy, rate_type, source, ts)
);6.2 bid / ask / mid 与点差
市场上没有”一个汇率”,只有”买价(bid)“和”卖价(ask)“。二者之差是点差(spread),一般用于费用或做市收益。核心规则:
- 用户卖出 base 买入 quote 时,按
bid成交; - 用户买入 base 卖出 quote 时,按
ask成交; - 报表、展示用
mid =(bid + ask) / 2; - 账务落地用成交价(actual fill),不用 mid,否则点差收益无法归集。
6.3 三角换算与精度丢失
没有直接报价的冷门对必须三角换算,例如
CNY/MXN 往往走
CNY -> USD -> MXN:
rate(CNY/MXN) = rate(CNY/USD) × rate(USD/MXN)
= (1 / rate(USD/CNY)) × rate(USD/MXN)
精度控制的两条原则:
- 先乘后除。所有汇率都以 12
位以上小数存储,链式乘法用
Decimal保留完整精度,到最后一步再按目标币种minor_unit舍入。 - 舍入方向明确。收费类换算用
ROUND_HALF_UP(用户友好);对账与结算用ROUND_HALF_EVEN(银行家舍入,减少系统性偏置)。
from decimal import Decimal, ROUND_HALF_EVEN, getcontext
getcontext().prec = 28
def cross_rate(base_usd: Decimal, quote_usd: Decimal) -> Decimal:
return (quote_usd / base_usd)
usd_cny = Decimal("7.1923")
usd_mxn = Decimal("17.0821")
cny_mxn = cross_rate(usd_cny, usd_mxn) # 2.375...
amount_cny_minor = 100_00 # 100.00 CNY
amount_mxn = (Decimal(amount_cny_minor) / 100 * cny_mxn)
amount_mxn_minor = int(
(amount_mxn * 100).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
)6.4 “基准币种”的选择
国际大型银行选 USD 或 EUR 做
base;跨境收单机构选自己主营地区的本币;交易所根据
quote market
设计(BTC/USDT、ETH/USDT)。关键是单一事实来源:任何一对币种的换算,存储层只保留
n 条路径(最好一条),否则出现 A/B ≠ 1 /(B/A)
的套利窟窿。
七、代码示例:Money 类型
7.1 Go 实现(minor unit + currency)
package money
import (
"errors"
"fmt"
"math/big"
)
type Currency struct {
Code string
MinorUnit int8
}
var (
USD = Currency{"USD", 2}
CNY = Currency{"CNY", 2}
JPY = Currency{"JPY", 0}
BHD = Currency{"BHD", 3}
BTC = Currency{"BTC", 8}
)
type Money struct {
minor *big.Int
ccy Currency
}
func New(minor int64, ccy Currency) Money {
return Money{minor: big.NewInt(minor), ccy: ccy}
}
func (m Money) Currency() Currency { return m.ccy }
func (m Money) Minor() *big.Int { return new(big.Int).Set(m.minor) }
var ErrCurrencyMismatch = errors.New("currency mismatch")
func (a Money) Add(b Money) (Money, error) {
if a.ccy.Code != b.ccy.Code {
return Money{}, ErrCurrencyMismatch
}
return Money{minor: new(big.Int).Add(a.minor, b.minor), ccy: a.ccy}, nil
}
func (a Money) Sub(b Money) (Money, error) {
if a.ccy.Code != b.ccy.Code {
return Money{}, ErrCurrencyMismatch
}
return Money{minor: new(big.Int).Sub(a.minor, b.minor), ccy: a.ccy}, nil
}
// MulRatio: amount × numerator / denominator, 银行家舍入
func (m Money) MulRatio(num, den int64) Money {
if den == 0 {
panic("division by zero")
}
n := new(big.Int).Mul(m.minor, big.NewInt(num))
q, r := new(big.Int).QuoRem(n, big.NewInt(den), new(big.Int))
// Banker's rounding
twiceRem := new(big.Int).Abs(new(big.Int).Lsh(r, 1))
absDen := new(big.Int).Abs(big.NewInt(den))
cmp := twiceRem.Cmp(absDen)
if cmp > 0 || (cmp == 0 && q.Bit(0) == 1) {
if (r.Sign() >= 0) == (den > 0) {
q.Add(q, big.NewInt(1))
} else {
q.Sub(q, big.NewInt(1))
}
}
return Money{minor: q, ccy: m.ccy}
}
// Allocate: 按权重拆分,保证各分片之和严格等于原值
func (m Money) Allocate(ratios []int64) []Money {
total := int64(0)
for _, r := range ratios {
total += r
}
parts := make([]Money, len(ratios))
remainder := new(big.Int).Set(m.minor)
for i, r := range ratios {
parts[i] = m.MulRatio(r, total)
remainder.Sub(remainder, parts[i].minor)
}
// 把最后一分钱塞给第一个分片,避免凑不齐
if remainder.Sign() != 0 {
parts[0].minor.Add(parts[0].minor, remainder)
}
return parts
}
func (m Money) String() string {
return fmt.Sprintf("%s %s", m.minor.String(), m.ccy.Code)
}Allocate
是支付分账里最常见的刚需。举例:100.03 元按
[1, 1, 1] 拆三份,不能拆成
33.34 / 33.34 / 33.34(合计 100.02
少一分),也不能
33.35 × 3(多一分)。上面实现保证合计严格相等。
7.2 Python 实现(Decimal + 规范化)
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP, getcontext
getcontext().prec = 40
@dataclass(frozen=True)
class Currency:
code: str
minor_unit: int
USD = Currency("USD", 2)
CNY = Currency("CNY", 2)
JPY = Currency("JPY", 0)
BHD = Currency("BHD", 3)
BTC = Currency("BTC", 8)
@dataclass(frozen=True)
class Money:
minor: int
currency: Currency
@classmethod
def from_decimal(cls, amount: Decimal | str, ccy: Currency,
rounding=ROUND_HALF_EVEN) -> "Money":
d = Decimal(amount)
scaled = (d * (10 ** ccy.minor_unit)).quantize(
Decimal(1), rounding=rounding)
return cls(int(scaled), ccy)
def to_decimal(self) -> Decimal:
return Decimal(self.minor) / (10 ** self.currency.minor_unit)
def _check(self, other: "Money") -> None:
if self.currency != other.currency:
raise ValueError(f"currency mismatch: {self.currency.code} vs {other.currency.code}")
def __add__(self, other: "Money") -> "Money":
self._check(other)
return Money(self.minor + other.minor, self.currency)
def __sub__(self, other: "Money") -> "Money":
self._check(other)
return Money(self.minor - other.minor, self.currency)
def mul_rate(self, rate: Decimal, rounding=ROUND_HALF_EVEN) -> "Money":
scaled = (Decimal(self.minor) * rate).quantize(
Decimal(1), rounding=rounding)
return Money(int(scaled), self.currency)
def allocate(self, weights: list[int]) -> list["Money"]:
total = sum(weights)
parts = []
remainder = self.minor
for w in weights:
p = self.minor * w // total
parts.append(Money(p, self.currency))
remainder -= p
for i in range(remainder):
parts[i] = Money(parts[i].minor + 1, self.currency)
return parts7.3 Java 实现(BigDecimal 封装)
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
public final class Money {
private final long minor; // 最小单位
private final Currency currency;
public Money(long minor, Currency currency) {
this.minor = minor;
this.currency = Objects.requireNonNull(currency);
}
public static Money of(String amount, Currency ccy) {
BigDecimal d = new BigDecimal(amount)
.movePointRight(ccy.minorUnit())
.setScale(0, RoundingMode.HALF_EVEN);
return new Money(d.longValueExact(), ccy);
}
public BigDecimal toDecimal() {
return BigDecimal.valueOf(minor, currency.minorUnit());
}
public Money add(Money other) {
require(other);
return new Money(Math.addExact(minor, other.minor), currency);
}
public Money subtract(Money other) {
require(other);
return new Money(Math.subtractExact(minor, other.minor), currency);
}
public Money multiply(BigDecimal rate, RoundingMode mode) {
BigDecimal result = BigDecimal.valueOf(minor).multiply(rate)
.setScale(0, mode);
return new Money(result.longValueExact(), currency);
}
private void require(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"currency mismatch: " + currency + " vs " + other.currency);
}
}
@Override public String toString() {
return toDecimal().toPlainString() + " " + currency.code();
}
}几个细节:
Math.addExact/subtractExact在溢出时抛异常,宁可 fail-fast 不要静默翻转;longValueExact()同理——BigDecimal转long有损失必抛;- 构造函数用
String而非double,禁止new BigDecimal(double)陷阱; setScale必须显式传RoundingMode,没有默认值——这是 Java 的可取之处。
7.4 Rust 实现(rust_decimal + newtype)
use rust_decimal::{Decimal, RoundingStrategy};
use rust_decimal_macros::dec;
use std::ops::{Add, Sub};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Currency {
pub code: &'static str,
pub minor_unit: u8,
}
pub const USD: Currency = Currency { code: "USD", minor_unit: 2 };
pub const JPY: Currency = Currency { code: "JPY", minor_unit: 0 };
pub const BTC: Currency = Currency { code: "BTC", minor_unit: 8 };
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Money {
minor: i128,
currency: Currency,
}
#[derive(Debug, thiserror::Error)]
pub enum MoneyError {
#[error("currency mismatch: {0} vs {1}")]
CurrencyMismatch(&'static str, &'static str),
#[error("overflow")]
Overflow,
}
impl Money {
pub fn new(minor: i128, currency: Currency) -> Self {
Self { minor, currency }
}
pub fn from_decimal(amount: Decimal, ccy: Currency) -> Self {
let scale = Decimal::from(10i64.pow(ccy.minor_unit as u32));
let scaled = (amount * scale).round_dp_with_strategy(
0, RoundingStrategy::MidpointNearestEven);
Self::new(scaled.mantissa(), ccy)
}
pub fn checked_add(self, other: Self) -> Result<Self, MoneyError> {
if self.currency.code != other.currency.code {
return Err(MoneyError::CurrencyMismatch(
self.currency.code, other.currency.code));
}
let m = self.minor.checked_add(other.minor)
.ok_or(MoneyError::Overflow)?;
Ok(Self { minor: m, currency: self.currency })
}
}
impl Add for Money {
type Output = Money;
fn add(self, rhs: Self) -> Self {
self.checked_add(rhs).expect("money add failed")
}
}Rust
的优势是把”币种不匹配”、“溢出”这类运行时错误提升到类型系统或显式
Result。对延迟敏感的系统甚至可以把
Currency 作为泛型参数
Money<USD>、Money<JPY>,让编译器阻止跨币种相加——代价是分账、汇率换算这类需要运行时币种的场景得写额外抽象层。
7.5 舍入模式速查
| 模式 | 说明 | 适用场景 |
|---|---|---|
ROUND_UP / CEILING |
远离零 / 向正无穷 | 借款利息按日计提(对贷方有利) |
ROUND_DOWN / FLOOR |
向零 / 向负无穷 | 消费退款按低舍(对商户友好) |
ROUND_HALF_UP |
四舍五入 | 发票金额、用户可见展示 |
ROUND_HALF_DOWN |
五舍六入 | 少见,监管个别要求 |
ROUND_HALF_EVEN |
银行家舍入(向最近偶数) | 对账、结算默认,减少系统性偏置 |
关键点:
- 同一系统里所有金额舍入必须显式声明模式,不要依赖语言默认;
- 税费、汇率、分账按字段级别定义舍入,写入配置而非硬编码;
- 舍入”误差”进专门的差错账户,定期由财务核销,不要静默吞掉。
八、数据库存储方案
8.1 三选一
| 方案 | 示例 | 优点 | 缺点 |
|---|---|---|---|
BIGINT(minor unit) |
9990 /* USD cents */ |
最快、跨语言零歧义 | 单位隐含、跨币种混算易错 |
NUMERIC(p, s) |
NUMERIC(20,4) 99.9000 |
直观、聚合精确 | 比 BIGINT 慢;分片哈希差 |
| 字符串 | "99.90" |
可无限精度、跨系统安全 | 不能走 SQL 聚合;必须应用层解析 |
建议:
- 主账务表:
amount_minor BIGINT NOT NULL+currency CHAR(3) NOT NULL,并加CHECK约束; - 汇率、利率、费率:
NUMERIC(24, 12); - 原始报文、外部系统 raw payload:字符串,不解析,便于回放;
- 高并发撮合引擎:只用
BIGINT,禁止NUMERIC。
8.2 建表示例
CREATE TABLE ledger_entry (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
direction CHAR(2) NOT NULL CHECK (direction IN ('DR','CR')),
amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
currency CHAR(3) NOT NULL,
txn_id BIGINT NOT NULL,
posted_at TIMESTAMPTZ NOT NULL,
FOREIGN KEY (currency) REFERENCES currency(code)
);
CREATE INDEX idx_ledger_account_time
ON ledger_entry (account_id, posted_at DESC);
CREATE INDEX idx_ledger_txn
ON ledger_entry (txn_id);8.3 聚合的精度陷阱
SUM(BIGINT)永远精确(只要不溢出);SUM(NUMERIC)精确,但小心(p, s)溢出——NUMERIC(20,4)汇总十亿条就可能超;SUM(FLOAT / DOUBLE)禁止;AVG(NUMERIC)的结果是更高精度的NUMERIC,但除法仍按上下文舍入,报表字段必须显式ROUND()或改用SUM / COUNT分开落盘。
8.4 索引与分片
- 以
BIGINT存金额时,不要把金额列做分片键(冷热严重不均); - 账号 + 时间复合索引是账务查询主力;
- 对账报表类聚合走列存或 OLAP 引擎,不要拉爆 OLTP;
- TiDB / OceanBase 等分布式数据库对
NUMERIC的支持与 MySQL 不完全一致,选型时测聚合与小数位裁剪行为。
九、常见 Bug 清单
9.1 JSON 浮点序列化
后端: amount = Decimal("99.90")
序列化: {"amount": 99.9} # JSON number
浏览器: JSON.parse → 99.9 (float64)
前端拼: amount * 100 → 9899.999999999999
所有对外金额字段用字符串序列化,或配对
amount_minor 整数字段。
9.2 Excel 打开 CSV
- 长数字变成科学记数法(
1.23E+16); - 银行卡号、订单号末尾精度丢失;
- 本地化下
,和.互换——德国用户打开美式 CSV 金额错位 100 倍; - 推荐导出
.xlsx直接指定列格式为”文本”;或前置单引号'1234567890123456强制文本。
9.3 隐式类型转换
BigDecimal a = new BigDecimal(0.1); // 实际是 0.10000000000000000555...
BigDecimal b = new BigDecimal("0.1"); // 精确 0.1BigDecimal(double)一旦写出就是 bug,lint 禁用;- Go 里
float64(amount)转int64(amount * 100)的经典写法必错; - JavaScript 的
Number没有整数/浮点区分,Number.MAX_SAFE_INTEGER = 2^53 − 1,超过就不可靠。
9.4 默认舍入方向
- Python 的
round()是银行家舍入(round(0.5) == 0),Decimal.quantize()默认也是,但很多库文档不写明; - Java 的
BigDecimal.setScale(2)没有默认,不传RoundingMode会抛异常(这是好设计); - MySQL 的
ROUND()实现随版本和 OS 不同而不同(历史上 Linux glibc 上ROUND(0.5)=0、Windows 上ROUND(0.5)=1),需要显式CAST到DECIMAL再ROUND。
9.5 时区与汇率快照
汇率随时间变化,一笔跨境交易的”换算价”必须固定成交时刻的快照(fx_rate_snapshot),不要在展示时动态查最新汇率——不然用户每次刷新看到的金额都不一样,客诉直接起飞。
9.6 负号丢失
- 某些定制 JSON 库把
-0序列化成0,借贷方向错乱; - SQL 的
ABS()被误用于”去掉导入数据里的格式问题”,结果把退款当付款; - 对账差错处理中
amount_minor允许为负,不要在应用层加CHECK >= 0,应该在direction = 'CR' / 'DR'上约束。
十、精度生命周期:一笔钱走完全流程
下面这张图描述一笔用户消费从前端到报表的精度生命周期:
flowchart TD
A["前端输入<br/>User types 99.9 USD"] --> B["客户端校验<br/>parse locale-aware<br/>Decimal 99.90"]
B --> C["HTTP 请求<br/>JSON {amount_minor:9990, currency:USD}"]
C --> D["API 网关<br/>schema 校验<br/>拒绝 float"]
D --> E["支付路由<br/>Money(9990, USD)<br/>风控 / 限额判断"]
E --> F["账务库<br/>ledger_entry<br/>BIGINT minor + CCY"]
F --> G["清算批处理<br/>GROUP BY currency<br/>SUM(BIGINT)"]
G --> H["汇率快照换算<br/>Decimal × rate<br/>ROUND_HALF_EVEN"]
H --> I["汇总报表<br/>NUMERIC(24,4)"]
I --> J["BI / Excel<br/>按 locale 渲染"]
J --> K["用户账单邮件<br/>Intl / Babel 格式化"]
F -.->|对账| L["银行 / 通道对账单<br/>minor unit 核对"]
L -.->|差异| M["差错账户<br/>人工核销"]
流程里的关键”单位边界”:
- A → C:locale 到规范化整数,这一步的
bug 最难发现(用户输入
1.000在法语里是1,在英语里是1000); - F → G:聚合前分币种,这一步错了整个报表错;
- G → H:汇率快照必须是成交时的,不是报表生成时的;
- I → J:最后一公里,locale 渲染,按目的受众动态决定。
十一、真实案例
11.1 国际:Knight Capital 2012
根因与金额单位没有直接关系,但事故背景揭示了”类型 / 标志
/ 单位”的隐含约定在部署漂移下的危险。SEC 行政处罚决定 34-70694
记录:同名变量 Power Peg
在老代码里是订单路由测试开关,在新代码里含义变更,但 8
台服务器中 1 台未完成部署,导致老代码被误激活,短时间内产生
4 百万 + 笔错误订单,自营账户持仓偏离,税前亏损约
4.6 亿美元。
工程启示:金额、方向、币种、环境这四类”业务语义字段”必须有强类型和跨版本契约检查,不能仅靠命名约定。
11.2 国际:2012 年 Mt. Gox 重复提现
Mt. Gox 在 2011—2014 年间因精度与并发问题多次出现 BTC
重复提现、负余额。事后分析1显示:内部账本用
float 存 BTC 数量,高并发下
balance -= amount
的”先读后写”没做原子化,加上浮点比较
balance >= amount 在边界值不可靠。2014
年破产时丢失 BTC 约 85
万枚,虽然主因是热钱包被盗,但账务系统本身的精度脆弱让问题更难追踪。
11.3 中国:人民币跨境结算中的汇率快照
CIPS(人民币跨境支付系统)在 PBOC 公开文档
中明确要求跨境人民币汇款报文(MT103 / ISO 20022
pacs.008)填写 ExchangeRate
字段,并锁定结算当日中国外汇交易中心(CFETS)中间价或商业行自报价。国内一家股份行在
2019
年曾因夜间批处理误用”系统启动时刻汇率”而非”业务日期汇率”,导致一笔人民币对港币结算的中间汇兑损益错计约
12
万元,次日对账发现后由差错账户调整。汇率快照口径是
bug 高发地。
11.4 中国:第三方支付分账的一分钱问题
某出行平台早年按”乘客:司机:平台 = 3:65:32”分账,实现时用
amount × ratio / 100
独立计算三方,结果合计经常比原订单多/少 1
分。财务月度差错数以万计,虽然每笔只差 1
分,但对账工作量巨大。修复后采用”先算前 N-1
方,最后一方兜底”的 Allocate 算法(见
§7.1),差错降为零。
十二、工程坑点
- “JPY 没有小数”不代表可以四舍五入到个位数。跨境换算中间过程必须保留完整精度,只在最终落账时按 JPY 0 位取整。
BIGINT上限是9.22×10^18。看似够,但如果用”纳元”(10^-9)存,超过 92 亿美元就溢出。系统精度设计要预留 10 倍以上业务上限。- 汇率表不要只存最新。必须按时间存历史,
(base, quote, rate_type, source, ts)复合主键,查询时WHERE ts <= :biz_time ORDER BY ts DESC LIMIT 1。 - 不要用视图把
BIGINT / 100直接包成DECIMAL。看起来方便,实际 BI 工具在此之上再做计算又丢精度。应用层控制更可靠。 - 负金额在某些 ORM
会被自动变正。
abs()滥用是账务第一杀手,代码评审重点检查。 - 前端
Number.toFixed(2)不是四舍五入。(1.005).toFixed(2) === "1.00",不是"1.01"。任何对外金额都不要在 JS 里做舍入,要么后端返回已格式化字符串,要么前端用big.js / decimal.js。 - 不要假设
amount >= 0。冲正、退款、差错、利息返还都会出现负分录。约束应该落在direction上而非金额符号。 - Protobuf 里用
sint64而非double。同样的字段被 gRPC 规范化后跨语言传输才稳定。 - 对账差异不要追”绝对零”。约定一个”容差”(如 1 分/币种/日),小于容差的走自动差错账户核销,避免工程师深夜查 0.01 元。
- Excel 金额列加保护:导出时给金额列设置”文本”或固定位数的”数字”格式,并锁定;提醒接收方不要手工改单元格类型。
十三、落地清单
写账务前,按下面清单逐条对齐,可以避免 80% 的金额 bug:
十四、参考资料
- ISO 4217 货币代码标准:https://www.iso.org/iso-4217-currency-codes.html
- ISO 4217 数据维护:https://www.six-group.com/en/products-services/financial-information/data-standards.html
- IEEE 754 浮点标准(2019 版):https://standards.ieee.org/ieee/754/6210/
- Python
decimal模块:https://docs.python.org/3/library/decimal.html - Java
BigDecimal与RoundingMode:https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html - shopspring/decimal(Go):https://github.com/shopspring/decimal
- rust_decimal:https://docs.rs/rust_decimal/
- Unicode CLDR:https://cldr.unicode.org/
- Unicode UAX #9(BIDI 算法):https://www.unicode.org/reports/tr9/
- Babel(Python i18n):https://babel.pocoo.org/
- ECMAScript
Intl.NumberFormat:https://tc39.es/ecma402/ - SEC 关于 Knight Capital 的处罚决定 34-70694:https://www.sec.gov/litigation/admin/2013/34-70694.pdf
- PBOC / CIPS 官方:http://www.pbc.gov.cn/、https://www.cips.com.cn/
- CFETS(中国外汇交易中心):https://www.chinamoney.com.cn/
- Martin Fowler《Patterns of Enterprise Application Architecture》第 6 章 “Money” 模式
- Matt Bishop,《Rounding in Financial Systems》(业内技术博客合集)
十五、小结
金额类型是金融系统的”原子”。本文把它拆成四件互相独立的事:数值表示(float
不行、Decimal
或整数最小单位)、币种模型(ISO 4217 +
扩展,重视 minor
unit)、单位生命周期(存储、计算、展示三层分离)、汇率与换算(快照、点差、舍入)。再叠加一层本地化(CLDR、BIDI、括号法),和一层存储
/ 聚合纪律(BIGINT vs
NUMERIC,容差对账)。
把这些钉死之后,后面十几篇讨论复式记账、账务库、支付网关、撮合、清结算、跨境、风控的内容才有地基。下一篇进入【复式记账工程化】:在你已经能正确表达”一笔钱”之后,如何把任意一笔业务事件拆成守恒的借贷分录。
上一篇:《金融科技工程全景:从支付到交易所的系统分类与读图》
参考事后第三方审计报告与 Kraken CEO Jesse Powell 在 2014 年的公开陈述。↩︎
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【金融科技工程】13 跨境支付工程:代理行、nostro/vostro、汇率锁定、对手方风险
从工程视角拆解跨境支付的参与方、资金流、汇率、合规与对账:代理行与 SWIFT MT103/gpi、Nostro/Vostro 账户、Wise/Airwallex 的"本地收本地付"、FX 点差与锁定、稳定币与 CBDC 跨境(mBridge)、中国出海商户的持牌链路、AML/制裁名单工程。
【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
【金融科技工程】复式记账工程化:科目、分录、余额、对账
把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。
【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户
账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。