接口重复请求如何过滤

wen 开源项目 27

从原理到实战,彻底解决前后端数据一致性问题

目录导读

  1. 为什么接口重复请求必须过滤?(常见场景与危害)
  2. 前端拦截方案对比(防抖、节流、请求锁、唯一标识)
  3. 后端幂等性设计(Token机制、去重表、状态机)
  4. 全链路实战案例(注册、支付、表单提交)
  5. 常见问题QA(高并发、网络波动、分布式场景)

为什么接口重复请求必须过滤?

场景重现:用户连点两次提交按钮

用户小王在支付页面点击“确认支付”,由于网络延迟,页面3秒没有反应,他下意识又点了一次——结果银行扣款两次,订单生成了两个,这就是典型的接口重复请求问题

接口重复请求如何过滤

重复请求的危害

  • 数据不一致:重复创建订单、重复扣款、重复注册
  • 服务器压力:无意义的请求消耗数据库连接和计算资源
  • 用户体验差:页面显示“提交中”却无法阻止二次点击

核心矛盾

前端无法100%保证用户只发一次请求(用户手速、浏览器自动重试、爬虫攻击),后端必须自己具备幂等性——即同样的请求无论调用多少次,结果都与调用一次相同。

前端拦截方案对比

方案1:防抖(Debounce)与节流(Throttle)

// 防抖:只执行最后一次
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}
// 节流:单位时间内只执行一次
function throttle(fn, interval) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

缺点:依赖用户行为时间间隔,无法应对“用户连点两次间隔极短”或“网络延迟导致的自动重试”。

方案2:请求锁(Loading状态)

let isLoading = false;
async function submitOrder(data) {
  if (isLoading) return alert('正在提交,请勿重复操作');
  isLoading = true;
  try {
    await API.submit(data);
  } finally {
    isLoading = false;
  }
}

缺点:如果用户刷新页面或关闭浏览器,锁会失效;且无法应对多标签页场景。

方案3:唯一请求标识(UUID)

// 每次请求生成唯一ID
function generateRequestId() {
  return 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2);
}
// 发送时携带
async function safeRequest(data) {
  const requestId = generateRequestId();
  // 存到localStorage用于检测是否已发送过(配合后端)
  localStorage.setItem('lastRequestId', requestId);
  return API.call(data, { headers: { 'X-Request-ID': requestId } });
}

优点:可配合后端实现全链路幂等,即使页面刷新也能恢复状态。

后端幂等性设计(核心)

方案1:全局唯一Request-ID + Redis去重表

用户请求 → 生成唯一ID(前端或后端生成)→ Redis SETNX(如果不存在则设置成功)
→ 成功:执行业务逻辑 → 记录结果到数据库
→ 失败:直接返回上次结果(查Redis或数据库)

实现要点

  • 使用Redis的SET key value NX EX 300(NX:不存在才设置,EX:过期时间300秒)
  • 业务完成后更新Redis中的状态(从“处理中”变为“已完成”)
  • 过期时间根据业务场景设置(支付建议5分钟,查询建议30秒)

方案2:数据库去重表(适用于订单、注册)

CREATE TABLE idempotent (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  request_id VARCHAR(64) UNIQUE,   -- 唯一索引
  business_type VARCHAR(20),        -- 业务类型(支付/注册)
  status TINYINT DEFAULT 0,         -- 0:处理中 1:成功 2:失败
  result TEXT,
  create_time DATETIME
);
-- 插入时使用唯一索引保证幂等
INSERT INTO idempotent (request_id, business_type) VALUES (?, ?);
-- 如果报错 Duplicate entry,说明已处理过,直接返回上次结果

方案3:状态机(适用于流程型业务)

// 订单状态流转:待支付 → 已支付 → 已发货
public boolean payOrder(String orderId, String requestId) {
  // 使用乐观锁更新状态
  int rows = db.update("UPDATE orders SET status='已支付', request_id=? WHERE id=? AND status='待支付'", requestId, orderId);
  if (rows == 0) {
    // 查询是否是本请求导致的重复(request_id相同)
    Order order = db.getOne("SELECT * FROM orders WHERE id=?", orderId);
    if (requestId.equals(order.getRequestId())) {
      return true; // 幂等返回成功
    }
    throw new ConflictException("订单已支付,请勿重复操作");
  }
  return true;
}

全链路实战案例

案例1:用户注册防重复

  • 前端:生成注册请求唯一ID,存在sessionStorage,点击后禁用按钮
  • 后端:使用Redis缓存请求ID(过期时间5分钟),先检查再创建用户
  • 数据库:用户表增加reg_request_id字段,唯一索引
    ALTER TABLE users ADD COLUMN reg_request_id VARCHAR(64) UNIQUE;

案例2:支付系统防重复扣款

  • 前端生成支付流水号(UUID),存入localStorage
  • 后端使用数据库去重表,请求ID作为主键
  • 核心逻辑:支付结果回调也要带请求ID,避免异步通知重复

案例3:分布式场景下的去重

多个微服务共享同一个Redis去重层,每个服务都从Redis获取请求状态,若服务A处理中突然崩溃,另一个服务B看到请求ID仍在Redis中(状态为处理中),需等待超时或手动清理。

常见问题QA

Q1:防抖、节流和请求锁,哪个最好用?

A:没有最好,只有场景适配。防抖适合输入框搜索(延迟发送),节流适合滚动加载(限制频率),请求锁适合按钮提交,但它们都无法应对后端分布式场景,唯一请求ID是终极方案

Q2:高并发下,Redis SETNX会性能差吗?

A:Redis单机QPS可达10万+,SETNX是原子操作,性能远高于数据库去重,但需注意哈希槽分布,同一请求ID应打到同一Redis节点(通过hash tag实现)。

Q3:网络波动导致请求成功但前端没收到响应,用户刷新页面后?

A:前端应将请求ID存入localStorage,刷新后先检查该ID是否已被成功处理(调用查询接口),后端应提供一个“通过请求ID查询结果”的接口。

Q4:去重表会不会成为性能瓶颈?

A:建议对request_id建立唯一索引,定期清理历史数据(保留1-7天),对于极高并发场景,可以先用Redis拦截,再异步写入数据库。

Q5:如果后端收到请求但还没返回结果,前端又发了相同请求?

A:后端在Redis中标记为“处理中”,第二个请求应等待(轮询或回调)直到第一个请求完成,而不是直接返回失败,可以设置等待超时(如5秒),超时后返回“请求处理中,请稍后”。

过滤接口重复请求,前端是辅助(用户体验优化),后端是核心(数据一致性保障),推荐组合方案:

  1. 前端:按钮防连点 + 生成唯一请求ID + 存储请求状态
  2. 后端:Redis去重表(SETNX) + 数据库唯一索引 + 幂等状态机
  3. 兜底:定期清理过期请求数据,监控重复请求数量与类型

只有前后端形成全链路闭环,才能从根本上杜绝重复请求导致的业务异常,下一期将深入探讨分布式事务中的幂等性实现,敬请关注。

抱歉,评论功能暂时关闭!