背景与问题
做订单系统时,“用户只点了一次,系统却下了两单” 是最典型、也最致命的问题之一。
在 Java Web 项目里,尤其是基于 Spring Boot + MyBatis 的经典三层架构,高并发下重复提交通常来自这几类场景:
- 前端重复点击
- 用户手抖连续点“立即下单”
- 按钮防抖没做好
- 网络重试
- 网关、客户端 SDK、浏览器重发请求
- 移动端弱网导致请求超时后重试
- 消息重复投递
- 下单后异步流程触发多次消费
- 服务并发竞争
- 同一个用户、同一商品、同一业务动作,在极短时间内同时进入系统
- 分布式部署
- 单机内存锁失效,多实例下重复请求被不同节点同时处理
很多团队一开始会说:“前端加个按钮禁用不就好了?”
但做过线上系统的人都知道,前端防重只能减少,不可能替代后端幂等。
订单接口一旦没有兜底,最常见的后果是:
- 重复生成订单
- 重复扣库存
- 重复支付流水
- 优惠券重复核销
- 对账异常,补单、退单成本高
所以本文不只讲“怎么防重复提交”,更重点讲:在 Spring Boot + MyBatis 架构下,如何分层构建一个真正能扛高并发的幂等下单方案。
背景下的核心目标
我们先把目标说清楚,不然后面容易“技术做了很多,业务却没兜住”。
一个高并发订单接口,至少要满足:
- 同一个业务请求只成功落库一次
- 重复请求要么直接复用第一次结果,要么明确拒绝
- 不能因为加锁把吞吐量拖死
- 数据库层必须有最终兜底
- 代码要便于排查线上问题
我通常会把它拆成三层防线:
- 请求层防重:幂等 Key / Token / 防重复提交
- 业务层控制:状态判断、分布式锁、去重表
- 数据库层兜底:唯一索引、事务、乐观/悲观控制
这三层里,数据库唯一约束是最后一道防线,不能省。
核心原理
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 优化。
不要一上来就搞很重的分布式锁方案,结果主干逻辑反而变复杂。
数据模型设计
为了让示例可运行,我们设计两张表:
t_idempotent_record:幂等记录表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. 失败补偿和结果查询接口要配套
真正线上稳定的方案,通常不只一个“创建订单”接口,还会配:
查询幂等请求结果接口查询订单状态接口
因为客户端遇到超时后,最佳做法不是直接无限重试创建,而是:
- 先带原
idempotentKey查询结果 - 如果结果明确成功,直接展示订单
- 如果还在处理中,再做退避重试
这个思路能显著降低重复流量。
一套更稳的落地架构建议
如果你在做中高并发订单系统,我建议按下面这个层次落地:
flowchart LR
A[前端按钮防抖] --> B[网关限流与签名校验]
B --> C[Redis短期防重]
C --> D[Spring Boot业务服务]
D --> E[MyBatis事务处理]
E --> F[MySQL唯一索引兜底]
D --> G[结果查询接口]
E --> H[日志/监控/告警]
这套设计的核心不是“用的技术多”,而是每一层职责清晰:
- 前端:减少误触
- 网关:拦恶意流量
- Redis:挡住短时重复洪峰
- 业务服务:保证状态正确流转
- MySQL:提供最终一致性兜底
- 监控:帮助你在出问题时定位
边界条件与适用范围
这套方案并不是万能钥匙,使用时要知道边界。
适合的场景
- 创建订单
- 支付请求受理
- 优惠券领取/核销
- 报名/预约类一次性提交操作
不太适合直接照搬的场景
- 高频写入且完全允许重复的日志型接口
- 强依赖外部三方回调结果、且本地无法快速确认状态的流程
- 跨多个异构系统、缺少统一幂等标识的复杂分布式事务
如果你的业务已经进入“多服务、多消息队列、多外部支付渠道”的复杂阶段,那么还要进一步引入:
- 消息幂等消费
- Outbox / 本地消息表
- 分布式事务补偿
- 状态机驱动
但即便如此,订单入口接口的幂等仍然是第一步。
总结
高并发订单系统里,接口幂等和防重复提交不是“锦上添花”,而是交易系统的基础能力。
这篇文章的核心结论,我建议你直接记住这几条:
- 前端防重只能辅助,不能替代后端幂等
- 真正可靠的兜底一定要落到数据库唯一约束
- 推荐用“幂等记录表 + 唯一索引 + 状态流转”实现订单接口幂等
- 重复请求最好返回第一次成功结果,而不是简单报错
- 同一个幂等键必须校验请求内容一致性
- 高并发优化可以加 Redis,但不要让 Redis 成为唯一真相
- 日志、监控、结果查询接口,是线上可运维性的关键
如果你现在正要落地一个中级规模的 Spring Boot + MyBatis 订单系统,我的可执行建议是:
- 第一阶段先上:
- 幂等键
- 幂等记录表
- 唯一索引
- 事务控制
- 第二阶段再补:
- Redis 前置防重
- 查询结果接口
- 归档清理
- 监控告警
- 第三阶段按流量演进:
- 分库分表
- 异步化
- 消息幂等
一句话收尾:
防重复提交解决的是“少进来”,接口幂等解决的是“进来了也不能出错”。在订单系统里,后者才是真正保命的能力。