支付状态与订单状态分离:三张表、三层幂等与最终一致性落地
参考来源:SpringCloud 微服务实战:支付全链路生产级落地(接口对接 + 异步通知 + 订单状态闭环),https://jishuzhan.net/article/2036259148061540353
服务拆分与回调服务独立部署
按单一职责拆分为网关服务(SpringCloud Gateway,负责转发、鉴权、限流、日志)、订单服务(订单创建与状态管理,是订单状态的唯一权威数据源)、支付服务(三方支付对接、预支付、支付记录、结果查询)、回调通知服务(独立部署,接收回调、验签、消息分发,与核心业务隔离),以及 Nacos、Sentinel、RocketMQ、Redis 等公共基础服务。
回调服务单独拆出来的原因只有一个:回调接口是三方支付直接访问的入口,它的可用性不取决于业务逻辑有多复杂,而取决于它有多快、多稳。独立部署后,核心业务的流量波动、发布、故障都不会影响回调可达性,也便于单独做安全防护与扩容。
设计原则
- 安全第一:签名验签、全程 HTTPS、敏感信息加密。
- 幂等性优先:所有支付相关接口,尤其是回调接口,必须幂等。
- 最终一致性:用可靠消息加兜底补偿保证订单状态与支付状态一致,不追求强一致。
- 可追溯性:下单、预支付、回调、状态更新全链路留日志。
- 快速响应与降级:核心接口尤其是回调接口 100ms 内返回,非核心逻辑异步解耦,支持熔断降级。
三张核心表
t_order_info:订单信息表
订单状态的唯一权威数据源,由订单服务写入。
字段:id、order_no(全局唯一)、user_id、product_id、order_amount、pay_amount、order_status、pay_type(1 微信 2 支付宝)、transaction_id(三方流水号)、pay_time、expire_time、create_time、update_time、remark。
order_status 取值:0 待支付、1 支付中、2 支付成功、3 支付失败、4 已取消、5 已完成。
索引:PRIMARY(id)、UNIQUE uk_order_no(order_no)、KEY idx_user_id、KEY idx_create_time。
CREATE TABLE `t_order_info` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单号,全局唯一',
`user_id` bigint NOT NULL,
`product_id` bigint NOT NULL,
`order_amount` decimal(10,2) NOT NULL COMMENT '订单金额(元)',
`pay_amount` decimal(10,2) DEFAULT NULL COMMENT '实付金额(元)',
`order_status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1支付中 2支付成功 3支付失败 4已取消 5已完成',
`pay_type` tinyint DEFAULT NULL COMMENT '1微信 2支付宝',
`transaction_id` varchar(64) DEFAULT NULL COMMENT '三方支付流水号',
`pay_time` datetime DEFAULT NULL,
`expire_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`remark` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
t_pay_record:支付记录表
每笔支付请求一条记录,与订单是多对一:一个订单可能因超时关闭、重新发起等原因产生多条支付流水。
字段:id、pay_no(唯一)、order_no、user_id、pay_amount、pay_type、pay_status、transaction_id、prepay_id、pay_time、create_time、update_time。
pay_status 取值:0 待支付、1 支付中、2 成功、3 失败、4 已关闭。
索引:PRIMARY(id)、UNIQUE uk_pay_no(pay_no)、KEY idx_order_no、KEY idx_user_id。
CREATE TABLE `t_pay_record` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`pay_no` varchar(64) NOT NULL COMMENT '支付流水号,全局唯一',
`order_no` varchar(64) NOT NULL,
`user_id` bigint NOT NULL,
`pay_amount` decimal(10,2) NOT NULL,
`pay_type` tinyint NOT NULL COMMENT '1微信 2支付宝',
`pay_status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1支付中 2成功 3失败 4已关闭',
`transaction_id` varchar(64) DEFAULT NULL COMMENT '三方支付流水号',
`prepay_id` varchar(128) DEFAULT NULL,
`pay_time` datetime DEFAULT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pay_no` (`pay_no`),
KEY `idx_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
t_pay_callback_log:支付回调日志表
所有回调全量落库,既是排查依据,也是幂等体系的第一层保障。
字段:id、order_no、transaction_id、pay_type、request_body(text)、request_header(text)、sign_verify_result(0 失败 1 成功)、handle_result(0 待处理 1 成功 2 失败)、response_body、callback_count、create_time、update_time。
索引:PRIMARY(id)、UNIQUE uk_order_transaction(order_no, transaction_id)、KEY idx_create_time。
CREATE TABLE `t_pay_callback_log` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL,
`transaction_id` varchar(64) NOT NULL,
`pay_type` tinyint NOT NULL COMMENT '1微信 2支付宝',
`request_body` text COMMENT '原始回调报文',
`request_header` text COMMENT '原始请求头',
`sign_verify_result` tinyint NOT NULL DEFAULT 0 COMMENT '0失败 1成功',
`handle_result` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1成功 2失败',
`response_body` varchar(512) DEFAULT NULL,
`callback_count` int NOT NULL DEFAULT 1 COMMENT '该笔回调次数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_transaction` (`order_no`, `transaction_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
两个设计取舍
订单状态与支付状态为什么分开。 order_status 由订单服务维护,描述交易本身的生命周期(待支付到已完成);pay_status 由支付服务维护,描述某一次支付请求的结果。一个订单可以对应多次支付尝试,把两者压进一张表,就会出现「订单已取消但支付成功」「多次支付流水互相覆盖」这类无法表达的状态。分表之后,每个状态只有一个写入方,回调和查单只更新支付记录,订单状态由订单服务根据支付结果推进。
回调日志为什么全量落库。 回调是外部系统触发的、不可重放的输入,丢掉一次请求就等于丢掉对账依据。日志表同时承担三个职责:原始报文可回查、验签与处理结果可统计、order_no + transaction_id 联合唯一索引在数据库层直接拒绝重复回调插入。
所有订单号、支付流水号都建唯一索引,唯一性由数据库保证,不依赖应用层判断。三张表都带 create_time、update_time,便于对账和排查。
支付对接中与数据层相关的约束
以微信支付 V3 为例,支付宝逻辑一致,差异在签名方式和参数。签名为 SHA256 with RSA,商户私钥签名、平台公钥验签,请求头携带 Authorization(包含签名信息、商户号、证书序列号),必须走 HTTPS。
与数据一致性直接相关的几条:
- 统一下单的
out_trade_no就是幂等号,用支付流水号(pay_no)生成,保证每次请求唯一,重复请求不会创建新的预支付单。 - 金额单位是分,必须是整数。前端传入的
total_amount是元字符串、保留两位小数;落库与三方交互按整数分处理,避免浮点误差。 - 服务器出口 IP 必须加入商户平台 IP 白名单,否则请求直接被拦截。
- 回调地址必须是公网 HTTPS,不带参数、不挂登录鉴权拦截,否则三方回调根本进不来;本地调试用 frp 或花生壳做内网穿透。
- 生产密钥放配置中心并加密(如 Nacos 配置加密),禁止硬编码进代码仓库;环境隔离,HTTP 客户端配置连接池与超时。
回调与幂等
微信支付在未收到成功响应时,按 15s、15s、30s、1m、2m、5m、10m、30m、1h、2h、3h、3h、3h、6h、6h 的间隔重试,24 小时内共 15 次。重复回调是常态,不是异常。
回调设计原则:快速响应、验签优先、全链路日志、多层幂等、异常隔离。
三层幂等,每层拦截不同的东西
- DB 唯一索引:
t_pay_callback_log上order_no + transaction_id联合唯一。重复回调在插入日志这一步就被数据库拒绝,拦截的是完全相同的重复报文。 - Redis 分布式锁:以订单号加锁,锁超时设 10 分钟。拦截的是并发到达的同一订单回调,避免多个线程同时进入业务处理。
- 订单状态机前置校验:只有待支付、支付中的订单才能被更新为支付成功;已经是支付成功的订单直接返回成功、不做任何处理。这一层是最后兜底,拦截索引与锁都没覆盖到的时序问题。
回调接口的处理顺序
取原始报文 → 取签名相关请求头(Wechatpay-Serial、Signature、Timestamp、Nonce)→ 校验证书序列号 → 验签 → 解密报文(AES-GCM,使用 APIv3 密钥)→ 幂等校验,重复直接返回成功 → 落回调日志 → 发 MQ 消息异步处理业务 → 返回 200 与 {"code":"SUCCESS"}。
验签失败直接返回 400;只有业务处理失败才返回 FAIL,让三方按重试策略再推。回调接口内部不做任何复杂业务,先落库、再异步。
失败重试与归档
处理失败的回调记录留在日志表,定时任务每 5 分钟扫描失败记录重发 MQ,最多重试 10 次,超限告警;MQ 配置死信队列,由人工介入;回调日志按天归档。
订单状态闭环
状态闭环由三部分组成:状态机前置校验、可靠消息(RocketMQ)、定时兜底查单。回调到达后不直接改订单,而是落库并投递消息,由订单服务消费消息推进订单状态;定时任务扫描长时间处于「支付中」的订单,主动向三方查单补齐结果。
这套组合保证的是最终一致,目标是杜绝「支付成功但订单还是待支付」这种状态。强一致在这个链路上做不到,也不需要。
金额口径统一一遍:前端 total_amount 是元字符串、保留两位小数,三方接口金额单位是分且必须为正整数,库内按整数分处理以避免浮点误差。