ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

金融级服务系统实践:幂等、分布式事务与账务一致性设计

金融级服务系统实践:幂等、分布式事务与账务一致性设计 金融服务这个领域我做了不少年头。外人眼里金融系统就意味着“高大上”“核心系统”“不能挂”但真正身在其中才会明白这行最磨人的不是什么高深的算法或者花哨的架构而是那些零散的、重复出现的工程细节一笔账怎么记才不会错一个请求怎么重试才不会重复扣款一个服务挂了怎么保证另一台机器能接得住。这些事单拎出来都不难难的是把它们全部放到一条链路上在千万级流量和随时可能出现的故障面前做到不出错、可追溯、能快速恢复。这篇文章想聊的就是我在金融级服务系统项目里的一些实践和思考。项目本身覆盖了交易、账务、风控、对账等环节这一类系统无论挂在什么名字下面——支付中台、账务中心、交易引擎——核心的约束条件几乎是一样的资金安全、数据一致、链路可追溯。文章不会去讲某家银行或某个支付系统的内部实现而是聚焦在通用方案和踩坑经验上。如果你正在做支付、交易、账户、会员余额之类的后端服务或者准备接手金融业务系统的改造这篇文章应该对你有参考价值。1. 项目概述为什么金融服务系统“难伺候”1.1 从业务边界看金融服务系统的独特约束很多人一开始觉得金融服务系统无非就是“增删改查 加钱减钱”数据库里update一下余额不就行了真做起来你会发现普通业务系统和金融系统的差别有点像日常记账和朋友合伙开公司记账的差别前者记错了顶多自己对不上账后者记错了涉及的是真金白银是要担责的。金融级服务系统的第一约束是资金安全。这意味着系统里每一分钱都不能凭空产生或消失账务流水必须完备所有涉及余额变动的操作都得留痕。第二约束是数据一致。在微服务架构下一个用户操作往往要经过多个服务的多次调用任何一次网络超时、进程重启都可能造成两边数据不一致。第三约束是链路可追溯。一旦用户发起投诉或者内部对账发现问题你要能从一笔交易反推出它经过的所有系统、所有状态、所有关键报文而不是对着日志大海捞针。说句实在话金融系统真正的技术难点从来不是“性能压测能到多少万TPS”而是“在故障发生时系统依然能够自洽”。这也是为什么很多金融项目的技术选型看起来偏保守——不是大家不会用新东西而是在资金场景里“确定能工作”比“看起来先进”重要太多。1.2 技术选型的核心思路稳定压倒一切我在这个项目里最深的体会是金融级系统的技术选型本质上是风险控制。举个例子缓存。普通互联网系统恨不得把一切热点数据都丢进Redis把数据库打穿也顶多报个错。但在金融服务系统里账务数据、余额数据是绝对不能只放Redis的。缓存可以用于做限流计数、风控特征读取、热点查询加速但余额的最终依据必须落在数据库里而且必须用可靠的事务机制来保障。数据库的选择上很多金融系统核心账务用的是关系型数据库MySQL或者商业数据库关键表行数不多但对事务的ACID要求极高。分布式存储、NoSQL可以作为辅助承载流水归档、行为数据、非核心查询等场景但核心账务长期保留在关系模型里还有一个实际原因对账和审计需要的SQL复杂度和可解释性远非KV结构方便提供。再比如消息队列。金融场景里MQ可以说是“下单后通知下游、异步对账、状态同步”这类需求的标准答案。但消息队列本身也是把双刃剑如果你没处理好消息幂等消费端重试几次就可能导致重复入账。我们当时对MQ的使用原则是可以异步但必须做好消费幂等可以削峰但削峰后的处理进度必须可观测、可控。至于微服务拆分我同样建议金融服务系统采用“适度拆分”的策略。服务边界要清晰但不要拆得太碎。我们项目里账户服务、交易服务、风控服务、消息通知、对账任务是独立的模块但每个模块内部的高内聚程度很高跨服务调用链尽量短。为什么因为每多一次网络调用就多一分不一致的概率。在金融领域分布式系统理论上的“CAP不可兼得”不是理论问题而是每天都在发生的现实问题。1.3 一个金融级服务的清晰架构分层基于上述思路我在项目中沉淀了一套自己比较认可的架构分层接入层负责协议转换、鉴权、参数校验、限流。这里要挡掉大量无效请求脏数据绝不能往下层传。服务编排层负责交易流程的编排比如“冻结-扣款-通知”这种多步操作的组合。这一层本身尽量不持有核心状态但要有超时控制和补偿调度。核心业务层账户、交易、额度、产品、风控等具体服务域。每个域独立部署通过RPC或消息交互。数据层关系型数据库存放核心账务与流水NoSQL/ES用于查询、归档和大数据分析Redis用于缓存与临时状态。这个分层本身不稀奇但稀奇的、也是决定成败的是层与层之间交互的契约设计尤其是幂等与状态机。我把这部分当作整个项目的地基接下来详细展开。2. 核心机制解析一致性、幂等与账务安全2.1 幂等设计的三道防线先问一个问题如果用户点了支付按钮前端超时了用户又点了一次后端到底应该扣一笔钱还是两笔钱答案很明显只能扣一笔。这个问题的技术术语叫幂等。在金融服务系统里幂等不是一种可选优化而是一种强制约束。任何可能被重试的接口都必须做到“同一个请求标识被执行多次效果等同执行一次”。我在实践中的做法是三道防线第一道防线在网关层给每个请求生成全局唯一的requestId并且对短时间内重复的requestId直接拦截。这里的细节是拦截不能只靠Redis里的一个key来简单setnx因为如果第一次请求还在处理中、尚未返回结果第二次请求直接放行或者直接拒绝都不对。正确的做法是把“处理中”和“已完成”都作为幂等状态来考虑处理中就返回“处理中”已完成就直接返回第一次的结果。第二道防线在业务层每个核心接口都定义一个业务单号比如交易订单号、流水号在数据库唯一索引层面做约束。这样即使分布式环境下多个应用实例同时收到重复请求数据库的唯一键也能兜底只允许一条记录插入成功。这一条非常重要哪怕前面的缓存拦住了99%的重复请求最后这1%也要靠数据库兜住。第三道防线是状态机的配合。账务状态不能允许随意跳变比如一笔订单只允许“待支付→支付中→已支付→已结算”这条路径。只要状态机定义得当重复更新时“目标状态不合法”就能直接被拒绝不会出现重复入账或重复结算的情况。有一回我们灰度上线正好撞上线上网络抖动某个应用层的幂等拦截因为Redis集群切换短暂失效结果大量重试请求打到了账务核心。全靠数据库唯一索引兜住最终资金数字没有任何异常。这个经历让我对“兜底设计”特别执念你永远不知道前面的环节会因为什么诡异原因失效所以最底层的数据约束一定要稳。2.2 分布式事务的理性取舍别迷信强一致接下来聊聊分布式事务这是金融系统绕不开的话题也是很多团队容易走极端的地方。金融场景天然需要强一致但分布式系统理论告诉我们跨服务的强一致非常昂贵。在实际项目里我对“强一致”的理解不是“任何时刻所有节点数据都一致”而是“最终一致的结果正确且不一致的时间窗口可控、可检测”。举个例子用户从A账户转账到B账户。在单体系统里一个数据库事务就搞定了。但在微服务架构下A账户和B账户可能分属两个服务甚至两个数据库。这时候你要是用两阶段提交2PC硬来协调者一挂、参与者一直锁资源整个系统的可用性就崩了。所以绝大多数金融项目并不会在所有跨服务场景里强行上2PC而是基于业务特性选择方案。我们这个项目的做法简单概括就是尽量缩小强一致的边界边界之外的用可靠异步加补偿。核心账务更新比如扣款、入账放在同一个事务边界内要么同一个库要么同一套事务机制保证原子性。跨服务的操作比如“下单后扣库存冻结资金生成物流单”则采用“本地消息表状态机驱动”的方式核心服务先在自己的事务里写入业务数据和一条待发送消息事务提交后再把消息发出去下游消费消息后执行自己的业务并通过回调或者异步对账来确认结果。很多人一听到“本地消息表”觉得是老古董但它在金融场景里恰恰是最稳的。要实现它不依赖任何外部分布式事务中间件业务数据与消息数据共存于一个数据库事务里天然保证了“业务操作和消息记录要么都成功、要么都失败”。我第二次踩到分布式事务坑的时候回来就老老实实把核心链路改成了这种方案效果立竿见影。还有一个需要提醒的地方是补偿。异步消息以及Saga这类长事务必须有完善的逆向操作比如撤销、解冻、退款。而且补偿动作本身也要幂等否则补偿过程中网络异常导致的重复执行又会制造新的问题。2.3 账户与账务模型从手工记账到复式记账账务模型是整个金融服务系统的内核。我见过不少项目一开始用的是“余额字段”直接加减表里就一个balance扣款就UPDATE balance balance - amount。这在数据量小、并发低的时候问题不大但一旦并发上来行锁竞争、超扣、对不上账的问题就全都冒出来了。稳妥的做法是引入流水账分户账的双层结构。分户账保存账户当前余额可以按币种、资金类型等分多个子账户流水账记录每一笔资金的变动明细。任何余额变动都必须先写流水再更新余额并且流水的编号和唯一性要严格保证。更进一步很多金融系统会采用复式记账的思路。简单说一笔资金变动会涉及借贷两个方向比如扣款方记“减”收款方记“加”两边的金额恒相等。引入复式记账后系统内部每天定时对总分账目进行轧差核对只要两边不相等就说明有账务异常系统立刻告警。我在设计账务表的时候给大家一个建议不要只记一个变动金额还要记录“变动前余额”和“变动后余额”。这有两个作用一是方便排查问题一眼看出这笔操作对账户的实际影响二是可以做余额连续性校验——后一笔流水的变动前余额必须等于前一笔流水的变动后余额如果不等于就说明中间丢了数据或者被人为改过数据。3. 实操还原从下单到入账的完整链路设计3.1 场景设定与核心交易流程拆解理论和模型说得再多不如直接还原一条真实链路。下面我用一个典型的“用户购买理财产品”场景来串一遍完整过程。先说业务需求用户从余额账户买入一笔金额为10000元的理财产品前端展示预期收益用户下单后系统先冻结余额确认产品份额有效后扣减余额并增加用户的理财产品持仓。这个需求看起来很简单但落到系统设计上至少要拆分出以下几步用户发起申购交易服务创建交易订单状态为待支付。交易服务调用账户服务请求冻结用户余额中的10000元。账户服务生成冻结流水并返回冻结编号。交易服务调用产品/持仓服务确认用户申购的产品份额是否还有额度并创建持仓记录。确认无误后交易服务更新订单状态为已支付或已确认。账户服务把冻结资金转为扣款生成扣款流水余额减少。异步向用户发送成交通知同时将交易数据写入对账系统。这里面的每一步单独看都不复杂但组合起来就涉及多次跨服务调用。每一次调用都可能超时、失败、重复所以每个接口都必须是幂等的流程中间还必须记录状态便于失败后恢复。3.2 关键接口与数据模型设计要点基于上面的流程提几个核心接口设计时容易出错、但只要改好就能让后面省很多事的关键点。交易订单接口入参必须包含用户ID、产品ID、业务单号、金额、幂等键。其中幂等键最好是前端生成并透传比如UUID也可以是网关根据关键参数生成的哈希但不能只用用户ID产品ID因为同一用户同一产品可能会买多笔。返回结果要区分“处理中”和“失败”不允许只返回成功/失败两种。因为超时场景下请求可能已经在服务端执行成功了只是响应丢失此时返回“处理中”并引导用户查询订单状态比直接返回失败要安全得多。账户冻结接口冻结操作本身要生成独立的冻结流水号同时要记录“冻结类型”比如申购冻结、退款冻结、风控冻结。不同类型冻结的解冻和转扣款逻辑不同如果没有类型字段后面很容易出乱子。冻结金额和实际扣款金额可能不一致比如部分赎回、产生费用所以扣款时要支持“部分转扣款”剩余部分自动解冻。产品持仓接口核心是保证持仓数据不能为负、不能超产品总额度。这里可以用数据约束加上乐观锁来实现UPDATE持仓表 SET holdings holdings #{delta} WHERE user_id #{userId} AND product_id #{productId} AND holdings #{delta} 0。数据模型上最核心的几张表我会这样设计交易订单表订单号唯一、用户ID、产品ID、金额、状态、幂等键唯一索引、创建时间、更新时间。账户流水表流水号唯一、账户ID、变动类型、变动金额、变动前余额、变动后余额、关联订单号、幂等键唯一索引、创建时间。冻结记录表冻结编号唯一、账户ID、订单号、冻结金额、已解冻金额、已扣款金额、状态、创建时间。持仓表用户ID、产品ID、持仓份额、可用份额、锁定份额、更新时间。这几张表的共同特点是都有唯一键、都有状态字段、都带关联订单号。这些字段不是为了规范而规范而是为了后面做幂等、做对账、做追溯时能够非常快地定位数据。3.3 失败处理、补偿与对账机制落地上面那条链路里最容易出问题的环节是“冻结成功但确认持仓失败”或者“确认持仓成功但扣款失败”。这种“两边操作无法同时成功”的经典情况需要靠状态机和补偿流程来兜底。我们项目里引入了一个定时调度任务作用是对“处理中”状态且超过一定时间比如30秒的订单进行巡检重新驱动流程往前走。比如发现订单冻结成功但迟迟没有创建持仓记录调度任务会重新发起确认持仓请求如果重试多次仍然失败就自动走撤销流程把冻结资金解冻订单状态改为失败。这个调度任务的价值在于它把“分布式系统里不可避免的失败”变成了“可以通过重试和补偿来收敛的异常”。我强烈建议所有做资金链路的团队都维护这样一个巡检任务不是有了它系统就一定没问题而是没有它你只能在用户投诉之后才去翻日志找问题。对账机制也是必选项。我们内部的对账分成两个层面系统内部对账每天凌晨跑批对所有账户余额、所有流水、所有订单状态做总分核对。比如账户的所有流水变动之和应当等于账户当前余额减去初始余额当天所有成功订单的金额之和应当等于所有账户扣款的金额之和。外部渠道对账假如你对接支付通道、银行、清算机构每天要拉取对方的账单文件和本地交易记录逐笔比对。比对结果分为“本地有、渠道无”“渠道有、本地无”“金额不一致”三种情况分别走不同的处理流程。对账不是形式主义它往往能在用户发现之前先发现问题。我对团队的一个要求是告警要能定位到具体订单、具体流水、具体差异金额而不是只给一个“对账不平”的模糊信息。否则半夜收到告警光排查就能把人折腾到天亮。3.4 可观测性与审计链路追踪和日志金融服务系统上线后运维的核心问题不是“系统还活着吗”而是“某笔资金现在到底处于什么状态”。为此可观测性建设必须做到三个层次第一层是链路追踪。每一次用户请求都会生成一个traceId贯穿从网关到交易、账户、持仓的全部调用。任何一个环节出了问题都可以通过traceId把所有日志串起来。第二层是业务状态的可视化。光有技术日志还不够还要有业务维度的监控比如“每分钟新增申购订单数”“每分钟冻结成功金额”“冻结失败率”“扣款延迟分布”。这些指标与用户的资金直接相关变化异常时要在分钟级内感知。第三层是审计日志。金融系统对审计有非常高的要求关键操作不能只记代码日志还要单独记录操作人、操作时间、操作内容、操作前后的数据快照、以及数据变更原因并且日志要具备防篡改能力至少要保证日志写入后无法轻易被覆盖删除。这些日志在内部审计、监管检查、用户纠纷处理时都是硬性依据。我在实践中还有一个体会日志的字段设计要“宁多勿少”。刚开始觉得“变动前余额”这种字段占地方、没意义真到了排查问题的时候才发现有这个字段能瞬间判断是一条重复写入还是真实业务操作比分析半天日志高效得多。4. 踩坑记录真实故障的排查实录4.1 资金核对不平都是时间窗口惹的祸有一次日终对账系统提示“总账不平”差额是0.01元。我们当时第一反应是查代码逻辑怀疑某处四舍五入出了问题。排查过程很有意思查流水、查订单、查账户所有记录都是正常的。最后发现问题出在“当日订单”和“当日流水”的统计口径不一致上。有一笔交易是在23:59:59下单但扣款操作跨了零点流水记在了第二天而订单统计已经按“下单日”算作当日两边一分钱对不上。这类问题非常典型很多资金系统初次上线都会遇到。解决方案也比较机械但必须做明确所有日切、轧差、分账的时点并统一使用资金实际变动时间流水时间作为账务归属的时间。业务看板可以按下单日展示但资金核对必须按流水日。4.2 用户重复提交幂等键的失效瞬间还遇到过一回用户反馈被重复扣款。查下来不是我们代码逻辑的问题而是前端在用户连续点击时两次请求的幂等键竟然生成了同一个但第一次请求被网关拦截返回异常后有同事“好心”把幂等缓存清掉了导致第二次请求绕过了幂等校验。这个案例暴露出两个问题一是幂等键的生成规则要足够可靠不能依赖前端用户输入框里的某个重置逻辑二是幂等状态一旦生成就不能因为某次请求“异常”而随意删除。除非你确认第一次请求真正失败了比如系统明确返回业务失败并且没有产生任何资金流水否则宁可让用户等一等、查一查也不能把幂等记录擦掉放行新请求。后来我们把幂等键改成网关层基于“用户ID业务类型业务参数”的确定性哈希同时对幂等记录的清理增加了一个非常严格的业务确认流程只有当前请求的状态机明确为失败或者已经做了逆向补偿之后才允许清除幂等记录。这条经验写出来也就几句话但当时我们排查这个问题花了整整半天。4.3 高峰期的性能劣化别让风控拖垮主链路金融系统上线一段时间后会迎来流量高峰压测不一定能暴露所有问题。我们遇到过一件事大促时系统整体响应变慢最开始怀疑数据库瓶颈后来定位发现是风控引擎拖慢了主链路。风控系统正常逻辑应该是“尽量快”的比如从Redis里读取用户历史行为特征、做规则判断、返回放行或拦截。但那次因为风控系统添加了一批实时外部数据源调用某个外部接口超时时间设置过慢30秒而且调用方式是同步阻塞的导致大量交易线程卡在风控环节。这件事的教训有两条。第一任何对实时性要求高的场景主链路里的远程调用必须设置严格超时时间和降级开关。风控可以改成异步评分主链路只消费风控预判结果如果风控服务不可用宁可放行并转入事后抽检也不能让交易主流程被拖垮。第二压测时不要只测主路径的“理想情况”要模拟外部依赖延迟和异常的情况。很多问题不是“压力大了才出”而是“某个依赖越来越慢才积累成灾”。4.4 常见问题速查表现象可能原因排查思路与解法日终对账不平统计口径不一致订单日 vs 流水日统一按资金变动时间归属账务日并核对日切时点用户被重复扣款幂等键生成不稳定或幂等记录被误删网关生成确定性哈希幂等键禁止随意清除幂等记录转账成功但双方余额不变分布式链路中某个更新被异常回滚用流水号事务边界逐步核对定位具体未提交事务高峰期接口超时外部依赖慢或同步阻塞给远程调用设置超时与降级开关必要时改异步数据库死锁多个事务以不同顺序更新账户统一账户更新顺序减少锁竞争必要时引入异步削峰冻结资金无法解冻状态机缺失或补偿任务未触发完善状态流转定义配置巡检调度任务补充驱动这张表其实可以继续写很长但核心思路是一致的所有问题最终都可以归结到“某个环节的数据状态不对”因此状态可查、动作可回溯、补偿可执行就是金融系统的护身符。5. 经验沉淀与上线建议5.1 灰度与回滚变更风险管控三板斧金融系统的任何变更哪怕是改一个文案我都建议走灰度。我们项目里有一个不成文的规定核心账务模块的变更必须经过“数据库脚本预检→线上影子库对比→小流量灰度→按百分比逐步放量→全量”五步。灰度过程中的一个关键抓手是“资金校验”。比如这次变更涉及新的扣款逻辑灰度期间要对比新旧两个逻辑在同一批请求上的结果余额变动是否一致、流水是否一致、异常率是否上升。只要有任何一个差异宁可马上回滚也不能抱着“再观察观察”的心态硬撑。资金场景不像普通互联网功能一旦出问题影响面和善后成本是完全不同量级的。回滚也必须在设计阶段就考虑清楚。上线新版本之前老版本必须保持可一键回切的状态数据库变更要尽量向前兼容不能出现“新代码跑了10分钟后老代码已经完全无法运行”的情况。我见过很多团队因为数据库表加了非空字段或者改了唯一索引导致回滚非常痛苦。这个坑提前设计才能避开加字段尽量带默认值删字段永远不要真删改唯一索引前先确认旧索引不影响回滚。5.2 团队协作与文档让系统可交接可演进最后聊点软的但可能比技术方案更影响长期效果。金融服务系统往往寿命很长三五年甚至十年都很正常。一个系统能不能撑过几代工程师的交接很大程度上取决于文档和协作习惯而不是代码本身。我在项目里坚持做两件事。第一核心接口文档与状态机定义必须与代码同步维护并且放在团队都能访问到的地方不允许只在某个人脑子里或者某个临时群聊里流传。第二每一次线上事故无论大小都要沉淀一份问题复盘里面必须包含现象、影响范围、根因、处理过程、改进动作。这些文档不只是给当前团队看的更是给未来接手的同事的“避坑指南”。很多人觉得写文档浪费时间但金融系统的历史包袱往往就来自于“代码在、逻辑不明、决策不可考”。等出了问题没人说得清为什么当初要这么设计时成本早就超过了几十次文档写作。这个系统做完我最大的感受是金融服务系统里的每一个“保守设计”背后几乎都是一次事故换来的经验。所以如果你要接手或者新建一个金融相关项目请一定把精力重心放在幂等、账务一致性、状态机、可追溯和故障恢复这些“不性感的基石”上。它们不炫酷、不吸睛但它们决定了一个系统能不能在真实的生产环境里长期安稳地跑下去。就聊到这儿。如果这篇文章对你有用或者你也有自己在资金链路上的踩坑经验欢迎在评论区交流。我自己也是在一次一次对账不平、一次一次半夜告警里慢慢攒出来的这些经验希望屏幕前的你能少踩几个。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进