跳转到内容
123xiao | 无名键客

《Java Web开发实战:基于Spring Boot与MyBatis实现高并发订单接口的幂等性与性能优化》

字数: 0 阅读时长: 1 分钟

Java Web开发实战:基于Spring Boot与MyBatis实现高并发订单接口的幂等性与性能优化

在订单系统里,“重复提交”和“高并发压垮数据库”几乎是必考题。用户点了两次下单、前端重试、网关超时后补发请求、消息投递重复……这些都可能让同一笔业务被执行多次。
如果接口没有幂等保护,轻则生成重复订单,重则库存扣错、金额对不上,后续补偿也非常痛苦。

这篇文章我换一个更偏架构实战的角度来讲:不是只教你“加个 token”或“做个唯一索引”,而是从请求链路、数据模型、并发控制、SQL 优化、容量边界几层一起落地,做一个能在 Spring Boot + MyBatis 中跑起来的方案。


背景与问题

先看一个常见的下单接口:

@PostMapping("/orders")
public Long createOrder(@RequestBody CreateOrderRequest request) {
    return orderService.createOrder(request);
}

表面上很简单,但在真实环境里会遇到这些问题:

  1. 用户重复点击提交
  2. 前端超时重试
  3. 服务调用链中网关或中间件重复投递
  4. 服务实例扩容后,多节点同时处理同一业务请求
  5. 数据库在高并发下出现锁竞争、慢 SQL、连接池耗尽

很多同学一开始会写成:

  • 先查有没有这笔订单
  • 没有就插入
  • 插完再扣库存

这种“先查后写”在并发下很容易失效。两个线程同时查都不存在,然后都插入,重复订单就出现了。

所以这里有两个核心目标:

  • 幂等性:同一业务请求只执行一次
  • 性能:在高并发下仍然能稳定返回,不把数据库打爆

方案总览:从“防重复”到“抗高并发”

我比较推荐把订单接口拆成三层保障:

  1. 请求层幂等:用 idempotent_key 标识同一业务请求
  2. 数据库层兜底:唯一索引保证“物理上只能成功一次”
  3. 服务层优化:减少无效查询、缩短事务、控制锁粒度

整体链路图

flowchart TD
    A[客户端发起下单请求] --> B[携带幂等键 idempotentKey]
    B --> C[Spring Boot Controller]
    C --> D[Service 参数校验]
    D --> E[尝试插入幂等记录]
    E -->|首次请求| F[创建订单]
    E -->|重复请求| G[查询已有处理结果]
    F --> H[写订单表]
    H --> I[更新幂等记录状态与结果]
    I --> J[返回订单号]
    G --> J

核心原理

1. 什么叫订单接口幂等

幂等不是“接口只能调一次”,而是:

对于同一业务语义的请求,无论调用多少次,结果都应一致。

比如用户点击支付前下单,如果请求参数相同且幂等键相同,那么最终只能生成一笔订单。


2. 为什么“查一下再插入”不可靠

很多人会写这种逻辑:

select * from order where user_id = ? and biz_no = ?
如果不存在 -> insert

这在单线程没问题,但并发下两个线程都可能读到“不存在”。

所以更可靠的方式是:

  • 先写入一条幂等记录
  • 利用数据库唯一索引争抢“处理权”
  • 拿到处理权的线程继续创建订单
  • 其他线程直接返回已有结果

这本质上是把并发竞争交给数据库的唯一约束来解决。


3. 基于唯一索引的幂等设计

我们引入一张幂等表 idempotent_record

  • idempotent_key:幂等键
  • status:处理中 / 成功 / 失败
  • biz_type:业务类型,比如 CREATE_ORDER
  • result:处理结果,比如订单号

状态流转图

stateDiagram-v2
    [*] --> PROCESSING
    PROCESSING --> SUCCESS: 订单创建成功
    PROCESSING --> FAILED: 执行异常
    FAILED --> PROCESSING: 可选重试
    SUCCESS --> [*]

这张表的价值是:

  • 能防止重复执行
  • 能保存处理结果,便于重复请求直接返回
  • 能做故障排查,看到请求到底卡在哪一步

4. 为什么不只用 Redis

很多文章会说“用 Redis setnx 就行”。这当然能做,但在订单这种关键链路里,我通常建议:

  • Redis 可做前置削峰
  • MySQL 唯一索引必须做最终兜底

原因很现实:

  1. Redis 有过期、主从延迟、运维复杂度
  2. 订单最终状态仍然落在数据库
  3. 最可靠的幂等边界,还是数据库唯一约束

所以本文以 MySQL 唯一索引 + MyBatis 落库 作为主方案。


表结构设计

1. 订单表

CREATE TABLE `t_order` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `order_no` VARCHAR(64) NOT NULL,
  `user_id` BIGINT NOT NULL,
  `product_id` BIGINT NOT NULL,
  `amount` DECIMAL(10,2) NOT NULL,
  `status` TINYINT NOT NULL DEFAULT 0,
  `idempotent_key` VARCHAR(64) NOT 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_order_no` (`order_no`),
  UNIQUE KEY `uk_idempotent_key` (`idempotent_key`),
  KEY `idx_user_id_create_time` (`user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. 幂等记录表

CREATE TABLE `t_idempotent_record` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `idempotent_key` VARCHAR(64) NOT NULL,
  `biz_type` VARCHAR(32) NOT NULL,
  `status` TINYINT NOT NULL,
  `result` VARCHAR(128) DEFAULT NULL,
  `request_body` TEXT,
  `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_key_biz_type` (`idempotent_key`, `biz_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

方案对比与取舍分析

常见方案对比

方案优点缺点适用场景
前端按钮置灰实现简单只能防用户重复点击,防不了重试和多节点体验优化
Token 机制能防表单重复提交生命周期管理麻烦,不适合链路重试页面提交类业务
Redis setnx性能高,适合削峰结果存储、过期策略、故障恢复要额外设计高并发快速拦截
MySQL 唯一索引强一致,最终可靠直接打数据库,热点键有竞争订单、支付等核心链路
状态机 + 幂等表可观测、可恢复设计略复杂核心交易系统

我的建议

对于“高并发订单接口”:

  • 主方案:MySQL 唯一索引 + 幂等记录表
  • 增强方案:前置 Redis 缓冲热点请求
  • 超高并发场景:下单入口异步化,库存与订单拆链路

实战代码(可运行)

下面给一个精简但可运行的示例,核心点包括:

  • Spring Boot
  • MyBatis
  • 基于唯一索引的幂等控制
  • 重复请求直接返回已创建订单

为了让示例聚焦,库存扣减、MQ 等逻辑先不展开。


1. Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.3.1</version>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2. application.yml

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis:
  type-aliases-package: com.example.demo.domain
  configuration:
    map-underscore-to-camel-case: true

3. 请求对象

package com.example.demo.dto;

import lombok.Data;

import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;

@Data
public class CreateOrderRequest {

    @NotBlank
    private String idempotentKey;

    @NotNull
    private Long userId;

    @NotNull
    private Long productId;

    @NotNull
    @DecimalMin("0.01")
    private BigDecimal amount;
}

4. 实体类

package com.example.demo.domain;

import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
public class OrderDO {
    private Long id;
    private String orderNo;
    private Long userId;
    private Long productId;
    private BigDecimal amount;
    private Integer status;
    private String idempotentKey;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}
package com.example.demo.domain;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class IdempotentRecordDO {
    private Long id;
    private String idempotentKey;
    private String bizType;
    private Integer status;
    private String result;
    private String requestBody;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

5. Mapper 接口

package com.example.demo.mapper;

import com.example.demo.domain.OrderDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface OrderMapper {
    int insert(OrderDO orderDO);

    OrderDO selectByIdempotentKey(@Param("idempotentKey") String idempotentKey);
}
package com.example.demo.mapper;

import com.example.demo.domain.IdempotentRecordDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface IdempotentRecordMapper {
    int insert(IdempotentRecordDO record);

    IdempotentRecordDO selectByKeyAndBizType(@Param("idempotentKey") String idempotentKey,
                                             @Param("bizType") String bizType);

    int updateStatusAndResult(@Param("idempotentKey") String idempotentKey,
                              @Param("bizType") String bizType,
                              @Param("status") Integer status,
                              @Param("result") String result);
}

6. MyBatis XML

OrderMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.OrderMapper">

    <insert id="insert" parameterType="com.example.demo.domain.OrderDO" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO t_order (
            order_no, user_id, product_id, amount, status, idempotent_key
        ) VALUES (
            #{orderNo}, #{userId}, #{productId}, #{amount}, #{status}, #{idempotentKey}
        )
    </insert>

    <select id="selectByIdempotentKey" resultType="com.example.demo.domain.OrderDO">
        SELECT id, order_no, user_id, product_id, amount, status, idempotent_key, create_time, update_time
        FROM t_order
        WHERE idempotent_key = #{idempotentKey}
    </select>

</mapper>

IdempotentRecordMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.IdempotentRecordMapper">

    <insert id="insert" parameterType="com.example.demo.domain.IdempotentRecordDO">
        INSERT INTO t_idempotent_record (
            idempotent_key, biz_type, status, result, request_body
        ) VALUES (
            #{idempotentKey}, #{bizType}, #{status}, #{result}, #{requestBody}
        )
    </insert>

    <select id="selectByKeyAndBizType" resultType="com.example.demo.domain.IdempotentRecordDO">
        SELECT id, idempotent_key, biz_type, status, result, request_body, create_time, update_time
        FROM t_idempotent_record
        WHERE idempotent_key = #{idempotentKey}
          AND biz_type = #{bizType}
    </select>

    <update id="updateStatusAndResult">
        UPDATE t_idempotent_record
        SET status = #{status},
            result = #{result}
        WHERE idempotent_key = #{idempotentKey}
          AND biz_type = #{bizType}
    </update>

</mapper>

7. Service 实现

这里是最关键的部分。

package com.example.demo.service;

import com.example.demo.domain.IdempotentRecordDO;
import com.example.demo.domain.OrderDO;
import com.example.demo.dto.CreateOrderRequest;
import com.example.demo.mapper.IdempotentRecordMapper;
import com.example.demo.mapper.OrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

@Service
@RequiredArgsConstructor
public class OrderService {

    private static final String BIZ_TYPE = "CREATE_ORDER";
    private static final int PROCESSING = 0;
    private static final int SUCCESS = 1;
    private static final int FAILED = 2;

    private final OrderMapper orderMapper;
    private final IdempotentRecordMapper idempotentRecordMapper;

    @Transactional(rollbackFor = Exception.class)
    public String createOrder(CreateOrderRequest request) {
        boolean owner = tryCreateIdempotentRecord(request);

        if (!owner) {
            IdempotentRecordDO record = idempotentRecordMapper
                    .selectByKeyAndBizType(request.getIdempotentKey(), BIZ_TYPE);
            if (record != null && record.getStatus() == SUCCESS) {
                return record.getResult();
            }

            OrderDO existingOrder = orderMapper.selectByIdempotentKey(request.getIdempotentKey());
            if (existingOrder != null) {
                return existingOrder.getOrderNo();
            }

            throw new IllegalStateException("请求正在处理中,请稍后重试");
        }

        try {
            OrderDO orderDO = new OrderDO();
            orderDO.setOrderNo(generateOrderNo());
            orderDO.setUserId(request.getUserId());
            orderDO.setProductId(request.getProductId());
            orderDO.setAmount(request.getAmount());
            orderDO.setStatus(1);
            orderDO.setIdempotentKey(request.getIdempotentKey());

            orderMapper.insert(orderDO);

            idempotentRecordMapper.updateStatusAndResult(
                    request.getIdempotentKey(),
                    BIZ_TYPE,
                    SUCCESS,
                    orderDO.getOrderNo()
            );

            return orderDO.getOrderNo();
        } catch (Exception e) {
            idempotentRecordMapper.updateStatusAndResult(
                    request.getIdempotentKey(),
                    BIZ_TYPE,
                    FAILED,
                    null
            );
            throw e;
        }
    }

    private boolean tryCreateIdempotentRecord(CreateOrderRequest request) {
        IdempotentRecordDO record = new IdempotentRecordDO();
        record.setIdempotentKey(request.getIdempotentKey());
        record.setBizType(BIZ_TYPE);
        record.setStatus(PROCESSING);
        record.setRequestBody(buildRequestBody(request));

        try {
            return idempotentRecordMapper.insert(record) > 0;
        } catch (DuplicateKeyException e) {
            return false;
        }
    }

    private String generateOrderNo() {
        return "ORD" + System.currentTimeMillis() + UUID.randomUUID().toString().replace("-", "").substring(0, 6);
    }

    private String buildRequestBody(CreateOrderRequest request) {
        return String.format("{\"userId\":%d,\"productId\":%d,\"amount\":%s}",
                request.getUserId(), request.getProductId(), request.getAmount().toPlainString());
    }
}

8. Controller

package com.example.demo.controller;

import com.example.demo.dto.CreateOrderRequest;
import com.example.demo.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public String create(@RequestBody @Validated CreateOrderRequest request) {
        return orderService.createOrder(request);
    }
}

并发时序分析

只看代码还不够,最好把两个并发请求放在一起看。

sequenceDiagram
    participant C1 as Client-1
    participant C2 as Client-2
    participant S as OrderService
    participant DB as MySQL

    C1->>S: createOrder(idempotentKey=K1)
    C2->>S: createOrder(idempotentKey=K1)

    S->>DB: insert t_idempotent_record(K1)
    DB-->>S: success

    S->>DB: insert t_idempotent_record(K1)
    DB-->>S: DuplicateKeyException

    S->>DB: insert t_order
    DB-->>S: success

    S->>DB: update t_idempotent_record status=SUCCESS,result=orderNo
    DB-->>S: success

    S-->>C1: 返回新订单号
    S->>DB: select t_idempotent_record by K1
    DB-->>S: SUCCESS + orderNo
    S-->>C2: 返回已有订单号

这个过程里,真正决定“谁能执行”的不是 Java 锁,而是数据库唯一索引。
这样就算服务部署成多个实例,也不会失效。


性能优化:别只顾防重复,还要扛得住流量

幂等做完后,很多系统还是慢,原因往往不在业务逻辑,而在数据库和事务细节。


1. 事务要尽量短

一个常见问题是把很多非核心逻辑都放进事务里,比如:

  • 参数拼装
  • 远程调用
  • 日志写入
  • 发消息

事务越长,锁持有时间越长,并发性能越差。

建议:

  • 事务里只保留核心落库操作
  • MQ、通知、审计日志尽量异步化
  • 不要在事务里做慢接口调用

2. 索引必须围绕查询路径设计

本文里最常见的查询是:

  • idempotent_key + biz_type
  • idempotent_key
  • user_id + create_time

所以索引就要对应这些路径。
别为了“看起来保险”建一堆无效索引,不然写入性能反而下降。


3. 避免热点幂等键设计错误

如果幂等键生成不合理,会造成大量冲突甚至误判。比如:

  • 只用 userId
  • 只用 productId
  • 把时间粒度压得太粗

正确做法是让幂等键明确对应一次业务提交行为,例如:

  • 前端提交前生成 UUID
  • 网关透传 requestId
  • 业务方用 用户ID + 购物车快照哈希 + 客户端随机串

我踩过一个坑:某项目直接拿 userId 做幂等键,结果一个用户一天只能下一单,线上直接翻车。


4. 使用批量思维,而不是每步都查一次

例如重复请求来了,不要写成:

  1. 查幂等表
  2. 查订单表
  3. 再查状态表

链路越长,数据库压力越大。
最佳实践是以幂等表结果为主返回,订单表只作为异常兜底。


5. 连接池与线程池要匹配

高并发下还有一个隐蔽问题:应用线程很多,但数据库连接池很小,最后线程都堵在拿连接上。

建议关注:

  • Tomcat/Undertow 工作线程数
  • HikariCP 最大连接数
  • MySQL 最大连接数
  • 慢 SQL 比例与 RT

一个朴素原则是:
不要让应用并发远大于数据库可承载并发。


容量估算思路

这里给一个中级开发常用的估算方式。

假设:

  • 峰值 QPS:1000
  • 订单接口平均每次 2~3 次 SQL
  • 单条 SQL 平均耗时 5ms
  • 幂等命中率 20%

粗略估算数据库压力:

  • 每秒 SQL 次数约 = 1000 × 2.5 = 2500
  • 如果命中幂等直接返回,实际核心写操作会降低
  • 数据库连接池至少要能覆盖峰值活跃事务数

如果订单接口高峰持续时间长,建议进一步拆分:

  1. 订单创建
  2. 库存预占
  3. 支付确认
  4. 最终状态流转

也就是说,别把所有动作都塞进一次同步下单请求里。


常见坑与排查

这一节我尽量写得接地气一点,因为线上故障基本都不是“不会写”,而是“写了但边界没守住”。


坑 1:幂等键相同,但请求参数其实不同

比如第一次请求:

{
  "idempotentKey": "abc123",
  "userId": 1,
  "productId": 1001,
  "amount": 99.00
}

第二次请求:

{
  "idempotentKey": "abc123",
  "userId": 1,
  "productId": 1002,
  "amount": 199.00
}

如果你不校验请求体一致性,系统就会错误地把第二次请求当作重复请求。

建议:

  • 在幂等表中保存请求摘要
  • 重复请求时比对参数哈希
  • 不一致直接拒绝

可扩展字段示例:

ALTER TABLE t_idempotent_record ADD COLUMN request_hash VARCHAR(64) DEFAULT NULL;

坑 2:事务回滚了,但幂等记录状态没处理好

如果订单插入失败,而幂等记录还停留在 PROCESSING,后续请求可能永远卡住。

排查方法:

  • t_idempotent_record.status
  • 查是否存在对应 t_order
  • 看异常日志是否在更新状态前就回滚了

建议:

  • 失败状态更新要有明确策略
  • 对长时间 PROCESSING 的记录做超时巡检
  • 必要时增加后台补偿任务

坑 3:把幂等当成防刷接口

幂等和防刷不是一回事。

  • 幂等:同一业务请求执行一次
  • 防刷:限制恶意高频请求

如果一个用户不断生成新的幂等键,依然能把系统打满。

补充措施:

  • IP / 用户维度限流
  • 网关层熔断
  • 验签与风控
  • 滑动窗口统计

坑 4:DuplicateKeyException 没有正确捕获

不同驱动、不同框架版本下异常包装层级可能不同。
如果你只捕一个很窄的异常类型,线上可能直接抛 500。

建议:

  • 在测试环境压测重复请求
  • 明确异常链
  • 全局异常处理里记录 SQLState 和错误码

坑 5:重复请求直接查订单表,导致雪崩查询

高并发下,如果大家都在查订单表,就算不重复插入,数据库压力也会很大。

建议:

  • 重复请求优先走幂等表结果
  • 必要时可将成功结果缓存到 Redis
  • 对处理中状态返回“稍后重试”,避免死等

安全/性能最佳实践

这一节给可以直接落地的建议。


1. 幂等键不要信任客户端裸传

客户端传幂等键没问题,但不能完全信任。建议:

  • 服务端校验格式和长度
  • 与用户身份绑定
  • 必要时签名校验,避免撞库和伪造

2. 参数摘要要入库

建议保存:

  • request_body 或其摘要
  • user_id
  • biz_type
  • trace_id

这样线上排查很快,不然你看到一条重复请求记录,根本不知道当时传了什么。


3. 成功结果缓存化

如果同一个幂等键会被短时间内重试很多次,可以把成功结果缓存起来:

  • Redis Key:idem:CREATE_ORDER:{idempotentKey}
  • Value:订单号
  • TTL:30 分钟 ~ 2 小时

这样能明显减少数据库重复查询。


4. 慢 SQL 治理

重点关注这几类 SQL:

  • 基于非索引字段查询幂等记录
  • 大事务中夹杂多次查询
  • 更新语句未命中唯一索引
  • 无分页的历史数据清理任务

建议开启:

  • MySQL slow log
  • 应用层 SQL 耗时日志
  • APM 链路追踪

5. 历史幂等数据要归档

幂等表不是越存越好。它通常是“短期防重 + 排障辅助”的数据。
如果一直不清理,索引膨胀后写入和查询都会变慢。

建议策略:

  • 保留 7~30 天在线数据
  • 定时归档历史记录
  • 使用按时间分区或归档表

6. 高峰期考虑削峰与异步化

如果你的订单创建还涉及:

  • 优惠券核销
  • 多库存源扣减
  • 多个远程服务同步调用

那单纯优化 SQL 不够,应该考虑:

  • 请求先入队
  • 快速返回受理结果
  • 后台异步生成订单
  • 用状态查询接口返回最终结果

这个边界很重要:
同步幂等适合中高并发;超高并发要配合异步架构。


一个更稳妥的增强版思路

如果你想继续提升稳定性,我建议把方案演进成这样:

  1. 网关层限流
  2. 服务层幂等表争抢执行权
  3. 成功结果缓存 Redis
  4. 订单表唯一索引兜底
  5. 异常状态巡检补偿
  6. 核心事件异步投递 MQ

增强版架构图

flowchart LR
    A[客户端] --> B[API网关限流]
    B --> C[Spring Boot订单服务]
    C --> D[Redis结果缓存]
    C --> E[MySQL幂等表]
    C --> F[MySQL订单表]
    C --> G[MQ事件投递]
    G --> H[库存/通知/积分服务]

这个架构的优点是:

  • 核心一致性仍由数据库保证
  • 重复流量可被缓存和限流拦截
  • 下游非核心逻辑异步解耦

总结

高并发订单接口做幂等,最怕两种极端:

  • 只谈概念,不落到可执行代码
  • 只会“加唯一索引”,却不考虑性能和运维边界

这篇文章的核心结论可以浓缩成 4 句话:

  1. 订单幂等的核心,不是查有没有,而是让数据库唯一约束决定谁能执行。
  2. 幂等表不仅用于防重,更用于保存状态和结果,提升可观测性。
  3. 性能优化要围绕事务长度、索引设计、重复请求返回路径来做。
  4. 当流量继续上涨时,幂等只是基础,最终还要走限流、缓存、异步化。

如果你准备在项目里落地,我建议按这个顺序推进:

  • 第一步:给订单表和幂等表加唯一索引
  • 第二步:按本文代码把“抢执行权”逻辑做起来
  • 第三步:补上请求参数摘要校验
  • 第四步:加成功结果缓存和慢 SQL 监控
  • 第五步:评估是否需要异步化和削峰

最后提醒一个边界条件:
幂等保证的是“同一请求只成功一次”,它不等于分布式事务,也不自动保证库存、支付、优惠券三个系统天然一致。
如果你的链路已经跨多个服务,就要进一步配合状态机、消息最终一致性或 Saga 方案。

把这套基础打牢,订单接口的稳定性会提升非常明显。对于 Spring Boot + MyBatis 技术栈来说,这也是一套投入产出比很高的实战方案。


分享到:

上一篇
《从源码到生产:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南》
下一篇
《Kubernetes 集群架构实战:基于高可用控制平面与多可用区部署的设计要点与落地方案》