先交代一下背景。我手上这个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 对账系统:T+1对账与差异处理
对账系统平时默默无闻,但它是资金安全的最后一道防线。我们的对账任务是每天凌晨跑的,把渠道侧的交易文件和本地交易明细逐笔比对。渠道侧的文件一般T+1提供,所以我们也做成T+1。
对账的逻辑不复杂,难的是差异处理。先把本地交易记录和渠道记录都按业务订单号建索引,然后逐笔比对金额、状态、渠道流水号。比对结果分为几类:双边一致,这是最理想的;本地有但渠道没有,说明本地系统可能提前入账了,需要查渠道状态并做冲正;本地没有但渠道有,这种情况比较危险,说明本地漏单了,可能渠道回调丢失,需要补单并落账;金额不一致,多半是手续费或优惠计算有出入,需要人工介入。
对账结果要落表,方便日后追溯。我们建了对账结果表,每条记录标明对账日期、订单号、差异类型、处理状态。只要是差异单,就自动生成告警推送给当班工程师。财务部每天也会看差异报表,他们的要求比我们更细,甚至精确到分。
有一点实操经验值得分享:对账程序本身也要能被对账。什么意思?就是程序跑完之后要输出一个汇总报告,包括本次对账总单数、匹配单数、差异单数。如果哪天汇总报告没生成或者数字对不上,就要怀疑对账程序本身出了问题。我们是加了一个对账任务自检,每天检查前一天的报告是否生成,没生成就告警。这看着有点“自己查自己”的荒诞感,但确实拦住过一次定时任务被误删的故障。
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项目前后经历了几个大版本,有些坑是踩了才会长记性。最后分享一个我觉得最值得记住的经验:资金类系统的第一优先级永远是“可解释性”——每一笔钱的变化,都要能在流水里讲清楚来龙去脉。技术选型、代码设计、甚至排障方式,都要围绕这条主线来做。还有一个小技巧,新上线一个支付渠道时,前三天拿小额真实交易跑,同时手动核对每天的渠道对账文件,直到连续三天账实一致,再逐步放量。这个土办法看着笨,但在各种新渠道上帮我挡掉了至少三次潜在的资金事故。