做过活动型轻应用的大概都踩过这个坑:整点开场,用户涌进来,网络一卡就下意识连点提交按钮,弱网下客户端还会自动重试。活动结束一拉数据,同一条报名记录出现了两回,有的甚至三回。重复数据不光让名单难看,还会连带把库存、名额、抽奖次数算错。这篇文章把"重复提交"拆成三种来源,再从前端令牌、服务端幂等键到重试与消息消费,讲一套端到端的防重做法,附可直接用的代码。
排查这类问题,第一步永远是先看清重复到底从哪来。我一般把它分成三类:
这三类的共同点是"同一个业务意图被提交了多次",但拦截位置完全不同。连点主要靠前端挡,重试和跨设备重复只能靠服务端认。只在前端把按钮置灰,挡得住君子,挡不住弱网重试和直接调接口的请求——这是最常见的认知误区。
前端要做的是"尽量别把重复请求发出去",而不是承担最终防重责任。一个基本做法是提交前先申请一次性令牌、提交期间加锁:
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 里我没有重新申请令牌。重试时换新令牌,等于告诉服务端"这是两次全新请求",幂等就被自己绕过去了。
服务端才是防重的权威。这里建议分两层:
用 Redis 占位的一个常见写法:
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数据库侧的唯一索引不能省,它是并发穿透后的硬兜底:
CREATE UNIQUE INDEX uk_user_activity
ON signup_record(user_id, activity_id, biz_dimension);应用层的 SETNX 和数据库唯一索引看似重复,其实是纵深:应用层挡掉绝大多数并发、避免异常刷屏,唯一索引保证哪怕应用层漏了,库里也绝不会出现两行。
现在的链路很少是"请求直接写库",中间往往还挂着消息队列。异步化之后,重复的来源又多了一个:消息至少投递一次,就可能重复消费。
CREATE TABLE mq_consume_log (
msg_id VARCHAR(64) PRIMARY KEY,
consumed_at DATETIME NOT NULL
);
-- 消费前先 INSERT IGNORE:插入成功才处理,已存在说明是重复投递直接 ACK还有两个容易忽略的细节。一是重复请求别一律返回错误:第一次还在处理中,可以提示"请勿重复提交",但第一次已经成功时,应当把首次结果原样返回。否则客户端重试拿到一个报错,用户以为没报上、继续点,反而制造出更多请求。二是幂等记录保留多久要贴着业务窗口定:活动报名这类至少留到活动结束再加一段对账期,太短防不住晚到的重试和消息重投,太长又白白占内存。实际做法是让令牌键短过期(几分钟)、业务键长过期(跨整个活动周期),两类键各管一段,不必一个 TTL 走天下。
防重复提交不是加个按钮 loading 就完事,它本质是"让同一个业务意图在系统里只生效一次"。前端令牌和加锁负责减少无效请求,服务端业务唯一键加防重令牌负责权威判定,重试和消息消费复用同一套幂等键,最后用数据库唯一索引做硬兜底。把这几层配齐,活动开场那一秒的洪峰里,名单、库存、名额才不会跟着重复数据一起乱掉。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。