要做什么:一个会重复、会抖、会死在半路的支付闭环
这篇用 Spring Boot 搭一个”下单 → 支付回调 → 超时关单”的最小闭环。不接真实支付渠道,但把真实渠道带来的全部约束都模拟出来:回调会重发、网络会抖、进程会死在任何两行代码之间。”模拟支付”四个字会让每个设计决策都变重——订单系统错一次就是钱,而这道题里大部分”正确性”其实不来自 Java 代码,来自数据库语义。
| 指标 | 目标值 | 怎么测 |
|---|---|---|
| 回调幂等 | 同一笔渠道流水重放 N 次,扣款只发生一次 | 脚本对同一回调重放 10 次,流水表行数不变 |
| 状态不回退 | 已关单的订单不会再被回调改成已支付 | 先关单再重放支付回调,订单保持 CLOSED |
| 超时关单 | 创建后 15 分钟未支付自动关闭 | 造一条旧数据,等调度跑过后查状态 |
| 对账发现 | 渠道有账我方无单(或反之)能被报出 | 手工删一行流水,跑对账任务,看告警 |
| 事务边界 | 第三方调用不进本地事务 | 让渠道调用抛超时,订单仍以已创建状态落库 |
诚实声明:本文未实测,不写任何性能数字,以上是功能正确性的验收口径——这类系统的关键指标是”错不错”而不是”多快”。
表结构全景:一张主表,一张流水表
整个模拟系统只需要两张表。订单表记录”当前状态”,流水表记录”发生过什么”——前者可变、后者只增,这个分工是对账能成立的前提:
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL, -- 业务单号,应用侧生成
amount_cents BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'CREATED',
pay_token VARCHAR(64) NULL, -- 渠道预支付返回的凭证
paid_at DATETIME NULL,
closed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no),
KEY idx_close (status, created_at) -- 超时关单扫表专用的索引
) ENGINE = InnoDB;
两个细节值得点名:金额一律用分(BIGINT),浮点数算钱是另一个经典事故源;idx_close (status, created_at) 是为超时关单的扫表 WHERE 条件量身建的——没有它,关单任务每 5 分钟做一次全表扫描,表一大就把数据库拖下水。
订单状态机:所有变更走带条件的 UPDATE
| 状态 | 含义 | 允许迁往 | 触发者 |
|---|---|---|---|
| CREATED | 已创建未支付 | PAID / CLOSED | 支付回调 / 超时关单 |
| PAID | 已支付 | REFUNDED | 退款流程(本文不展开) |
| CLOSED | 已关闭(超时或取消) | 终态 | 关单任务 |
| REFUNDED | 已退款 | 终态 | 退款流程 |
状态机的核心是一条带前置条件的 CAS 更新:
// 支付成功回调:只有 CREATED 才能变 PAID
int updated = jdbc.update(
"UPDATE orders SET status = 'PAID', paid_at = NOW() " +
"WHERE order_no = ? AND status = 'CREATED'", orderNo);
if (updated == 0) {
// 状态不是 CREATED:要么已关单,要么已被并发回调改过——
// 返回当前实际状态而不是报错,渠道需要确定性答复
return jdbc.queryForObject(
"SELECT status FROM orders WHERE order_no = ?", String.class, orderNo);
}
反模式一:先查状态再更新。SELECT 判断 status == 'CREATED' 之后 UPDATE 不带条件,查和改之间的窗口里第二笔回调已经进来,两笔都判成”可支付”——重复扣款就是这样发生的。并发正确性压给数据库的条件更新,不压给应用层的判断顺序。
幂等键:唯一约束是最便宜的保险
CREATE TABLE payment_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
channel VARCHAR(16) NOT NULL, -- 模拟渠道名
channel_txn VARCHAR(64) NOT NULL, -- 渠道流水号,渠道侧唯一
amount_cents BIGINT NOT NULL, -- 金额用分,永远不用浮点
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_channel_txn (channel, channel_txn)
) ENGINE = InnoDB;
回调处理的顺序:先落流水(撞唯一约束说明处理过),再动订单状态;撞约束后查出已有记录,把与首次一致的响应还给渠道:
try {
jdbc.update(
"INSERT INTO payment_records (order_no, channel, channel_txn, amount_cents) " +
"VALUES (?, ?, ?, ?)", orderNo, channel, channelTxn, amountCents);
} catch (DuplicateKeyException e) {
// 渠道重发:不是错误,直接走查询返回既有结果
}
// 只有新流水才继续走订单状态机;老流水到此为止
为什么不用”先 SELECT 有没有这条流水”:判重放在应用层就有窗口期,唯一约束把判重下推到存储引擎,任何并发模型下都成立。框架(容器怎么装配、Controller 怎么接 HTTP)见《Spring Boot 框架入门》,但正确性这层框架不替你管。
反模式二:把幂等寄托在”渠道不会重发”上。重发是渠道的标配行为——它也怕你没收,重放窗口从秒级到小时级都存在。唯一约束是唯一不需要信任对方的判重方式。
事务边界:第三方调用不能进本地事务
最容易写错的一段是”下单时先调渠道预支付,再保存订单”:
// 反模式三:HTTP 调用包在事务里
@Transactional
public void create(OrderReq req) {
orders.insert(req); // 本地写
PayToken t = channel.prepay(req); // HTTP!慢/超时/失败都可能
orders.updateToken(req.orderNo(), t); // 若上面超时,本地写也一起回滚
}
三个病灶:渠道超时拖长事务,数据库连接和行锁被占着等网络;HTTP 成功但本地随后回滚,渠道侧却已开了单,两边状态永久分叉;整个事务重试会重复调渠道。
拆法是把”我方记录的事实”与”外部调用”分开:
public void create(OrderReq req) {
jdbc.update(
"INSERT INTO orders (order_no, amount_cents, status, created_at) " +
"VALUES (?, ?, 'CREATED', NOW())",
req.orderNo(), req.amountCents()); // 本地事务到此结束:
// "我要收款"这个事实先落库
try {
PayToken t = channel.prepay(req); // 事务外,失败可补偿
jdbc.update(
"UPDATE orders SET pay_token = ? WHERE order_no = ? AND status = 'CREATED'",
t.token(), req.orderNo());
} catch (ChannelException e) {
// 预支付失败不回滚订单:状态机有地方安放失败,留给关单或人工
log.warn("prepay failed for {}", req.orderNo(), e);
}
}
三条路线怎么选:
| 方案 | 适用 | 代价 | 边界 |
|---|---|---|---|
| 本地事务(订单+流水同库) | 两张表在同一个数据库 | 无额外设施 | 只保证库内一致,管不到外部系统 |
| 事务消息 / MQ | 拆了库、要跨服务同步 | 引入 MQ 与本地消息表,重投递机制 | 消费侧仍必须幂等,语义不降级 |
| 定时对账 | 任何方案的兜底层 | 对账任务与修复流程 | 滞后发现,不实时,但确定 |
诚实结论:模拟系统里订单和流水在同一个库,本地事务就够,不需要 MQ;但对账任务依然要写——不是因为现在需要,是因为”渠道侧状态我方控制不了”这件事,在接真实渠道的第一天就成立。系统真拆成多个服务之后要补的全景基础设施(注册中心、网关、链路追踪),见《微服务基础设施清单(注册中心/网关/链路追踪)》。
对账:两个方向的差集都要查
@Scheduled(cron = "0 0 3 * * ?")
public void reconcile() {
// 方向一:我方已支付却查无流水——常见于本地状态被误改,需人工核实
var orphans = jdbc.queryForList(
"SELECT order_no FROM orders o WHERE o.status = 'PAID' " +
"AND NOT EXISTS (SELECT 1 FROM payment_records p WHERE p.order_no = o.order_no)",
String.class);
if (!orphans.isEmpty()) alert.report("我方已支付但无流水: " + orphans);
// 方向二:渠道有账我方无单——回调彻底丢失时出现(模拟:对账表比对)
for (String txn : channel.exportYesterdayTxns()) {
int n = jdbc.queryForObject(
"SELECT COUNT(*) FROM payment_records WHERE channel_txn = ?",
Integer.class, txn);
if (n == 0) alert.report("渠道有账我方无单: " + txn);
}
}
对账的意义不在自动修复(修复逻辑往往要人工判断),而在”故障日的第二天早上,有人一定知道哪里错了”。它是滞后但确定的一道防线。
超时关单:三种实现横评
| 实现 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 定时扫表 | 调度任务周期性扫 CREATED 且已超时的单 | 简单可靠,不新增组件 | 关单延迟等于扫描间隔;大表要配索引 |
| 延迟消息 | 下单时发一条 15 分钟后投递的消息 | 延迟精准,无需扫表 | 要有支持延迟投递的 MQ,引入新组件 |
| Redis 过期监听 | key 过期事件触发关单 | 看似轻量 | 过期通知不保证及时与送达,不能独立承担正确性 |
模拟系统用扫表(也是最诚实的起点):
@Scheduled(fixedDelay = 300_000)
public void closeExpired() {
int n = jdbc.update(
"UPDATE orders SET status = 'CLOSED', closed_at = NOW() " +
"WHERE status = 'CREATED' AND created_at < NOW() - INTERVAL 15 MINUTE");
if (n > 0) log.info("closed {} expired orders", n);
}
条件 UPDATE 顺带化解了两个竞态:多实例同时跑关单任务,各自更新各自的受影响行;支付回调与关单同时发生,谁先把状态改走,另一个的 WHERE 就不再命中——不需要额外加锁。
反模式四:用 Redis 过期事件做关单的唯一机制。keyspace notification 是尽力而为的通知,进程重启或订阅断开期间的过期事件直接丢失。它可以做加速器,正确性必须由状态机的条件更新与扫表兜底。
验证
curl -X POST localhost:8080/orders -d '{"orderNo":"A1","amountCents":9900}'
curl -X POST localhost:8080/callback/pay \
-d '{"orderNo":"A1","channel":"mock","channelTxn":"T1","amountCents":9900}'
1. 用脚本把第二条回调重放 10 次,然后核对:
| 检查项 | 操作 | 期望 |
|---|---|---|
| 回调幂等 | 重放 10 次同一回调 | payment_records 仍 1 行,订单只变一次 PAID |
| 状态不回退 | 关单后重放支付回调 | 订单保持 CLOSED,回调拿到确定性答复 |
| 事务边界 | 让 channel.prepay 抛超时 | 订单仍以 CREATED 落库,无悬挂连接 |
| 对账发现 | 删一行流水后跑对账 | 收到”我方已支付但无流水”告警 |
总结
- 钱的正确性不靠代码顺序靠约束:状态机的条件 UPDATE 管并发,唯一约束管判重,两者都是数据库语义
- 幂等做在入口(唯一约束挡重放),状态机做在出口(条件更新挡回退),中间那段才是业务代码
- 外部调用永远在本地事务之外:先落”我要做什么”的事实,再做可能失败的动作,失败交给状态机与对账安放
- 对账不是微服务专用的高级货,接外部系统的第一天就需要;它滞后但确定
- 定时扫表是超时关单的诚实起点,延迟消息是优化,Redis 过期监听不能承担正确性
文中用到的 record 与模式匹配等新语法见《Java 现代语法特性》;如果项目的痛点是启动速度与内存占用而非订单语义,《Quarkus 入门》是另一条值得对比的路线。
版本核实(以官方文档为准):写作时点(2025 年 12 月)Spring Boot 最新大版本为 4.0(2025 年 11 月 GA,基于 Spring Framework 7,最低要求 Java 17,推荐 Java 25 LTS),3.5.x 仍是多数生产环境在用的稳定线;本文只用 Spring 生态长期稳定的 API(@Transactional、JdbcTemplate、@Scheduled、DuplicateKeyException),3.5 与 4.0 均可运行。安装命令未钉版本号。