支付系统设计实战:架构思维与踩坑心得
从零接入支付 SDK 到上线稳定运行,聊聊那些比写代码更重要的设计决策。
前言
最近独立负责了一个后端支付系统的 SDK 集成工作。回头看整个过程,真正花时间的不是写代码——HTTP 调一下、回调接一下、签名验一下,几天就写完了。真正耗时的是搞清楚三个问题:
- 支付网关和后端是什么关系?
- 客户端、后端、支付网关,谁跟谁说话?
- 钱到了但货没发出去怎么办?
一、先搞清楚谁是谁
任何一个支付系统,至少有三个角色:
客户端 ──── 游戏后端 ──── 支付网关 ──── 微信/支付宝问题的起点永远是:边界在哪里?
目前一般大众化的方式是分开部署
| 服务 | 只管什么 | 不管什么 |
|---|---|---|
| 支付网关 | 封装微信/支付宝/Steam 等多渠道差异 | 玩家背包、道具、订单履约 |
| 后端 | 商品定价、订单管理、发放物品 | 支付渠道的签名算法、证书管理 |
一个很常见的反模式(一开始我也以为是)是:把支付网关做成后端的一个子模块。短期虽省事,长期耦合到死——渠道换一个回调格式,游戏后端就得跟着发版。
核心原则:支付网关是支付网关,游戏后端是后端。它们通过 HTTP 说话,仅此而已。
二、谁跟谁说话?两种架构的取舍
这是整个设计阶段最值得纠结的问题。
方案 A:客户端直连支付网关
客户端拿到支付参数──直接调──► 支付网关 ──► 微信 │ 回调后端后端只做两件事:
- 下单:告诉客户端 “用这些参数去支付”
- 收回调:支付网关通知 “钱到了”,验签发货
方案 B:后端全代理
客户端 ──► 后端 ──代理调──► 支付网关 ──► 微信 ▲ │ └────── 回调 ─────────┘所有跟支付网关的交互由游戏后端代理,客户端只跟后端说话。
怎么选?
| 维度 | 方案 A | 方案 B |
|---|---|---|
| 后端复杂度 | 低 | 高 |
| 客户端复杂度 | 中 | 低 |
| 后端负载 | 低 | 高(多一跳代理) |
| 适合场景 | 移动端 App | H5 / 对客户端代码无控制权 |
当前也有不同的选择,需要根据环境具体分析。个人可能觉得A方案合适些(后端工作小很多)。移动端 App 集成微信/支付宝 SDK 是标准操作,客户端的复杂度本来就在那里,不需要后端再包一层。后端的职责是下单 + 收回调,而不是当代理。
决策的核心不是哪个更好,而是哪个更适合你的场景。
三、两条支付路径——不要混在一起
这点容易被忽略,但其实非常关键:系统里其实有两条完全不同的"支付"路径。
路径 1:用户真金白银买
创建订单 → 调支付网关 → 返回 pay_params → 客户端拉起支付 → 支付网关回调 → 验签 → 更新订单 → 发放奖励路径 2:运营直接发(不花钱)
创建订单 → 跳过支付网关 → 直接标记已支付 → 发放奖励两个路径走到最后都是"发货",但中间完全不同:
| 用户购买 | 运营发放 | |
|---|---|---|
| 真扣钱? | ✅ | ❌ |
| 走支付网关? | ✅ | ❌ |
| 要验签? | ✅ RSA2 | ❌ 内部认证 |
| 典型场景 | 玩家买钻石 | 补偿、补发、迁移 |
为什么不混在一起?因为它们的失败模式不一样:
- 用户购买怕的是“付了钱没拿到货”(最严重的事故)
- 运营发放怕的是“发多了/发错了”(内部操作风险)
如果混在一个函数里if isAdmin { skip },时间长了必出 bug——可能是运营操作走了支付验证,也可能是一个本该验签的入口被绕过了。
分开设计,各自独立,我觉得是更安全的做法。
四、签名验签——安全的底线
为什么需要两层验签?
一开始我也以为验签1次不是基本就OK了吗。微信自己不就已经验签了吗?为什么支付网关回调后端还要再验一次?
微信 ──验签①──► 支付网关 ──验签②──► 后端答案:验签①保护的是支付网关不被假微信骗;②保护的是后端不被假支付网关骗。信任域不同,不能互相替代。
RSA2 验签的原理
整个验签就四步,但每一步都可能踩坑:
参数字典 → 按 key 排序 → k1=v1&k2=v2 → SHA256 → RSA 公钥验签排序是坑:必须用 ASCII 码排序,不是字典序。AppId和app_id的顺序不一样。
空值是坑:空值参数要不要参与签名?这个要和支付网关对齐。一般规则是空值跳过——但如果你跳过而对方没跳过,签名就对不上。
编码是坑:k=v&k=v里面的值要不要 URL 编码?也是一致性问题。
验签失败 90% 是因为 “我拼接的字符串和你拼接的字符串不一样”。排查技巧:打印签名前的原始字符串,跟对方对比,一秒钟就能定位。
微信支付 V3 的自动验签
微信支付 V3 有个很实用的能力:AutoVerifySign()。它会自动下载和更新微信平台证书,你不用自己管证书过期的问题。
但注意——这只是微信 ↔ 支付网关之间的验签。支付网关 ↔ 后端之间的 RSA2 验签还是得自己写。
五、发货——最难的不是"发",是"一定能发到"
回调失败的真实场景
支付网关回调你的时候,什么情况都可能发生:
- 网络超时(支付网关发了,你没收到)
- 你收到了,但处理到一半 Redis 挂了
- 你发物品的时候用户仓库服务超时了
- 同一个订单回调了你三次(支付网关重试)
四道防线
靠一重保障远远不够,需要层层兜底:
第一道:实时发货 └─ 回调中同步处理。失败 → 记日志 + 告警 第二道:异步重试 └─ 失败消息进 MQ,延迟重试(指数退避:1s → 5s → 30s → 5min) 第三道:定时对账 └─ 每天凌晨拉支付网关账单,跟本地发货记录比对 「支付成功但没发货记录」的全部捞出来 第四道:人工兜底 └─ 运营后台一键补发有个容易被忽略的点:回调处理失败时,要不要给支付网关返回失败?
答案是:不要。返回
{"code": 0},让支付网关停止重试。然后把失败订单写入自己的重试队列,自己控制节奏。否则支付网关会疯狂重试,把你的服务打得更死。
幂等——回调三次跟回调一次结果一样
// 最简单的幂等:查状态order:=db.Find(orderID)iforder.Status=="PAID"{returnnil// 已经处理过了,直接返回成功}// 加锁 → 处理 → 更新状态核心思路:状态本身就是幂等键。设计状态机的时候,"已支付"到"已发货"必须是单向的,不可逆。
六、代码组织——三层够用,别过度设计
项目里我用的分层:
server/ → HTTP Handler:解析参数、验签、返回 JSON biz/ → 业务逻辑:创建订单、更新状态、发奖编排 repo/ → 数据层:数据库读写自己封装对支付网关的 HTTP 调用。
七、踩过的坑
坑 1:回调地址拼错了
回调地址其实是从三个地方拼出来的:网关域名 + 路由前缀 + 回调路径。三个配置散落在不同文件里,很容易改漏。后来才知道其实早就在 CI 配置和 API 网关路由里配好了,只需要确认。
教训:回调地址这种关键配置,最好一个地方集中管理,不要到处散落。
坑 2:签名排序不一致
Go 里sort.Strings()是字典序,Java 里TreeMap也是字典序——但 Python 的sorted()默认也是字典序。看起来都一样对吧?但如果 key 里有大写字母和数字混在一起,不同语言的 ASCII 排序行为可能不同。验签失败的时候打印原始字符串,跟支付网关侧对比,1 分钟就能定位。
坑 3:日志记了但查不到
一开始日志里没有统一的order_id,查问题的时候要从用户 ID、时间戳各种字段拼凑。后来强制所有支付相关日志必须带order_id,一行 grep 就能串起整条链路。
教训:日志不是记了就行,要能grep 一条 ID 串起来。
八、架构思维总结
回过头看,整个支付模块的开发,写代码大概只占 30%。剩下 70% 的时间花在:
| 思考 | 占比 |
|---|---|
| 搞清楚项目边界和服务间关系 | 20% |
| 选择正确的架构方案(A vs B) | 15% |
| 设计失败兜底策略(重试 + 对账) | 20% |
| 排查和调试(签名、回调路径) | 15% |
做支付最重要的三个意识
- 安全第一:验签不能省、不能简化、不能"先上线再补"
- 兜底意识:没有任何一个系统是 100% 可靠的——网络会断、服务会挂、回调会丢。必须有一个"当一切失败时怎么办"的答案
- 可观测性:出了问题能快速定位,比不出问题更重要(因为一定会出问题)
总结
支付系统的核心不是"怎么写",而是"怎么不丢"。
每一笔订单背后都是一个付了钱的真实用户——做支付,敬畏心比技术重要。