// —— R10.56:移动端左栏平铺恢复(目标永不移动——根治展开位移竞态;r-open 模式真机实测仍踩竞态,弃)—— // 附带:父级链接恢复可点(移除主题写死的 onclick='return false;')。 add_action( 'wp_head', function () { ?>
用 Java 写一个模拟订单支付系统:幂等、对账与事务边界

要做什么:一个会重复、会抖、会死在半路的支付闭环

这篇用 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 均可运行。安装命令未钉版本号。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇