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

《Java Web 开发中基于 Spring Boot + MyBatis 的高并发订单系统接口幂等与防重复提交实战》

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

背景与问题

做订单系统时,“用户只点了一次,系统却下了两单” 是最典型、也最致命的问题之一。

在 Java Web 项目里,尤其是基于 Spring Boot + MyBatis 的经典三层架构,高并发下重复提交通常来自这几类场景:

  1. 前端重复点击
    • 用户手抖连续点“立即下单”
    • 按钮防抖没做好
  2. 网络重试
    • 网关、客户端 SDK、浏览器重发请求
    • 移动端弱网导致请求超时后重试
  3. 消息重复投递
    • 下单后异步流程触发多次消费
  4. 服务并发竞争
    • 同一个用户、同一商品、同一业务动作,在极短时间内同时进入系统
  5. 分布式部署
    • 单机内存锁失效,多实例下重复请求被不同节点同时处理

很多团队一开始会说:“前端加个按钮禁用不就好了?”
但做过线上系统的人都知道,前端防重只能减少,不可能替代后端幂等

订单接口一旦没有兜底,最常见的后果是:

  • 重复生成订单
  • 重复扣库存
  • 重复支付流水
  • 优惠券重复核销
  • 对账异常,补单、退单成本高

所以本文不只讲“怎么防重复提交”,更重点讲:在 Spring Boot + MyBatis 架构下,如何分层构建一个真正能扛高并发的幂等下单方案。


背景下的核心目标

我们先把目标说清楚,不然后面容易“技术做了很多,业务却没兜住”。

一个高并发订单接口,至少要满足:

  • 同一个业务请求只成功落库一次
  • 重复请求要么直接复用第一次结果,要么明确拒绝
  • 不能因为加锁把吞吐量拖死
  • 数据库层必须有最终兜底
  • 代码要便于排查线上问题

我通常会把它拆成三层防线:

  1. 请求层防重:幂等 Key / Token / 防重复提交
  2. 业务层控制:状态判断、分布式锁、去重表
  3. 数据库层兜底:唯一索引、事务、乐观/悲观控制

这三层里,数据库唯一约束是最后一道防线,不能省


核心原理

1. 幂等不等于“接口只能调用一次”

先纠正一个常见误区。

幂等(Idempotent) 指的是:
同一个请求被执行一次和执行多次,结果应当一致。

例如:

  • 第一次请求创建订单成功,返回订单号 A123
  • 第二次相同请求过来,不应该再创建一个 B456
  • 而应该:
    • 返回第一次结果 A123,或者
    • 明确告知“重复请求”

对订单创建来说,最理想的是:识别同一业务意图,并复用已有结果


2. 防重复提交与业务幂等的区别

这两个概念经常被混用,但其实不一样。

防重复提交

偏前置拦截,重点是“不要让重复请求进来”。

常见手段:

  • 前端按钮置灰
  • 一次性 Token
  • Redis 短期去重 Key

业务幂等

偏后置保证,重点是“即使重复请求进来了,也不能产生重复业务结果”。

常见手段:

  • 幂等号唯一索引
  • 订单去重表
  • 状态机校验
  • 数据库事务兜底

经验结论:

防重复提交是降噪,业务幂等才是真正的安全边界。


3. 订单接口最稳的设计思路

对“创建订单”这类写操作,我更推荐的方案是:

  • 客户端生成或服务端下发一个 requestId / idempotentKey
  • 后端接收请求后,先尝试写入一张“幂等记录表”
  • 这张表对 idempotent_key 建唯一索引
  • 插入成功,说明第一次处理,继续执行业务
  • 插入失败,说明重复请求,直接查询历史结果返回

这个思路的优点很明显:

  • 不依赖单机锁
  • 多实例可用
  • 数据库天然能做唯一约束
  • 能保留处理记录,方便排查

4. 整体架构流程

flowchart TD
    A[客户端发起下单请求<br/>携带幂等键 idempotentKey] --> B[Spring Boot Controller]
    B --> C[Service 校验参数与业务规则]
    C --> D[插入幂等记录表]
    D -->|成功| E[创建订单主表记录]
    E --> F[更新幂等记录状态与订单号]
    F --> G[返回订单创建结果]
    D -->|唯一键冲突| H[查询幂等记录]
    H --> I[若已成功则返回历史订单号]
    H --> J[若处理中则提示稍后重试]

5. 时序视角:为什么数据库唯一索引很关键

sequenceDiagram
    participant C1 as 客户端请求1
    participant C2 as 客户端请求2
    participant App as Spring Boot服务
    participant DB as MySQL

    C1->>App: 创建订单(idempotentKey=K1)
    C2->>App: 创建订单(idempotentKey=K1)

    App->>DB: 插入幂等记录(K1)
    DB-->>App: 成功

    App->>DB: 插入幂等记录(K1)
    DB-->>App: 唯一键冲突

    App->>DB: 创建订单
    DB-->>App: 订单号 O1001

    App->>DB: 更新幂等记录结果
    DB-->>App: 更新成功

    App-->>C1: 返回 O1001
    App->>DB: 查询 K1 对应结果
    DB-->>App: O1001
    App-->>C2: 返回 O1001

这张图其实说明了一件非常重要的事:

在高并发场景里,真正能“决定只有一个成功进入业务主流程”的,不是 if 判断,而是数据库唯一约束。

因为两个请求几乎同时到达时,应用层的“先查再插”会有竞态条件。
唯一索引 + 插入尝试 是天然原子化的。


方案对比与取舍分析

在架构设计里,不同方案各有适用边界。下面是我比较常用的几种方式。

方案一:前端按钮禁用

优点:

  • 实现快
  • 能过滤掉一部分重复点击

缺点:

  • 无法应对网络重试、接口超时重发、恶意请求
  • 后端仍可能产生重复订单

适用场景:

  • 只能作为辅助,不可单独依赖

方案二:Redis SETNX 短锁

优点:

  • 性能好
  • 适合瞬时并发去重

缺点:

  • 锁过期时间不好控制
  • 业务执行时间不可预测时容易失效
  • 如果拿到锁后服务宕机,状态一致性要额外处理
  • 只靠 Redis 锁,不保底

适用场景:

  • 做第一层快速拦截可以
  • 不能替代数据库幂等兜底

方案三:数据库唯一索引 + 幂等记录表

优点:

  • 一致性强
  • 易追踪、易审计
  • 多实例天然支持

缺点:

  • 每次请求都要访问数据库
  • 热点业务下数据库压力会上升

适用场景:

  • 核心交易链路首选
  • 尤其适合订单、支付、券核销这类强一致场景

方案四:Redis + 数据库双层防线

思路:

  • Redis 负责快速挡住高频重复请求
  • 数据库唯一索引负责最终兜底

优点:

  • 性能和一致性平衡较好
  • 对高并发更友好

缺点:

  • 实现复杂度更高
  • 需要处理 Redis 与 DB 状态不一致的问题

我的建议:

如果是普通中型系统,先把数据库幂等做好,再考虑 Redis 优化
不要一上来就搞很重的分布式锁方案,结果主干逻辑反而变复杂。


数据模型设计

为了让示例可运行,我们设计两张表:

  1. t_idempotent_record:幂等记录表
  2. t_order:订单表

建表 SQL

CREATE TABLE t_idempotent_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotent_key VARCHAR(64) NOT NULL,
    biz_type VARCHAR(32) NOT NULL,
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0-处理中 1-成功 2-失败',
    order_no VARCHAR(64) DEFAULT NULL,
    request_json TEXT,
    error_msg VARCHAR(255) DEFAULT NULL,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_idempotent_key_biz_type (idempotent_key, biz_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE t_order (
    id BIGINT PRIMARY KEY 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,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有两个细节值得强调:

  • uk_idempotent_key_biz_type:同一业务类型下,幂等键必须唯一
  • status:不要只记录“有没有”,还要记录“处理中/成功/失败”

为什么要有“处理中”?
因为并发请求到来时,后来的请求看到前一个还没完成,就能知道当前不是失败,而是正在处理中,可以选择“稍后重试”或者“轮询结果”。


实战代码(可运行)

下面给一个简化但完整的 Spring Boot + MyBatis 示例。
重点是思路和关键代码,能直接落地到项目里。

1. 实体类

Order.java

package com.example.demo.domain;

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

public class Order {
    private Long id;
    private String orderNo;
    private Long userId;
    private Long productId;
    private BigDecimal amount;
    private Integer status;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public Long getProductId() {
        return productId;
    }

    public void setProductId(Long productId) {
        this.productId = productId;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public void setAmount(BigDecimal amount) {
        this.amount = amount;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public LocalDateTime getCreateTime() {
        return createTime;
    }

    public void setCreateTime(LocalDateTime createTime) {
        this.createTime = createTime;
    }

    public LocalDateTime getUpdateTime() {
        return updateTime;
    }

    public void setUpdateTime(LocalDateTime updateTime) {
        this.updateTime = updateTime;
    }
}

IdempotentRecord.java

package com.example.demo.domain;

import java.time.LocalDateTime;

public class IdempotentRecord {
    private Long id;
    private String idempotentKey;
    private String bizType;
    private Integer status;
    private String orderNo;
    private String requestJson;
    private String errorMsg;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getIdempotentKey() {
        return idempotentKey;
    }

    public void setIdempotentKey(String idempotentKey) {
        this.idempotentKey = idempotentKey;
    }

    public String getBizType() {
        return bizType;
    }

    public void setBizType(String bizType) {
        this.bizType = bizType;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public String getRequestJson() {
        return requestJson;
    }

    public void setRequestJson(String requestJson) {
        this.requestJson = requestJson;
    }

    public String getErrorMsg() {
        return errorMsg;
    }

    public void setErrorMsg(String errorMsg) {
        this.errorMsg = errorMsg;
    }

    public LocalDateTime getCreateTime() {
        return createTime;
    }

    public void setCreateTime(LocalDateTime createTime) {
        this.createTime = createTime;
    }

    public LocalDateTime getUpdateTime() {
        return updateTime;
    }

    public void setUpdateTime(LocalDateTime updateTime) {
        this.updateTime = updateTime;
    }
}

2. DTO 与返回对象

CreateOrderRequest.java

package com.example.demo.dto;

import java.math.BigDecimal;

public class CreateOrderRequest {
    private String idempotentKey;
    private Long userId;
    private Long productId;
    private BigDecimal amount;

    public String getIdempotentKey() {
        return idempotentKey;
    }

    public void setIdempotentKey(String idempotentKey) {
        this.idempotentKey = idempotentKey;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public Long getProductId() {
        return productId;
    }

    public void setProductId(Long productId) {
        this.productId = productId;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public void setAmount(BigDecimal amount) {
        this.amount = amount;
    }
}

OrderResponse.java

package com.example.demo.dto;

public class OrderResponse {
    private boolean success;
    private String message;
    private String orderNo;

    public OrderResponse() {
    }

    public OrderResponse(boolean success, String message, String orderNo) {
        this.success = success;
        this.message = message;
        this.orderNo = orderNo;
    }

    public static OrderResponse success(String orderNo) {
        return new OrderResponse(true, "success", orderNo);
    }

    public static OrderResponse fail(String message) {
        return new OrderResponse(false, message, null);
    }

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public String getMessage() {
        return message;
    }

    public String getOrderNo() {
        return orderNo;
    }
}

3. Mapper 接口

OrderMapper.java

package com.example.demo.mapper;

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

@Mapper
public interface OrderMapper {
    int insert(Order order);

    Order findByOrderNo(@Param("orderNo") String orderNo);
}

IdempotentRecordMapper.java

package com.example.demo.mapper;

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

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

    IdempotentRecord findByKeyAndBizType(@Param("idempotentKey") String idempotentKey,
                                         @Param("bizType") String bizType);

    int updateSuccess(@Param("idempotentKey") String idempotentKey,
                      @Param("bizType") String bizType,
                      @Param("orderNo") String orderNo);

    int updateFail(@Param("idempotentKey") String idempotentKey,
                   @Param("bizType") String bizType,
                   @Param("errorMsg") String errorMsg);
}

4. 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.Order" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO t_order (order_no, user_id, product_id, amount, status)
        VALUES (#{orderNo}, #{userId}, #{productId}, #{amount}, #{status})
    </insert>

    <select id="findByOrderNo" resultType="com.example.demo.domain.Order">
        SELECT id, order_no AS orderNo, user_id AS userId, product_id AS productId,
               amount, status, create_time AS createTime, update_time AS updateTime
        FROM t_order
        WHERE order_no = #{orderNo}
    </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.IdempotentRecord" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO t_idempotent_record
        (idempotent_key, biz_type, status, order_no, request_json, error_msg)
        VALUES
        (#{idempotentKey}, #{bizType}, #{status}, #{orderNo}, #{requestJson}, #{errorMsg})
    </insert>

    <select id="findByKeyAndBizType" resultType="com.example.demo.domain.IdempotentRecord">
        SELECT id,
               idempotent_key AS idempotentKey,
               biz_type AS bizType,
               status,
               order_no AS orderNo,
               request_json AS requestJson,
               error_msg AS errorMsg,
               create_time AS createTime,
               update_time AS updateTime
        FROM t_idempotent_record
        WHERE idempotent_key = #{idempotentKey}
          AND biz_type = #{bizType}
    </select>

    <update id="updateSuccess">
        UPDATE t_idempotent_record
        SET status = 1,
            order_no = #{orderNo},
            error_msg = NULL
        WHERE idempotent_key = #{idempotentKey}
          AND biz_type = #{bizType}
    </update>

    <update id="updateFail">
        UPDATE t_idempotent_record
        SET status = 2,
            error_msg = #{errorMsg}
        WHERE idempotent_key = #{idempotentKey}
          AND biz_type = #{bizType}
    </update>
</mapper>

5. Service 实现

这是核心逻辑。

OrderService.java

package com.example.demo.service;

import com.example.demo.dto.CreateOrderRequest;
import com.example.demo.dto.OrderResponse;

public interface OrderService {
    OrderResponse createOrder(CreateOrderRequest request);
}

OrderServiceImpl.java

package com.example.demo.service.impl;

import com.example.demo.domain.IdempotentRecord;
import com.example.demo.domain.Order;
import com.example.demo.dto.CreateOrderRequest;
import com.example.demo.dto.OrderResponse;
import com.example.demo.mapper.IdempotentRecordMapper;
import com.example.demo.mapper.OrderMapper;
import com.example.demo.service.OrderService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Objects;
import java.util.UUID;

@Service
public class OrderServiceImpl implements OrderService {

    private static final String BIZ_TYPE = "CREATE_ORDER";

    private final IdempotentRecordMapper idempotentRecordMapper;
    private final OrderMapper orderMapper;
    private final ObjectMapper objectMapper;

    public OrderServiceImpl(IdempotentRecordMapper idempotentRecordMapper,
                            OrderMapper orderMapper,
                            ObjectMapper objectMapper) {
        this.idempotentRecordMapper = idempotentRecordMapper;
        this.orderMapper = orderMapper;
        this.objectMapper = objectMapper;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public OrderResponse createOrder(CreateOrderRequest request) {
        validateRequest(request);

        tryInsertIdempotentRecord(request);

        IdempotentRecord record = idempotentRecordMapper.findByKeyAndBizType(request.getIdempotentKey(), BIZ_TYPE);
        if (record != null && !Objects.equals(record.getStatus(), 0)) {
            if (Objects.equals(record.getStatus(), 1)) {
                return OrderResponse.success(record.getOrderNo());
            }
            return OrderResponse.fail("请求已处理失败,请更换幂等键后重试");
        }

        try {
            String orderNo = generateOrderNo();

            Order order = new Order();
            order.setOrderNo(orderNo);
            order.setUserId(request.getUserId());
            order.setProductId(request.getProductId());
            order.setAmount(request.getAmount());
            order.setStatus(0);

            orderMapper.insert(order);
            idempotentRecordMapper.updateSuccess(request.getIdempotentKey(), BIZ_TYPE, orderNo);

            return OrderResponse.success(orderNo);
        } catch (Exception e) {
            idempotentRecordMapper.updateFail(request.getIdempotentKey(), BIZ_TYPE, e.getMessage());
            throw e;
        }
    }

    private void tryInsertIdempotentRecord(CreateOrderRequest request) {
        IdempotentRecord record = new IdempotentRecord();
        record.setIdempotentKey(request.getIdempotentKey());
        record.setBizType(BIZ_TYPE);
        record.setStatus(0);
        record.setRequestJson(toJson(request));

        try {
            idempotentRecordMapper.insert(record);
        } catch (DuplicateKeyException e) {
            IdempotentRecord exist = idempotentRecordMapper.findByKeyAndBizType(request.getIdempotentKey(), BIZ_TYPE);
            if (exist == null) {
                throw e;
            }
            if (Objects.equals(exist.getStatus(), 1)) {
                throw new RepeatSubmitSuccessException(exist.getOrderNo());
            }
            if (Objects.equals(exist.getStatus(), 0)) {
                throw new RepeatSubmitProcessingException("请求正在处理中,请稍后再试");
            }
            throw new RepeatSubmitFailedException("请求已处理失败,请更换幂等键后重试");
        }
    }

    private void validateRequest(CreateOrderRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("请求不能为空");
        }
        if (request.getIdempotentKey() == null || request.getIdempotentKey().trim().isEmpty()) {
            throw new IllegalArgumentException("idempotentKey不能为空");
        }
        if (request.getUserId() == null || request.getProductId() == null || request.getAmount() == null) {
            throw new IllegalArgumentException("请求参数不完整");
        }
    }

    private String toJson(CreateOrderRequest request) {
        try {
            return objectMapper.writeValueAsString(request);
        } catch (JsonProcessingException e) {
            return "{}";
        }
    }

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

上面这段代码里,我故意把“重复请求”做成异常分支,是因为这样更容易在 Controller 层统一处理。

自定义异常

package com.example.demo.service.impl;

public class RepeatSubmitSuccessException extends RuntimeException {
    private final String orderNo;

    public RepeatSubmitSuccessException(String orderNo) {
        super("重复请求,返回历史结果");
        this.orderNo = orderNo;
    }

    public String getOrderNo() {
        return orderNo;
    }
}
package com.example.demo.service.impl;

public class RepeatSubmitProcessingException extends RuntimeException {
    public RepeatSubmitProcessingException(String message) {
        super(message);
    }
}
package com.example.demo.service.impl;

public class RepeatSubmitFailedException extends RuntimeException {
    public RepeatSubmitFailedException(String message) {
        super(message);
    }
}

6. Controller

OrderController.java

package com.example.demo.controller;

import com.example.demo.dto.CreateOrderRequest;
import com.example.demo.dto.OrderResponse;
import com.example.demo.service.OrderService;
import com.example.demo.service.impl.RepeatSubmitFailedException;
import com.example.demo.service.impl.RepeatSubmitProcessingException;
import com.example.demo.service.impl.RepeatSubmitSuccessException;
import org.springframework.web.bind.annotation.*;

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public OrderResponse createOrder(@RequestBody CreateOrderRequest request) {
        try {
            return orderService.createOrder(request);
        } catch (RepeatSubmitSuccessException e) {
            return OrderResponse.success(e.getOrderNo());
        } catch (RepeatSubmitProcessingException e) {
            return OrderResponse.fail(e.getMessage());
        } catch (RepeatSubmitFailedException e) {
            return OrderResponse.fail(e.getMessage());
        }
    }
}

7. 启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class IdempotentOrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(IdempotentOrderApplication.class, args);
    }
}

8. 配置示例

application.yml

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

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.demo.domain

server:
  port: 8080

9. 请求示例

curl --location 'http://127.0.0.1:8080/orders' \
--header 'Content-Type: application/json' \
--data '{
  "idempotentKey": "req-10001",
  "userId": 1,
  "productId": 101,
  "amount": 99.90
}'

如果你连续发两次、三次同样的请求,理论上都应该返回同一个 orderNo,而不会生成多条订单。


状态设计建议

实际项目里,光有成功和失败往往不够。我比较推荐的幂等记录状态机如下:

stateDiagram-v2
    [*] --> PROCESSING
    PROCESSING --> SUCCESS
    PROCESSING --> FAILED
    FAILED --> [*]
    SUCCESS --> [*]

可以进一步扩展成:

  • PROCESSING:处理中
  • SUCCESS:成功
  • FAILED:失败
  • UNKNOWN:外部依赖超时,结果待确认

如果下单链路里还调用了库存、营销、支付预创建等外部服务,那 UNKNOWN 状态会非常实用。
否则你会遇到一个经典问题:本地超时了,但对方其实已经成功了。


常见坑与排查

这部分我想讲得更接地气一点,因为很多问题代码看着没毛病,线上一压就出事。

1. 只做“先查后插”,不做唯一索引

错误写法通常是:

IdempotentRecord record = mapper.findByKeyAndBizType(key, bizType);
if (record == null) {
    mapper.insert(...);
}

看起来没问题,但并发下两个线程都可能查到 null,然后都去插入。
根因:查和插不是原子操作。

正确做法

  • 表上加唯一索引
  • 代码里直接尝试插入
  • DuplicateKeyException 判断重复

2. 幂等键设计不合理

有些系统把 userId + 当前秒级时间戳 当幂等键,这几乎等于没做。

合理的幂等键要求

  • 同一业务请求意图保持稳定
  • 唯一且不可预测
  • 最好由客户端生成 UUID,或者服务端先下发 Token

典型错误

  • 每次重试都生成新 key
    这样服务端根本无法识别是同一个请求
  • 用业务字段硬拼接,但字段不完整
    比如只用 userId + productId,会误伤用户正常多次购买

3. 事务边界不清晰

我踩过一个坑:
幂等记录插入和订单创建不在同一个事务控制逻辑里,结果出现:

  • 幂等记录插入成功
  • 订单插入失败
  • 后续重复请求一来,发现已经有幂等记录,却拿不到正确结果

这时系统就会进入“卡死态”。

建议

  • 幂等记录与主业务操作尽量放在一个完整事务流程里
  • 至少要把失败状态明确落表
  • 对处理中超时的数据,要有补偿机制

4. 将失败请求永久锁死

有些实现里,只要某个 idempotentKey 用过一次,不管成功失败都不允许再用。

这在支付类场景有时合理,但在普通订单创建场景未必合理。
如果第一次是因为参数异常或库存瞬时不足失败,是否允许用户重新发起,要看业务。

建议

区分两类失败:

  • 业务确定性失败:如参数错误、商品下架
    可直接失败,不建议同 key 重试
  • 系统临时性失败:如网络抖动、数据库超时
    可以引导客户端查询结果或使用新 key 重试

5. 重复请求返回不一致

这类问题很隐蔽:

  • 第一次返回完整订单信息
  • 第二次重复请求只返回“请勿重复提交”
  • 第三次又返回处理中

前端一看就蒙了,用户体验也差。

建议

重复请求返回策略要统一:

  • 如果第一次成功,尽量返回同一个业务结果
  • 如果第一次处理中,返回明确状态码和提示
  • 如果第一次失败,给出可执行处理建议

6. 日志没带幂等键

没有 idempotentKey 的日志,排查重复订单几乎像盲人摸象。

最佳实践

在日志 MDC 中统一注入:

  • traceId
  • userId
  • idempotentKey
  • orderNo

这样一条链路从网关到业务层都能串起来。


安全/性能最佳实践

订单幂等不只是“别重复插单”,还涉及安全和性能。

1. 幂等键不要裸奔信任

如果幂等键完全由客户端随便传,恶意用户可以:

  • 猜测别人的 key
  • 构造冲突请求
  • 利用重复请求探测系统行为

建议

  • 幂等键长度限制,例如 32~64
  • 字符白名单校验
  • 与用户身份绑定校验
  • 必要时服务端下发一次性 token

2. 请求参数要做“同 key 同内容”校验

这一点非常重要,也很容易漏。

假设同一个 idempotentKey

  • 第一次请求:商品 A,金额 99
  • 第二次请求:商品 B,金额 1

如果你只按 key 去重,却不校验请求体一致性,就可能引发安全问题或业务歧义。

建议

在幂等记录表中保存请求摘要,例如:

  • 请求体 JSON
  • 参数哈希值(推荐)

若同 key 但参数摘要不同:

  • 直接拒绝
  • 记录安全日志

3. Redis 作为前置挡板,而不是唯一真相

如果系统 QPS 很高,可以增加一层 Redis:

  • SETNX idempotent:{bizType}:{key},设置短 TTL
  • 抢不到就直接判重
  • 抢到后继续数据库逻辑
  • 最终仍以数据库唯一键为准

这可以降低部分数据库压力。
但我还是强调一次:Redis 是加速器,不是最终裁判。


4. 幂等记录表要考虑归档与清理

这张表如果不清理,订单量一上来会越来越大。

建议

  • create_time 建普通索引
  • 保留近 30~90 天热数据
  • 历史数据归档到冷库
  • 定时删除无用失败记录或已归档记录

5. 容量估算要提前做

以一个中型电商系统为例:

  • 峰值下单 QPS:2000
  • 幂等记录写入:与下单请求同量级
  • 若保留 30 天,记录数可能是千万到亿级

这会影响:

  • 唯一索引大小
  • 查询延迟
  • 主从复制压力
  • 清理任务成本

经验建议

  • idempotent_key 不要设计得太长
  • biz_type 建议短字符串或枚举编码
  • 热点接口单独分表/分库时,要同步考虑幂等表路由策略
  • 不要把大段请求体原文都塞进表里,必要时只存哈希与关键字段

6. 失败补偿和结果查询接口要配套

真正线上稳定的方案,通常不只一个“创建订单”接口,还会配:

  • 查询幂等请求结果接口
  • 查询订单状态接口

因为客户端遇到超时后,最佳做法不是直接无限重试创建,而是:

  1. 先带原 idempotentKey 查询结果
  2. 如果结果明确成功,直接展示订单
  3. 如果还在处理中,再做退避重试

这个思路能显著降低重复流量。


一套更稳的落地架构建议

如果你在做中高并发订单系统,我建议按下面这个层次落地:

flowchart LR
    A[前端按钮防抖] --> B[网关限流与签名校验]
    B --> C[Redis短期防重]
    C --> D[Spring Boot业务服务]
    D --> E[MyBatis事务处理]
    E --> F[MySQL唯一索引兜底]
    D --> G[结果查询接口]
    E --> H[日志/监控/告警]

这套设计的核心不是“用的技术多”,而是每一层职责清晰

  • 前端:减少误触
  • 网关:拦恶意流量
  • Redis:挡住短时重复洪峰
  • 业务服务:保证状态正确流转
  • MySQL:提供最终一致性兜底
  • 监控:帮助你在出问题时定位

边界条件与适用范围

这套方案并不是万能钥匙,使用时要知道边界。

适合的场景

  • 创建订单
  • 支付请求受理
  • 优惠券领取/核销
  • 报名/预约类一次性提交操作

不太适合直接照搬的场景

  • 高频写入且完全允许重复的日志型接口
  • 强依赖外部三方回调结果、且本地无法快速确认状态的流程
  • 跨多个异构系统、缺少统一幂等标识的复杂分布式事务

如果你的业务已经进入“多服务、多消息队列、多外部支付渠道”的复杂阶段,那么还要进一步引入:

  • 消息幂等消费
  • Outbox / 本地消息表
  • 分布式事务补偿
  • 状态机驱动

但即便如此,订单入口接口的幂等仍然是第一步


总结

高并发订单系统里,接口幂等和防重复提交不是“锦上添花”,而是交易系统的基础能力。

这篇文章的核心结论,我建议你直接记住这几条:

  1. 前端防重只能辅助,不能替代后端幂等
  2. 真正可靠的兜底一定要落到数据库唯一约束
  3. 推荐用“幂等记录表 + 唯一索引 + 状态流转”实现订单接口幂等
  4. 重复请求最好返回第一次成功结果,而不是简单报错
  5. 同一个幂等键必须校验请求内容一致性
  6. 高并发优化可以加 Redis,但不要让 Redis 成为唯一真相
  7. 日志、监控、结果查询接口,是线上可运维性的关键

如果你现在正要落地一个中级规模的 Spring Boot + MyBatis 订单系统,我的可执行建议是:

  • 第一阶段先上:
    • 幂等键
    • 幂等记录表
    • 唯一索引
    • 事务控制
  • 第二阶段再补:
    • Redis 前置防重
    • 查询结果接口
    • 归档清理
    • 监控告警
  • 第三阶段按流量演进:
    • 分库分表
    • 异步化
    • 消息幂等

一句话收尾:

防重复提交解决的是“少进来”,接口幂等解决的是“进来了也不能出错”。在订单系统里,后者才是真正保命的能力。


分享到:

上一篇
《Docker Compose 实战:为中型项目构建可复用的多环境开发与部署配置》
下一篇
《自动化测试中的测试数据管理实战:构建稳定、可复用的中级测试体系》