基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

接口幂等性设计中,如何利用Redis防止前端用户因网络卡顿而导致的重复提交订单?

知识点图片

在接口幂等性设计中,利用 Redis 防止前端网络卡顿导致的重复提交订单,业界最常用且最可靠的方案主要有两种:Token 机制(防重令牌)分布式锁机制(Idempotency-Key)

下面为你详细拆解这两种方案的设计流程、核心代码逻辑以及避坑指南。


方案一:Token 机制(防重令牌)—— 推荐方案

这是目前主流的防重提交方案。核心思想是:“先申请令牌,再携带令牌提交,令牌一次性有效”

1. 业务流程

  1. 进入订单确认页:前端在进入“提交订单”页面时,向后端发起请求获取 Token。
  2. 生成并缓存 Token:后端生成一个全局唯一的 Token(如 UUID),将其存入 Redis,并设置一个过期时间(如 15 分钟),然后将 Token 返回给前端。
    • Redis Key 格式示例:order:submit:token:{userId}:{UUID}
  3. 提交订单:用户点击“提交订单”,前端将该 Token 放在请求头(Header)或请求体中传给后端。
  4. 校验 Token(核心原子操作):后端接收到请求后,去 Redis 中删除该 Token。
    • 如果删除成功(返回 1):说明是第一次提交,继续执行创建订单的业务逻辑。
    • 如果删除失败(返回 0):说明 Token 不存在(已被其他请求删除或已过期),判断为重复提交或非法请求,直接返回错误提示(如“正在处理中,请勿重复提交”)。

2. 代码实现关键点(以 Java + Spring Boot 为例)

绝对不能先 GET 再 DEL,这在并发下会产生竞态条件(两个请求同时 GET 到 Token 都为 true,然后都去执行业务)。必须利用 Redis 的单线程特性保证原子性

推荐做法:直接使用 DEL 命令的返回值

java
// 从前端请求头获取 token
String token = request.getHeader("Idempotent-Token");
if (StringUtils.isBlank(token)) {
    throw new BusinessException("非法请求,缺少防重 Token");
}

String redisKey = "order:submit:token:" + userId + ":" + token;

// 直接执行 delete 操作。Redis 的 delete 命令是原子的,并且会返回成功删除的 key 的数量
Boolean isDeleted = redisTemplate.delete(redisKey);

if (Boolean.TRUE.equals(isDeleted)) {
    // 成功删除,说明是第一次请求,执行真正的下单逻辑
    try {
        createOrder(orderDTO);
    } catch (Exception e) {
        // 注意:如果是业务参数校验失败(如库存不足),可以考虑重新把 Token 放入 Redis,让用户可重试。
        // 但如果是网络超时或未知异常,通常不建议回滚 Token,让用户刷新页面重新获取最安全。
        throw e;
    }
} else {
    // 删除失败(Token 不存在),说明是重复请求
    throw new BusinessException("订单正在处理中,请勿重复提交!");
}

方案二:前端生成 Idempotency-Key + Redis 分布式锁

如果不想多出一次获取 Token 的网络交互,可以让前端利用算法生成唯一标识,配合 Redis 的 SETNX(Set if Not eXists)来实现。

1. 业务流程

  1. 前端生成 Key:用户点击“提交订单”时,前端生成一个唯一的标识(UUID),通常称为 Idempotency-Key
  2. 携带 Key 提交:前端将请求参数和 Idempotency-Key 一并发送给后端。
  3. 加锁校验:后端利用 Redis 的 SETNX 命令,尝试以该 Key 为键,存入 Redis,并设置一个较短的过期时间(例如 5 秒或 10 秒,覆盖网络卡顿的重试时间即可)。
    • 如果 SETNX 成功:说明是第一次请求,执行业务逻辑。
    • 如果 SETNX 失败:说明 Redis 中已经存在该 Key,判断为重复提交,直接拦截。

2. 代码实现关键点

java
String idempotencyKey = request.getHeader("Idempotency-Key");
String redisKey = "order:lock:" + userId + ":" + idempotencyKey;

// 使用 SETNX 尝试加锁,设置 10 秒过期时间保证不产生死锁
// setIfAbsent 是原子操作
Boolean isFirstRequest = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", 10, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(isFirstRequest)) {
    try {
        // 执行下单逻辑
        createOrder(orderDTO);
    } finally {
        // 疑问:下单完成后要不要删除这个 Key?
        // 答:防网络卡顿场景下,*不要删除*。如果删除了,第二次卡顿的请求刚好到达,就会再次加锁成功,导致重复下单。
        // 让它自然过期即可(10秒后自然消失)。
    }
} else {
    throw new BusinessException("正在提交中,请耐心等待...");
}

两种方案的对比与选择

维度 方案一:防重 Token(推荐) 方案二:前端 Idempotency-Key + SETNX
交互次数 需多一次接口请求获取 Token 只有一次提交请求
可靠性 极高,后端严格管控 较高,依赖前端生成的唯一性和质量
适用场景 订单提交、支付等高核心、高价值场景 点赞、评论、普通表单等容忍度较高的场景
防重范围 只能防一次页面生命周期内的重复提交 根据 Key 的过期时间决定防重窗口期

💡 生产环境避坑与最佳实践(必读)

仅靠 Redis 还是不够的,一个工业级的防重/幂等设计应该是多层防御的:

  1. 前端防御(第一道防线)
    • 用户点击提交后,立即 Disable 按钮(置灰),或者显示 Loading 遮罩层,防止用户在卡顿时疯狂点击。
  2. Redis 防御(第二道防线)
    • 就是上述的 Token 或分布式锁机制,拦截掉 99% 突破前端限制的重复并发请求。
  3. 数据库唯一索引(最后一道防线 / 兜底)
    • Redis 极端情况下可能会发生主从切换导致数据丢失,从而使防重失效。
    • 因此,数据库层面必须建立唯一约束(Unique Index)。例如,基于订单号(如果订单号是前端生成的),或者在订单表里加一列 request_id 并设为唯一索引。一旦发生重复,数据库会抛出 DuplicateKeyException,后端捕获异常并给用户友好提示。
  4. Token 删除与业务失败的权衡
    • 在 Token 方案中,如果在 DEL Token 成功后,业务代码因为(如:商品突然下架、余额不足)报错了,我们要不要把 Token 恢复回 Redis?
    • 建议:不恢复。 即使是业务报错,本次“提交意图”也已经消耗了。如果恢复 Token,可能引发意想不到的并发漏洞。正确的做法是要求前端捕获错误后,静默调用一次获取 Token 接口更新 Token,或者让用户重新刷新页面。
00:00
00:00