从原理到实战,彻底解决前后端数据一致性问题
目录导读
- 为什么接口重复请求必须过滤?(常见场景与危害)
- 前端拦截方案对比(防抖、节流、请求锁、唯一标识)
- 后端幂等性设计(Token机制、去重表、状态机)
- 全链路实战案例(注册、支付、表单提交)
- 常见问题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秒),超时后返回“请求处理中,请稍后”。
过滤接口重复请求,前端是辅助(用户体验优化),后端是核心(数据一致性保障),推荐组合方案:
- 前端:按钮防连点 + 生成唯一请求ID + 存储请求状态
- 后端:Redis去重表(SETNX) + 数据库唯一索引 + 幂等状态机
- 兜底:定期清理过期请求数据,监控重复请求数量与类型
只有前后端形成全链路闭环,才能从根本上杜绝重复请求导致的业务异常,下一期将深入探讨分布式事务中的幂等性实现,敬请关注。