1. 什么样的支付系统,才当得起“炉火纯青”这四个字
1.1 先讲一个让我印象深刻的评审现场
前几天团队空降了一位新同事,前阿里P8,来之前我就听说了,来之后第一周基本没怎么吭声,第二周开始接我们拖了半年的支付系统重构方案评审。
本来我做好了心理准备——这种背景的同学,多半会从高并发、分布式、微服务治理这些大词讲起。结果他在会议室里关了投影,拿了一支白板笔,直接画了三张图:一张是“业务订单、支付单、渠道单”的三层单据模型,一张是支付状态机的流转图,一张是“账务记账 + 对账异常处理”的闭环流程。三张图画完,会议室里安静了大概五秒钟,然后负责支付和账务的同事开始疯狂追问,那个气氛比平时技术分享会还热烈。
那几分钟让我特别有感触。所谓“炉火纯青”,真的不是指用了多牛的技术栈、挂了多少个节点,而是指对支付业务本质理解到位——资金怎么流转、状态怎么收敛、账怎么算平、异常怎么自动恢复。这几个问题想透了,系统自然优雅;想不透,堆再多设计模式也是白搭。很多人以为支付系统难在“高并发”,其实难在“把复杂资金场景的所有分支都收敛到可控范围内”。
1.2 一套优雅的支付系统,通常有四个共同特征
我用这些年踩坑的经验总结了一下,凡是被称得上“优雅”的支付系统,基本都有四个共同特征。
第一个是职责清晰。接入层归接入层,支付核心归支付核心,渠道层归渠道层,账务归账务。每层只干一件事,谁也不能跳过分层直接改别人的数据。很多团队的支付系统为什么越改越乱?我见过最多的场景就是:业务系统为了赶进度,直接往支付订单表里插数据、改状态,几天内看着很爽,一个月后整个数据关系就变成了一笔糊涂账。
第二个是状态可控。支付单的状态不是随便改的,必须走状态机。不允许跳状态、不允许回退、不允许绕过检查点。哪怕线上紧急修复,也得通过标准接口、标准流程来推进,而不是让DBA连上数据库直接update。现场那位P8说过一句话我记得很清楚:“状态回退是支付系统的癌症早期症状,一次回退看着没什么,一年后就是晚期。”
第三个是可对账、可追溯。每一笔支付,无论成功还是失败,都要留痕。系统必须能回答三个问题:这笔钱从哪个业务来?在哪个渠道出去的?渠道最终回传的结果是什么?这三个问题回答不了,资金安全就无从谈起,出了问题也没法定位。
第四个是异常兜底。回调丢失、超时、重复通知、渠道挂掉,这些不是意外,而是常态。优雅的系统不会把“人工介入”当作第一手段,而是用补偿任务、对账任务、重试机制去自动收敛,人工只需要处理极少数真正异常的工单。一套上线后天天需要人工改数据库的支付系统,再好看也不是优雅,而是定时炸弹。
这四个特征,我后来总结成一句话:把简单留给业务方,把复杂留给自己,把确定性留给资金。这也是为什么同样是做支付,有人能用两张表撑两年,有人用三套微服务还是天天出事故——差的就是对这个行业本质的理解深度。
2. 从那份让全场安静的设计里,拆一套核心架构
2.1 三层单据模型:业务订单、支付单、渠道单
他画的第一张图,是“三层单据模型”。我把这套模型消化完之后,最大的感受是:很多团队一开始就把关系搞乱了,才会在后面越做越痛苦。
先说最常见的反面案例。早期自研支付系统,很多团队就是“一张订单表走天下”:订单表里带上支付状态、支付渠道、支付流水号,用户下单后直接在同一个表里把状态改成“已支付”。这样做最直接的后果是什么?等你想支持部分支付、拆单支付、多渠道组合支付的时候,订单表根本扛不住。更麻烦的是,订单表和支付流水一旦混在一起,每次状态变更都要锁订单记录,并发一上来就是锁等待。
而现场这位P8给的方案很干净,把三个生命周期完全不同的东西拆开:
业务订单只关心业务本身:买了什么、数量多少、金额多少、要不要退款。它不关心用户是用微信还是支付宝付款。交易单/支付单才是连接业务和支付的那根线。一笔业务订单可以对应多笔支付单,比如定金一笔、尾款一笔,或者一笔订单拆成两期支付。支付单记录的是“本次支付应收多少钱、当前处于什么状态、关联的是哪笔业务订单”。渠道单才是真正和外部通道打交道的地方。一笔支付单发起过程中,可能因为超时、重试,对同一个渠道发起多次请求,每一次请求都是一条独立的渠道单。
这三层对应的是三个独立的数据模型、三套独立的状态机。业务订单达到了,尾款支付单可能还在进行中;支付单显示支付完成了,渠道单可能还在等回调确认。三个状态如果混在一张表里管理,互相污染,根本没法支撑复杂业务。
我在不同团队里推行这套模型时,大家最容易问的一个问题是:渠道单到底要落到什么粒度?我的建议是:每一次向渠道发起的独立请求,都落一条渠道单。比如同一笔支付单请求超时后重试了三次,那就是三条渠道单,主单号相同但渠道侧的唯一请求号不同。这样任何时候要追“我们到底和渠道说了几次话”,都能查得清清楚楚。
2.2 支付核心和渠道层解耦:只定义接口标准,让适配器去落实
第二张图里,打动我的地方在于“渠道层”的处理方式。
P8在设计里明确了一点:支付核心层绝对不允许直接引用任何一家渠道的SDK。核心只定义标准接口,比如“发起支付”“查询订单”“申请退款”“退款查询”,然后由渠道适配器去实现。这样做好处很多,往深了讲有三点。
第一,渠道方改接口、升级SDK、调整签名逻辑,永远是适配器的事情,核心代码完全不受影响。我见过一些团队把微信支付SDK直接粘在核心服务里,结果微信一升级SDK,整个支付核心都要跟着发版,风险大、节奏被动。第二,因为核心依赖的是自己定义的接口,单测就可以完全脱离真实支付环境去跑,用mock就能模拟各类渠道返回结果,不需要每次测试都真花一分钱。第三,新增渠道的成本被压到最低:写一个适配器,走一遍联调,配置路由规则,完事。核心代码一行不用动。
渠道层里还有一个容易被忽略的模块——路由。每笔支付单在发起前都会走到路由层,路由层根据一组可配置规则,决定最终走哪个渠道、哪个通道。路由规则不只是“谁便宜选谁”,我实际做过的系统里,常被用到的维度包括:渠道当前可用性、渠道对业务类型的支持能力、单笔限额和日累计限额、渠道费率、灰度批次、用户指定的支付方式等等。
路由层落地时,一般是做成规则引擎或者策略模式,规则调整靠配置下发,不需要发版改代码。我自己的习惯是:把“渠道挂了自动摘除”这条规则放在最前面,一个渠道出现大量超时的时候,路由自动把它降权,别让流量死磕一个不健康的通道。这比事后告警再手动切流量要靠谱得多。
2.3 账务、清算、对账:为什么这三件事必须绑在一起设计
第三张图,是这次评审里我认为最值钱的一张:账务、清算、对账必须从一开始就绑在一起设计,不能拆开,也不能拖到二期。
很多自研支付的团队,第一版都是订单状态变“已支付”就结束了,根本没有账务层的概念。但现实是:你在系统里看到“已支付”,和钱真正到你结算账户,中间隔着一大段路。用户付款成功只是第一步,渠道侧接着要完成资金冻结和结算,结算又是T+1或者T+0到账。这个链路里任何一个环节出偏差,都需要有数据去对、有流程去纠正。账务系统承担的核心职责,是把每一笔钱的来龙去脉精确记录下来。
账务设计里最有名也最重要的原则,就是“有借必有贷,借贷必相等”。不管是一笔收款、一笔退款、一笔手续费,都要在账务流水里体现为至少两条记录,方向相反、金额相等。很多程序员一听记账就觉得是财务的事,但这条原则恰恰是技术系统里最可靠的“数据一致性校验器”。比如某笔退款被重复执行了两次,账务流水立刻就会出现借贷不平,系统马上能报警。
清算模块负责把需要结算给商家、渠道的资金算清楚,生成结算单。对账模块则承担“本地流水 vs 渠道账单”的比对。把三者绑在一起,本质是建立一套资金闭环:业务支付产生流水 → 账务记账 → 清算生成结算单 → 对账发现差异 → 差异进入差错处理队列。
我后来在不少团队里,都建议他们把对账任务放在第一版就上线,哪怕先只做最简单的每日汇总比对。因为资金类系统最怕的不是出问题,而是出了问题没人知道。等到月底财务来问“这个月手续费怎么差了两万”的时候,你再去翻日志翻流水,已经是事故级别了。
这整个架构里其实没有特别玄乎的新技术,但每一条都打在支付系统最容易出问题的点上。看完这套方案,我心里冒出来的一句话是:真正的优雅,是先把事情想简单、想清楚,而不是把架子搭得花里胡哨。
3. 藏在细节里的功夫:幂等、状态机、资金账、对账引擎
3.1 幂等设计:支付系统最容易翻车的细节,没有之一
聊支付系统的技术细节,幂等是绕不开的第一课。它在支付系统里的重要性,怎么强调都不为过。我见过太多线上事故,最后查来查去,根因就是“重复请求没有被拦住”。
什么是幂等?简单说就是:同一个操作,执行一次和执行一万次,结果必须一样。拿支付来说,用户手一抖点了三次“立即支付”,系统应该只创建一笔支付单,而不是三笔。这个需求听着简单,但很多系统的实现是不过关的。
实现幂等最常见也最可靠的方式是数据库唯一键。比如支付单表里对“支付请求号”加唯一索引;创建支付单时,先尝试插入,插入成功就继续走正常流程;插入因为唯一键冲突失败,就转而查询已经存在的支付单,返回旧单。这种方式的好处是,不依赖分布式锁、不依赖Redis,数据库本身就能兜住底。
但这里有个特别容易被忽略的细节:唯一键要设计在“业务上唯一”的字段上,而不是随机的流水号上。比如用户下单场景,“用户ID + 业务订单号 + 支付方式”才能构成业务上唯一的请求标识。如果你唯一键用的是一把random字符串,那它只能对“这一次点击”唯一,用户重复点击,照样能创建出多笔支付单。
另外还有一个重要原则:幂等不只要对“创建支付单”保证,也要对“回调处理”保证。渠道回调因为网络原因重发多次,是家常便饭。每一次回调都应该走同一个处理逻辑,最后只成功入账一次。这块如果偷懒,就会出现用户付了一次钱,系统给账户加了两次余额的事故。这种事故一旦发生,影响的不只是技术口碑,还有赔偿和合规成本。
3.2 状态机:把支付状态的所有变化都关进笼子里
支付系统里,状态管理是另一个大难点。如果不做约束,支付单的状态就会像散养的家禽:今天有人支持从A跳B,明天有人加一条“从成功回退到待支付”的路径,后天又来一个“失败之后还能手动改成成功”的暗门。最后整个系统谁也不敢动,因为没人知道改一个状态会连带到什么。
现场那位P8的做法,是把状态机定义成一份独立的配置,并用代码强制校验。支付单只能按预定义的状态机流转。一条典型的支付单状态路径是这样的:待支付 → 支付中 → 已成功;待支付 → 支付中 → 已失败;待支付 → 已关闭;已成功 → 退款中 → 已退款。
这套设计里有三个细节,值得展开说说。
第一,不允许回退。已经成功或者失败的支付单,不允许再改回“支付中”。如果渠道后来通知你这笔支付实际是失败的,你要做的是基于“已成功”单发起撤销或退款,而不是把状态改回去。回退操作是所有状态管理里最危险的动作,它会破坏掉之前所有对账数据的假设,能不碰就绝对不碰。
第二,每个状态变更都要落流水。状态变更记录表里要写清楚:从哪个状态、到哪个状态、由什么动作触发、操作人或者系统是谁、具体时间、原始上下文。有了这张表,任何一笔状态异常都能回溯操作现场,而不是只能看到最终结果。
第三,状态机要有全局出口。“支付中”这种中间态不能永远停留,必须有超时关闭或对账兜底任务,把悬空单收敛到终态。我在项目里比较常用的做法是,支付中超过30分钟还没有收到渠道结果,就触发主动查询;超过2小时仍未确认,就进入异常工单队列,由人工介入处理。
实际落地时,很多团队都用枚举加switch硬编码状态流转,短平快,前期很爽。但状态一多就会变成噩梦。我建议状态机单独抽出来做一个模块,用配置表维护。这样产品经理过来说“我要加一个待确认状态”,你改配置就行,业务代码一行不用动,安全性也高很多。
3.3 分布式事务:能不用就不要用,别拿“最终一致”当挡箭牌
谈到支付系统,很多人的第一反应是:“是不是得上分布式事务?”“要不要引入消息队列保证最终一致性?”
这里我想说一句不中听的大实话:在支付系统里,强一致事务永远是奢侈品,能局部就局部,不要动不动就跨服务开一个大事务。
举个例子,一个最简单的下单支付流程,涉及订单服务、支付服务、账务服务。如果三个服务都要在一个事务里,就需要分布式事务管理器,复杂度、故障面、锁冲突、性能损耗一起上来。而真实场景里,用户付款这个动作,其实只需要先保证“支付单状态正确”和“账务流水正确”这两个本地事务。至于订单状态更新,完全可以异步订阅支付结果来推进,不需要和支付强一致。
当然,“最终一致性”这个词已经被讲烂了,很多人还有个误解,以为最终一致性就是“随便延迟、随便对不上账,反正后面能补”。这是完全错误的。最终一致性的前提是:要有闭环、要有补偿、要有对账。你必须能证明数据最终会收敛到正确状态,而且有代码和任务来保证这个过程自动发生。如果没有这些兜底机制,那不叫最终一致性,叫“永远不一致”。
比较稳妥的设计是:核心的资金状态变更(支付单状态加账务流水)放在同一个本地事务里;跨服务的数据同步,通过可靠的MQ或者事件驱动完成;同时必须配套对账任务定期扫描异常场景。比如支付成功但订单没变成已支付,对账任务要能发现并自动补齐,而不是干等业务方来报障。
3.4 资金账与流水的设计:每一分钱都要有明确的去处
接下来单独聊一聊资金账。很多团队的资金流水,其实就是一张“事件日志表”,记录一下“用户付了100块”。但如果只有事件日志,没有把“这笔钱进哪个账户”“这笔钱对应的借贷方向”理清楚,后面做清结算、做对账、做退款都会非常吃力。
我比较推荐的设计是“账户 + 流水”双模型。账户是余额的事实来源,流水是余额变化的凭证。用户账户上有余额,每发生一笔交易就写流水,流水表记录账户余额在这一笔操作前后的变化。加余额和减余额都通过标准接口进行,任何业务代码都直接不允许update余额字段,只能调用账务接口,避免有人绕过流水偷偷改数字。
流水表里至少要有这些字段:流水号、账户ID、变动金额、变动前余额、变动后余额、业务单号、业务类型(支付、退款、手续费、调账)、发生时间、关联的支付单ID。有了这些字段,任何一个余额数字都能追根溯源,哪里不平一查就通。
还有一条设计原则,做支付的人应该都听过,但我还是想再强调一次:金额一律用“分”为单位的整数存储,不要用浮点数。浮点数在大量加减运算中会出现精度问题,支付系统里一行double算错,很可能就是一次资金事故。用整数存储、整数运算,能帮你挡掉90%以上和金额相关的坑。
4. 实操落地:从0到1搭建一套支付系统的实践路径
4.1 领域建模先行:把边界彻底划清楚
如果让我从零开始设计一套支付系统,我第一件事绝对不是建表,也不是写代码,而是先把领域模型画出来。
要画的图就三张。第一张是流程泳道图:从用户点击支付按钮开始,到支付成功,中间经过哪些系统、每个系统各自做什么。第二张是对象关系图:业务订单、支付单、渠道单、账户、流水、退款单、对账任务之间的关系。第三张是状态机图,把支付单、退款单、结算单的状态流转完整列出来。
这三张图画完,基本就能看出设计的边界在哪。画的过程中,要反复问自己两个问题:某个对象是不是承担了太多职责?某个状态是不是被两个地方同时修改?这两个问题能过滤掉大部分后续注定返工的设计。
我的实践经验是,画领域模型的阶段,一定要拉上产品、财务、运营一起过。财务会告诉你手续费什么时候计入,运营会告诉你退款是不是可以退到任意渠道,产品会告诉你有哪些组合支付的场景。这些业务规则都会直接影响模型。等到代码写完了再补业务规则,成本是前期确认的十倍不止。
4.2 表结构设计的取舍:唯一索引、状态字段、余额字段
领域模型确定之后,进到建表阶段,有几个核心表的设计心得可以分享。
支付单表:主键ID自增;业务唯一键用“支付请求号/req_no”,必须加唯一索引;状态字段用tinyint表示;关键字段包括:支付单号、业务订单号、用户ID、支付金额、支付渠道、渠道单号、状态、创建时间、更新时间。这张表会频繁查询,索引设计一定要提前想好,尤其“商家ID + 创建时间”这类业务查询组合。
渠道单表:主键、支付单号、渠道类型、渠道请求号、渠道状态、请求参数和响应结果的JSON冗余、重试次数、回调时间等。渠道单需要按“渠道侧请求号”建唯一索引,防止同一次渠道请求被重复记录。把渠道的原始请求和响应JSON存下来是非常重要的一件事,后面排查任何渠道相关的问题都靠它,别省这张“日志表”的空间。
账户流水表:唯一索引要建在“业务请求号 + 账户ID”上,保证同一笔业务只能写一次流水。流水号建议单独生成,不要用自增主键对外透出,因为流水号在很多场景里是要给用户和财务看的,用ID自增容易泄露业务量。
这里我专门提醒两点。第一,不要在状态字段上单独建普通索引然后频繁查询“所有成功订单”,状态字段区分度差,查询效率会很难看;正确的做法是结合时间范围做组合索引,或者单独维护一张事件表。第二,支付单表和渠道单表的关联,一定要用“支付单号”,不要用“业务订单号”,因为一笔业务订单可能对应多笔支付单,用业务订单号关联渠道单,数据关系会乱。
4.3 通知、补偿与对账:把系统的自愈能力放进骨架里
系统骨架里,有两件看起来不起眼、但决定系统长期稳定的事:通知重试机制和对账补偿任务。
通知机制解决的是“支付结果如何让业务方知道”。支付系统应该定义标准的通知接口,支持业务方以webhook形式订阅支付结果。通知不是发一次就结束,要设计重试机制:第一次失败后隔多久重试,最多重试多少次,超过上限之后进入待人工处理队列。这些参数不是拍脑袋定的。我在实际项目里常用的配置是:间隔1分钟、10分钟、1小时、12小时,最多重试4次。这样既不会频繁打扰业务方,也能在多数场景下完成最终投递。
对账补偿任务,核心职责是识别“那些不应该永远停留的中间状态”。比如支付单状态是“支付中”超过30分钟,就要主动查询渠道状态;超过2小时仍未确认,就进入异常工单队列。这类任务不能用一个单机任务扫全表,要把扫描范围按照某个维度分片并行,不然凌晨半夜一个定时任务直接把数据库拖垮,那可不值得。
我在架构上很推崇的一个组合,是“可靠消息 + 定时补偿 + 对账任务”。这三个东西配合起来,能解决支付系统里绝大部分一致性问题,而且实现成本比上分布式事务低得多。很多团队一上来就想着用Seata、用Saga,结果测试环境跑得挺欢,到生产环境一压测就暴露各种问题。支付系统真正长期稳定运行的关键,真不在前沿技术,而在于这些“笨功夫”有没有做到位。
5. 实拍:我在生产环境中踩过的典型支付事故与排查路径
5.1 “支付中”卡死:为什么支付单永远停在中间态
这是我接手过的支付系统里最常见的一类事故。某天运营反馈,一批订单一直停在“支付中”状态,用户说钱已经付了,但订单迟迟没有变成已支付。
排查路径是这样的。先查支付单的“支付中”停留时长,发现这批单大部分超过2小时,于是基本排除正常回调延迟。接着查渠道单表,看有没有对应的成功回调记录——结果发现渠道侧早就回传了支付成功,但本地漏处理了。为什么漏?查消息队列消费日志,发现有一条回调消息消费失败了,而且是重试多次失败,最后没有进死信队列,直接被丢掉了。这事的根因是:回调处理逻辑没有做好幂等,消费失败重试时一直报错,最后消息还丢了。
这个事故的教训有两层。第一,回调处理必须幂等,而且要允许重复消费,不能因为一条消息消费失败就把整个后续链路卡死。第二,消息重试必须配合死信队列,处理不了的消息不能静默丢弃,要进人工队列。另外,靠人工在数据库里update状态的服务,本质上是在给这种事故“续命”,而不是在根治问题。正确做法是让对账任务定期扫渠道单和支付单,发现渠道已成功但本地还在支付中,自动补齐。
5.2 重复回调:用户只付了一次款,系统却给加了两笔余额
这是我经历过后果最严重的一次事故,直接导致公司赔偿了用户一笔钱。场景是用户通过某渠道付款100元,渠道因为内部重试机制,把回调消息发了两次。第一次消费成功,支付单状态改成已支付,账户加了100元。第二次消费时,支付单状态已经是已支付,但代码里没有做“状态判断”,照样去账务系统加了一次钱,账户瞬间多出100元。
说实话,这种问题出现的概率不算小。很多系统的回调处理逻辑是写在前面的,后面因为需求迭代,有人改过这段代码,把“幂等处理”给覆盖掉了。从那以后,我对“回调处理”模块有一个特殊要求:代码合并必须过一份幂等检查清单,凡是涉及资金变动的接口,都必须有“状态预检”和“唯一流水约束”双重保障。状态预检指的是看到已经终态的支付单,直接返回不再处理;唯一流水约束指的是账务流水表那边还有一层唯一索引兜底,就算代码被改坏了,数据库也会挡住重复入账。
这个案例我还想多说一句:别相信代码评审能发现所有问题。人的注意力是有限度的,把关键约束放在数据库层面,让它成为谁都无法绕过的底线,才是资金系统最可靠的做法。
5.3 对账不平:差一分钱的排查,比写一天代码还累
对账是支付系统里最繁琐、最磨人的环节,尤其当对账结果出现“不平”的时候。
有一次我们的系统对账差了一分钱。听起来只是“一分钱”,但资金系统不允许有这种模糊地带。排查一线:先看筛选出来的差异单子,发现多为渠道手续费,和本地记录的手续费不一致。再看手续费计算逻辑,渠道侧是按比例收取,但会四舍五入到分,而本地系统是按“总额乘以费率后再四舍五入”计算的。同一个订单,因为四舍五入基数不同,最后差了1分。
这类问题的本质是“金额计算规则不统一”。排查之后,我们做了一件事:把所有费率计算规则集中在同一个算法模块里,任何地方要算手续费,都调用同一套函数,不允许各写各的。同时,在账务系统里增加了一个“调整项”科目,用于处理渠道结算后的微小差异。对账发现不平后,先把差异记录归到调整项,再走差错处理流程,而不再直接把异常单挂起。这套机制上线后,对账异常处理速度至少提升了一个量级。
5.4 退款误判成功:最怕你以为退了,其实没退
最后一个案例,是退款状态误判。当时用户申请退款,渠道侧因为系统异常,退款请求真正结果未知。本地系统在一段时间后收到了“退款成功”的消息,就把退款单状态改成了“已退款”,同时解冻了用户资金。但随后渠道对账发现,这笔退款在渠道侧实际上是失败的,资金原路退回给了用户。因为本地状态已经是“已退款”,这个不一致没有被任何系统发现,直到用户投诉说“钱没回来”。
这个问题的根因还是没有把“本地退款单状态”和“渠道实际退款结果”严格绑定。退款和支付一样,属于资金动作,必须等渠道明确结果才能改终态。渠道没有明确回结果之前,本地最多只能把退款单置为“退款中”。
排查路径上要给对账任务增加一个“反向核对”维度:不只检查支付单,还要检查退款单。凡是本地显示已退款、但渠道账单里没有对应的成功退款记录,都是重大异常,必须立刻进入人工工单。我们后来在退款模块里增加了一条硬性规定:退款单的终态,只能由渠道明确回结果触发,任何超时场景下,宁可多查几次,也不能靠猜把状态改成成功。
支付系统就是这样一个领域,看起来业务规则很简单,无非是付款、退款、查单、对账,但每一个细节背后都可能藏着一笔真金白银的风险。我始终觉得,做支付系统的人,心里要有一根弦:每一行代码,最后都对应着一个真实用户的钱。想明白这一点,你自然会敬畏那些“笨功夫”——幂等、状态机、对账、补偿,这些才是支付系统真正的护城河。