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

推荐订阅源

Application and Cybersecurity Blog
Application and Cybersecurity Blog
A
About on SuperTechFans
S
SegmentFault 最新的问题
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Help Net Security
Help Net Security
有赞技术团队
有赞技术团队
博客园 - 【当耐特】
O
OpenAI News
美团技术团队
月光博客
月光博客
Apple Machine Learning Research
Apple Machine Learning Research
Schneier on Security
Schneier on Security
Webroot Blog
Webroot Blog
Cyberwarzone
Cyberwarzone
Hacker News - Newest:
Hacker News - Newest: "LLM"
Google Online Security Blog
Google Online Security Blog
T
Tenable Blog
S
Security Affairs
博客园_首页
S
Schneier on Security
Security Latest
Security Latest
T
Threat Research - Cisco Blogs
T
Tailwind CSS Blog
大猫的无限游戏
大猫的无限游戏
Spread Privacy
Spread Privacy
量子位
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
K
Kaspersky official blog
Hugging Face - Blog
Hugging Face - Blog
TaoSecurity Blog
TaoSecurity Blog
博客园 - 聂微东
Vercel News
Vercel News
M
MIT News - Artificial intelligence
T
Troy Hunt's Blog
B
Blog
MongoDB | Blog
MongoDB | Blog
Martin Fowler
Martin Fowler
Attack and Defense Labs
Attack and Defense Labs
L
LINUX DO - 最新话题
D
DataBreaches.Net
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Stack Overflow Blog
Stack Overflow Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
博客园 - Franky
W
WeLiveSecurity
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
F
Fortinet All Blogs
www.infosecurity-magazine.com
www.infosecurity-magazine.com
C
Check Point Blog
H
Hacker News: Front Page

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

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 排序基准测试:用数据说话
【金融科技工程】钱的建模:金额精度、币种、会计单位、多语言金额
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 的真实演进路径。