financial-services 这个名字,是我给一套个人金融服务后端起的仓库名。说它是个项目,不如说它是我反复推翻重来了大半年的“实验田”:最早期它只是一个能给 Demo 提供余额扣款的简单服务,后来慢慢加入了支付回调、定时对账、审计日志,变成了一个麻雀虽小五脏俱全的账务系统。如果你正准备做钱包、会员余额、积分支付,或者任何需要跟“钱”打交道的后端服务,这篇文章应该能帮你少走很多弯路。我会把账务模型怎么设计、事务边界怎么画、并发扣款怎么防、对账审计怎么做,全部摊开来讲,也会告诉你在实际运行中哪些环节最容易翻车。
1. 账务模型:这个项目最不能省的一步
1.1 先分清你是“流水型业务”还是“余额型业务”
很多人拿到需求第一反应是建表:用户表、订单表、账单表,然后就开始写接口。但金融服务的特殊性在于,它跟普通 CRUD 有本质区别——你要搞清楚自己到底在做流水型业务,还是余额型业务。
纯流水型业务,比如积分发放记录、浏览历史,数据只增加不修改,查错了顶多展示难看。余额型业务就完全不一样:钱包余额、优惠券金额、信用额度,每一次变更都会影响后续所有操作的正确性。我最初犯的错误就是把余额当成普通字段,业务里直接把balance改一下,结果上线后怎么都对不平账。后来痛定思痛,把整个模型重构成了“流水是事实,余额是状态”的思路。
简单类比一下:余额就像手机屏幕上的电量数字,流水则是每一次充电和耗电的历史记录。电量数字你可以随时刷新甚至重置,但历史记录绝对不能丢,因为出了纠纷要靠它还原真相。
所以不管项目规模多小,建议第一版就把账户与流水分离,不要贪图省事只存一个余额字段。省掉的建模时间,将来都会用翻倍的排查成本还回来。
1.2 账户、流水、余额三张表的咬合关系
我在 financial-services 里最终采用了三张核心表互相配合的模型:账户表、流水表、余额快照表(或叫分户账变更表)。
账户表负责基础信息:账户 ID、用户 ID、币种、账户状态(正常/冻结/销户)、版本号。流水表负责记录每一笔资金变动:流水 ID、账户 ID、业务单号、业务类型、变动金额、变动前余额、变动后余额、创建时间。余额快照表则记录关键时点(如每日终)的账户余额,用来做对账基数。
这三张表的咬合关系是:任何一笔余额变更,必须在同一个事务里完成“更新账户余额 + 插入一条流水”,并且流水中必须带上变动前余额和变动后余额。
CREATE TABLE account ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, currency CHAR(3) NOT NULL, balance BIGINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_currency (user_id, currency) ); CREATE TABLE txn_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, biz_id VARCHAR(64) NOT NULL, biz_type VARCHAR(32) NOT NULL, amount BIGINT NOT NULL, balance_before BIGINT NOT NULL, balance_after BIGINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_type, biz_id) );注意金额字段我统一用了BIGINT,单位是分,而不是DECIMAL(10,2)。这样做的好处是避免浮点数误差,也方便对接渠道账单里以分为单位的金额。业务上你愿意展示成“元”,那是展示层的事,底层计算一律用整数。
1.3 为什么业务单号不能当流水号用
还有一个很容易踩的坑:把业务单号直接当成流水号用。用户下单产生订单号A001,扣款记录也拿A001当唯一键。看似省事,但业务单号代表的是业务语义,它会变:退款时要引用原单号,人工调账时要生成新单号,渠道回调时又可能带另一个渠道侧单号。如果把这些都塞进同一个字段,你会发现自己根本分不清这笔流水的真实来源。
流水号的要求是全局唯一、趋势递增、不能重复。我在项目里用“时间戳前缀 + 机器 ID + 序号”的方式生成流水号,没有直接用数据库自增 ID,因为自增 ID 容易暴露业务量,而且多库场景下不好扩展。对账时用流水号排序,再和余额快照对比,误差一眼就能看出来。
这里也有个反向经验:如果你的系统一天就几百笔交易,别过度设计。数据库自增 ID 加唯一索引,完全够用,过度设计只会让排查问题的成本变高。
2. 事务边界与幂等:金融系统最见功底的地方
2.1 一次扣款操作里,事务边界到底画到哪
最初我写扣款接口,习惯性地把整段逻辑塞进一个大事务:先查用户,再扣余额,然后调用消息队列发通知,更新订单状态,最后提交。看起来挺完整,实际上问题很大:事务里握着余额的行锁时间过长,一旦下游接口慢,整个系统的扣款并发能力就会被拖垮。
后来我把事务边界收窄成一句话:事务里只做账务本身必须保证一致的操作,外部副作用一律放到事务提交之后。扣款这个动作,事务内只需要两件事:更新账户余额、插入交易流水。发通知、更新外部订单状态、调用回调钩子,全都在事务提交后做。
@Transactional public void deduct(Long accountId, Long amount, String bizId) { int updated = accountMapper.deductBalance(accountId, amount); if (updated == 0) { throw new InsufficientBalanceException("余额不足或账户不存在"); } txnLogMapper.insert(bizId, bizType, -amount); }事务提交后再发消息,会有一个小窗口:钱扣成功了,但消息没发出去。这不是 bug,是取舍。对账任务会像兜底网一样把这些“消息丢失但账实一致”的情况捞出来。关键原则是:账不能错,通知可以延迟。
2.2 幂等键:回调、重试、重复请求怎么挡
金融系统面向的调用方不只是你的前端,还有支付宝、微信这类渠道方。渠道的回调、网关的重试、用户的连点,任何一条链路都可能把同一个请求送进来两次。幂等设计挡不住就是重复扣款,这是最伤用户信任的事故。
我的做法是给每个业务动作定义一个业务幂等键,比如biz_type + biz_id,在数据库里建唯一索引。处理流程是:先尝试插入幂等记录,插入成功说明这是第一次,继续执行;插入报唯一索引冲突,说明这个请求已经处理过,直接返回上次结果。
这里有个细节:不要用“先查再插”的方式判断幂等。两个并发请求同时来,都查到“没有记录”,然后都去插入,照样会有重复风险。只有数据库唯一索引才能真正挡住并发,insert if not exists的语义要落在数据库层。
public void handlePaymentCallback(String channelTxnId, Long amount) { // 幂等表插入成功才继续,冲突直接返回 try { idempotentMapper.insert("PAY_CALLBACK", channelTxnId); } catch (DuplicateKeyException e) { return; // 已处理过 } doSettle(channelTxnId, amount); }有人说用 Redis 的 SETNX 不也一样吗?小流量可以,但我建议至少双保险:Redis 做前置过滤,数据库唯一索引做最终保证。Redis 可能丢 key,数据库不会。
2.3 资金动账的“五要素”检查
在动账入口处,我加了一个固定的五要素校验,这个习惯救过我很多次:用户 ID、账户 ID、币种、金额、业务类型。看似啰嗦,但能在源头挡住大部分脏数据。
比如某天需求方跟我说“把用户 123 的余额加 100000”,如果接口不做校验,传成amount = -100000会把钱越加越少;如果币种不校验,美元和人民币混着扣,账目会直接崩坏。金额也建议统一为最小货币单位,接口参数不接收浮点数。
这个校验不要散落在各处,做成一个入参对象,在 Service 入口统一执行。不需要什么复杂框架,就是一组 if 判断加一个统一异常,简单可靠。
3. 并发扣款:从余额不准到账实相符的修正过程
3.1 为什么“先查余额再扣款”必挂
我第一版扣款逻辑写得很直白:查出余额,判断够不够,够了就减掉,再写回去。结果压测一上来,余额一百块的两个请求同时扣六十,最后余额变成四十甚至负数,资金对不上。原因很经典:两个事务同时读到余额 100,都认为余额充足,各自执行balance = 100 - 60,后提交的覆盖先提交的。
这就是并发下的快照读问题。普通业务里这种覆盖多半无伤大雅,顶多展示数据不准,但金融系统里这就是资损事故。所以“先查后改”这个朴素思路在资金场景下是走不通的。
3.2 用乐观锁和条件更新改写扣款逻辑
后来我把扣款 SQL 改成了带条件约束的更新:
UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE id = #{accountId} AND balance >= #{amount} AND status = 1;这条 SQL 用数据库行锁保证了原子性,同时把“余额是否足够”的判断放进条件里。影响行数等于 1 说明扣款成功;等于 0 说明要么账户余额不足,要么账户状态不对,上层直接抛出对应异常。
这里我特意没有在 WHERE 里写version = #{oldVersion},因为余额本身就是最关键的约束,只要balance >= amount就能保证不会扣成负数。如果你还需要防并发下的“丢失更新”,可以保留 version 字段,用version = #{oldVersion}做乐观锁。两种方式都行,但至少要把余额条件加上。
这个思路还能顺带解决缓存一致性问题。账户余额不要放在 Redis 里做“先读后写”,要用数据库作为唯一事实来源。缓存只能做查询展示,不能做扣款依据。
3.3 账户流水编号与顺序一致性
有了原子扣款还不够,我还碰到过一个隐蔽问题:流水编号乱序。高并发下,A 请求和 B 请求几乎同时扣款,但由于数据库分配 ID 的时序和实际业务发生顺序可能不一致,导致对账时按流水号排序看到的余额变化链是断的。
解决思路是:让流水号生成和余额变更在同一个事务里,并且由同一行账户记录的行锁来串行化。
-- 扣款事务内 SELECT id FROM account WHERE id = #{accountId} FOR UPDATE; -- 生成流水号,插入流水,更新余额 -- 提交事务只要同一账户的流水都经过这行锁,顺序就是确定的。代价是同一账户的高并发扣款会被串行化,但绝大多数 C 端产品里,单个用户同一时刻的扣款频率不会高到成为瓶颈。如果真碰到热点账户并发巅峰的场景,那就要考虑更复杂的方案了,比如账户预扣、子账户拆分,这些就不是一个简单项目该碰的东西了。
4. 对账与审计:金融服务日常运转的“体检”
4.1 每日对账任务怎么设计
账务系统跑起来之后,最让我踏实的一件事就是加了定时对账。对账的意义在于:不管代码写得多么谨慎,实际操作中总会出现本地记了一笔、渠道没回调,或者渠道退款成功、本地订单还是支付成功的情况。没有对账,这些问题可能藏几个月才发现。
我在 financial-services 里用定时任务每天凌晨拉取各渠道的账单文件,解析后落库,再和本地流水做逐笔比对。对账任务分三步走:
第一步是总量核对:对比渠道账单的当日总金额、总笔数和本地的支付成功总金额、总笔数。总量对不上再往下走,省得每笔捞出来比较浪费资源。
第二步是明细核对:按渠道单号关联两边的记录,核对金额、状态、时间。两边一致的标记为“已核对”;不一致的落到差异表。
第三步是差异归类,把问题分成几类:本地有但渠道没有(疑似漏支付或渠道数据延迟)、渠道有但本地没有(疑似漏回调或重复支付)、两边金额不一致(疑似恶意篡改或计算错误)。
4.2 差异处理流程:先挂起还是先冲正
对账最忌讳的是写一堆自动冲正逻辑,发现差异就咔咔改状态。资金操作要讲证据链,自动冲正搞错了,比不处理还严重。我的原则是:先挂起,人工介入确认,再决定怎么处理。
差异表我会设计成可以撑住整个处理流程:
| 字段 | 说明 |
|---|---|
| 对账批次号 | 每天一个批次,方便按天查询 |
| 渠道 | 区分微信、支付宝、银行卡等 |
| 渠道单号 | 渠道侧的交易编号 |
| 本地单号 | 本地订单/流水编号 |
| 本地金额 | 单位分 |
| 渠道金额 | 单位分 |
| 差异类型 | 本地有渠道无 / 渠道有本地无 / 金额不一致 |
| 状态 | 待处理 / 已确认 / 已冲正 / 已忽略 |
处理顺序上,建议优先处理“渠道有本地无”的差异,因为可能意味着用户付了钱但你没给东西,这是客诉重灾区。确认渠道确实到账后,再补记一笔“渠道入账”流水。至于“本地有渠道无”,先看是不是渠道数据延迟,等第二天的账单再核一遍,不要当天就下结论。
只有一种情况我会写自动冲正:渠道侧明确返回了退款成功,但本地订单状态仍是支付成功。即便这样,冲正也必须是有限额、有日志、可追溯的,并且每天冲正笔数设置一个上限。
4.3 审计日志库的落地细节
钱这个东西,最怕的是说不清楚。系统运营到后来越来越多的问题都指向同一个诉求:你给我查一下,这笔钱当时是谁、在哪个环节、变成了什么样。所以我给 financial-services 增加了一套独立的审计日志库。
审计日志和业务日志是两码事。业务日志可以随便打,甚至可以只留最近七天的滚动日志;审计日志则要求全量、只追加、不可修改。我专门建了一张审计表,记录操作人、操作时间、操作类型、操作前后值、IP、请求 ID,任何涉及资金变动的敏感操作(人工调账、余额修正、冻结解冻、状态强制变更)都必须写审计。
这里有一个我自己踩过的坑:审计日志一开始用异步线程池去写,图快。结果一次上线后线程池满了导致部分审计日志丢失,后来排查资金问题怎么都对不上时间线,非常被动。最后我把审计日志的写入放回了业务主事务里,同一个事务提交,确保只要业务成功,审计一定存在。性能上会有一点损耗,但资金相关的操作本身就不追求极致的吞吐量,多一次 INSERT 完全能接受。
另一个方面是数据脱敏。我在展示用户信息时,手机号、身份证号、卡号都做了脱敏;审计日志里也一样,不要出现完整敏感字段。否则审计日志本身就会成为新的数据泄露点。
我还养成了一个习惯:每次发版前,都会手动跑一遍“关键流水——账户余额——当日报表”的一致性检查。这个动作不复杂,却能在问题爆发前兜住大部分低级错误。毕竟在 financial-services 这类系统里,底层账务一旦错乱,上层所有业务都会跟着不可信。
到这儿,这四个模块基本覆盖了金融服务后端最核心的骨架:账务模型定地基,事务与幂等守一致,并发控制保安全,对账审计兜底线。每个模块单独拆出来都不算复杂,难的是把它们咬合在一起时,不留那个“看起来没事、一压测就出事”的缝隙。如果你也正在搭类似的系统,建议从最小的账户加流水开始,先让贷方、借方、余额三者始终自洽,再一步步往上加业务。等跑过一轮完整对账,你会回来感谢那个愿意在建模阶段多花两天的自己。