尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

金融微服务实战:账户建模、幂等设计与对账避坑指南

金融微服务实战:账户建模、幂等设计与对账避坑指南 前阵子接手一个代号为financial-services的长期维护任务最初以为它只是某个普通的订单服务结果把模块边界打开之后才发现账户、交易、计费、对账、清结算几乎全都挂在里面。这类服务在行业内通常被归到金融服务域落到工程上就是一套围绕资金流展开的微服务集群而 financial-services 往往就是其中最核心的编排入口。这篇文章我会站在实际落地者的角度从领域拆解、账户建模、幂等设计、对账实现到最终一致性逐层讲清楚并把项目推进过程中踩过的坑一并写出来。如果你正在做内部结算系统、支付中台或者想在个人项目里加入金融场景完全可以把这篇当作一份工程参考。1. 从项目代号到核心领域financial-services 到底在解决什么问题1.1 一个代号背后的业务集合很多人看到 financial-services 以为它是一个服务真实情况是一组服务加一堆文档的总称。我接手时的第一件事不是看代码而是先梳理 API 清单和 ER 图。当时列出来的入口包括账户开户、冻结、解冻、交易受理、交易查询、对账文件解析、退款、调账看起来每个都很简单但它们共用同一套资金能力。这里的核心不是有多少个接口而是每一笔钱都要能说得清来源和去向。业务系统可以允许订单状态模糊一点金融域不行——不能多付、不能少扣、不能重复入账、不能凭空产生一笔余额。所以 financial-services 的第一原则是资金安全第二原则才是业务响应速度。一个常见的误区是试图在订单服务里直接维护余额字段省掉金融域这一层。订单是业务视角账户和交易是资金视角混在一起就会出现这种问题用户退款后订单显示已退款账户余额却没恢复或者两笔并发请求同时扣款余额被扣成负数。把金融能力拆成独立服务不是为了微服务而微服务而是让资金变化只有一条受控的路径。1.2 服务边界金融域与业务域的分工项目里最终把金融域拆成了六个模块路由接入、账户、交易、清算、对账、风控。边界划分花了大约两周这比写代码的时间还长但非常值得。模块核心职责关键实体边界提醒路由接入统一接收业务方请求分发到交易模块渠道配置、商户产品配置只做参数校验和签名验签不写账户账户维护余额、冻结、解冻、账户流水账户表、流水表、可用余额余额只能在账户模块内变更交易受理支付、退款、撤销维护交易状态机交易主表、交易明细、幂等表交易状态与账户流水分离清算计算渠道手续费、结算金额生成结算单清算批次、结算明细只算钱不动账对账下载并解析渠道文件逐笔比对差异对账批次、差异记录、差错单发现差异只记录不自动改余额风控校验限额、频次、黑名单输出拦截结果规则配置、拦截日志失败时返回明确原因码边界划错的表现是循环调用。比如账户模块为了生成流水去查交易状态交易模块又为了校验余额去查账户表最后服务之间纠缠不清连事务边界都没法定。我最终的划分原则很简单金融域只处理和钱直接相关的状态业务状态交给上层账户模块不关心这笔钱买的是什么交易模块不关心余额存了多少。1.3 技术全景与角色定位金融服务项目在技术上并不玄乎但选型必须保守。我们用的是 Spring Boot MySQL RabbitMQ缓存只用来做查询加速不参与资金计算任何余额读写都直接走数据库事务避免缓存与数据库不一致带来的对账问题。MySQL 在这个场景里仍是最稳的选择因为金融核心链路的数据量通常不是问题问题是要求可追溯、可核对、可恢复。Redis 可以放在对账文件暂存、热点配置等场景但绝不能放在扣款链路里。分布式事务也没选后面在讲最终一致性时会详细说为什么。如果你的项目和技术栈不同也没关系重点是理解每个模块的职责和边界这套领域模型可以平移到任何语言和框架上。2. 账户与余额模型资金系统里最容易被做错的地基2.1 余额为什么不能只是一行数字新手做账户模块最容易写出的表结构是CREATE TABLE account ( account_no VARCHAR(32) PRIMARY KEY, balance DECIMAL(12,2) NOT NULL DEFAULT 0, update_time DATETIME );扣款时一条 UPDATEUPDATE account SET balance balance - 100 WHERE account_no P001;如果这是个单人、单次的演示项目这么写没问题。一旦放到真实并发场景问题立刻冒出来两笔请求同时读到余额 500各自扣 100 后都写回 400实际应该剩下 300。即使 MySQL 的行锁能避免丢失更新这种直接改余额的方式也无法回答这 500 是怎么变成 400 的。资金系统的核心要求有两个可追溯和可恢复。要做到这两点余额必须由流水推导而来或者至少每次余额变化都对应一条不可篡改的流水记录。我常跟团队说一句话余额不是状态余额是结果。2.2 事件溯源思路让流水先行的记账模式financial-services 的账户模块没有采用纯事件溯源而是采用了流水先行 余额冗余的模式。先定义账户流水表CREATE TABLE account_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(32) NOT NULL, request_no VARCHAR(64) NOT NULL, balance_type TINYINT NOT NULL COMMENT 1-可用余额变更,2-冻结余额变更, change_type TINYINT NOT NULL COMMENT 1-入账,2-出账,3-冻结,4-解冻,5-冲正, amount DECIMAL(12,2) NOT NULL, before_balance DECIMAL(12,2) NOT NULL, after_balance DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-有效,1-冲正, created_at DATETIME NOT NULL, UNIQUE KEY uk_request_account (request_no, account_no, change_type) ) ENGINEInnoDB;关键点在于request_no。每一笔流水必须携带一个全局唯一的业务请求号这个请求号同时是幂等键。事务里先校验余额再插入流水最后更新账户余额全部在同一数据库事务内完成。账户余额表保留余额字段是出于性能考虑——查询余额不需要每次 SUM 全部流水。但这张表里额外增加了version列作为乐观锁CREATE TABLE account ( account_no VARCHAR(32) PRIMARY KEY, balance DECIMAL(12,2) NOT NULL, frozen_balance DECIMAL(12,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL );扣款逻辑用 Java 伪代码表达就是这样Transactional public void deduct(AccountCommand cmd) { // 1. 幂等检查同一个 request_no 是否已处理 if (accountFlowMapper.exists(cmd.getRequestNo(), cmd.getAccountNo())) { return; } // 2. 加行锁读取账户 Account account accountMapper.selectForUpdate(cmd.getAccountNo()); if (account.getBalance().compareTo(cmd.getAmount()) 0) { throw new InsufficientBalanceException(); } // 3. 先记流水 BigDecimal after account.getBalance().subtract(cmd.getAmount()); accountFlowMapper.insert(buildFlow(account, cmd, after)); // 4. 再更新余额 accountMapper.updateBalance(cmd.getAccountNo(), cmd.getAmount().negate()); }为什么是先记流水、再更新余额因为流水是账本余额是汇总。如果先更新余额后写流水流水插入失败时余额已经变了事务回滚后虽然数据能恢复但排查问题时缺少一条可对账的记录。反过来先写流水即使余额更新异常流水的存在也能帮助快速定位。2.3 冻结与解冻可用余额和冻结余额的拆分真实业务里还有一类高频场景——冻结。用户发起提现、预授权、纠纷处理时钱不能直接从可用余额扣走而是先冻结。这里如果只用一个余额字段会很难处理所以账户表里要拆出balance可用余额和frozen_balance冻结余额。冻结操作不是把 balance 减掉再把 frozen_balance 加上这么简单。从语义上冻结是把可用额度转成不可用额度但资金所有权没有变。冻结流水要记录原始业务单号解冻时通过原单号反向去找冻结流水避免任意解冻。冻结的核心 SQL 逻辑UPDATE account SET available_balance available_balance - #{amount}, frozen_balance frozen_balance #{amount}, version version 1 WHERE account_no #{accountNo} AND available_balance #{amount} AND version #{expectVersion};这里用乐观锁替代了行锁。两者的取舍是行锁在事务内更直接但遇到长事务时持有时间过长乐观锁在冲突不高时性能更好失败后由上层重试。账户扣款我推荐行锁冻结这类低频操作推荐乐观锁原因是冻结流程通常还会伴随其他校验逻辑事务时间偏长没必要把行锁一直握在手里。2.4 出现负余额怎么办流水冲正即使做了全套校验线上还是可能出现异常比如渠道回调重复、人工误操作。这时候绝对不能直接改余额把负数纠正过来正确做法是冲正。冲正不是 DELETE 一条流水而是新增一条反向流水把原来那笔标记为已冲正同时余额做反向变更。所有痕迹都保留在账本里审计时能看到完整的资金轨迹。我用一个很朴素的原则要求团队账本只做加法不做减法纠错的方式永远是再记一笔而不是抹掉一笔。这在业务上也有意义——如果发现某笔扣款错误用户可能已经基于余额做了下一步操作直接抹掉记录会导致后续对不上。冲正留下了发生过错误并已纠正的证据这才是资金系统该有的样子。3. 支付受理与幂等设计重复回调这道坎不过去就别上线3.1 一次线上事故引出的幂等设计项目联调阶段出过一次很典型的事故测试人员在支付结果页连续点了两次确认支付前端防重没生效两个请求同时打到交易服务。第一次事务提交成功第二次因为余额不足被拒绝但此时渠道网关也做了重试回调报文重复到达。处理逻辑里没有幂等判断直接按支付成功更新了订单还各加了一笔余额流水用户账户凭空多出 100 元。排查的过程让我意识到支付链路的幂等不能只靠前端防重必须在接口层、消息层、数据库层同时设防。任何一次外部调用都可能被重复发送不是用户点的就是网关重试或网络抖动导致的。只要最终结果是幂等的重复多少次都不会出错。3.2 幂等键的三层设计交易服务里我设计了三层幂等键缺一不可。层级幂等键作用示例接口层业务请求号 request_id业务方生成防止同一业务动作重复受理商户订单号 操作类型渠道层渠道交易号 channel_trade_no event_type网关回调可能以不同事件多次到达payment.success、refund.success数据库层request_no change_type account_no防止流水重复入账一次支付不能产生两笔入账流水真实处理回调时逻辑是这样的Transactional public void handleChannelCallback(ChannelCallback cb) { // 1. 幂等表插入利用唯一键去重 try { callbackRecordMapper.insert(cb.getChannelTradeNo(), cb.getEventType()); } catch (DuplicateKeyException e) { // 已处理过该事件直接返回 return; } // 2. 根据 event_type 找到对应的交易记录 TradeOrder order tradeOrderMapper.selectByChannelTradeNo(cb.getChannelTradeNo()); if (order null) { throw new TradeNotFoundException(); } // 3. 校验当前交易状态是否允许该跳转 if (!order.getStatus().canTransferTo(cb.getEventType())) { return; } // 4. 执行账户入账/出账 accountService.record(cb...); // 5. 更新交易状态 tradeOrderMapper.updateStatus(order.getId(), order.getStatus().next(cb.getEventType())); }关键的细节在第三步不是所有回调都需要处理。比如交易已经关闭此时又来一个支付成功回调不能盲目入账必须先判断状态机是否允许。支付成功、退款成功、关闭这三个事件之间是有序的乱序到达时要能够安全丢弃或进入人工处理。3.3 交易状态机显式流转禁止裸 UPDATE交易订单不能只有一个状态字段否则时间一长就没人知道哪些状态是合法的。我在项目里用枚举约束状态机public enum TradeStatus { PENDING, // 已受理等待支付 PROCESSING, // 支付处理中 SUCCESS, // 支付成功 FAILED, // 支付失败 REFUNDING, // 退款中 REFUNDED, // 已退款 CLOSED; // 已关闭 public boolean canTransferTo(String eventType) { switch (this) { case PENDING: return payment.success.equals(eventType) || payment.fail.equals(eventType) || payment.close.equals(eventType); case PROCESSING: return payment.success.equals(eventType) || payment.fail.equals(eventType); // 其他状态类似 } return false; } }这个枚举是所有状态变更的唯一入口。任何地方要改订单状态都必须先拿到当前状态再调用canTransferTo判断不允许直接UPDATE trade_order SET status SUCCESS。早期我们吃过一次亏运维为了修一个脏数据手动把某笔订单从 CLOSED 改回 PENDING结果渠道的重试回调再次到达重新触发了一次入账。从那以后数据库账号的 UPDATE 权限收掉状态变更只能走服务接口并且接口里必须带当前状态快照做条件更新。3.4 回调乱序与延迟到达真实网络环境里回调可能先到失败后到成功或者退款回调比支付回调先到。处理这类乱序的方案是回调事件也走一条独立的顺序判定用渠道交易号 事件类型 渠道侧的最终状态做判断。如果本地交易已经在 SUCCESS再来 payment.fail 就直接忽略如果本地还是 PENDING先收到 refund.success 就要标记异常进入人工核实不能自动改成退款成功。延迟到达的回调更麻烦。用户已经发起退款退款成功事件还没回来支付成功的补发回调却到了。这种情况没有完美的自动化方案我的处理是交易存在退款中或已退款状态时任何支付成功类回调都只记录日志不操作余额并触发一个告警通知运营确认。宁可慢一步不能错一步。4. 对账与清结算让每一分钱都有据可查4.1 为什么必须做对账账户和交易模块做得再严谨也只能保证系统内部一致。渠道侧的网络抖动、超时、风控拦截、人为退款都会导致我方状态和渠道方状态产生差异。如果没有对账这些差异可能要等用户投诉才会暴露那时不仅影响体验资金追回也困难。financial-services 里对账的定位是最后一道防线。它不参与实时链路而是以 T1 的方式拉取渠道文件逐笔比对交易号、金额、状态、手续费输出差异清单。对账模块启动后我才算敢把系统真实交易量放开。4.2 对账文件解析先统一口径再逐笔核对渠道方给的对账文件格式五花八门有 CSV、Excel、固定长度文本、JSON。我第一版的实现直接按渠道格式解析结果每接一个新渠道就重写一遍解析逻辑维护成本极高。后来的做法是定义统一的内部对账模型public class ReconciliationRecord { private String channelNo; // 渠道编号 private String channelTradeNo; // 渠道交易号 private String localTradeNo; // 我方交易号 private BigDecimal amount; // 金额 private String status; // 渠道侧状态 private LocalDateTime tradeTime; private BigDecimal channelFee; // 渠道手续费 }每个渠道写一个转换器把原始文件行转成ReconciliationRecord后续的比对逻辑全部针对统一模型。解析文件时要做行级容错某一行字段缺失或类型错误不能导致整个文件加载失败正确的行照常处理异常行单独记录到错误明细表等人工排查。我们真实遇到过渠道文件里某一行金额多了个千分位逗号第一版解析直接抛异常中断后面全部停摆。加上行级容错之后这类问题的影响面从全量中断降到了单条待处理。4.3 差异分类与差错处理流程比对后的差异分成三类本地有、渠道无本地显示交易成功渠道文件里没有。可能是渠道文件生成时刻该笔交易还没有最终落账也可能是渠道漏出账。这种差异会触发渠道侧待确认先不操作余额等渠道人工查询。渠道有、本地无渠道侧扣了款本地没有交易记录。通常是异步回调丢失我方没有感知。这种差异需要先到渠道侧查询原始请求确认是我们发出的交易后按照本地补单流程重建交易和流水。金额不一致本地金额和渠道金额不一致。这类情况不允许自动处理直接标记为高风险差错转人工核查。真实项目里出现过渠道手续费计算口径变化导致的金额差异自动化处理很容易误判。差错处理有一个铁律对账模块只生成差异记录和差错单不做任何自动改账操作。改账必须走账户模块的调账接口并且带审批记录。哪怕差异已经 100% 确认调用调账接口时也要走一遍完整的流水和幂等逻辑。4.4 清结算模型把渠道手续费算明白对账之后是清结算。很多项目把清算和结算混在一起实际上清算算的是钱结算动的是账。清算模块按批次计算每个商户的应收金额、渠道手续费、平台分成生成结算单。结算单是纯计算不会直接改账户。后面的结算动作才通过账户模块给商户入账。简单表结构如下CREATE TABLE settlement_batch ( id BIGINT AUTO_INCREMENT PRIMARY KEY, merchant_no VARCHAR(32) NOT NULL, batch_date DATE NOT NULL, total_amount DECIMAL(12,2) NOT NULL, fee_amount DECIMAL(12,2) NOT NULL, net_amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL COMMENT 0-待结算,1-已结算,2-结算失败, created_at DATETIME NOT NULL, settled_at DATETIME );清算模块最容易忽略的是重复计算。同一笔交易不能被两个批次重复计入所以交易明细表要增加settlement_status字段和batch_id标记。批次计算时锁定批次状态结算完成后状态置为终态不允许回滚重算。如果发现某批数据算错正确做法是新增一个冲正结算批次而不是修改原批次。5. 服务间通信与最终一致性本地消息表模式的实战选择5.1 为什么不选分布式事务金融域涉及账户、交易、清算、消息等多个模块按照微服务的习惯自然会想到分布式事务。我们评估过可靠性但最终没采用。分布式事务的主要问题是性能损耗和协调复杂度。全局事务需要在所有参与方之间维持锁和协调状态吞吐量大幅下降这对于支付核心链路是难以接受的。更重要的是资金系统真正需要的往往不是同时成功而是最终一致且不产生错账。以支付成功为例交易状态要改为成功账户要入账消息要发出。这三个动作如果拆到三个服务里去做分布式事务原理上可行但任何一个参与方宕机都可能导致整个链路长时间阻塞。金融系统的可用性要求决定了我们不能接受这种模式的脆弱性。5.2 本地消息表把可靠性建立在数据库事务上项目里采用的最终一致性方案是经典的本地消息表。核心思路是业务操作和消息写入放在同一个本地事务里事务提交后消息一定存在后台任务扫消息表把未发送的消息投递到 MQ投递成功后标记已发送。支付成功后要发一条通知用户、同时更新交易状态的场景落地的伪代码如下Transactional public void confirmPayment(TradeOrder order) { // 1. 业务操作更新交易状态 tradeOrderMapper.updateStatus(order.getId(), TradeStatus.SUCCESS); // 2. 业务操作账户入账 accountService.deposit(order.getAccountNo(), order.getAmount(), order.getRequestNo()); // 3. 同一个本地事务里写消息 outboxMapper.insert( new OutboxMessage(trade.trade-success, order.getId(), payload) ); }消息表和业务数据在同一个数据库里利用本地事务保证原子性。之后由一个轮询任务扫描处于待发送状态且未超过重试次数的消息发送到 MQ。这种模式牺牲了一点实时性但换来了极强的可靠性——只要数据库不丢消息就不会丢。消息表结构CREATE TABLE outbox_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, topic VARCHAR(64) NOT NULL, biz_id VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待发送,1-已发送,2-发送失败,3-已确认, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );这里有三个容易被忽略的点。第一轮询发送和业务消费方之间的确认机制消息发送成功后不能马上丢弃要等消费方 ack 后再更新状态。第二重试必须有退避策略不能无脑循环轰炸 MQnext_retry_time用指数退避。第三超过最大重试次数的消息必须进入死信表并触发告警不能悄无声息地消失。5.3 订阅方的幂等消费消息模式里消费方必须保证幂等因为消息可能被重投。我们约定消费端所有处理函数都使用消息里的biz_id做唯一约束处理前先查一遍是否已处理已处理则直接 ack。这和第三章讲过的数据库层幂等是同一个思路只是落地到了消息消费端。曾经有个同事问既然 MQ 本身有 at-least-once 保证为什么还要自己做幂等答案是MQ 的可靠投递只能保证消息不丢不保证不重复而且消息消费成功后如果中间件在 ack 阶段超时大概率会重投。任何标榜不重复的中间件都可能因为网络原因出现重复所以幂等必须由业务自己负责。5.4 我的一些参数配置经验项目运行一段时间后我对轮询任务做了一些调优轮询间隔设为 2 秒批量拉取 100 条待发送消息单条发送失败不影响其他消息。消费方处理超时时间建议设成 10 秒超时不要立即重试先看日志定位。重试次数上限设在 15 次间隔依次是 1 分钟、5 分钟、15 分钟、1 小时……超过 15 次的消息进入死信表每天 10 点巡检一次人工介入。这套参数不是拍脑袋定的而是根据业务对实时性和可靠性的容忍度倒推出来的。支付成功通知用户可以延迟几十秒用户不会感知但如果消息丢了账就对不上代价就大了。所以宁可把重试生态做得保守一点也不能为了省事绕过重试机制。6. 复盘项目推进过程中踩过的几个坑6.1 余额更新和流水写入拆到了两个事务里早期版本为了性能把扣款操作拆成两步一个事务写流水另一个事务更新余额。结果有一次应用在流水写完、余额更新前重启重启后余额没变流水却多了对账时怎么都对不上。后来统一改成单事务内完成校验余额、写流水、更新余额、写消息彻底消灭了这类中间状态。这个教训让我明白资金链路里不要为了性能去拆事务先把正确性保住性能优化可以通过批量、异步等方式另找空间。6.2 回调幂等键没有包含事件类型一开始幂等表的主键只用了渠道交易号结果退款成功回调和支付成功回调同用一个交易号后来的事件直接覆盖了前面的事件状态。改成渠道交易号 事件类型联合唯一之后支付成功和退款成功才能作为两条独立事件各自处理。这个细节很容易被忽略但一旦漏掉线上会发生状态错乱。6.3 对账文件解析缺少行级容错渠道对账文件是 CSV 格式我们第一版解析在遇到一个格式异常的行时直接抛异常整个批次失败。后来加了行级容错解析失败的行单独记录错误原因成功行照常进入比对逻辑。这个改动看起来不大却避免了大量一个坏数据挂掉全量对账的运维事件。6.4 状态机缺乏逆向流转保护交易状态从 CLOSED 被运维手动改回 PENDING 那次事故让我意识到状态机不能只存在于代码注释里。后来我在交易状态变更的唯一方法里增加了状态流转规则校验任何非法跳转直接抛异常同时数据库字段也加了条件更新校验确保并发环境下状态不会跳变。现在即使有 DBA 权限的人去手改数据也只能改字段值绕不过状态校验逻辑。做金融项目这么多年最大的体会是慢就是快。只要每一步都可追溯、可恢复短期看起来多写了很多代码长期省下的排查成本却是巨大的。如果你也在做类似的 financial-services 项目先把账户流水、幂等、对账这三件事想清楚再考虑性能和架构扩展方向就不会跑偏。
返回列表