Java 接口幂等性设计:别让用户点两下,数据库多出两条数据

共 3300字,需浏览 7分钟

 ·

2026-07-13 03:32

在实际项目开发中,我们经常会遇到一个非常经典的问题:

用户明明只想提交一次请求,后端却执行了两次。

例如用户点击“提交订单”按钮,由于网络延迟没有立即响应,于是用户又点击了一次。

结果数据库中出现了两条订单。

类似的问题还可能发生在:

  • 表单重复提交
  • 支付回调重复通知
  • MQ 消息重复消费
  • 网络超时自动重试
  • 前端重复点击按钮

解决这类问题,就需要保证接口的幂等性。

一、什么是接口幂等性?

简单来说:

同一个请求执行一次和执行多次,最终产生的结果应该保持一致。

例如查询接口:

GET /user/1001

无论调用一次还是调用十次,都只是查询用户数据。

因此 GET 请求通常天然具有幂等性。

但是新增接口就不同了:

POST /order/create

如果调用两次,可能创建两条订单数据。

因此在实际开发中,需要针对关键业务接口进行幂等设计。

二、最简单的方案:数据库唯一索引

假设订单表中存在业务订单号:

CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
UNIQUE KEY uk_order_no (order_no)
);

通过给 order_no 添加唯一索引,可以避免相同订单重复插入。

Java 代码:

public void createOrder(Order order) {
orderMapper.insert(order);
}

即使接口被重复调用,第二次插入相同 order_no 时,数据库也会抛出唯一键冲突异常。

可以统一捕获异常:

try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
throw new BusinessException("订单已存在,请勿重复提交");
}

这种方案非常简单,而且可靠。

对于订单号、流水号等存在明确业务唯一标识的场景,我通常优先考虑数据库唯一索引。

因为数据库才是最终的数据防线。

三、Redis + Token 实现防重复提交

对于普通表单提交,可以使用 Redis Token。

基本流程如下:

  1. 前端请求获取 Token
  2. 后端生成唯一 Token
  3. Token 保存到 Redis
  4. 前端提交数据时携带 Token
  5. 后端删除 Token
  6. 删除成功才执行具体业务

生成 Token:

public String createToken() {
String token = UUID.randomUUID().toString();

redisTemplate.opsForValue().set(
"submit:token:" + token,
"1",
5,
TimeUnit.MINUTES
);

return token;
}

提交接口:

@PostMapping("/submit")
public Result submit(@RequestHeader("token") String token) {

String key = "submit:token:" + token;

Boolean success = redisTemplate.delete(key);

if (!Boolean.TRUE.equals(success)) {
return Result.error("请勿重复提交");
}

orderService.createOrder();

return Result.success();
}

这里有一个关键点:

必须使用 Redis 的原子操作。

不能先查询 Token 是否存在,然后再删除。

错误写法:

if (redisTemplate.hasKey(key)) {
redisTemplate.delete(key);
createOrder();
}

因为在高并发情况下,两个请求可能同时判断 Token 存在。

结果两个请求都会进入业务逻辑。

这就是典型的并发问题。

四、使用 Redis SETNX 实现业务幂等

如果业务存在唯一请求 ID,可以直接使用 Redis 的 SETNX。

例如:

String key = "order:idempotent:" + requestId;

Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.MINUTES);

if (!Boolean.TRUE.equals(success)) {
throw new BusinessException("请求正在处理中,请勿重复提交");
}

createOrder();

setIfAbsent 底层逻辑类似 Redis:

SET key value NX EX 600

只有 Key 不存在时才能设置成功。

因此多个请求同时进入时,只有一个请求能够继续执行。

这种方式非常适合:

  • 创建订单
  • 发放奖励
  • 创建任务
  • MQ 消息消费
  • 第三方接口回调

五、支付回调一定要做幂等

支付平台的回调通知通常不是只发送一次。

如果第一次通知没有收到正确响应,支付平台可能再次发送通知。

因此支付回调必须判断订单状态。

例如:

@Transactional
public void payCallback(String orderNo) {

Order order = orderMapper.selectByOrderNo(orderNo);

if (order == null) {
throw new BusinessException("订单不存在");
}

if (OrderStatus.PAID.equals(order.getStatus())) {
return;
}

order.setStatus(OrderStatus.PAID);

orderMapper.updateById(order);

accountService.addBalance(order.getUserId(), order.getAmount());
}

如果订单已经是 PAID 状态,则直接返回。

否则重复回调可能导致余额重复增加。

用户可能很开心。

财务和老板通常不会。

六、实际项目应该怎么选择?

不同业务场景应该选择不同的幂等方案。

业务场景推荐方案
普通查询接口无需特殊处理
表单重复提交Redis Token
创建订单唯一索引 + Redis
支付回调业务状态判断
MQ 消息消费消息 ID + Redis/数据库
高并发请求Redis SETNX
核心资金业务数据库约束 + 状态机

需要注意的是:

Redis 并不能完全代替数据库约束。

Redis 可以作为第一层拦截,提高系统性能。

数据库唯一索引则应该作为最终的数据安全保障。

在重要业务中,我通常会采用:

Redis 防重复 + 数据库唯一索引

即使 Redis 出现异常,数据库依然可以保证不会产生重复数据。

总结

接口幂等性看起来只是“防止重复提交”,实际上涉及并发控制、数据一致性和业务状态设计。

实际项目中常见的解决方案包括:

  1. 数据库唯一索引
  2. Redis Token
  3. Redis SETNX
  4. 业务状态判断
  5. 请求唯一 ID

没有一种方案适用于所有业务。

真正重要的是根据业务场景选择合适的实现方式,并为核心数据设置最终的数据约束。

毕竟线上系统最可怕的问题往往不是程序报错。

而是程序没有报错,却悄悄写入了两条错误数据。


浏览 60
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报