接手这个支付模块的时候,它已经在生产环境跑了四年多。表面上看,它一直挺"能用"——用户能正常付款,订单状态大致正确,偶尔出点小问题也有人工脚本兜底。但真正开始重构它之后,我才发现"能用"是这个行业里最危险的一个词:它意味着没人在乎状态机是否完整,没人在乎回调是否可靠,更没人在乎重复请求会不会把订单搞成两笔支付。直到某一天线上同时冒出一批"订单已支付但业务没反应"的工单,这个模块的底账才被彻底翻了出来。
我花了两个多月把这块老代码从"能跑就行"改成了"结构清楚、有状态约束、有幂等保护、有对账兜底"的模块,中间踩了不少坑,也总结出五件我认为最值得记住的事。这篇文章偏实战,适合正在接手支付、订单、交易类老模块的人;如果你们团队刚好打算做一次大范围重构,也可以把它当一份反向清单用。这篇我按自己实际操作的顺序来写:先讲动手前做了什么,再讲真正影响可靠性的五件事——状态机、幂等、超时重试与回调、对账、灰度。每一件都是这次重构里真实踩过坑的地方。
1. 动手之前,先把"能用"翻译成具体缺陷
1.1 我接手时的支付模块是什么状态
这个模块的代码不是那种一眼就看得出烂的"屎山",它甚至称得上工整:有分层、有注释、有工具类,核心方法还被人精心封装过。但问题恰恰藏在工整背后。一个支付service压了八百多行,状态字段用的是魔法数字,0表示未支付、1表示已支付、2表示退款中、3表示已退款,偶尔还有地方直接写布尔值表示"是否支付成功"。所有状态变更都是散装赋值,任何方法只要拿到订单对象,就能随手setStatus(1),完全没有前置校验。
更麻烦的是,整个模块对"重复"这件事几乎没有防御。回调处理逻辑里只有一句简单的update pay_order set status = 1 where order_no = ?,不管当前订单处于什么状态,只要支付成功回调来了,就无条件改成已支付。我后来翻git提交记录,看到第一个人写这段代码的时候加过一行注释:"这里需要判断订单状态,否则退款单会被覆盖。"结果三年过去,这行注释还在,判断始终没加上。这种代码就是典型的"能用"——只要所有外部系统都按顺序正常工作,它就不会出问题;可支付这个场景最不缺的就是乱序、重复和超时。
1.2 真正的问题清单来自日志和对账单,不来自code review
我一开始也犯了所有工程师都会犯的错:抱着源码从头读到尾,试图在代码层面找出所有不合理的地方。读了两天,列出来的问题全是风格层面的——方法太长、命名不规范、重复代码多。这些问题改起来舒服,但都不是这次重构要解决的真正的痛。
后来我把思路换了一下,不再盯着代码看,而是去翻线上问题单和日志。我把过去三年的支付相关工单全部拉出来,逐个打标签,再结合每月的对账单差异记录,最后得到一张很扎眼的清单:
| 问题现象 | 出现过的大致次数 | 影响 |
|---|---|---|
| 订单已支付成功,但业务侧没有发货/开通 | 十几次 | 用户投诉,需要人工补单 |
| 用户重复点击支付,生成了两笔支付单 | 七次 | 重复扣款风险,退款流程繁琐 |
| 已退款订单被支付成功回调改回已支付 | 三次 | 资金和订单状态严重不一致 |
| 支付中状态订单长时间卡住,无人处理 | 二十多次 | 用户流失,客服被反复问询 |
| 对账单与本地流水对不上,月底才发现 | 每月都有 | 财务核对成本高,差异难追溯 |
这张清单彻底改变了我的重构优先级。代码再丑,只要线上没出问题,短期内它就不算"必须改";而这张清单里的每一项,都是真实影响用户和资金的问题。后来我给自己定了个规矩:重构任何老模块,先回答"它最近一年在线上捅过哪些娄子",再决定从哪下手。
1.3 把重构范围圈出来,别顺手把整个体系都换了
一开始我是想借着这次重构,把支付模块的底层表结构、对外接口、数据库连接方式全都换掉。还好当时拉住了自己。支付模块和普通业务模块不一样,它牵扯资金、对账、下游通知、第三方网关,任何一个环节出问题都不是发个热修能解决的。
最后我划定了一个很明确的范围:内部支付处理逻辑、回调处理、退款流程、超时任务、对账任务这五块可以改;库存表结构、第三方网关接口签名、对外提供的查询/回调接口,一律不动。也就是说,改的是"内部怎么处理状态和事件",而不是"外部怎么和我们对接"。边界一旦划清楚,风险就变成了可控的:外部系统感知不到我们在重构,真正出问题顶多影响内部状态流转,不会把下游所有依赖全拉下水。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一件事:支付状态机是可靠性的地基
2.1 状态散装赋值,是支付模块最容易爆雷的地方
说句实话,重构之前我对状态机的理解很浅,觉得无非就是"给状态划几个枚举,统一一下取值"。直到我看到那个线上事故:一笔订单先走了退款流程,退款成功回调进来,订单被置成3(已退款);几分钟后,支付成功回调因为网关重试又推了一次,老代码不管三七二十一,直接把这笔订单改回1(已支付)。结果就是财务系统里显示已退款,订单系统里却显示已支付,两边对不上,最后靠人工翻日志才把账平掉。
这就是状态散装赋值的代价。每个方法都能改状态,等于每个方法都有机会改出非法状态。而支付场景里,"非法状态"从来不是理论问题,它会直接变成资金损失或者用户投诉。后来我总结了一句话:状态字段本身不复杂,复杂的是"谁在什么条件下,有资格把订单从什么状态改成什么状态"。这个条件如果没有明确约束,那所有写状态的地方都是一颗雷。
2.2 用状态迁移表收敛所有状态变更
重构时我做的第一件正事,是把订单状态抽象成完整枚举,并且整理了一张状态迁移表。这张表不是画在文档里好看的,而是直接落到代码里的规则:所有状态变更必须经过一个统一的状态机服务,只有状态机允许的迁移才能执行,否则直接拒绝并告警。
| 当前状态 | 允许迁移到 | 触发动作 |
|---|---|---|
| CREATED(已创建) | PAYING(支付中)/ CLOSED(已关闭) | 发起支付 / 超时关闭 |
| PAYING(支付中) | PAID(已支付)/ CLOSED(已关闭) | 支付成功回调 / 超时或用户取消 |
| PAID(已支付) | REFUNDING(退款中)/ CLOSED(已关闭) | 发起退款 / 业务关闭 |
| REFUNDING(退款中) | REFUNDED(已退款)/ PARTIAL_REFUNDED(部分退款) | 退款成功回调 |
这张表看起来简单,但它解决了一个核心问题:任何代码要改订单状态,都必须先回答"我是谁,我凭什么把它从A改成B"。乱序回调来了怎么办?状态机直接拒绝,当前状态是REFUNDED,不允许变成PAID,同时记一条警告日志,告诉我们"有个诡异事件在试图修改订单"。我们不猜它为什么来,但绝不让它得逞。光这一条,就把"已退款订单被改回已支付"这类事故从根上堵死了。
2.3 存量脏状态要平移,不是强制纠正
状态机上线前,我本来想得很简单:把线上所有订单状态一次性对齐到新枚举,跑个SQL改一改就完事。结果一查数据就发现根本没这么容易。线上有大量历史订单处于"已支付但支付时间为空"、"退款中但退款金额为0"、"状态是已关闭但支付渠道单号还在"这类中间状态。如果直接用新规则去校验,它们全都不合法。
做增量容易,做兼容才难。最后我采用了一个折中方案:状态机支持"旧数据兼容映射",比如老代码里的0、1、2、3分别映射到CREATED、PAID、REFUNDING、REFUNDED,历史数据在读取时走映射层,不真正改写;只有新产生的订单,才要求完整走新状态机的合法迁移路径。这样既保证了新逻辑的严谨性,又不会因为一次数据矫正把线上业务搞挂。等跑了一段时间确认稳定之后,再慢慢把老数据分批迁移到新枚举。这个过程中我最大的体会是:数据迁移永远要向后兼容,改字段不如加字段,重写数据不如渐进收敛。
3. 第二件事:幂等设计必须覆盖每条写路径
3.1 支付模块里有五条路径必须幂等
很多人一谈幂等,第一反应是"给订单表加唯一索引,重复请求就会报错,然后再去重"。但支付模块里需要幂等的远不止一个地方。我这次重构时盘了一下,至少五条写路径必须做到"重复调用和一次调用效果完全一样"。
一是创建支付单。用户快速点了两次支付按钮,或者前端做了重试,后端不能被创建出两笔支付单。二是接收支付成功回调。网关推消息经常推两遍,第二遍不能把订单状态再改一遍甚至改错。三是发起退款申请。用户申请退款时如果点了两次,不能生成两笔退款单。四是接收退款结果回调。同样,重复回调不能影响退款状态。五是关闭超时订单。定时任务跑了两遍,不能把已经关闭的订单又关闭一次或做两次后置操作。
老代码的问题在于只对第一条路径做了检查——创建支付单之前查一下订单存不存在,另外四条全是裸奔。我后来跟同事开玩笑说,这个模块的可靠性,基本全靠"网关不会乱推"这个假设在撑着。
3.2 幂等键这么组合,才能区分"同一件事被重放"和"另一件事碰巧相似"
幂等键设计看起来简单,实际很容易踩坑。最开始我想用支付单号做支付成功回调的幂等键,后来一想不对:支付成功回调和退款成功回调可能都带同一个支付单号,如果幂等键只用支付单号,那"支付成功"和"退款成功"这两个完全不同的事件会互相把对方当成重复请求,直接丢掉一个。
最后定的规则是:幂等键 = 单号 + 事件类型 + 渠道。比如(order_no, PAY_SUCCESS, WECHAT)是一个幂等键,(order_no, REFUND_SUCCESS, WECHAT)是另一个幂等键,两者互不干扰。同理,创建支付单用(business_order_no, amount, channel)做唯一约束,防止用户同一笔业务订单生成多笔支付单。退款申请则用退款请求号做唯一键。
为了让幂等判断真正落到数据库层面,我建了一张事件记录表,核心结构大概是这样的:
sql复制CREATE TABLE pay_event_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_no VARCHAR(64) NOT NULL COMMENT '支付单号或退款单号',
event_type VARCHAR(32) NOT NULL COMMENT 'PAY_SUCCESS / REFUND_SUCCESS / CLOSE',
channel VARCHAR(16) NOT NULL COMMENT '支付渠道',
payload TEXT COMMENT '原始回调内容',
created_at DATETIME NOT NULL,
UNIQUE KEY uk_biz_event (biz_no, event_type, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
处理回调时先尝试往这张表插入记录,插入成功说明是第一次来,继续处理;插入冲突说明之前已经处理过,直接返回成功。这套方案比Redis做幂等更可靠,因为数据库唯一索引不会丢数据,也不依赖缓存服务的可用性。
3.3 并发回调来了,靠什么兜底
幂等表建好之后,我一度觉得万事大吉。直到压测时发现,两个完全相同的回调请求同时到达,数据库唯一索引会保证一个插入成功、另一个抛DuplicateKeyException。这个异常在代码里很容易被当成系统异常处理,于是很多人的第一反应是"抛异常出去,让网关重试",结果就是回调来了两次,系统处理两次,第一次处理完,第二次又抛异常,又触发重试,把自己搞成死循环。
正确做法是把重复键异常当成一个正常分支来处理:捕获到这个异常,说明事件已经处理过了,直接查一次最新状态返回给调用方就行,不要抛出,不要触发重试。这一点在代码review时特别容易漏掉,因为单测只测了"正常插入"和"重复插入",很少测"重复插入的同时并发查询",真到了线上并发场景才会暴露。
注意:幂等不是把异常吞掉,而是让重复请求走一个"已处理过"的分支。写代码时一定要把重复键异常和真正的系统异常分开处理,前者归为业务正常现象,后者才需要告警和重试。
4. 第三件事:超时、重试与回调通知要放在一起设计
4.1 外部支付网关的响应不代表最终结果
这个模块最早的设计有一个很天真的假设:调用支付网关接口,只要它返回成功,这笔支付就成了。实际根本不是这么回事。第三方支付网关返回的"受理成功"只是"我收到你的请求了",真正的扣款结果以异步回调为准。老代码把同步返回当成终态来处理,结果就是用户手机里已经完成支付,我们系统里订单却一直卡在"支付中",直到某个定时任务去查单才慢慢追回来。
我重构时把"支付中"当成一个完全正常的中间状态来设计了。发起支付后,立刻把订单置为PAYING,然后不再傻等同步响应,而是启动一个主动查单任务;同步结果就算返回成功,也只是作为参考,最终状态一律以"主动查单结果 + 异步回调"双通道校准。这样做的好处是,前面提到的"订单已支付但业务没反应"的情况会大幅减少,因为查单任务会把漏掉的回调补回来。
4.2 重试策略要区分错误类型,否则越重试越乱
主动查单和回调通知都涉及重试,但老代码的重试策略是一刀切——出错了就每隔几秒重试一次,最多重试十次,再不行就放弃。这个方案有两个问题:第一,对不可重试的错误也反复重试,白费资源;第二,重试间隔太短,在支付这个场景里根本没有意义,因为网关那边可能也需要时间来更新最终状态。
我把重试对象分成两类。可重试的是网络超时、网关5xx、限流这类临时性错误,等一等可能就好了;不可重试的是参数错误、签名错误、订单不存在这类问题,重试一万次结果也一样,应该直接进人工处理队列。只有可重试的错误才走重试逻辑。
主动查单的时间序列,我最后定的是:1分钟、5分钟、15分钟、30分钟、1小时、2小时,最多6次,之后把订单标记为异常并告警。这个节奏不是拍脑袋定的,是参考了网关侧"支付结果通常在几分钟内稳定"的实际表现,既不想给网关造成太大压力,又不想让用户等太久。查太频繁,网关会限流你;查太晚,用户早就忘了这笔单。回调通知的重试序列类似:立即、1分钟、5分钟、30分钟、2小时、6小时、24小时,超时未送达的进死信表,人工介入处理。
4.3 回调通知不再依赖内存,改成可靠投递表
老模块里,支付成功后的业务通知是同步处理的——回调来了,直接调用下游接口发消息,发失败就丢了,连个日志都没有。我重构时建了一张notify_record表,把需要通知下游的每个事件先落库,再由一个独立的投递任务从表里捞数据、按重试节奏发送。发送成功就更新状态;发送失败就留着等下一轮重试。
这个思路其实很朴素:把"要做的事"先记在可靠的存储里,再慢慢做,而不是"边接收边处理,处理不了就放弃"。支付成功通知这种环节宁可重复处理(重复了靠幂等挡掉),也不能漏。毕竟用户付了钱,业务侧如果没拿到通知,就等于把到手的订单丢了。这张表后来成了整个支付模块最关键的几张表之一,很多复杂问题靠它追溯。
5. 第四件事:对账才是支付模块的最后一道保险
5.1 对账不光是每天拉个文件比一比
老模块没有对账功能,这让我特别震惊。财务那边每个月月底自己手动拉账单,和系统流水对一遍,对不上就提工单让开发查。也就是说,"资金到底对不对"这个问题,在重构之前居然没有任何自动化手段保障。我后来跟团队说,对账不是锦上添花,它是支付模块最后一道安全网——如果所有代码逻辑都出问题了,至少对账能在资金层面发现异常,而不是等用户投诉。
对账的核心逻辑不复杂:每天凌晨拉取前一日第三方支付账单,和本地支付流水逐笔比对。但"比对"这两个字背后藏着很多细节:账单文件有不同格式、有重复行、有跨天延迟入账的订单、有退款和支付的交叉记录。我花了整整一周时间,才把账单解析这块的边界情况摸清。
5.2 四类最常见的差异,以及自动处理逻辑
对账跑起来之后,我把线上常见的差异归成了四类,每一类都配了不同的处理策略。
| 差异类型 | 本地状态 | 账单状态 | 自动处理逻辑 |
|---|---|---|---|
| 本地已支付、账单无记录 | PAID | 无 | 发起主动查单;确认失败则回滚,确认成功则补记账并告警 |
| 账单已支付、本地未支付 | 无或PAYING | SUCCESS | 说明回调丢了,主动补齐状态并触发下游通知 |
| 金额不一致 | PAID | SUCCESS(金额不同) | 不做自动处理,直接进人工冻结 |
| 状态一致、时间差过大 | PAID | SUCCESS(时间差超阈值) | 告警,排查链路或时钟问题 |
第一类差异最危险,因为可能是本地"假成功";第二类差异最常见,基本都是回调丢失导致的;第三类差异绝对不能自动处理,涉及资金的事,宁可慢一点也不能自作主张;第四类属于异常信号,通常预示着某个环节有隐患。这里我给自己定了一个原则:自动处理逻辑一定要写得保守,拿不准的一律转人工,机器只做能明确判断的事。
5.3 对账任务本身也要可靠
对账任务上线的第一个星期,它自己挂了两天,而且挂了之后没有任何告警,等于那两天根本没对账,但所有人都以为对得很顺利。后来我意识到,对账这个"查漏"的任务,自己也需要一整套可靠性设计。
首先任务要有批次号,每天只跑一次,重复执行也要能识别这是同一个批次,不能重复比对产生重复差异单。其次任务跑完必须回写统计结果,比如"今日账单总数、本地流水总数、差异单数量",如果某个批次没有统计结果,立即告警。再有,每一条差异单都要有独立的处理状态和处理留痕,人工处理完要能追溯是谁、什么时候、做了什么。说白了,对账任务本身也要有状态机、有幂等、有告警,不然它就成了一个"看起来在干活,实际上可能一直在睡觉"的摆设。
6. 第五件事:上线不是发布代码,而是逐步放量
6.1 新旧逻辑先用开关并存,跑一段时间影子对比
重构完成之后,我一度想把新代码一次性切换上线,因为单测写了、压测也过了,看起来没什么问题。但后来冷静下来想了想,支付模块这种涉及资金的地方,最怕的就是"你以为你测到了所有场景"。
最后我采用开关并存的方式上线:代码里同时保留新旧两套逻辑,通过配置中心开关控制走哪一套。先在内部测试渠道和低流量渠道放开新逻辑,观察一段时间,再逐步扩大流量。更关键的是,我在新逻辑里加了一个"影子对比"功能——同一笔支付,新逻辑的处理结果会记录下来,但真正生效的仍然是旧逻辑。每天对比新旧逻辑的结果,看有没有差异,如果有,说明新逻辑对某个场景的理解有问题,趁还没全量放开就赶紧修。这一步帮我抓到了至少两个隐藏问题,其中一个是对某个小众支付场景的状态判断,旧逻辑是对的,我重构成新枚举时反而理解偏了。
6.2 数据库变更向后兼容,回滚才有意义
代码回滚不难,数据库回滚才要命。这次重构涉及订单状态字段的调整,我最开始想直接改掉status字段的语义——把魔法数字改成新的枚举值。还好及时刹住了车:线上有几十万历史订单,如果新代码有问题要回滚,老代码根本读不懂新数据,那才是真正的灾难。
正确的做法是新增字段,而不是改字段。我加了一个status_v2字段,新代码读和写都走新字段,老代码继续读写老字段;两个字段之间通过兼容层做映射,切换过程是渐进的。如果新代码出了问题,只需要把开关切回去,让所有读写回到老字段,不需要回滚数据库。等新逻辑稳定运行一段时间后,再考虑把老字段废弃。这个策略让回滚成本从"可能丢数据/改数据"降低到了"改个配置开关",代价只是一段时间内多存一个冗余字段,但换来的安全感非常值。
6.3 灰度节奏和上线验收指标
灰度期间,我给自己定了一个节奏:1%流量观察一天,5%观察一天,20%观察一天,50%观察两天,100%全量。每个阶段不是"发完没报错就算过",而是要盯一组硬指标:
- 支付成功率:不能低于重构前近一周的基线
- 支付成功到回调到达的时长中位数和90分位:不能明显劣化
- 对账差异率:不能上升
- 卡在PAYING状态的异常订单数:不能增加
- 人工介入处理的数量:不能因为新逻辑引入新的手工操作
任何一项指标跌破红线,立即切回旧逻辑,先恢复再说,不等复盘。灰度不是走流程,它是给系统一个"后悔药"的机会。我特别庆幸当时设了这个原则,因为全量切换后第三个小时,对账任务的告警突然响了,发现有一类历史订单在新逻辑下被重复对账了,虽然没造成资金损失,但如果当时没有回滚预案,处理起来会麻烦得多。
重构完这个支付模块,我最深的体会是:"能用"和"可靠"之间,差的不是某一次代码改动,而是一整套约束。状态机是约束,幂等是约束,重试策略是约束,对账是约束,灰度也是约束。约束越多,系统越不容易被意外事件带偏。现在我再接手类似的老模块,第一件事不再是打开源码从头读到尾,而是先问三个问题:状态变更的入口在哪里?重复请求会不会造成两笔账?对不上账的时候,系统自己能不能发现?如果这三个问题答不上来,那这个模块无论跑得多稳,本质上都还在"能用"的阶段。这套判断方法,也是这次重构留给我最大的收获。
