ARTICLE DETAIL

资讯详情

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

金融级系统的确定性设计:从幂等、对账到分布式事务的工程实践

金融级系统的确定性设计:从幂等、对账到分布式事务的工程实践 金融服务这个领域平时聊的人很多但真正把它当成一套系统来建设的人聊着聊着就会碰到同一个问题到底什么才算金融级。不少人以为金融级就是高并发、大流量、性能压测顶得住实际上这只是冰山一角。我过去几年一直在做金融业务的技术支撑从最开始的交易链路搭建到后面资金对账、差错处理、合规审计一路踩过来最大的感悟是金融服务的技术核心不是把系统做快而是把系统做确定。这篇文章就围绕 financial-services 这个主题把我积累的实践经验拆开讲清楚尤其是那些文档里不会写、但实际运营中一定绕不开的环节。1. 金融服务的技术边界从一张支付凭证说起金融业务里有一个很朴素但极其重要的概念叫资金凭证。我们平时看到的一笔支付成功在前台可能就是几行页面提示但在系统里它代表着一次账务状态的变更、一条不可抵赖的记录、一组参与方之间的债权债务关系变化。金融系统的技术边界就是从这一张凭证延伸出去的。1.1 凭证的生成链路与幂等设计很多人第一次接触金融服务最先遇到的就是怎么保证同一笔请求不会被重复扣款。网上支付、转账、代扣随便一个场景客户端超时重试、网关重发、用户手滑多点了一次都会触发重复请求。如果系统不做幂等保护用户余额就会莫名其妙变少投诉立刻就来。我早期做过一个代扣项目上线第一周就出了单边账故障。排查到最后根因就是请求在网关层重试时服务端没有统一的幂等键校验结果一个扣款指令被连续执行了两次。后来我们重新设计了完整的幂等方案规则说起来其实不复杂每个交易请求必须携带全局唯一的幂等键通常由业务单号加操作类型拼接而成服务端收到请求后先查幂等表存在就直接返回上一次的处理结果不存在才继续执行幂等表的写入必须和业务操作放在同一个本地事务里避免并发穿透这里有一个关键细节幂等表不是随便插一条记录就行。如果两个请求同时到达数据库层面需要通过唯一索引来兜底否则两个请求都查到不存在然后都去执行业务操作照样会重复扣款。唯一索引加事务才是真正挡住并发穿透的防线。1.2 记账的确定性借贷平衡不是一句口号金融服务最底层的账务系统通常采用复式记账法。每一笔交易至少涉及两个账户一借一贷金额相等。表面上看起来很简单但在分布式环境下要做到绝对平衡非常难。比如用户从A账户转100元到B账户A账户减100B账户加100这两个动作必须保证同时成功或者同时失败。有的团队试图用数据库本地事务解决单库场景当然没问题。但业务量上来之后库要分片A账户和B账户很可能不在同一个库分布式事务的问题就暴露出来了。我们当时用的方案是本地消息表对账兜底。核心思路是主交易单先落库状态标记为待处理然后通过消息队列异步通知下游记账服务去完成借和贷的动作。消息发出去了下游可能成功也可能失败所以还需要一个对账任务定期扫描状态不一致的记录自动或人工干预处理。这套方案很多团队都在用。但我想强调的是不要把最终一致性当成随便延迟的借口。交易主链路和记账动作虽然解耦但监控必须跟得上消息积压量、处理失败率、对账差异数每一个指标都要有实时告警。否则等到日终结算时才发现账不平就很难定位到底是哪条链路出了问题。我见过有团队半年没看过对账明细等监管检查时拉出几千条差错记录场面一度非常被动。2. 资金链路的高可用设计不是堆机器就行金融服务最怕的是什么是系统不可用。尤其到了月底、节假日、促销日流量一冲系统抖一下用户感知到的就是支付失败转账迟迟不到账。外部用户不会关心你是不是数据库抖动、负载偏高他们只认结果。资金链路的可用性设计和普通互联网业务有很大区别核心差异在于资金状态不能乱链路不能断。2.1 容量规划扩多少台机器才够刚接手金融系统时我和很多人一样觉得高可用就是多部署几台机器加个负载均衡。后来经历了一次大促压测才发现根本不是这么回事。压测过程中系统吞吐量一上来数据库连接池先耗尽紧接着应用层全线超时。加机器确实能提升应用层的处理能力但如果下游的数据库、缓存、风控接口成了瓶颈加再多应用节点也只是把压力更快地传导到下游。容量规划的核心是找出整条链路上最薄弱的环节然后针对性地做优化。支付系统里常见的瓶颈点包括数据库连接数和事务吞吐量缓存热点 key 的访问压力外部渠道接口的限流阈值消息队列的生产消费速率我们团队的做法是每季度做一次全链路压测专门针对大促峰值流量的80%、100%、120%三档进行摸底。每一档都要观察核心接口的RT和错误率同时盯住数据库慢查询和连接池水位。压测不是为了完成报告而是为了找到那个再往上就要出事的临界点然后提前扩容或削峰。2.2 降级与限流保障核心账务不被拖垮金融系统里不可能所有功能在峰值时都保持完整可用。设计阶段就要想清楚哪些可以做降级哪些绝对不行。比如用户查询历史账单属于相对低优的读服务缓存顶不住时可以适当降级返回基础信息即可而扣款、入账、清算这类写操作属于核心账务绝对不允许随意降级因为一旦资金记录丢失或错乱后果比服务不可用严重得多。限流也需要分层设计。接入层要按客户端AppId限流应用层要按接口维度限流底层还要针对关键账户做单独的额度保护。为什么要针对账户做保护因为热点账户比如电商平台的商户收款账户可能同时被海量交易写入单账户并发过高会导致数据库锁竞争激烈拖慢整个链路。我印象很深的一个教训是有一年做积分兑换活动某款热门商品的商户账户成了热点账户瞬时并发写入量暴增余额更新语句不断锁等待最终把核心账务库的连接池打满了。后面我们在应用层加了一层热点账户合并更新的机制先把同一账户的多笔更新请求合并到内存队列中按顺序批量刷新到数据库。这样既保证了账户余额不错乱也大幅降低了数据库压力。这个优化的效果非常明显线上再没出现过同类问题。3. 数据一致性分布式事务在金融场景的真实解法分布式事务是金融服务绕不开的硬骨头。前几年我们团队花了很多精力在这个问题上尝试过各种方案包括两阶段提交、TCC、SAGA、事务消息。每种方案都有适合的场景选错了付出的代价就是无尽的补数据、修数据、赔用户。3.1 两阶段提交为什么不适合高并发支付两阶段提交2PC在教科书里很经典但在金融支付场景里除非你全部服务都在同一个数据库实例上否则几乎不会有人直接用。原因很简单2PC的协调者一旦在某个阶段挂掉所有参与者都会一直持有锁等待整个系统的吞吐量瞬间崩塌。金融系统要求7x24小时可用这种强一致方案在分布式环境下过于脆弱。我在项目初期也尝试过基于XA协议的分布式事务当时用的是开源框架结果线上遇到一次协调者重启直接卡了几千笔在途事务手工排查到凌晨才恢复。那次之后我们彻底放弃了强一致路线转而拥抱最终一致性。3.2 SAGA与TCC的取舍TCCTry-Confirm-Cancel适合什么场景适合那些资源预留成本低、业务方愿意配合的流程。比如账户冻结、退款预占。SAGA则更适合长流程、多步骤的业务比如跨行转账扣款、报文发送、对方入账每一步都有对应的补偿操作。我们最终的落地组合是核心账务走本地事务消息表保证绝对的记账一致跨系统长流程用SAGA每一步做好对应的补偿操作涉及外部资金冻结的场景用TCC的Try阶段锁住资源Confirm阶段真正扣减。这个组合看上去没有统一的技术栈但胜在灵活每一个场景都能找到最匹配的模型。还有一个很实用的技巧补偿操作一定要记录完整的原始上下文。SAGA的补偿动作比如退款必须知道当初扣款的金额、渠道、账户、请求流水号任何一个字段缺失补偿都可能失败。我们在设计补偿表时把所有关键字段快照放进去宁可冗余也要保证补偿动作能独立完成。3.3 消息最终一致性的兜底前面提到本地消息表这里展开说一说。生产者把消息和业务操作放在同一个本地事务里事务提交后消息才真正可见然后靠一个定时任务把消息状态从待发送推进到已发送。下游消费者处理成功后回调更新消息状态为已完成。这套方案的坑在哪里在于消息状态不能只推进一次必须有周期性的巡检任务扫描那些已发送但长时间未确认的消息重新推送。我们线上环境设置的是每5分钟扫描一次超过10分钟还未确认的消息自动重推超过30分钟的重推次数达到上限后转入人工处理队列。同时消费者必须实现幂等。即使消息被重推消费者收到后要去查业务单号是否已处理如果已处理直接返回成功绝不能重复入账。这两个机制配对使用最终一致性才有了落地的可能。之前有同事在消费者端省了幂等判断自认为消息只会推一次结果网络抖动重投后用户账户被重复入账了两笔。这种事一次都不能出。4. 对账与差错处理被低估的核心防线说到金融服务很多人第一反应是交易系统怎么设计、并发怎么扛但真正决定一个金融平台能不能稳定运营的其实是账能不能对平。对账这件事平时没人关注一旦出了问题就是资金风险事件。4.1 日终对账的基本盘对账的基本逻辑很简单把平台内部的账务流水和外部渠道银行、支付机构返回的清算流水逐笔核对。核对的内容包括交易金额、交易时间、交易状态、手续费等字段。对账不是只做一次。我们通常做三层第一层是渠道对账比对平台和银行/支付机构之间的交易明细第二层是内部账务核对检查会计总账和各分户账余额是否一致第三层是资金核对确认银行账户里的实际资金余额和账面余额相符。任何一层的差异都必须在规定时间内查明并处理。很多人觉得对账就是跑个批处理拿两份文件一比对输出差异就结束了。实际上对账批处理的设计远没这么简单。比如渠道文件可能因为银行系统问题延迟几个小时上传对账程序必须支持重跑和断点续跑渠道文件的格式也各不相同有的按天、有的按小时有的还是多语言编码解析模块要做好适配。4.2 差异处理的完整链路我来写一个对账差异从发现到关闭的典型流程。第一步发现差异。对账程序会把差异数据输出到差错表中包括平台单边、渠道单边、金额不一致、状态不一致等几类。第二步自动判重。部分差异是时间差导致的比如平台已入账但渠道清算文件还没包含这笔记录这种通常等次日文件到了再核对一遍就能自动消除。第三步人工干预。确实无法自动消除的差异进入人工处理工作台由运营人员逐笔查看原始报文确认到底是哪一方的问题。第四步账务调整。如果是平台记账错误要走内部调整流程生成冲正/补账凭证如果是渠道问题需要提交工单给渠道方核实等待对方回复后更新差错状态。这一整套流程必须留下完整的操作日志。差错记录、操作人、处理时间、调整凭证号、备注信息一样都不能少。监管检查时最常看的就是这个。4.3 单边账的根因与防范单边账是最常见的对账差异类型指一方有记录另一方没有。比如用户银行卡扣款成功但支付平台没有生成订单或者支付平台显示交易成功但银行扣款失败。根因通常是通信超时平台已经提交扣款请求但没有及时收到渠道的最终响应于是本地把交易标记为失败实际上渠道已经扣款成功。防范单边账的手段非常多核心是两条。第一渠道通知机制必须完善。异步回调、主动查询、批量核对三管齐下确保每一笔交易的最终状态都能被平台感知。第二超时订单不能直接置为失败。我们通常将超时未收到响应的订单置为处理中由后台任务去渠道查询最终状态再做后置处理。有用户会说处理中体验不好其实金融业务里处理中是诚实且安全的状态总比失败或成功的错误展示好得多。我在这个原则上的体会特别深宁可让用户体验一个中间的等待状态也不能给他们一个错误的结果——因为这个结果直接关联到他们的真金白银。5. 合规与审计让每一笔流水经得起倒查金融服务的合规性要求比一般业务高好几个量级。合规不是法务部门单独的事技术系统必须从架构上支撑全程留痕、可追溯、可审计这几个基础要求。5.1 审计日志的最佳实践审计日志和普通业务日志最大的区别在于普通日志是为了排查问题审计日志是为了还原事实。审计日志的关键要素包括操作人、操作时间、操作类型、操作对象、操作前后值、来源IP、会话ID等。当一个操作涉及资金状态变化时必须记录变更前后的余额快照。审计日志的存储也需要单独考虑。不能和应用日志放在一起更不能写到本地文件然后定期清理。我们采用的是独立的审计日志库按天分表保留期限至少3年以上按监管要求执行。审计日志的写入链路要独立于业务主链路避免业务高峰期影响日志完整性。这里还要提一个细节审计日志坚决不能修改只能追加。即便运营人员发现日志内容写错了也只能新增一条更正记录不能原地修改原日志。否则一旦被质疑日志可信度整个系统的公信力都会受影响。5.2 敏感操作的双人复核金融系统里运营人员操作权限必须分级。涉及资金调整、用户信息修改、商户费率变更等敏感操作仅有一名操作人员是不够的必须引入双人复核机制。实际落地中我们用的是发起复核两级流程操作人提交工单后复核人看到的是完整的操作对象和操作内容确认无误后点击通过操作才真正生效。这个机制看似增加了流程消耗但至少挡住了两类问题一是单人误操作比如手滑输错金额二是内部舞弊风险。双人复核的每一次操作和审批也要写入审计日志形成完整的证据链。权限管理还要遵循最小权限原则。我见过一些系统运营后台一旦登录就是超级管理员权限普通客服都能查到所有用户的资金明细这是巨大的安全隐患。金融服务应该按角色划分权限尽量做到无权不可见、越权必告警。5.3 追踪与分析全链路监控合规审计还有一个技术抓手就是全链路追踪。每一笔交易从进入网关开始到渠道请求、到账务处理、到通知返回每一步都携带同一个交易跟踪ID。通过这个ID可以快速梳理出整条链路上的所有环节、耗时、异常点。这套体系在为监管解释和问题定位时非常有用。以前没有全链路追踪的时候排查一笔异常交易需要在多个系统之间来回翻日志从应用日志看到数据库日志再看到渠道报文往往要花掉几个小时。有了追踪系统点一下即可看到完整路径效率提升了不止一个量级。全链路追踪的落地并不复杂核心就是让所有服务在发请求时自动携带traceId并在日志框架里自动打印。难的是坚持推动所有团队遵循这个规范。有的老系统改造难度大总想跳过但只要有一个系统不配合追踪链就是断的合规出报告时依然要手工拼凑。6. 压测、灰度与故障演练上线前的最后三关金融服务系统的大版本上线不能用开发自测通过作为标准。我经历过几次上线事故之后定下了一个规矩涉及资金链路的变更必须经过压测、灰度、故障演练三关任何一关不过不允许全量发布。6.1 压测不是测极限而是测安全水位很多团队做压测喜欢追求一个漂亮的数字比如单机TPS达到多少多少。但金融业务的压测目标不是把系统打挂而是摸出安全运行的水位线包括核心接口的RT百分位TP99、TP999要达到什么水平数据库连接池的水位峰值时还剩多少连接可用消息积压的恢复时间上游突发写入后队列积压多久可以清空外部渠道的限流余量我们的峰值请求量距离渠道限额还有多少余地压测过程最容易忽略的是数据准备。压测数据不能全用新造的数据因为真实业务里账户余额分布、订单大小分布有明显特征如果用全部小额均匀的数据压测热点账户和长尾数据的问题根本测不出来。我们压测时会从生产环境抽取脱敏后的业务数据尽量还原真实场景。6.2 灰度发布资金链路尤其要小步快跑灰度发布在互联网行业很常见但金融服务里的灰度有自己的特殊性你不能让一部分用户成功、一部分用户失败同一笔交易链路上的多个服务必须同时生效。否则用户请求打到灰度节点而下游记账服务还在旧逻辑极容易出现数据不一致。我们采用的做法是先按内部员工灰度再按白名单商户灰度最后按流量比例逐步放开。灰度期间必须安排专人盯日志和监控尤其是对账差异率和差错率这两个指标一有异常立即回滚。灰度窗口也尽量避开交易高峰期选择凌晨低峰时段进行把影响面降到最低。6.3 故障演练把日常小故障练成肌肉记忆很多人觉得故障演练浪费时间总觉得那个故障不可能发生。但金融服务就是充满了不可能。我们做过多次演练之后最大的收获不是方案本身而是整个团队的响应速度。故障演练的常见场景包括下游渠道接口挂掉、数据库主库宕机、消息队列积压、缓存集群不可用、核心账务接口超时等。演练时预案里的第一步应该是确认故障影响面第二步才是启动降级或切流。这里有一个很现实的教训有一次演练模拟数据库主从切换团队有人按流程执行了切换但没注意到某张业务表有跨库的外键依赖结果切换后部分数据写入报错。如果不是在演练环境里发现问题放到生产线上就是一次长时段故障。故障演练之后一定要做复盘把演练中发现的预案缺失点补充进文档形成闭环。我个人的原则是预案没有经历故障演练验证就不能视为可用预案。演练中暴露的问题越多越说明这份预案有价值而不是说明演练失败。做完这三关一个金融功能模块才能称得上可以上线。但上线只是开始运营期的监控和迭代才是常态。金融服务这个领域永远不会有做完的那一天每一秒都在和资金安全、用户体验、合规要求打交道。能把确定性做好比任何花哨的技术都更能赢得用户和监管的信任。
返回列表