首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >活动开场一秒上千次提交,后台多出一倍重复记录:轻应用的幂等键、请求令牌与重试防重实践

活动开场一秒上千次提交,后台多出一倍重复记录:轻应用的幂等键、请求令牌与重试防重实践

原创
作者头像
数字化落地笔记
发布2026-09-16 09:20:33
发布2026-09-16 09:20:33
1030
举报

导读

做过活动型轻应用的大概都踩过这个坑:整点开场,用户涌进来,网络一卡就下意识连点提交按钮,弱网下客户端还会自动重试。活动结束一拉数据,同一条报名记录出现了两回,有的甚至三回。重复数据不光让名单难看,还会连带把库存、名额、抽奖次数算错。这篇文章把"重复提交"拆成三种来源,再从前端令牌、服务端幂等键到重试与消息消费,讲一套端到端的防重做法,附可直接用的代码。

一、先分清三种"重复",别想一个按钮挡掉所有

排查这类问题,第一步永远是先看清重复到底从哪来。我一般把它分成三类:

  • 按钮连点:用户在请求还没返回时连续点击,前端没拦住,几个请求几乎同时到后端。
  • 超时/弱网重试:请求其实已经到了服务端、也写库成功了,只是响应在半路丢了,客户端(或网关、SDK)以为失败又发一次。
  • 重复进入提交:用户退回上一页再进、刷新、或者把链接在两个设备上各提交一次。

这三类的共同点是"同一个业务意图被提交了多次",但拦截位置完全不同。连点主要靠前端挡,重试和跨设备重复只能靠服务端认。只在前端把按钮置灰,挡得住君子,挡不住弱网重试和直接调接口的请求——这是最常见的认知误区。

二、前端这一层:减少无效请求,但默认它不可信

前端要做的是"尽量别把重复请求发出去",而不是承担最终防重责任。一个基本做法是提交前先申请一次性令牌、提交期间加锁:

代码语言:javascript
复制
let submitting = false;
let idemToken = null;

// 进入页面时先向后端申请本次提交的幂等令牌
async function fetchToken(activityId) {
  const res = await fetch(`/api/idem-token?activityId=${activityId}`);
  const data = await res.json();
  idemToken = data.token;
  return idemToken;
}

async function submit(form) {
  if (submitting) return;          // 挡住同页面的连点
  if (!idemToken) await fetchToken(form.activityId);
  submitting = true;
  try {
    const res = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ...form, idemToken }),
    });
    const data = await res.json();
    if (data.ok) { location.href = '/success'; }
    else { submitting = false; idemToken = null; alert(data.msg); }
  } catch (e) {
    // 关键:超时重试必须复用同一个 idemToken,不能在这里重新申请
    submitting = false;
  }
}

注意 catch 里我没有重新申请令牌。重试时换新令牌,等于告诉服务端"这是两次全新请求",幂等就被自己绕过去了。

三、服务端幂等键:业务唯一键兜底,防重令牌拦并发

服务端才是防重的权威。这里建议分两层:

  • 业务唯一键:用业务上天然唯一的组合,比如"用户ID + 活动ID + 报名维度",在数据库建唯一索引。它解决"同一个人对同一场活动报两次"的问题,是最后一道防线。
  • 防重令牌(幂等 token):解决同一业务意图在几百毫秒内并发打进来、连唯一索引都可能撞出异常的问题。令牌先占位,处理完记录结果。

用 Redis 占位的一个常见写法:

代码语言:python
复制
import redis, json, uuid

r = redis.Redis(host='localhost', port=6379, db=0)

def signup(user_id, activity_id, idem_token, payload):
    # 令牌键:拦截同一次提交的连点/重试
    token_key = f'idem:signup:{idem_token}'
    # 业务键:拦截跨设备、跨页面的真实重复
    biz_key = f'biz:signup:{user_id}:{activity_id}'

    # SET NX:只有第一个请求能占位成功,EX 兜底过期
    got = r.set(token_key, 'PROCESSING', nx=True, ex=300)
    if not got:
        state = r.get(token_key)
        if state == b'PROCESSING':
            return {'ok': False, 'msg': '正在处理,请勿重复提交'}
        return json.loads(r.get(token_key + ':resp') or b'{}')  # 返回首次结果

    if r.exists(biz_key):
        r.set(token_key, 'DUP', ex=300)
        return {'ok': False, 'msg': '您已经报过名了'}

    try:
        order_id = do_insert(user_id, activity_id, payload)   # 内部走唯一索引
        r.set(biz_key, order_id, nx=True, ex=86400)
        resp = {'ok': True, 'orderId': order_id}
        r.set(token_key + ':resp', json.dumps(resp), ex=86400)
        r.set(token_key, 'DONE', ex=86400)
        return resp
    except Exception:
        r.delete(token_key)          # 失败释放,允许用户重试
        raise

数据库侧的唯一索引不能省,它是并发穿透后的硬兜底:

代码语言:sql
复制
CREATE UNIQUE INDEX uk_user_activity
ON signup_record(user_id, activity_id, biz_dimension);

应用层的 SETNX 和数据库唯一索引看似重复,其实是纵深:应用层挡掉绝大多数并发、避免异常刷屏,唯一索引保证哪怕应用层漏了,库里也绝不会出现两行。

四、超时重试、消息重投也要吃同一套幂等

现在的链路很少是"请求直接写库",中间往往还挂着消息队列。异步化之后,重复的来源又多了一个:消息至少投递一次,就可能重复消费。

  • 客户端/网关的超时重试要带上同一个幂等键,服务端对重复键返回首次结果,而不是报错——这样用户那边重试一次就能看到成功页,体验和一致性都顾上了。
  • 消费端写库前,同样用"业务唯一键 + 去重表"判断。消息里没有天然唯一键时,用生产端写入的消息ID做去重表主键。
代码语言:sql
复制
CREATE TABLE mq_consume_log (
  msg_id      VARCHAR(64) PRIMARY KEY,
  consumed_at DATETIME NOT NULL
);
-- 消费前先 INSERT IGNORE:插入成功才处理,已存在说明是重复投递直接 ACK

还有两个容易忽略的细节。一是重复请求别一律返回错误:第一次还在处理中,可以提示"请勿重复提交",但第一次已经成功时,应当把首次结果原样返回。否则客户端重试拿到一个报错,用户以为没报上、继续点,反而制造出更多请求。二是幂等记录保留多久要贴着业务窗口定:活动报名这类至少留到活动结束再加一段对账期,太短防不住晚到的重试和消息重投,太长又白白占内存。实际做法是让令牌键短过期(几分钟)、业务键长过期(跨整个活动周期),两类键各管一段,不必一个 TTL 走天下。

踩坑清单

  • 坑1:只在前端禁用按钮。 弱网重试、脚本直接打接口全都绕过前端,该重复还是重复。前端只能减负,服务端必须兜底。
  • 坑2:重试时重新生成令牌。 每次重试换新 token,服务端视为新请求,幂等键彻底失效。重试链路必须复用同一个键。
  • 坑3:幂等键只有随机 UUID,没有业务维度。 结果要么把用户"上午报一次、下午改主意再报一次"的正常行为误杀,要么拦不住跨设备的真实重复。要业务唯一键和防重令牌分层,各管一段。
  • 坑4:SETNX 占位成功后业务代码崩了,键没释放。 用户之后每次重试都拿到"正在处理",永远成功不了。状态要区分处理中/成功/失败,失败时释放令牌,再配合过期时间兜底。
  • 坑5:先写库后登记幂等,或 MQ 消费端不做幂等。 高并发下两个请求都通过了"没查到"的判断再各自插库;消息重投又来一遍。务必让唯一索引兜底、消费端用去重表,顺序上先占位再写库。

结语

防重复提交不是加个按钮 loading 就完事,它本质是"让同一个业务意图在系统里只生效一次"。前端令牌和加锁负责减少无效请求,服务端业务唯一键加防重令牌负责权威判定,重试和消息消费复用同一套幂等键,最后用数据库唯一索引做硬兜底。把这几层配齐,活动开场那一秒的洪峰里,名单、库存、名额才不会跟着重复数据一起乱掉。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 导读
  • 一、先分清三种"重复",别想一个按钮挡掉所有
  • 二、前端这一层:减少无效请求,但默认它不可信
  • 三、服务端幂等键:业务唯一键兜底,防重令牌拦并发
  • 四、超时重试、消息重投也要吃同一套幂等
  • 踩坑清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档