如何保证接口幂等
接口幂等性设计:确保重复请求安全处理的完整方案
接口幂等性是分布式系统设计中的关键特性,指多次执行相同请求时,系统最终状态与执行一次相同。这一特性可有效避免因网络重试、用户误操作等导致的数据重复或状态异常。本文将分析重复请求的成因,并提供从前端到后端的完整解决方案。
重复请求的常见场景
重复请求的产生可能源于客户端、网络或服务端,典型场景包括:
- 客户端操作失误:
- 用户快速点击提交按钮;
- 刷新页面、使用后退 / 前进按钮重复提交表单;
- 浏览器历史记录重复提交。
- 网络与重试机制:
- 网络延迟导致客户端未收到响应,触发重试;
- 分布式系统中的请求重试机制(如 Feign 重试、消息队列重试)。
- 系统设计缺陷:
- 前端未禁用重复提交;
- 后端未处理重复请求,导致重复写入数据库。
接口幂等性解决方案
确保幂等性需从前端防重和后端校验两方面入手,形成多层防护。
1. 前端防重:减少重复请求产生
前端是防止重复请求的第一道防线,通过限制用户操作和请求发送,从源头减少重复请求。
(1)禁用提交按钮
点击提交后立即禁用按钮,防止短时间内重复点击:
<button id="submitBtn" onclick="submitForm()">提交</button>
<script>
function submitForm() {
const btn = document.getElementById("submitBtn");
// 禁用按钮
btn.disabled = true;
btn.innerText = "提交中...";
// 发送请求
fetch("/api/submit", { method: "POST" })
.then(response => {
// 处理响应
btn.innerText = "提交成功";
})
.catch(error => {
// 失败时恢复按钮(允许重试)
btn.disabled = false;
btn.innerText = "重试";
});
}
</script>
(2)使用重定向(Post/Redirect/Get 模式)
表单提交后通过重定向到结果页,避免用户刷新页面导致的重复提交:
- 提交表单后,后端处理请求并返回
302 重定向到成功页; - 刷新成功页时,浏览器仅重新请求结果页,而非表单提交接口。
public String submitOrder(Order order) {
// 处理订单提交
orderService.create(order);
// 重定向到成功页(避免刷新重复提交)
return "redirect:/order/success?orderId=" + order.getId();
}
public String successPage( String orderId, Model model) {
model.addAttribute("orderId", orderId);
return "orderSuccess"; // 成功页
}
(3)请求防抖与节流
通过防抖(Debounce)或节流(Throttle)限制请求频率:
// 防抖:短时间内多次触发,仅执行最后一次
let timer = null;
function debounceSubmit() {
clearTimeout(timer);
timer = setTimeout(() => {
// 发送请求
fetch("/api/submit", { method: "POST" });
}, 500); // 500ms 内重复点击不触发新请求
}
2. 后端校验:确保重复请求只执行一次
前端防重无法覆盖所有场景(如网络重试、绕过前端的恶意请求),需后端通过幂等设计确保安全性。
(1)基于 Token 的幂等校验(推荐)
核心思路:一次请求对应一个唯一 Token,后端通过 Token 判断是否为重复请求。
实现步骤:
生成 Token:
前端请求表单页时,后端生成唯一 Token(如 UUID),存入 Redis(或 Session),并返回给前端:public String getForm(Model model, HttpSession session) { // 生成 Token String token = UUID.randomUUID().toString(); // 存入 Session(或 Redis,分布式系统推荐) session.setAttribute("requestToken", token); // 传递给前端 model.addAttribute("token", token); return "form"; }前端携带 Token 提交:
表单中添加隐藏字段存储 Token,提交时一并发送:<form action="/api/submit" method="post"> <input type="hidden" name="token" value="${token}"> <!-- 其他表单字段 --> <button type="submit">提交</button> </form>后端校验 Token:
- 从请求中获取 Token,与存储的 Token 比对;
- 一致则处理请求,并删除存储的 Token(确保只能使用一次);
- 不一致则视为重复请求,直接返回结果。
public String submit( String token, HttpSession session) { // 从 Session 获取存储的 Token String storedToken = (String) session.getAttribute("requestToken"); // 校验 Token if (token == null || !token.equals(storedToken)) { return "重复请求,已忽略"; } // 校验通过,删除 Token(防止重复使用) session.removeAttribute("requestToken"); // 处理业务逻辑(如创建订单) orderService.create(...); return "处理成功"; }
分布式系统适配:
- 用 Redis 替代 Session 存储 Token,确保多节点共享 Token;
- 设置 Token 过期时间(如 10 分钟),避免内存泄漏。
(2)数据库层幂等保障
即使前端和 Token 校验失效,数据库层仍需作为最后一道防线,防止重复写入。
① 唯一索引(适合 INSERT 操作)
对唯一标识字段(如订单号、用户 ID + 业务类型)创建唯一索引,重复插入时数据库会抛出 DuplicateKeyException,后端捕获并处理:
-- 订单表:订单号唯一
CREATE TABLE `order` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` bigint NOT NULL,
-- 其他字段...
UNIQUE KEY `uk_order_no` (`order_no`) -- 唯一索引
);
public void createOrder(Order order) {
try {
orderMapper.insert(order); // 插入订单
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突,视为重复请求
log.warn("订单已存在:{}", order.getOrderNo());
// 可返回已存在的订单信息
}
}
② 乐观锁(适合 UPDATE 操作)
通过版本号(Version)控制更新,确保只有版本匹配时才执行更新,避免重复修改:
-- 商品表:包含版本号字段
CREATE TABLE `product` (
`id` bigint PRIMARY KEY,
`stock` int NOT NULL, -- 库存
`version` int NOT NULL DEFAULT 0 -- 版本号
);
public boolean reduceStock(Long productId, int quantity) {
// 1. 查询商品及当前版本
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
return false; // 库存不足
}
// 2. 携带版本号更新(仅当 version 匹配时生效)
int rows = productMapper.reduceStock(
productId,
quantity,
product.getVersion() // 当前版本
);
// 3. 更新行数为 0 表示版本不匹配(已被其他请求修改)
return rows > 0;
}
对应的 SQL 语句:
<update id="reduceStock">
UPDATE product
SET stock = stock - #{quantity}, version = version + 1
WHERE id = #{productId} AND version = #{version}
</update>
(3)基于状态机的幂等设计
对于有明确状态流转的业务(如订单状态:待支付→已支付→已完成),通过状态机约束重复操作:
- 定义状态流转规则(如 “已支付” 状态不能再次支付);
- 处理请求时检查当前状态,不符合流转规则则拒绝操作。
public void payOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 检查状态:只有“待支付”状态可执行支付
if (!"PENDING_PAY".equals(order.getStatus())) {
throw new IllegalStateException("订单状态错误,无法支付");
}
// 执行支付逻辑
paymentService.pay(order);
// 更新状态为“已支付”
order.setStatus("PAID");
orderMapper.updateById(order);
}
各方案适用场景对比
| 方案 | 适用操作 | 优点 | 缺点 |
|---|---|---|---|
| 前端防重(禁用按钮、防抖) | 所有操作 | 简单直观,减少无效请求 | 无法防⽌绕过前端的恶意请求 |
| Token 校验 | 所有操作 | 通用性强,支持分布式 | 需额外存储 Token,增加开销 |
| 数据库唯一索引 | INSERT 操作 | 底层保障,可靠性高 | 仅适用于有唯一标识的场景 |
| 乐观锁 | UPDATE 操作 | 性能好,无锁竞争 | 需额外维护版本号字段 |
| 状态机 | 有状态流转的操作(如订单) | 符合业务逻辑,安全性高 | 适用场景有限 |
最佳实践
- 多层防护:结合前端防重 + 后端 Token 校验 + 数据库约束,形成完整防护链;
- 分布式适配:分布式系统中,用 Redis 存储 Token 和分布式锁,确保多节点一致性;
- 异常处理:重复请求应返回 “处理成功”(而非错误),避免客户端误解为失败而重试;
- 日志记录:记录重复请求的关键信息(如 Token、请求参数),便于问题排查