上个月朋友让我帮忙看代码,他们的仓库名特别正经,叫financial-services,点进去才发现支付单、账户、钱包、营销返现、客服退款全塞在同一个服务里,一个接口依赖二十几张表,每次发版都像拆炸弹。这名字我见过太多次了——很多团队为了把“跟钱有关的逻辑”收敛到一起,顺手起了个financial-services,但真正让项目翻车的从来不是名字,而是没人把边界、账务模型和一致性保障讲清楚。这篇就围绕financial-services这个服务群的搭建过程,把架构拆分、账户建模、资金一致性、高并发扣款和上线前检查清单一次讲透,适合正在做支付、账户、钱包类服务,或者准备把零散资金逻辑收敛成独立服务群的人参考。
1. financial-services 不是单个服务,是一组资金链路的边界
拿到一个叫financial-services的项目,我第一件事不是看代码,而是问三个问题:里面有几个可独立部署的服务?核心表有哪几张?改代码的人是不是横跨支付、营销、客服好几个小组?如果三个问题都答得含糊,这个项目大概率已经在往“资金乱葬岗”的方向走了。
1.1 该进群的服务和不该进群的服务
先明确一件事:financial-services在绝大多数场景下是一个泛化目录名,里面装的应该是一组服务,而不是一个单体内包含所有业务逻辑。
该放进来的,是真正跟资金状态强相关的链路:
- 账户服务:开户、冻结、解冻、余额查询
- 账务服务:记账、流水、试算平衡
- 支付服务:支付单、渠道路由、回调处理
- 结算服务:清算核对、结算单、出账
- 钱包服务:钱包账户、支付密码、交易限额
不该放进来的,是那些“看似和钱沾边,但实际是业务活动”的逻辑,比如营销活动的发券、积分累计、抽奖,或者客服工单里的退款审核,甚至BI的统计任务。原因很现实:资金链路的发布节奏和技术要求,跟营销活动完全不一样。营销活动可能一周上线三次,状态流转粗放,流量还可能突然暴涨;资金服务要求每次变更都经过严格的回归验证,两拨人挤在一个仓库里,最后的结果就是互相踩发布窗口,谁也发不出去。
1.2 一个可以参考的模块拆分
如果团队规模不大,可以先用一个仓库管理多个模块,但模块之间必须用接口隔离,保证未来能拆成独立服务。我常用的拆分方式是:
| 模块 | 核心职责 | 关键表 | 主要调用方 |
|---|---|---|---|
| account-service | 账户生命周期、冻结解冻 | account, account_freeze | 业务后端、支付服务 |
| ledger-service | 记账、分录、日终快照 | ledger_entry, daily_balance_snapshot | 所有涉及资金变动的服务 |
| payment-service | 支付单、渠道路由、回调 | payment_order, payment_channel_request | 前端、业务后端 |
| settlement-service | 清算核对、生成结算单 | settlement_bill, settlement_detail | 财务、运营后台 |
| wallet-service | 钱包开户、消费限额 | wallet, wallet_transaction | APP端、活动后端 |
每个模块对外暴露的接口要尽量窄,比如余额查询接口就只做查询,不要在内部顺带做扣款。这样做的目的很朴素:资金链路的每一笔操作都要能讲清楚“谁在什么时候调了谁、改了哪张表、留下什么流水”,接口窄了,审计链路才不会被绕过去。
1.3 资金服务和普通业务服务的本质差异
普通业务服务像超市收银员,录入商品、收钱、找零,流程能走通就行。资金服务更像银行金库管理员,每一笔进库出库都要登记、复核、留凭证,账不平就要停下来查清楚。
落到技术上,差异至少有三点。第一,资金服务的所有写操作都必须可重放,也就是说要从流水记录中还原出任意时刻的余额,而不是只靠一张余额表打天下。第二,对外接口必须有签名验签、防重放的机制,因为资金接口一旦被恶意重放,损失是真金白银。第三,状态流转必须由状态机约束,不能出现“支付成功后再被改成失败”这种混乱迁移。
2. 账务建模:余额是算出来的,流水才是真相
很多人在搭建financial-services时,第一个想设计的就是余额字段。但我的经验是:余额字段只是缓存,流水表才是账务系统的真相。把这一层想通了,后面的一致性方案会顺手很多。
2.1 余额口径:可用、冻结、在途
如果账户只有“余额”一个字段,业务一复杂就要出问题。用户发起支付时,钱不能立刻扣掉,得先冻结;支付失败时,冻结要解掉;提现时也要先冻结,等渠道结果回来再决定是扣减还是解冻。所以账务系统里至少要区分三种口径:
可用余额 = 账户余额 - 冻结金额 - 在途金额举一个最常见的支付链路例子:
- 用户下单支付 100 元,可用余额减少 100,冻结余额增加 100
- 支付成功,冻结余额减少 100,账户余额减少 100
- 支付失败,冻结余额减少 100,可用余额恢复 100
如果不区分这些口径,用户在“支付处理中”的状态下去买别的东西,系统可能把同一笔钱用两次。这个问题在并发场景下尤其致命,所以账务系统一定要有冻结子状态或独立的冻结流水,不能用“扣了再加回来”的方式模拟冻结。
2.2 流水表:可重放的账务真相
账务流水表的核心是记录“发生了什么”,而不是记录“现在是多少”。一张设计合理的分录流水表通常包含这些字段:
| 字段 | 作用 |
|---|---|
| id | 主键 |
| account_no | 账户号 |
| biz_type | 业务类型:支付、退款、冻结、解冻、提现 |
| amount | 金额,单位建议用分 |
| direction | 借/贷方向 |
| balance_after | 操作后的余额,用于快速查询 |
| order_no | 业务订单号 |
| request_no | 请求号,用于幂等 |
| created_at | 创建时间 |
为什么说流水表比余额表更重要?因为余额表只是一个可变的快照,一旦某次更新出错,很难知道错在哪一步。而流水表是追加写的,不可修改不可删除,可以从任意一个快照点重放所有流水,重新算出历史余额。这也是审计、对账、排查问题的根基。
注意:流水表的
balance_after虽然叫余额,但它本质上是“这行流水发生后账户的余额快照”,更新流水时不能直接改它,而是要在事务里按顺序计算后写入。
2.3 支付状态机:先定义迁移,再写代码
资金服务的状态流转,最忌讳用一堆if散落在业务代码里。正确做法是先画一张状态迁移表,再拿着这张表写代码。
拿支付单举例,我会允许这样一组迁移:
| 当前状态 | 允许迁移到 |
|---|---|
| INIT | PROCESSING, CLOSED |
| PROCESSING | SUCCESS, FAILED, CLOSED |
| SUCCESS | REFUNDING |
| REFUNDING | REFUND_SUCCESS, REFUND_FAILED |
对应到代码,就是一个明确的允许迁移表:
private static final Map<String, Set<String>> ALLOWED_TRANSITIONS = Map.of( "INIT", Set.of("PROCESSING", "CLOSED"), "PROCESSING", Set.of("SUCCESS", "FAILED", "CLOSED"), "SUCCESS", Set.of("REFUNDING"), "REFUNDING", Set.of("REFUND_SUCCESS", "REFUND_FAILED") ); public boolean canTransit(String current, String target) { return ALLOWED_TRANSITIONS.getOrDefault(current, Set.of()).contains(target); }这套代码很简单,但它把“业务规则”从散落的if里收拢到了一个表里。后面新增退款状态,只需要改这个 Map,而不是翻遍整个 service 层找哪里调了setStatus。
2.4 防跳变更新:状态守卫与乐观锁
有状态机还不够,并发下两个请求可能同时读到同一行支付单,一个要改成 SUCCESS,一个要改成 FAILED,最后谁后提交谁就“赢”了。所以状态更新必须带上条件:
UPDATE payment_order SET status = 'SUCCESS', version = version + 1, updated_at = NOW() WHERE order_no = #{orderNo} AND version = #{oldVersion} AND status = 'PROCESSING';受影响行数如果是 1,说明成功;如果是 0,说明当前状态已经被别的事务改过了,要重新查单再决定怎么处理。这个字段不只是status判断,再加上version,能挡住绝大多数并发覆盖问题。
3. 幂等、事务、对账:资金一致性的三道闸门
我在不同项目里见过各种各样的资金事故,总结下来,90% 的问题都能归到三类:重复请求、分布式事务处理不当、没有对账兜底。所以financial-services里必须把这三道闸门立好。
3.1 幂等设计:请求号要跟业务语义绑定
资金接口的幂等,不能只靠前端“不要重复点击”来保证。渠道回调、MQ 重投、用户连续点提交,任何一个环节都可能造成重复请求。幂等的第一道关是请求号(request_no),但这个请求号不能是随手UUID.randomUUID()生成的,因为它必须能表达业务语义。
举个例子,一个商户重复提交同一笔订单的支付请求,如果每次请求号都不一样,系统就无法识别“这是同一笔业务”。正确的做法是把请求号换成bizCode + merchantOrderNo这类业务唯一键:
CREATE TABLE payment_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_code VARCHAR(32) NOT NULL, merchant_order_no VARCHAR(64) NOT NULL, request_no VARCHAR(64) NOT NULL, payload TEXT, status TINYINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_biz_order (biz_code, merchant_order_no) );插入时先走INSERT,如果唯一键冲突,说明之前已经处理过,直接查询已有结果返回。这个过程必须在事务里完成,才能避免“插入一半、业务没执行”的尴尬残留。
3.2 分布式事务:选型要克制
资金链路一定会遇到跨服务调用,比如支付成功后要调账务服务记账、要调通知服务发结果。很多人第一反应是上分布式事务框架,但我要泼一盆冷水:在financial-services里,同步强一致的分布式方案通常是包袱而不是救星。
| 方案 | 一致性 | 开发量 | 适用场景 |
|---|---|---|---|
| 本地消息表 | 最终一致 | 中 | 内部服务间异步通知、记账 |
| 事务消息 | 最终一致 | 低 | 消息中间件本身支持时 |
| TCC | 最终一致,但业务补偿明确 | 高 | 跨服务复杂资金操作 |
| Saga | 最终一致 | 高 | 长流程业务 |
| 2PC | 同步强一致 | 高,锁范围大 | 不建议在资金链路中滥用 |
我的实践经验是:大部分“支付成功 -> 记账 -> 发通知”的场景,最终一致性加上对账兜底已经够了。把整个链路强行包进一个分布式事务里,带来的结果是数据库锁时间变长、公共依赖出问题面变大。真出现短暂的不一致,靠流水和对账任务补上,比在事务框架里查来查去容易得多。
如果需要本地消息表的落地方式,核心就是一张 outbox 表:
CREATE TABLE outbox_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL );业务事务里写业务数据和 outbox,事务提交后再由调度任务把消息发到 MQ,发送成功才更新状态。这样做的好处是,业务库和消息表在同一个事务里,不会出现“账记了,消息没发出去”的经典事故。
3.3 对账设计:把兜底放在最后一道
对账不是可有可无的“运营报表功能”,它是资金链路里最后一道保险。渠道账单和本地流水总会因为各种原因产生差异,比如渠道扣款成功但本地回调没收到、本地显示成功但渠道实际退款了、金额被渠道手续费吃掉一部分等等。
我一般把对账拆成三步:
- 拉取渠道对账单文件,解析成标准格式
- 与本地
payment_order和ledger_entry按订单号关联,找出金额不一致、单边成功、单边失败 - 把差异记录写入
reconcile_difference,触发差错处理任务
差异处理建议走“挂账 -> 调账 -> 退款/补单”的流程,而不是自动直接改余额。因为自动改账很容易把原本一个小 diff 放大成严重问题。差错的每条记录都要留操作人和原因,方便事后追溯。
3.4 回调乱序时怎么保住一致性
支付回调是最容易出现乱序的地方。渠道回调成功后又补了一条超时通知,或者退款成功通知先到、原支付失败通知后到,如果代码里只做setStatus(新状态),就会把已经 SUCCESS 的单子改成 FAILED。
解决办法还是前面那套:状态机 + 条件更新。回调处理里先查当前状态,如果已经 SUCCESS 且新状态是 FAILED,直接拒绝这个迁移,把回调记录到日志和callback_event表里,留给人工或后续对账排查。不要试图在业务代码里用if处理所有乱序,状态机已经挡掉大部分,剩下的交给对账兜底。
4. 高并发扣款的实战细节:从乐观锁到热点账户
账户扣款是financial-services里最容易出线上事故的场景,尤其是并发扣款导致超扣。下面这些细节,都是我实际排查问题过程中总结出来的。
4.1 超扣为什么发生,以及怎么靠 CAS 拦住
最原始的扣款代码是:先查余额,判断足够,再减扣。问题出在“查余额”和“更新余额”之间不是原子的,两个请求同时读到余额 100,都判断可以扣 60,结果余额变成 -20。
正确做法是把判断放到更新语句里,用乐观锁,也叫 CAS(Compare And Set):
UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND balance >= #{amount} AND version = #{oldVersion};这里的三个约束缺一不可:balance >= #{}挡住余额不足,version = #{}挡住基于旧版本的更新,扣减金额放在SET里而不是先查出结果再传入,避免读取和更新的时间差。如果影响行数为 0,就重新查询并做后续处理,而不是直接报系统异常。
4.2 热点账户的拆分玩法
有些账户天然是热点,比如直播平台的主播收益账户、活动红包的大池子账户、电商平台的结算中间户。所有请求都压在一个account_no上,数据库锁竞争会非常激烈,TPS 上不去,还会拖垮其他账户操作。
我见过两种靠谱的拆分方式。一种是水平拆子账户,把一个大账户拆成 N 个子账户,入账时轮询或哈希分配,出账时按顺序逐个尝试,最后再做汇总。另一种是批量合并,把高频小额入账先写进一个临时的累计结构,攒到一定阈值或固定时间后再合并进账户余额。
注意:热点账户拆分不是免费的午餐。拆了之后,余额查询、对账、日终快照都会变复杂,所以只对确实高并发的账户做拆分,不要拍脑袋把所有账户都拆成几十个子账户。
4.3 锁竞争和死锁的排查思路
并发扣款时数据库死锁经常静默出现,表现就是接口偶发超时。排查时我会先看两样东西:
SHOW ENGINE INNODB STATUS;然后查information_schema.innodb_trx,看看当前有哪些事务在等待锁,等待了多久。死锁最常见的原因是两笔扣款事务以不同顺序更新多个账户,比如事务 A 先锁账户 1 再锁账户 2,事务 B 先锁账户 2 再锁账户 1。解法很粗暴:所有涉及多个账户的操作,先对account_no排序,再按统一顺序加锁。
另外,innodb_lock_wait_timeout不要调得太大,我一般设置成 5 秒左右。锁等待时间过长会占用数据库连接,连接池一旦被打满,整个服务的可用性都会出问题。
4.4 压测时容易漏掉的两种场景
很多团队压测只测“多用户并发下单”,但这覆盖不了资金服务的高风险点。我建议至少再补两个场景:
- 同一账户并发扣款:一台机器对同一个
account_no发起大量并发扣款,观察是否出现超扣或锁等待 - 多账户逆序更新:模拟两个事务同时操作同一组账户但顺序相反,确认死锁是否会被触发
压测时不要只看吞吐量,还要盯锁等待次数、数据库活跃连接数、单笔扣款的 P99 延迟。资金服务的核心不是快,而是在极端流量下每笔账都算得对。
5. 上线前的体检清单:追踪、指标与安全基线
代码写完了,不代表可以上线。我在financial-services类项目上线前,会按下面这份清单逐项过一遍,少了任何一项都可能让问题在线上变得极难排查。
5.1 链路追踪:从网关到 MQ 一路带上 traceId
资金链路跨服务、跨消息,一个支付请求从网关进来,可能会经过支付服务、账务服务、通知服务,中间还会投递几次 MQ。如果没有统一的traceId,出问题时要翻好几个服务的日志拼图,效率极低。
做法是在入口网关生成traceId,放到 RPC 调用链的 header 里,投递 MQ 时也放进消息头。日志里至少要带上三个维度:traceId、业务单据号、账户号。我常用的日志格式是这样的:
[traceId=a1b2c3][orderNo=202401010001][accountNo=10001] action=deduct amount=10000 result=success这个格式在按orderNo搜索日志时特别方便,一次能查出整条链路的操作轨迹。
5.2 资金指标和告警分级
资金服务的监控指标,不能只放系统层面的 CPU、内存和 QPS。以下四类指标必须单独建设:
| 指标 | 计算方式 | 推荐告警级别 |
|---|---|---|
| 对账未达笔数 | 本地有、渠道没有,或金额不一致 | P0,金额差异就是事故 |
| 支付失败率 | 失败支付单 / 总支付单,对比前一小时 | P1 |
| 记账延迟 P99 | 从支付成功到账务流水写入的时间 | P1 |
| 退款失败率 | 失败退款单 / 总退款单 | P1 |
P0 告警意味着立刻拉群处理,不要等到第二天对账才发现。资金业务里“账不平”就是最高优先级,很多连锁问题都是从一笔未达开始扩大的。
5.3 安全基线的几个硬性要求
资金服务的安全掌握不住,后续很容易被动。我会在代码审查阶段就盯这几件事:
- 关键字段加密存储:卡号、支付密码等字段在数据库里不能明文,应用层用专门的加密服务处理
- 支付密码不能反解:只存 bcrypt 等哈希结果,登录和支付要区分开
- 对外接口签名验签:请求方需要携带签名和时间戳,服务端校验签名并拒绝过期请求,防止重放
- 日志脱敏:日志里不允许打印完整卡号和支付密码,打印前要脱敏成
622202****1234这种格式
这些看似是安全团队的事,但资金服务只有自己主动守好,才能少挨几顿骂。
6. 我在这个项目里真实踩过的五个坑
最后分享几个我实际踩过的坑,每一个都对应过一次线上排查或对账事故。写出来是希望大家能直接绕过去。
6.1 double 存金额,对账对到崩溃
早期有个系统用double存金额,线上发现一笔退款总是差 0.0000000000000004 元。原因就是浮点数精度问题,0.1 + 0.2在二进制里是个无限循环小数。资金系统里金额一律用“分”为单位的整数存储,或者用BigDecimal并在数据库里存DECIMAL(18,2)。对外 API 接收金额时也要用字符串,不能直接用float解析,否则账还没记就已经错了。
6.2 时区不一致引发的日切错账
有一次对账差异全部集中在每天 0 点到 8 点之间,查到最后发现数据库会话时区是 UTC,应用服务器是东八区,日切统计用CURDATE()把凌晨的订单归到了前一天。解决思路很简单:时间统一按 UTC 或带时区的DATETIME存储,业务日(也就是“算哪一天的钱”)在应用层用一个可配置的时区计算。日切任务不要依赖数据库的CURDATE(),而是由调度系统把“业务日”作为参数传下去。
6.3 幂等键没覆盖退款场景,重复退款
支付幂等做得很好,但退款幂等没做全。用户发起退款,前端连续点了两次,两个退款请求的幂等键分别是两把不同的 UUID,系统当成两笔新退款处理,结果退了双倍。修正方法是把退款幂等键设计成原支付单号 + 退款请求号的联合唯一键,同时在退款单状态机里禁止“已经退款成功的单子再次进入退款处理中”。
6.4 回调乱序把成功单打成失败单
有次渠道先回了支付成功,又因为内部超时补偿回了一条失败通知,我们的代码看到失败通知就直接把payment_order翻成了 FAILED,导致订单状态和账务流水对不上。后来在状态更新 SQL 里加了状态守卫,只有PROCESSING状态才能迁移到SUCCESS或FAILED,已经SUCCESS的单子面对失败回调直接拒绝,并记录回调事件供排查。这种坑用状态机挡在最前面,成本最低。
6.5 长事务把数据库连接池打满
一笔支付成功处理的代码,把扣余额、写流水、发 MQ、更新任务状态放在同一个事务里,MQ 发送慢一点,事务就一直不提交,数据库连接被越占越多,最终连接池被打满。现在我的准则是:事务里只做数据库写操作,消息发送放到事务提交后的回调里,或者用 outbox 表由后台任务投递。事务越短,连接池压力越小,问题也越好排查。
如果让我给还在搭financial-services的团队一句话,不是去抄开源项目的代码,而是先把状态机表、幂等表和对账任务设计好。我在前两个地方偷过的懒,最后都变成了线上事故的素材,等你把它们补齐了,这个服务群才真正配得上这个名字。