ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

金融服务系统架构设计:账户、支付与对账实战

金融服务系统架构设计:账户、支付与对账实战 先交代一下背景。我手上这个financial-services项目是这几年做过的后端系统里最折腾、也最值得复盘的一套。它不是一个具体业务而是一组金融能力的集合账户、支付、风控、对账、清结算全在里面。很多人一听“金融服务”就头大觉得离自己很远其实拆开看就是一套把“钱”这件事管明白的系统只要涉及线上收款、余额、账单、退款都会碰到这里面的问题。这篇东西我写给自己团队新人看也写给所有打算做或正在做资金类后端的朋友。无论你是后端开发、架构师还是刚转岗的技术负责人只要系统里碰过“钱不该多、不该少、不能乱”这三个要求下面的内容都能直接参考。我会把项目拆成几块讲清楚整体设计怎么想的、每个核心模块真正的难点在哪、实操中怎么落地以及我踩过的那些常规文档里根本不会写的坑。1. 项目定位与整体设计思路1.1 这个项目到底是干什么的financial-services从名字就能看出来它负责的是平台所有的资金相关能力。拿我们当时的业务举例用户在App里充值、下单支付、退款、提现运营后台要给商户结算财务每天要看资金流水和渠道对账。这些能力如果每个业务线各做一套后面一定乱成一锅粥。所以这个项目做的就是把这些能力统一收拢对外暴露一套稳定接口对内保证每一笔钱都有据可查。本质上它像水电煤一样的基础设施。业务方不需要知道钱是怎么从用户账户到商户账户的也不需要关心银行渠道怎么对接直接调用我们的接口就行。核心要求就三条钱不能错、不能丢、不能慢。错指账实不符丢指消息或请求遗漏导致资金悬空慢指接口性能差到影响用户体验。所有架构上的取舍基本都是围绕这三个要求展开的。这里先说明一下网上资料经常把这类系统称为“支付系统”或“账务系统”但financial-services的边界比单纯支付更宽。它包含支付也包含账户管理、资金冻结解冻、账单生成、对账差异处理甚至要配合财务做记账凭证。所以在设计时我把它定义为一组微服务的集合而不是一个单独的应用。1.2 为什么选微服务而不是单体架构刚接手的时候这套系统其实是个单体应用。业务量不大的时候其实还好所有代码在一个仓库里部署也简单。但它有个很致命的问题只要有一小块逻辑改动比如改了对账的报表格式整条资金链路都得跟着重新发版风险面会非常大。我们当时出现过一次因为账单模块的小改动导致支付回调处理暂停了十几分钟的事故从那以后我下定决心拆分。拆成微服务之后几个明显的好处体现出来了不同模块可以独立发布、独立扩容、独立降级。比如支付峰值和账单计算峰值根本不是同一个时间点拆开之后可以分别扩资源费用更可控。故障隔离也更清晰风控服务挂了不会直接导致支付接口完全不可用可以降级为放行并异步补查这在单体里是做不到的。但微服务不是没有代价。最大的代价就是把原来的本地调用变成了网络调用分布式事务问题随之而来。跨服务转账如果中途失败怎么办账户服务扣了钱交易服务却更新状态超时这笔账算谁的这些都是拆分后必须正面解决的问题。我后面会在实操部分详细讲怎么通过幂等和状态机兜底。拆分的具体策略上我们没有按页面或按功能拆而是按业务能力和数据归属拆。账户、交易、风控、对账、通知各是一组服务每组服务拥有自己独立的数据库别的服务一律不允许直连数据库只能通过接口访问。这个边界划得很死初期觉得繁琐后期维护起来是真的舒服。1.3 服务拆分的边界怎么定这里多讲一点因为边界划对了后面所有事情都顺。我听很多人说微服务难做其实难的不是技术是边界划得不清不楚。最简单的判断标准问自己“这份数据到底归谁管”。用户余额归账户服务管交易流水归交易服务管风控规则归风控服务管。谁管数据谁就对数据完整性负责别人想改必须经它同意。我当时还定了一条铁律余额的变更只能发生在账户服务内部其他任何服务不允许直接修改余额字段。比如交易服务收到支付回调它只能调用账户服务的接口“入账”或“出账”具体怎么改余额、怎么写流水账户服务说了算。这条纪律一开始执行有点痛苦因为大家习惯了直接操作数据库但坚持下来之后账务不清的问题少了很多。兼容性也是边界设计里必须考虑的事。金融服务系统寿命很长一个接口可能要兼容十来年。所以设计接口时参数尽量用扩展性强的结构字段命名别省版本号从一开始就留好。我们曾经有一个查询余额的接口最初只返回一个数字后来要返回冻结金额、可用金额、币种结果接口直接不够用了只能新开一个版本老版本继续维护这就很被动。2. 核心模块拆解与技术要点2.1 账户与账务模块钱怎么记才不出错账户模块是整个系统的地基。如果账户数据都是脏的后面支付、对账做得再好都没有意义。所以这块我花的时间最多也建议大家一开始就重视。先说账户模型。简单的系统可能只有一个余额字段但真实业务远不止这么简单。用户充值之后钱是可用的下单时可以冻结一部分退款成功后又解冻回来。如果只有一个余额字段这些状态根本表达不清楚。我最终采用的是主账户加子账户的结构主账户记录用户的资金概况子账户分别记录可用余额、冻结余额、在途资金等。每个账户还有自己的币种多币种业务后面扩展就比较顺。账务模块的核心其实是复式记账的思想。这个思想从几百年前的簿记时代就定了每一笔资金变动至少涉及两个账户有借有贷借贷平衡。放到系统里实现就是每一次入账出账操作都会产生一组流水记录涉及来源账户和目标账户。这样做的好处是天然自带校验能力——如果某天发现借贷不平系统直接告警说明一定哪里出了问题。我们做过一次演练故意在测试环境乱改一笔余额复核时立刻就被流水差出来了这个设计的价值是实打实的。还有一点非常关键余额永远不能直接“改”只能“记”。意思是任何余额变动都必须附带一笔流水记录先记流水再更新余额两者在同一个数据库事务里完成。这么做一方面是审计需要另一方面是对账需要。财务来查账时不是看余额数字而是看流水明细。余额只是流水的汇总结果。2.2 交易支付模块一次支付请求的完整旅程交易模块负责的是用户从发起支付到资金落账的整个过程。很多人以为支付就是把钱从A转到B其实中间是一个复杂的状态机。我拿一次典型的线上支付来讲。用户发起支付后交易服务会创建一个交易订单状态变成“处理中”。在真正调用外部渠道之前有一个非常关键的步骤预冻结。也就是把用户账上的钱先冻结起来防止在支付过程中又用了这笔钱。冻结成功后系统再去调渠道接口渠道返回成功后我们才把这笔冻结转成正式扣款更新状态为“成功”。如果渠道失败了就把冻结解掉状态更新为“失败”。这套流程里最容易出问题的是渠道回调。外部渠道回调可能延迟、可能重复、甚至可能丢失。所以回调处理的第一步永远是验签确保请求真的来自渠道。验签通过后查本地订单如果订单已经在终态直接忽略。这就是幂等。然后才是状态扭转从处理中变成成功或失败。这里必须强调回调处理里禁止直接改余额所有资金变动继续走账户服务的接口。还有一个细节是状态机要定义得足够细。我们最初只定义了成功、失败两种终态后来发现渠道返回了“处理中”或“未知”导致一堆订单挂在半空中。后来增加了“关闭”“部分成功”等状态每种状态之间的流转关系用一张图表钉在文档里代码里也做了严格的合法性校验。从那以后订单状态混乱的工单少了很多。2.3 风控模块规则引擎与实时拦截金融系统如果不做风控等于开着门让人搬钱。风控模块的核心是在资金操作的关键节点尽量早地拦截可疑行为。我们采用的第一版方案是规则引擎没有一上来就上机器学习。原因很简单金融场景对可解释性要求极高规则引擎每条拦截都能说出为什么而机器学习模型给出的概率分数很难跟用户解释。常见的拦截规则有几类频次限制比如同一用户一分钟内支付超过5次金额阈值比如单笔金额超过设定值设备指纹比如新设备突然登录并大额操作商户黑名单比如某些风险商户的交易直接拒绝。规则可以配置化运营同学在后台改规则配置不用发版。风控介入的时机也很讲究。我们在三条链路都埋了检查点交易创建前查一次支付动作前查一次资金结算前再查一次。前置检查拦截早期风险后置检查兜住漏网之鱼。多层布防的效果是单点规则被绕过时后面还有防线。但风控不能只堵不放。误杀率太高真实用户会被拦得火大。我们做了三个配套机制白名单用户直接放行命中规则的交易进入人工审核队列由风控专员复核每天自动统计规则命中率和误杀率超阈值的规则自动降级或告警。这套组合拳用下来拦截效果和用户体验基本平衡住了。2.4 数据安全与合规设计金融项目的数据安全不是可选项是底线。资金相关的系统里存着大量敏感信息手机号、证件号、银行卡号。这些字段存储在数据库里必须加密日志里必须脱敏接口返回时必须打码。我们用的是AES-256-GCM算法做字段加密这个算法除了加密还带AAD认证可以防篡改比单纯ECB或CBC模式安全得多。加密有一个绕不开的坑没法模糊查询。数据库里存的是密文想按手机号查用户就没法用like。我们的方案是增加一个摘要字段把手机号做SHA-256哈希查询时先哈希再精确匹配。这样既支持查询又不暴露原文。注意哈希和加密要分开用哈希不可逆但可碰撞适合查询加密可逆适合还原原文。审计日志也是必须的。谁在什么时间查了哪个用户的敏感数据SDK有没有批量导出这些都要留下记录。我们的审计日志独立存储权限与业务日志隔离开防止删库跑路时把证据也一起删掉。虽然听着极端但金融行业这种事不是没发生过。合规设计上我们做了数据分类分级对不同密级的数字有不同的保留期限和访问控制比如超过保留期限的数据定期清理或匿名化。这些规则能用配置系统管理配合定时任务自动执行。3. 关键实操从零搭建一个可落地的金融服务3.1 数据库建模资金类表结构怎么设计理论讲完该落到代码和表结构上了。这里我直接给出一版经过实践验证的表结构设计大家可以根据自己业务做裁剪。第一张表是账户表。核心字段包括账户ID、用户ID、账户类型、币种、可用余额、冻结余额、版本号、创建时间、更新时间。其中版本号是给乐观锁用的每次更新余额时都带上where version 上次读到的版本号防止并发更新互相覆盖。这是非常经典的ABA问题解决方案在高并发资金操作中必不可少。第二张表是流水表。这张表是整个账务系统的灵魂字段包括流水号、账户ID、变动前余额、变动后余额、变动金额、收支方向、业务类型、业务单号、幂等键、创建时间。流水号要保证全局唯一业务单号用来关联交易订单幂等键用来防重。核心查询场景是按账户ID和创建时间查近期流水所以这个联合索引必须建上。流水表只做插入禁止更新和删除这是铁律。第三张表是交易订单表。字段包括订单号、用户ID、业务类型、金额、币种、渠道、状态、回调状态、创建时间、更新时间。状态字段配合状态机使用每次状态变更都记录状态变更时间方便排查超时订单。分库分表的策略也提前说一句。不要一开始就分但表设计时要预留分片键。我们是在单表超过2千万行时开始做的分片分片键选的是用户ID相关性高的字段比如账户ID或订单号。按用户维度分片同一个用户的账户和流水落在同一片事务范围可控避免跨片事务这是金融数据分片的一个重要原则。3.2 幂等设计重复请求为什么不能多扣钱幂等是金融系统里出现频率最高的词没有之一。我先讲个场景用户点了支付按钮客户端请求超时用户又点了一次如果系统没有幂等保护同一笔订单就可能被扣两次钱用户直接炸锅。幂等设计分两层。第一层是接口层交易服务在处理请求时先根据业务订单号或请求唯一ID在数据库里查已存在记录。如果记录存在且状态是终态直接返回原结果。如果记录存在但还在处理中返回“请求处理中请稍后再试”。如果不存在就插入一条新记录这里靠唯一索引兜底防止两个并发请求同时插入成功。第二层是账户服务扣款时的幂等。账户服务在入账出账时以业务单号加账户ID作为幂等键写入流水表。流水表对幂等键建唯一索引重复扣款请求第二次执行时会因为唯一约束冲突而直接失败。这个设计的好处是即使交易服务发了两遍扣款请求账户服务也只会认第一笔。我还想特别强调一点Redis做分布式锁可以做辅助但不能做唯一屏障。原因很简单Redis主从切换时锁可能丢一旦丢了重复请求就会穿透到数据库。所以最终的兜底一定在数据库唯一索引。我见过不止一次团队只上了Redis锁高并发一压测就出重复扣款换上唯一索引之后立刻稳定。这个经验不值钱但能省下很多个不眠夜。3.3 对账系统T1对账与差异处理对账系统平时默默无闻但它是资金安全的最后一道防线。我们的对账任务是每天凌晨跑的把渠道侧的交易文件和本地交易明细逐笔比对。渠道侧的文件一般T1提供所以我们也做成T1。对账的逻辑不复杂难的是差异处理。先把本地交易记录和渠道记录都按业务订单号建索引然后逐笔比对金额、状态、渠道流水号。比对结果分为几类双边一致这是最理想的本地有但渠道没有说明本地系统可能提前入账了需要查渠道状态并做冲正本地没有但渠道有这种情况比较危险说明本地漏单了可能渠道回调丢失需要补单并落账金额不一致多半是手续费或优惠计算有出入需要人工介入。对账结果要落表方便日后追溯。我们建了对账结果表每条记录标明对账日期、订单号、差异类型、处理状态。只要是差异单就自动生成告警推送给当班工程师。财务部每天也会看差异报表他们的要求比我们更细甚至精确到分。有一点实操经验值得分享对账程序本身也要能被对账。什么意思就是程序跑完之后要输出一个汇总报告包括本次对账总单数、匹配单数、差异单数。如果哪天汇总报告没生成或者数字对不上就要怀疑对账程序本身出了问题。我们是加了一个对账任务自检每天检查前一天的报告是否生成没生成就告警。这看着有点“自己查自己”的荒诞感但确实拦住过一次定时任务被误删的故障。3.4 加密与密钥管理实践前面提到了字段加密这里补充一些密钥管理的实操细节。密钥千万不要硬编码在配置文件里也不要放在代码仓库里。我们用的是集中式密钥管理服务应用启动时只获取密钥的引用或版本号真正解密时按引用去KMS拉取当前版本的密钥。这样做的最大好处是支持密钥轮换旧的密钥可以按版本保留数据解密用老版本新写入用新版本等老数据逐步迁移完再下线旧版本。还有一个小细节加密字段在数据库里用VARBINARY而不是VARCHAR。因为密文是二进制数据如果强行用VARCHAR存储字符集转换可能导致解密失败。这个坑我们踩过迁移时才发现的重新改列类型费了好大劲。密钥轮换的频率也要定好不能一年不换也不能一天一换。我们当时的策略是核心加密密钥90天轮换一次审计日志的签名密钥30天一次。轮换过程中旧密钥在过渡期保留等所有关联数据都改写完成再清掉。整个过程通过脚本自动完成但每个步骤都有日志任何一步失败都会暂停并告警确保人工介入前不会出现半个系统用了新密钥、半个系统还用旧密钥的不一致状态。4. 工具选型解析4.1 关键中间件怎么选选型这件事我向来比较务实。金融系统不需要最酷的技术需要最稳的技术同时要在换技术栈时少折腾。下面几个中间件是我最终保留下来并且长期验证过的组件用途选型理由MySQL账务主存储事务强一致资金数据最看重这一点Redis缓存、分布式锁、频次统计性能高热点数据访问必须用它扛Kafka消息异步化、削峰填谷、对账文件传递吞吐量高支持消息回溯适合高峰削峰etcd服务发现和配置管理一致性协议成熟比分片前的架构管理成本低Elasticsearch业务查询和日志检索交易查询和对账查询条件复杂MySQL扛不住MySQL和Redis有个搭配原则我在团队里反复强调Redis永远只是缓存不是主存储。账户余额可以缓存到Redis供读请求快速访问但写请求必须穿透到MySQL写成功后更新缓存。缓存和数据库不一致的问题通过设置缓存过期时间兜底过期后强制重新读库。这个原则守住就不会出现“Redis里的余额和MySQL里的不一致”这种灾难。Kafka在资金链路里主要解决两个问题一是削峰比如支付渠道回调洪峰时先把回调消息打到Kafka由消费程序按它能承受的速度处理二是解耦对账文件传输、通知服务、审计日志收集都走消息队列。这里有个经验消息消费失败一定不能无限重试超过重试次数后要把消息打入死信队列并辅以告警人工介入。死信队列比很多人想象的更常用我们每个月都要靠它捞回几条重要的资金消息。4.2 监控与告警体系怎么搭资金系统的监控比普通业务系统要求更高因为任何一个静默失败都可能造成资金损失。我按重要性列一下必须监控的指标。业务成功率和高延迟排在第一位。支付的调用成功率从99.99%掉到99%可能不是大故障但要有人知道。我们监控每个渠道的支付成功率、回调成功率、退款成功率这些指标是按渠道维度拆分的一旦单渠道成功率下降超过阈值立刻告警。资金差错率是金融系统特有的指标。每天对账完成后自动计算差异单数占总单数的比例。一个健康系统这个比例应该是极低的比如百万分之几。如果某天突然升高那一定不是运气问题而是系统里出现了新的bug这种告警基本一告一个准。系统资源指标反倒是比较常规的。CPU、内存、磁盘、JVM GC这些用成熟组件监控即可。我要特别提醒的是交易链路必上traceId。一套全链路日志追踪系统从用户请求进来开始生成一个traceId一路透传到数据库操作、消息发送、回调处理整个链路的日志都能按traceId串起来。排查问题时一张trace图就能定位出慢在哪一环而不是像无头苍蝇一样翻几十个服务的日志。5. 常见问题与排查技巧实录5.1 对账不平先从这三个地方查对账不平是资金系统最头痛的事我处理过太多次了。根据我的经验90%以上的对账不平都能从三个地方找到根因。第一是时区问题。渠道侧的文件可能是UTC时间而本地记录是北京时间两边在同一天的切割线上自然对不上。这个很简单统一转换后再比对。我们有一段时间频繁出现凌晨附近的差异单排查半天发现是时区边界问题。第二是渠道手续费和优惠未入账。很多渠道是扣完手续费后净额结算而本地最开始记录的是订单原金额。如果对账程序没有把这部分差异纳入逻辑就会误报。我们的方案是按渠道配置各种计费规则在对账比对前先计算“应结算金额”再与渠道文件比对这样差异才准确。第三是渠道回调丢失导致漏单。本地订单状态还是“处理中”但渠道已经扣款成功。这种情况只能在用户投诉或对账时发现。我们遇到之后的处理方式是对账发现本地漏单时自动触发补偿流程先从渠道接口查询真实支付状态确认成功后补记账。补记账也需要幂等键防止和原来可能的回调冲突。5.2 接口超时导致重复扣款的经典场景这个场景我上面提到过值得单独拿出来复盘一遍因为它的隐蔽性很强。当时的业务背景是用户在支付结果页点了两次“确认”最终显示扣了两次款。从监控看第一次请求在渠道侧成功了但因为网络原因网关层超时客户端自动重试又发了一次请求。我们的第一版修复是加网关层超时时间以为把超时调长就能避免。结果发现治标不治本第二次请求依然打进了交易服务。真正的修复是在交易服务入口加业务幂等请求到达后先查订单状态订单已经是成功终态的直接返回成功结果不重复走支付流程。网络层重试依然会发生但业务层已经能兜住。这个案例给我的教训是超时重试和幂等是两个层面的问题超时设置只能减少重试概率只有业务幂等才能100%保证重复请求无副作用。做资金系统的人一定要把这条刻在脑子里。5.3 热点账户导致数据库锁等待另一个经典问题是热点账户。我们有一个优惠活动用户抢到券后同时去下单结果券池账户被大量并发请求同时扣减余额数据库行锁竞争剧烈直接导致接口TP99飙升到几秒钟。第一次遇到这种情况第一反应是加缓存但仔细想清楚就发现缓存解决不了写锁问题。后来我们用了两个方案组合解决。第一个是拆子账户把单一热点账户拆成多个子账户每个子账户有不同的余额段请求按某种规则分散到不同子账户写锁压力自然摊开。第二个是合并扣减把高频的小额扣减请求先放到内存队列积攒到一定阈值后再批量更新数据库。这两个方案各有适用场景拆子账户适合金额分散的请求合并扣减适合大量小额重复扣减。实际项目里两者都用了。这种问题的核心思路就是不要把所有写压力压在一行数据上想方设法把它打散。5.4 风控规则误伤真实用户风控拦得太多会伤害体验拦得太少会带来资损这个度很难拿捏。我们有一段时间在大促前临时加严了交易频次规则结果上线当天一大批正常用户的连续多笔支付被误拦客人投诉瞬间爆表。排查的时候风控平台显示拦截量是之前的五倍一眼就知道是阈值设置不合理。但阈值调回原值就安全了吗不一定因为大促这种场景的支付频次天然比平时高。我们最后的解法是规则分层高阈值硬拦截用于明显攻击特征低阈值软拦截只是进入人工审核队列由风控专员判断。这样既不会放过可疑交易也不会误伤正常用户。另外一个经验新规则上线之前先用全量历史交易回放看看它对过去一个月真实交易的影响。这个回放工具花不了多少时间但能提前暴露误伤范围比上线后回滚好用得多。我建议所有做风控规则的团队都把这个回放机制做成标准流程。结尾这套financial-services项目前后经历了几个大版本有些坑是踩了才会长记性。最后分享一个我觉得最值得记住的经验资金类系统的第一优先级永远是“可解释性”——每一笔钱的变化都要能在流水里讲清楚来龙去脉。技术选型、代码设计、甚至排障方式都要围绕这条主线来做。还有一个小技巧新上线一个支付渠道时前三天拿小额真实交易跑同时手动核对每天的渠道对账文件直到连续三天账实一致再逐步放量。这个土办法看着笨但在各种新渠道上帮我挡掉了至少三次潜在的资金事故。
返回列表