惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - Franky
N
Netflix TechBlog - Medium
宝玉的分享
宝玉的分享
Google DeepMind News
Google DeepMind News
腾讯CDC
G
Google Developers Blog
Martin Fowler
Martin Fowler
Microsoft Security Blog
Microsoft Security Blog
Recent Announcements
Recent Announcements
爱范儿
爱范儿
Engineering at Meta
Engineering at Meta
Microsoft Azure Blog
Microsoft Azure Blog
A
About on SuperTechFans
aimingoo的专栏
aimingoo的专栏
有赞技术团队
有赞技术团队
Jina AI
Jina AI
人人都是产品经理
人人都是产品经理
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
M
MIT News - Artificial intelligence
罗磊的独立博客
博客园 - 三生石上(FineUI控件)
美团技术团队
WordPress大学
WordPress大学
阮一峰的网络日志
阮一峰的网络日志

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计
【金融科技工程】钱的建模:金额精度、币种、会计单位、多语...
2026-04-22 · via 土法炼钢兴趣小组的算法知识备份

导语

金融系统里最朴素也最容易被低估的问题,是”一笔钱”怎么在代码里表示。从注册界面上用户输入的 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)

把所有金额按”最小单位”存成整数。人民币和美元就是”分”;比特币是 satoshi1 BTC = 10^8 satoshi);以太坊是 wei1 ETH = 10^18 wei)。

优点:

  • 运算是原生整数,一条 ADD 指令,零误差;
  • 存储紧凑,BIGINT 8 字节覆盖 ±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 是联合国货币代码标准,定义三位字母码(USDCNYJPY)和三位数字码,并规定每种货币的 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–999000–099 部分区段保留),字母码沿用市场惯例(BTCETHSOLUSDTUSDC)。
  • 最小单位显式声明BTC 定 8 位(satoshi),ETH 实际支持到 18 位(wei),但账务展示层普遍截到 8 位;稳定币 USDT 在不同链上精度不同(Ethereum 6 位、Tron 6 位、Solana 6 位,以合约 decimals() 为准)。
  • 链感知的 “asset”:同一个 USDTERC20 / 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 格式化的四个正交维度

一个金额字符串的展示,至少有四个独立变量:

  1. 货币符号¥$(全角 vs 半角)、US$CA$
  2. 千分位分隔符:英语 1,234.56、法语 1 234,56、德语 1.234,56、印度 1,23,456.78(lakh 分组);
  3. 负数表示-1234.56(1234.56)(会计括号法)、1234.56-(尾置负号,某些欧洲银行);
  4. 符号位置:符号前置($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-SAhe-ILfa-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 版本号写进前后端构建元信息,便于追溯。


五、会计单位与显示单位分离

这是本文最想强调的一条原则。系统里存在至少三种”单位”

  1. 账务单位(ledger unit):最小整数单位,永不变化,是事实来源;
  2. 业务单位(business unit):和币种 minor_unit 对齐,如”元”;
  3. 显示单位(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 禁止在存储层做汇总时跨单位

报表层如果要聚合不同币种,必须:

  1. 先按币种分别汇总(GROUP BY currency),得到每币种的 BIGINT 总和;
  2. 再用某个”报表基准币种 + 当日快照汇率”换算;
  3. 换算结果标记为”派生量”(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)

精度控制的两条原则:

  1. 先乘后除。所有汇率都以 12 位以上小数存储,链式乘法用 Decimal 保留完整精度,到最后一步再按目标币种 minor_unit 舍入。
  2. 舍入方向明确。收费类换算用 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 “基准币种”的选择

国际大型银行选 USDEUR 做 base;跨境收单机构选自己主营地区的本币;交易所根据 quote market 设计(BTC/USDTETH/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 parts

7.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() 同理——BigDecimallong 有损失必抛;
  • 构造函数用 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.1
  • BigDecimal(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),需要显式 CASTDECIMALROUND

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),差错降为零。


十二、工程坑点

  1. “JPY 没有小数”不代表可以四舍五入到个位数。跨境换算中间过程必须保留完整精度,只在最终落账时按 JPY 0 位取整。
  2. BIGINT 上限是 9.22×10^18。看似够,但如果用”纳元”(10^-9)存,超过 92 亿美元就溢出。系统精度设计要预留 10 倍以上业务上限。
  3. 汇率表不要只存最新。必须按时间存历史,(base, quote, rate_type, source, ts) 复合主键,查询时 WHERE ts <= :biz_time ORDER BY ts DESC LIMIT 1
  4. 不要用视图把 BIGINT / 100 直接包成 DECIMAL。看起来方便,实际 BI 工具在此之上再做计算又丢精度。应用层控制更可靠。
  5. 负金额在某些 ORM 会被自动变正abs() 滥用是账务第一杀手,代码评审重点检查。
  6. 前端 Number.toFixed(2) 不是四舍五入(1.005).toFixed(2) === "1.00",不是 "1.01"。任何对外金额都不要在 JS 里做舍入,要么后端返回已格式化字符串,要么前端用 big.js / decimal.js
  7. 不要假设 amount >= 0。冲正、退款、差错、利息返还都会出现负分录。约束应该落在 direction 上而非金额符号。
  8. Protobuf 里用 sint64 而非 double。同样的字段被 gRPC 规范化后跨语言传输才稳定。
  9. 对账差异不要追”绝对零”。约定一个”容差”(如 1 分/币种/日),小于容差的走自动差错账户核销,避免工程师深夜查 0.01 元。
  10. Excel 金额列加保护:导出时给金额列设置”文本”或固定位数的”数字”格式,并锁定;提醒接收方不要手工改单元格类型。

十三、落地清单

写账务前,按下面清单逐条对齐,可以避免 80% 的金额 bug:


十四、参考资料


十五、小结

金额类型是金融系统的”原子”。本文把它拆成四件互相独立的事:数值表示(float 不行、Decimal 或整数最小单位)、币种模型(ISO 4217 + 扩展,重视 minor unit)、单位生命周期(存储、计算、展示三层分离)、汇率与换算(快照、点差、舍入)。再叠加一层本地化(CLDR、BIDI、括号法),和一层存储 / 聚合纪律(BIGINT vs NUMERIC,容差对账)。

把这些钉死之后,后面十几篇讨论复式记账、账务库、支付网关、撮合、清结算、跨境、风控的内容才有地基。下一篇进入【复式记账工程化】:在你已经能正确表达”一笔钱”之后,如何把任意一笔业务事件拆成守恒的借贷分录。


上一篇《金融科技工程全景:从支付到交易所的系统分类与读图》

下一篇《复式记账工程化:科目、分录、余额、对账》


  1. 参考事后第三方审计报告与 Kraken CEO Jesse Powell 在 2014 年的公开陈述。↩︎

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】复式记账工程化:科目、分录、余额、对账

把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。

2026-04-22 · architecture / fintech

【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户

账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。