对账与金额精度:钱为什么会对不上
月底,财务发现平台的流水和渠道账单差了 3 分钱。查了一天,从手续费口径查到汇率,最后发现:手续费是用二进制浮点算的——单笔的零头小到看不见(0.1 + 0.2 加出来是 0.30000000000000004),成千上万笔攒下来,正好差出这 3 分钱。
钱为什么会对不上?答案分两半:一半在表示(金额怎么存、怎么算),一半在核对(两本账怎么比)。两半都做对,才谈得上「一分不差」。
对账不是对总数,是对每一笔
先看最常见的错误做法:拿两个汇总之和比对。「今天平台流水 100 万,渠道账单也是 100 万,平了。」
它的问题:
| 核总数 | 逐笔核 | |
|---|---|---|
| 看什么 | 两个汇总之和 | 两边的每一笔 |
| 怎么认同一笔 | 不需要 | 靠流水号配对 |
| 一进一出(付了又退) | 互相抵消,看不出来 | 两笔各算,认得出 |
| 能发现「少一笔 / 多一笔」 | 可能仍然平 | 挑得出来 |
| 适合 | 只看个大概 | 要「每一分都有来路」 |
总数是「看着平」,逐笔才是「真的平」。 逐笔的前提是:每一笔钱在两边都带上同一个流水号。平台内部一个、渠道一个,两边的号都留在记录里,配对时靠共同号认「同一笔」。
sequenceDiagram
participant P as 平台账
participant G as 渠道账
P->>G: 发起支付,带上共同流水号
G-->>P: 回调结果
Note over P,G: 两边各自记账,共同流水号都留下
P->>G: 定期对账:逐笔配对
Note over P,G: 对上的平;对不上的挂待查池,追到平
图 1:对账泳道——平台账与渠道账各自记账,配对靠共同流水号;对不上的先挂「待查池」,不直接改账。
差异的三类来路
对不上不等于做假,也不等于算错。最常见的差异就三类:
| 差异形态 | 典型原因 | 处理方向 |
|---|---|---|
| 只有我这边有 | 校验没过 / 交易没成 | 追状态,必要时冲销 |
| 只有渠道有 | 漏记 / 回调丢了 | 补记,并查为什么漏 |
| 两边都有、金额不同 | 手续费 / 汇率 / 减免 | 查口径,按规则调 |
flowchart TD
D["对账发现一笔对不上"] --> Q{"哪边有这笔记录?"}
Q -->|"只有我方有"| R1["校验没过 / 交易没成<br/>追状态,必要时冲销"]
Q -->|"只有渠道有"| R2["漏记 / 回调丢了<br/>补记,并查为什么漏"]
Q -->|"两边都有、金额不同"| R3["手续费 / 汇率 / 减免<br/>查口径,按规则调"]
图 2:差异因果链——对不上一笔,先问「哪边有记录」:只有我方有、只有渠道有、还是两边金额不同;三种来路,三种处理。
常见来路清单:重试产生的重复、退款、手续费口径、跨日/时区的时间差、漏记、金额口径不同(含税 vs 不含税)。
处理纪律只有一条,但至关重要:没配上的差异不能直接改。 一改总数就假平了,真差的那笔再也找不回来。正确路径:先挂进「待查池」(保留原始流水号和两侧时间)→ 顺着号追到卡点 → 查清楚再动账。
还有一条时间维度的诚实:在途 ≠ 错。 因为结算周期、跨日、时区,一边记了、另一边还没记——这种差异等另一边记上自然就平。对账总是按一段一段核(按日、按结算期),当天差一笔,先别下结论。
时间戳铁律:记账时间 ≠ 对账时间
这是内源教训里最贵的一条:记账时间(CreatedAt)与对账时间(ReconciledAt)不可混用。
混用了会怎样?账天天对不上,怎么查都查不出——因为「以哪个时间对齐两边」这件事本身就错了。落库时两个时间各存各的:一个回答「这笔什么时候发生」,一个回答「这笔什么时候被核对过」。
真实事故:两个经典
Vancouver 交易所:22 个月「跌掉一半」
1982 年 1 月,温哥华证券交易所的指数从 1000 起步。指数每天更新约 3000 次,每次计算把结果只保留三位小数,三位以后直接砍掉——不是四舍五入,是截断。
截断是系统性向下的误差。单次砍掉的零头微不足道,但一天 3000 次、一个月约 20 个交易日,误差逐笔累积,指数以每月约 25 点的速度「下跌」。22 个月后,指数跌到约 524.811——而按正确算法计算出的真实值,一直在一千点附近徘徊。
1983 年 11 月的一个周末,修复上线,指数从 524.811 一步跳回 1098.892。跌掉的那一大截,压根不是行情,是零头攒出来的。
这个故事常被当作笑话讲,但它是所有对账差异的原型:误差本身很小,系统性累积使它巨大。
TSB 银行:数据迁成功了,平台崩了
2018 年 4 月,英国 TSB 银行把约 13 亿条客户数据迁入新系统。迁移本身完成得很干净——然后平台立刻出了故障:全部门店受影响,在其约 520 万客户中,相当一部分人无法正常办理业务,将近 200 万客户一度进不去自己的账户,业务直到 2018 年 12 月才恢复。
后果:已赔付客户 3270 万英镑;2022 年 12 月,英国金融行为监管局(FCA)与审慎监管局(PRA)合计罚款 4865 万英镑(FCA 2975 万 + PRA 1890 万),定性为运营风险管理与治理失败。
它和「对账」的关系:迁移的完成不等于账的延续。 新旧系统之间,历史的每一笔是否仍然可对、可查、可追溯——这需要在迁移设计里就把「逐笔可对」当作验收标准,而不是迁移完了再补。
金额精度:为什么 0.1 + 0.2 ≠ 0.3
现在翻到表示的那一半。
计算机用二进制浮点表示实数,国际标准是 IEEE 754(首版 1985,现行 IEEE 754-2019)。IEEE 754 保证的是一致性(同样的输入,所有平台算出同样的结果),不保证十进制精确。
原因用一句人话就能说清:十进制的 1/3 = 0.3333… 永远写不完;二进制里的 0.1 也是这么个写不完的数。写不完就存不下,只能存近似——你存进去的「0.1」,已经短了一丁点。
于是有了那个著名的现世报:
0.1 + 0.2 = 0.30000000000000004
不用定点(decimal)而用浮点存钱,有四个真实后果:
- 累加漂移:100 笔 0.1 加起来 ≠ 10.0;
- 比较失败:
0.1 + 0.2 == 0.3为假,条件分支走错; - 对账对不上:差几分钱,查半天(就是本文开头那一幕);
- 报表 / 发票金额错:错在最显眼的地方。
金额该怎么存
| 表示方式 | 精确表示 0.1/0.3 | 舍入可控 | 代价与适用 |
|---|---|---|---|
float32 / float64 |
✗ | ✗ | 禁止用于金额——累加漂移 + 比较失败 |
| 整数「分」 | ✓ | ✓ | 成本最小;需统一最小单位与币种换算(Stripe 采用的路线) |
DECIMAL(p,s) |
✓ | ✓ | 成本中等;需选对精度(数据库侧普遍选择) |
| 字符串 | ✓ | ✓ | 成本大;用于传输 / 展示,避免多次浮点转换 |
三个配套纪律:
- 传输与展示用字符串——「每转一道手,它就可能再被『差不多』一次」;
- 位数要留足:Autional 的金额统一
decimal(20,4),历史上从float64迁移到decimal、精度从(15,2)升到(20,4),共 7 个模型迁移;税率单独用decimal(10,6)——多币种与税额计算需要更多小数位; - 币种代码用 ISO 4217(三位字母代码),不要自造。
舍入:规则必须写死,不能靠默认
| 舍入方式 | 偏差方向 | 适用 |
|---|---|---|
| 截断(砍掉) | 系统性向下(Vancouver 事故的根源) | 几乎不适用于金额 |
| 四舍五入 | 近似无偏,但 .5 恒向上 |
通用,但大量 .5 时仍轻微有偏 |
| 银行家舍入(.5 向偶数) | 接近无偏 | 财务 / 统计常用;需显式声明 |
不论选哪种:舍入规则在代码里写死,不靠语言默认。 不同语言、不同库的默认行为不完全一致,把「钱怎么进位」交给默认值,等于把账本交给运气。
汇率不是数字,是「会过期的报价单」
汇率在系统里应该建模成一个值对象:{From, To, Rate, QuoteID, ExpiresAt}——带报价号和过期时间。
为什么要有过期时间?因为汇率随时在动。拿一张陈旧汇率去结算,事后对账时两边金额必然不同。过了期就得换,不能拿旧的算——这条规则写在数据模型里,比写在制度文档里可靠。
对账 ≠ 审计 ≠ 账本防篡改
这三件事常被混为一谈,目标其实完全不同:
| 对账 | 审计 | 账本防篡改 | |
|---|---|---|---|
| 管什么 | 对不对得上 | 谁在什么时候做了什么 | 记下来之后有没有被改 |
| 靠什么 | 共同流水号 + 逐笔比 | 不可篡改的记录 | 哈希链 + 序号 |
| 发现问题后 | 挂待查池,追平 | 留证,追责 | 逐笔验,报警 |
在对账的上游,「账本」本身也有工程要求:只追加的事件流(每笔钱一条事件:金额、变动前余额、变动后余额、序号、前序哈希、本笔哈希),余额是事件的投影,(wallet_id, sequence_num) 唯一索引防重复序号,哈希链让每一条都能被验证。再加每日快照,让回溯不必从头重放。
标准侧值得知道的锚点:ISO 20022 的现金管理报文 camt.052 / camt.053 / camt.054(账户报告 / 银行对客户对账单 / 借贷通知)官方描述就写明用于 “cash management and/or reconciliation”;camt.053 在设计上替代 SWIFT 的 MT940 / MT950 对账单。会计口径里,这种「两边对不上先挂起来」的差异有个正式名字:未达账项(items in transit)。至于「记录不可篡改」的监管要求(如 SEC 17a-4 与 WORM 存储),各家条款与实现的逐条映射差异很大,公开资料通常不完整——落到自己的合规清单时请以官方原文为准。
Autional 的做法
对账链路分布在 pay-service、billing-service 与 wallet-service:
- 每笔钱都带平台流水号:
GatewayRef(渠道侧)与InternalRef(内部)两边都留,配对靠共同号认「同一笔」。 - 定时逐笔核:
SchedulerBillingReconciliation按天/按结算期拉两边账核对,先数对上的(MatchedCount),再数对不上的(MismatchedCount)。 - 差异挂着追:对不上的记成
ReconciliationRecord(两边金额都保留:GatewayAmountvsInternalAmount,外加DiffAmount),一挂上就告警,等人追查——不自动改数。 - 两个时间各存各的:
CreatedAt(记账)与ReconciledAt(对账)不混用。 - 金额侧:
decimal(20,4)存储、字符串传输、Currency用 ISO 4217、汇率带QuoteID与ExpiresAt;钱包账本用事件流 + 哈希链 + 每日快照兜底。
诚实边界:各企业对账频率与差异口径差异很大,Autional 的默认节奏(按天/按结算期)是工程选择而非行业硬性规定;上文提到 SEC 17a-4 / WORM 属「可关联」级别的类比,条款级映射需按目标市场单独核实。
常见误区
- 「总数一样,账就对上了」——一笔「付了又退」两笔一起缺,一进一出正好抵消,总数看不出来;
- 「对不上 = 有人做假 / 自己算错」——多是手续费、退款、时差、漏记这类寻常差;
- 「对不上就改一笔凑平」——改了总数就假平,真差再也找不回来;
- 「对账的时间就是记账的时间」——混用会天天对不上(这是真实教训);
- 「0.1 + 0.2 只是显示问题」——二进制根本没有精确的 0.1,误差真实存在;
- 「小数点后两位就够」——多币种、汇率、税额需要更多位;
- 「精度事故是算错了」——往往是舍入规则写错(Vancouver 用了截断);
- 「汇率就是一个数字」——应是带报价号与过期时间的报价。
结语
对账不是对总数——是对每一笔:同一个号,认成同一笔;剩下的,追到平。
而钱不能「差不多」:数字可以差不多,钱不行。差一分,账就是错的。
三句判据,下次对不上账时按顺序问:
这两笔,凭什么说是同一笔?没配上的那些,去哪了?差的那些,是错了,还是还在路上?
相关阅读
准备好给应用加上身份层了吗?
Autional 提供认证、组织、访问控制、审计与开发者 API —— 面向 AI 生成应用的身份层。