0%

如何保证接口幂等

接口幂等性设计:确保重复请求安全处理的完整方案

接口幂等性是分布式系统设计中的关键特性,指多次执行相同请求时,系统最终状态与执行一次相同。这一特性可有效避免因网络重试、用户误操作等导致的数据重复或状态异常。本文将分析重复请求的成因,并提供从前端到后端的完整解决方案。

重复请求的常见场景

重复请求的产生可能源于客户端、网络或服务端,典型场景包括:

  1. 客户端操作失误
    • 用户快速点击提交按钮;
    • 刷新页面、使用后退 / 前进按钮重复提交表单;
    • 浏览器历史记录重复提交。
  2. 网络与重试机制
    • 网络延迟导致客户端未收到响应,触发重试;
    • 分布式系统中的请求重试机制(如 Feign 重试、消息队列重试)。
  3. 系统设计缺陷
    • 前端未禁用重复提交;
    • 后端未处理重复请求,导致重复写入数据库。

接口幂等性解决方案

确保幂等性需从前端防重后端校验两方面入手,形成多层防护。

1. 前端防重:减少重复请求产生

前端是防止重复请求的第一道防线,通过限制用户操作和请求发送,从源头减少重复请求。

(1)禁用提交按钮

点击提交后立即禁用按钮,防止短时间内重复点击:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<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 重定向 到成功页;
  • 刷新成功页时,浏览器仅重新请求结果页,而非表单提交接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
@PostMapping("/order/submit")
public String submitOrder(Order order) {
// 处理订单提交
orderService.create(order);
// 重定向到成功页(避免刷新重复提交)
return "redirect:/order/success?orderId=" + order.getId();
}

@GetMapping("/order/success")
public String successPage(@RequestParam String orderId, Model model) {
model.addAttribute("orderId", orderId);
return "orderSuccess"; // 成功页
}
(3)请求防抖与节流

通过防抖(Debounce)或节流(Throttle)限制请求频率:

1
2
3
4
5
6
7
8
9
// 防抖:短时间内多次触发,仅执行最后一次
let timer = null;
function debounceSubmit() {
clearTimeout(timer);
timer = setTimeout(() => {
// 发送请求
fetch("/api/submit", { method: "POST" });
}, 500); // 500ms 内重复点击不触发新请求
}

2. 后端校验:确保重复请求只执行一次

前端防重无法覆盖所有场景(如网络重试、绕过前端的恶意请求),需后端通过幂等设计确保安全性。

(1)基于 Token 的幂等校验(推荐)

核心思路:一次请求对应一个唯一 Token,后端通过 Token 判断是否为重复请求。

实现步骤:
  1. 生成 Token
    前端请求表单页时,后端生成唯一 Token(如 UUID),存入 Redis(或 Session),并返回给前端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @GetMapping("/form")
    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";
    }
  2. 前端携带 Token 提交
    表单中添加隐藏字段存储 Token,提交时一并发送:

    1
    2
    3
    4
    5
    <form action="/api/submit" method="post">
    <input type="hidden" name="token" value="${token}">
    <!-- 其他表单字段 -->
    <button type="submit">提交</button>
    </form>
  3. 后端校验 Token

    • 从请求中获取 Token,与存储的 Token 比对;
    • 一致则处理请求,并删除存储的 Token(确保只能使用一次);
    • 不一致则视为重复请求,直接返回结果。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @PostMapping("/api/submit")
    public String submit(@RequestParam 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,后端捕获并处理:

1
2
3
4
5
6
7
8
-- 订单表:订单号唯一
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`) -- 唯一索引
);
1
2
3
4
5
6
7
8
9
10
@Transactional
public void createOrder(Order order) {
try {
orderMapper.insert(order); // 插入订单
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突,视为重复请求
log.warn("订单已存在:{}", order.getOrderNo());
// 可返回已存在的订单信息
}
}
② 乐观锁(适合 UPDATE 操作)

通过版本号(Version)控制更新,确保只有版本匹配时才执行更新,避免重复修改:

1
2
3
4
5
6
-- 商品表:包含版本号字段
CREATE TABLE `product` (
`id` bigint PRIMARY KEY,
`stock` int NOT NULL, -- 库存
`version` int NOT NULL DEFAULT 0 -- 版本号
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
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 语句:

1
2
3
4
5
<update id="reduceStock">
UPDATE product
SET stock = stock - #{quantity}, version = version + 1
WHERE id = #{productId} AND version = #{version}
</update>
(3)基于状态机的幂等设计

对于有明确状态流转的业务(如订单状态:待支付→已支付→已完成),通过状态机约束重复操作:

  • 定义状态流转规则(如 “已支付” 状态不能再次支付);
  • 处理请求时检查当前状态,不符合流转规则则拒绝操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 操作 性能好,无锁竞争 需额外维护版本号字段
状态机 有状态流转的操作(如订单) 符合业务逻辑,安全性高 适用场景有限

最佳实践

  1. 多层防护:结合前端防重 + 后端 Token 校验 + 数据库约束,形成完整防护链;
  2. 分布式适配:分布式系统中,用 Redis 存储 Token 和分布式锁,确保多节点一致性;
  3. 异常处理:重复请求应返回 “处理成功”(而非错误),避免客户端误解为失败而重试;
  4. 日志记录:记录重复请求的关键信息(如 Token、请求参数),便于问题排查

欢迎关注我的其它发布渠道