支付服务那些坑:从先落库到分表预案,一笔钱背后的设计决策

支付服务:每个"为什么"背后都是真金白银 为什么支付服务和普通业务完全不一样 写业务代码,最常见的是 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)。退款独立成表才能记录多次退款历史,塞进支付单就毁了。 ② 状态机本质不同。支付单管"收钱":待支付 → 已支付 → 关闭/失败;退款单管"退钱":待处理 → 处理中 → 成功/失败 + 审核流。两个状态机混在一张表必然打架。 ...

十月 29, 2023 · 5 分钟 · 858 字 · yaomingye

微服务支付系统全景:从接入第三方到对账结算,一笔钱走完的九九八十一难

一笔钱在微服务里到底怎么走的 为什么支付是微服务里最难啃的骨头 做业务开发,碰到的最常见代码可能就是 CRUD。增删改查写熟了,觉得微服务也不过如此——直到某天被分配了支付模块。 支付和普通业务有本质区别:普通业务操作的是"信息",支付操作的是"钱"。写错一行代码,信息可以修,钱出去了就是真金白银的损失。更麻烦的是,支付不是自己一个服务就能搞定的事——要接微信、要接支付宝、可能还要接银联、接 Stripe。每家渠道的接口风格不同,回调机制不同,对账方式也不同。上游还有订单系统在等支付结果,下游有会计系统等着入账。 flowchart LR %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold; RISK1["[钱出去了\n回不来]"] RISK2["[重复支付\n多扣款]"] RISK3["[回调丢失\n订单卡死]"] RISK4["[对账不平\n财务追杀]"] RISK5["[渠道故障\n全站瘫痪]"] CORE["支付系统\n核心矛盾:\n复杂 × 高风险 × 强一致性"] RISK1 --> CORE RISK2 --> CORE RISK3 --> CORE RISK4 --> CORE RISK5 --> CORE class RISK1,RISK2,RISK3,RISK4,RISK5 reject; class CORE highlight; 把这些复杂度拆开来看,一个支付系统本质上要解决五个问题: 怎么收——对接各种支付渠道,屏蔽渠道差异 怎么记——每笔钱的来龙去脉都要有据可查 怎么验——回调确认钱真的到了,不是"用户说付了就算付了" 怎么对——自己的账和渠道的账对得上 怎么退——钱能收就能退,但不能退多了,也不能重复退 下面逐个拆解。 支付系统的"五脏六腑":模块全景图 在动手写代码之前,先搞清楚一笔钱在系统里要经过哪些模块。 flowchart TD %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; subgraph FRONT["接入层"] GATE["[支付网关\n路由/验签/限流/协议转换]"] end subgraph CORE_MOD["核心支付域"] ORDER["[支付订单服务\n订单创建/查询/状态流转]"] CHANNEL["[支付渠道服务\n渠道抽象/路由/适配器]"] CALLBACK["[回调处理服务\n异步通知/幂等/重试]"] REFUND["[退款服务\n退款申请/审核/执行]"] end subgraph BILLING["清算对账域"] RECON["[对账服务\nT+1对账/差异处理/长款短款]"] SETTLE["[结算服务\n分账/手续费/入账]"] end subgraph INFRA["基础设施"] MQ["[消息队列\n异步解耦/重试]"] IDEM["[幂等表\n防重支付/防重回调]"] LGR["[流水表\n不可变审计日志]"] end FRONT --> CORE_MOD CORE_MOD --> BILLING INFRA -.-> CORE_MOD INFRA -.-> BILLING class GATE highlight; class ORDER,CHANNEL,CALLBACK,REFUND process; class RECON,SETTLE data; class MQ,IDEM,LGR process; 每个模块解决一类问题: ...

二月 14, 2023 · 6 分钟 · 1167 字 · yaomingye

高并发企业自营电商小程序系统架构设计

🏗️ 高并发企业自营电商小程序系统架构设计:从需求分析到部署监控的全链路方案 从一个真实的架构评审说起 某团队接到一个需求:为公司开发一款自营电商小程序,C 端消费者可以在小程序上购买公司产品。预估用户量 5 万 ~ 10 万,产品部门希望赶在活动上线前交付。 架构师在评审会上画出了第一版方案:Spring Boot 单体应用 + MySQL + Redis 缓存。评审进行到一半,有人提出了一个问题:“如果 1 万个用户同时抢一个秒杀商品,这套架构能撑住吗?” 这个问题让团队陷入了沉默。单体应用的库存扣减在数据库层面是一条 UPDATE ... SET stock = stock - 1 WHERE stock > 0,在高并发下这条 SQL 会成为瓶颈——数据库行锁(InnoDB 行级锁,对某一行数据的排他锁定)会让所有请求串行化,QPS(Queries Per Second,每秒请求数)直接降到数据库单行更新的极限:约 500 ~ 1000。 ⚠️ 新手提示:数据库行锁串行化的意思是,当 1000 个请求同时更新同一行数据(比如同一商品的库存),InnoDB 会让它们排队执行,每个请求必须等前一个提交后才能继续。这不是"慢",而是"一个一个来"——1000 个请求就是 1000 次串行操作。 这就是本文要解决的核心问题: 如何设计一套能支撑 2 万 QPS 的企业自营电商系统,同时保证库存不超卖、订单不丢失、支付不重复。 📌 前置知识:阅读本文需要了解 Spring Boot 基础、Redis 基本操作、MySQL 基本用法、消息队列(RocketMQ/Kafka)的基本概念。如果对微服务架构不熟悉,建议先了解服务注册与发现(Nacos)的基本概念。 🏗️ 一、需求分析与业务建模 🎯 1.1 业务范围界定 在设计任何系统之前,第一步是明确边界——知道什么要做什么不做。 维度 内容 业务形态 企业自营 B2C 电商小程序,企业是唯一商家,面向 C 端消费者 用户规模 预估 1 万 ~ 10 万注册用户 峰值 QPS 预估 1000 ~ 5000(设计目标:支撑 2 万 QPS) 核心功能 商品浏览、下单、支付、退款、物流查询 这里有一个关键决策: 设计目标(2 万 QPS)远大于预估峰值(5000 QPS) 。这不是过度设计,而是为以下场景预留缓冲: ...

十月 13, 2022 · 14 分钟 · 2934 字 · yaomingye

云服务器选型实战

☁️ 云服务器选型实战:带宽、CPU、内存容量估算方法论 —— 以阿里云 ECS 为例 📌 一、问题切入:新项目上云,ECS 实例怎么选? 小张接到一个新项目——做一个面向 C 端用户的电商小程序后端,预计日均 UV 5 万,高峰期 QPS(每秒请求数)约 500 📊。技术栈是 Spring Boot + MySQL + Redis,全部部署在阿里云 ECS 上。 他打开阿里云 ECS 购买页面,面对几十种实例规格、上百个配置组合: ecs.g7.large 2vCPU 8GB 最高 10Gbps ecs.c7.xlarge 4vCPU 8GB 最高 12.5Gbps ecs.r7.large 2vCPU 16GB 最高 10Gbps ecs.g7.xlarge 4vCPU 16GB 最高 12.5Gbps ... 选低了——大促时服务崩掉 💥,用户投诉;选高了——老板看账单时脸色不好 😤。 服务器选型的本质是对 三个核心维度 的估算: 带宽(网络吞吐) 、 CPU(计算能力) 、 内存(数据缓存空间) 。三个维度相互独立又彼此制约,高估任何一个都是浪费 💸,低估任何一个都是事故 🚨。本文将给出每个维度的 可量化估算公式 ,结合阿里云 ECS 的具体实例规格,形成一套可复用的选型标准。 🔍 二、估算前置:三个维度的关系 选型之前,先明确三个维度分别决定什么: ...

九月 29, 2022 · 10 分钟 · 1926 字 · yaomingye

API 响应封装

API 响应封装:统一返回格式、全局自动包装与异常处理全解析 🤔 1. 问题切入:一个没有封装的 Controller 是怎样的? 在开始讲解之前,先看一段没有做任何统一封装的 Controller 代码: @RestController @RequestMapping("/api/product") public class ProductController { @Autowired private ProductService productService; @GetMapping("/findById") public ProductEntity findById(Long id) { ProductEntity product = productService.findById(id); if (product == null) { // 直接返回 null,前端收到空响应体,不知道发生了什么 return null; } return product; } @PostMapping("/insert") public String insert(@RequestBody ProductEntity product) { try { productService.insert(product); return "success"; // 字符串硬编码,前后端契约不统一 } catch (Exception e) { return e.getMessage(); // 把异常栈暴露给前端,安全风险 } } } 这段代码暴露了三个问题: ...

九月 26, 2022 · 10 分钟 · 2010 字 · yaomingye
Cat Radio