Java Web开发实战:基于Spring Boot与MyBatis实现高并发订单接口的幂等性与性能优化
在订单系统里,“重复提交”和“高并发压垮数据库”几乎是必考题。用户点了两次下单、前端重试、网关超时后补发请求、消息投递重复……这些都可能让同一笔业务被执行多次。
如果接口没有幂等保护,轻则生成重复订单,重则库存扣错、金额对不上,后续补偿也非常痛苦。
这篇文章我换一个更偏架构实战的角度来讲:不是只教你“加个 token”或“做个唯一索引”,而是从请求链路、数据模型、并发控制、SQL 优化、容量边界几层一起落地,做一个能在 Spring Boot + MyBatis 中跑起来的方案。
背景与问题
先看一个常见的下单接口:
@PostMapping("/orders")
public Long createOrder(@RequestBody CreateOrderRequest request) {
return orderService.createOrder(request);
}
表面上很简单,但在真实环境里会遇到这些问题:
- 用户重复点击提交
- 前端超时重试
- 服务调用链中网关或中间件重复投递
- 服务实例扩容后,多节点同时处理同一业务请求
- 数据库在高并发下出现锁竞争、慢 SQL、连接池耗尽
很多同学一开始会写成:
- 先查有没有这笔订单
- 没有就插入
- 插完再扣库存
这种“先查后写”在并发下很容易失效。两个线程同时查都不存在,然后都插入,重复订单就出现了。
所以这里有两个核心目标:
- 幂等性:同一业务请求只执行一次
- 性能:在高并发下仍然能稳定返回,不把数据库打爆
方案总览:从“防重复”到“抗高并发”
我比较推荐把订单接口拆成三层保障:
- 请求层幂等:用
idempotent_key标识同一业务请求 - 数据库层兜底:唯一索引保证“物理上只能成功一次”
- 服务层优化:减少无效查询、缩短事务、控制锁粒度
整体链路图
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_ORDERresult:处理结果,比如订单号
状态流转图
stateDiagram-v2
[*] --> PROCESSING
PROCESSING --> SUCCESS: 订单创建成功
PROCESSING --> FAILED: 执行异常
FAILED --> PROCESSING: 可选重试
SUCCESS --> [*]
这张表的价值是:
- 能防止重复执行
- 能保存处理结果,便于重复请求直接返回
- 能做故障排查,看到请求到底卡在哪一步
4. 为什么不只用 Redis
很多文章会说“用 Redis setnx 就行”。这当然能做,但在订单这种关键链路里,我通常建议:
- Redis 可做前置削峰
- MySQL 唯一索引必须做最终兜底
原因很现实:
- Redis 有过期、主从延迟、运维复杂度
- 订单最终状态仍然落在数据库
- 最可靠的幂等边界,还是数据库唯一约束
所以本文以 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_typeidempotent_keyuser_id + create_time
所以索引就要对应这些路径。
别为了“看起来保险”建一堆无效索引,不然写入性能反而下降。
3. 避免热点幂等键设计错误
如果幂等键生成不合理,会造成大量冲突甚至误判。比如:
- 只用
userId - 只用
productId - 把时间粒度压得太粗
正确做法是让幂等键明确对应一次业务提交行为,例如:
- 前端提交前生成 UUID
- 网关透传 requestId
- 业务方用
用户ID + 购物车快照哈希 + 客户端随机串
我踩过一个坑:某项目直接拿
userId做幂等键,结果一个用户一天只能下一单,线上直接翻车。
4. 使用批量思维,而不是每步都查一次
例如重复请求来了,不要写成:
- 查幂等表
- 查订单表
- 再查状态表
链路越长,数据库压力越大。
最佳实践是以幂等表结果为主返回,订单表只作为异常兜底。
5. 连接池与线程池要匹配
高并发下还有一个隐蔽问题:应用线程很多,但数据库连接池很小,最后线程都堵在拿连接上。
建议关注:
- Tomcat/Undertow 工作线程数
- HikariCP 最大连接数
- MySQL 最大连接数
- 慢 SQL 比例与 RT
一个朴素原则是:
不要让应用并发远大于数据库可承载并发。
容量估算思路
这里给一个中级开发常用的估算方式。
假设:
- 峰值 QPS:1000
- 订单接口平均每次 2~3 次 SQL
- 单条 SQL 平均耗时 5ms
- 幂等命中率 20%
粗略估算数据库压力:
- 每秒 SQL 次数约 =
1000 × 2.5 = 2500 - 如果命中幂等直接返回,实际核心写操作会降低
- 数据库连接池至少要能覆盖峰值活跃事务数
如果订单接口高峰持续时间长,建议进一步拆分:
- 订单创建
- 库存预占
- 支付确认
- 最终状态流转
也就是说,别把所有动作都塞进一次同步下单请求里。
常见坑与排查
这一节我尽量写得接地气一点,因为线上故障基本都不是“不会写”,而是“写了但边界没守住”。
坑 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_idbiz_typetrace_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 不够,应该考虑:
- 请求先入队
- 快速返回受理结果
- 后台异步生成订单
- 用状态查询接口返回最终结果
这个边界很重要:
同步幂等适合中高并发;超高并发要配合异步架构。
一个更稳妥的增强版思路
如果你想继续提升稳定性,我建议把方案演进成这样:
- 网关层限流
- 服务层幂等表争抢执行权
- 成功结果缓存 Redis
- 订单表唯一索引兜底
- 异常状态巡检补偿
- 核心事件异步投递 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 句话:
- 订单幂等的核心,不是查有没有,而是让数据库唯一约束决定谁能执行。
- 幂等表不仅用于防重,更用于保存状态和结果,提升可观测性。
- 性能优化要围绕事务长度、索引设计、重复请求返回路径来做。
- 当流量继续上涨时,幂等只是基础,最终还要走限流、缓存、异步化。
如果你准备在项目里落地,我建议按这个顺序推进:
- 第一步:给订单表和幂等表加唯一索引
- 第二步:按本文代码把“抢执行权”逻辑做起来
- 第三步:补上请求参数摘要校验
- 第四步:加成功结果缓存和慢 SQL 监控
- 第五步:评估是否需要异步化和削峰
最后提醒一个边界条件:
幂等保证的是“同一请求只成功一次”,它不等于分布式事务,也不自动保证库存、支付、优惠券三个系统天然一致。
如果你的链路已经跨多个服务,就要进一步配合状态机、消息最终一致性或 Saga 方案。
把这套基础打牢,订单接口的稳定性会提升非常明显。对于 Spring Boot + MyBatis 技术栈来说,这也是一套投入产出比很高的实战方案。