支付服务那些坑:从先落库到分表预案,一笔钱背后的设计决策
支付服务:每个"为什么"背后都是真金白银 为什么支付服务和普通业务完全不一样 写业务代码,最常见的是 CRUD。增删改查写熟了,觉得什么服务都差不多——直到被分配写支付服务。 支付和普通业务有本质区别:普通业务操作的是信息,支付操作的是钱。信息写错了能改,钱出去了就是真金白银的损失。更扎心的是,支付服务里每一个看似"怎么做都行"的设计决策,背后都藏着一个"做错了会怎样"的财务事故。 这一篇不讲支付怎么接入第三方(那是另一篇的活),专门讲设计决策:先落库还是先调渠道、支付单和退款单要不要分开、前端该直连支付还是走订单、状态机怎么拆、将来分库分表怎么留预案。这些决策不写明白,代码写对了也是悬的——哪天线上出了对不上账的事故,回头看全是今天的"小事"。 设计决策 1:先落库支付单,还是先调渠道 prepay? 踩坑现场 第一次写支付创建接口的人,几乎都会纠结这个顺序。有人觉得"先调渠道拿参数,再落库,这样能确认渠道成功"——听着有道理,其实是财务黑洞的开端。 为什么必须"先落库、后调渠道" 插入 pay_order 失败?→ 直接抛异常终止,永不调渠道 插入 pay_order 成功?→ 调渠道 prepay → 拿拉起参数 → 返回前端 这笔顺序是强同步串行的,理由有三个: ① pay_order 是"我方要收这笔钱"的唯一凭证。必须先记账,再去碰第三方。渠道 prepay 失败、渠道宕机时,本地已有待支付单——可重试、可追溯、可对账。反过来,渠道调好了本地啥都没有,这笔支付意图就丢了。 ② 反向顺序是财务黑洞。先调渠道拿参数、再落库,万一落库失败(DB 故障、唯一键冲突、事务回滚),渠道侧已经有一笔预支付交易、本地没有单。用户真拿着参数付了款,渠道回调过来本地无单可匹配——用户钱付了,我方账上没收,直接资金风险。 ③ prepay 本身不扣款。 alipay.trade.app.pay 只是"下单拿拉起参数",用户还没付款。所以先落库后调渠道,即使渠道失败也没有资金损失,重试即可。 顺带一个容易踩的长事务坑 创建接口标了 @Transactional ,prepay 这个外部 HTTP 调用被包进了本地事务——prepay 慢(外部网络)会长时间占用数据库连接,高并发下单时是隐患。严谨做法是把 prepay 移出事务: 事务 A:insert pay_order(本地,快) 无事务:调渠道 prepay(外部,慢) 事务 B:prepay 失败则更新 pay_order 状态 顺序本身不变,但别让外部调用拖住数据库事务。 设计决策 2:支付单和退款单,为什么分成两张表? 踩坑现场 看表结构时容易嘀咕:支付单和退款单字段挺像的——都有金额、订单号、渠道、用户、时间。为什么不合成一张表,用个"方向"字段区分? 为什么必须分开 ① 一对多是硬约束。一笔支付可以多次退款:买 399 退一件 99,再退一件 100——支付单只有一笔(399),退款单有两笔(99+100)。退款独立成表才能记录多次退款历史,塞进支付单就毁了。 ② 状态机本质不同。支付单管"收钱":待支付 → 已支付 → 关闭/失败;退款单管"退钱":待处理 → 处理中 → 成功/失败 + 审核流。两个状态机混在一张表必然打架。 ...
