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。
基本流程如下:
- 前端请求获取 Token
- 后端生成唯一 Token
- Token 保存到 Redis
- 前端提交数据时携带 Token
- 后端删除 Token
- 删除成功才执行具体业务
生成 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 出现异常,数据库依然可以保证不会产生重复数据。
总结
接口幂等性看起来只是“防止重复提交”,实际上涉及并发控制、数据一致性和业务状态设计。
实际项目中常见的解决方案包括:
- 数据库唯一索引
- Redis Token
- Redis SETNX
- 业务状态判断
- 请求唯一 ID
没有一种方案适用于所有业务。
真正重要的是根据业务场景选择合适的实现方式,并为核心数据设置最终的数据约束。
毕竟线上系统最可怕的问题往往不是程序报错。
而是程序没有报错,却悄悄写入了两条错误数据。
