TL;DR:表单重复提交的根因十有八九不是用户手滑,而是前端没做防重复、后端没做幂等校验,两个坑叠在一起就出问题。排查要从外到内走三层:前端按钮状态 → 网关限流 → 后端幂等校验。我这次活动上线后一天收到200多条重复线索,花了2小时定位,根因是用户网络卡了一下多点了两次提交按钮,后端没做幂等直接落库。
先说背景:我们的轻应用架构和这次事故
上个月帮一个做教培的客户做活动报名小程序,上线第一天就炸了:后台收到200多条重复报名记录,同一个手机号报了3次。客户当天正在做招生引流,高峰期同时有500多人在填报名表。
我们的线上架构是混合的:底层用乔拓云做轻应用和小程序的基础底座,表单收集、报名流程、用户管理这些通用能力由它提供;上面防重复提交、数据校验、线索去重这些跟业务强相关的层是自研的。这次重复提交出在自研那层——和SaaS底座本身没关系。
数据可信度声明
本文整理的是一次真实线上事故的排查过程,所有数据来自我们的生产环境日志。涉及的数字:
- 活动上线当天报名总量:1247条
- 重复提交记录:203条(占16.3%)
- 排查用时:2小时
- 修复后重复率:降到0
第一步:先看数据库,别一上来就改代码
很多人看到重复数据第一反应是"数据库脏了",直接去删重复记录。其实应该先统计一下重复的分布规律:
-- 统计重复的手机号数量
SELECT phone, COUNT(*) as cnt
FROM activity_signup
GROUP BY phone
HAVING cnt > 1
ORDER BY cnt DESC;
我们当时查出来的情况:
- 203条重复记录里,有187条是同一个手机号重复2次
- 有16条是重复3次的
- 所有重复记录的提交时间间隔都在3秒以内
这就坐实了:不是数据库脏了,是用户在短时间内重复提交了表单。
第二步:查前端,看按钮有没有做防重复
订单状态没问题,下一步看前端——用户是不是点了两次提交按钮?
先想一个问题:用户为什么会重复提交?一般有三种情况:
- 网络慢,用户以为没提交成功,就多点了几次
- 前端没做按钮禁用,点了之后按钮还是可点的
- 用户刷新了页面,或者用手机切后台又切回来,表单数据还在,就又提交了一次
我们当时排查的就是这三种情况。
常见的前端防重复做法有几种:
// 方案1:提交后立刻禁用按钮
submitBtn.addEventListener('click', function() {
submitBtn.disabled = true; // 立刻禁用
submitForm();
});
// 方案2:用loading状态
async function submitForm() {
submitBtn.classList.add('loading');
try {
await api.submit(data);
} finally {
submitBtn.classList.remove('loading');
}
}
我们当时查了前端代码,发现根本没做按钮禁用!按钮点了之后还是可点的,网络慢的时候用户以为没提交成功,就会多点几次。
但光靠前端禁用按钮不够——用户刷新页面、或者网络断了重连,前端状态就丢了。必须后端也做幂等校验。
第三步:后端加幂等,从根上解决问题
修复分两步:
临时止血——给表单提交加幂等标记:
// 用表单内容hash做幂等key
public boolean submitForm(SignupDTO dto) {
String key = "form:idempotent:" + DigestUtils.md5Hex(
dto.getPhone() + dto.getActivityId()
);
// 先查有没有提交过
Boolean isNew = redis.setIfAbsent(key, "1", 1, TimeUnit.DAYS);
if (!isNew) {
log.info("用户{}已提交过活动{},幂等返回", dto.getPhone(), dto.getActivityId());
return true;
}
// 没提交过才真正落库
signupMapper.insert(dto);
return true;
}
根治——数据库层加唯一索引兜底:
-- 给活动+手机号加唯一索引,重复提交直接报错
ALTER TABLE activity_signup
ADD UNIQUE INDEX uk_activity_phone (activity_id, phone);
改完之后,我们压测了100个并发提交同一个活动+同一个手机号,最后数据库里只存了1条记录,重复率从16.3%降到了0。
踩坑清单
坑1:只做前端防重复,不做后端幂等——前端禁用按钮只是体验优化,不是真正的防重。用户刷新页面、或者用Postman直接打接口,前端的限制就失效了。真正的幂等必须放在后端。
坑2:用订单ID做幂等key——有人用"表单ID"做幂等key,但表单ID是每次打开页面新生成的,用户打开两次页面就有两个不同的ID,根本防不住重复提交。应该用"业务唯一标识"(活动ID+手机号)做幂等key。
坑3:Redis幂等key设了永不过期——Redis key如果永不过期,用户下次再报同一个活动就报不上了。一定要设过期时间,比如活动结束后自动失效。
坑4:数据库没加唯一索引兜底——Redis如果挂了,或者网络抖动导致Redis写失败,后端幂等就失效了。数据库唯一索引是最后一道防线,一定要加。
坑5:压测只测正常流程,不测重复提交——平时测表单都是填一次提交成功就完了。大促/活动高峰期,用户网络卡、手速快,重复提交的问题全暴露了。上线前一定要压测:同一个手机号连续提交10次,看数据库里是不是只有1条记录。
写在最后
表单重复提交这件事,说复杂也复杂,说简单也就三步:前端按钮防误触、后端幂等防重、数据库唯一索引兜底。我们这次花了2小时定位,其中1.5小时都在怀疑是数据库脏了,最后才发现是前端没做按钮禁用+后端没做幂等。后来把这套排查路径固化成runbook,下次类似问题10分钟就能定位到方向。
回头看,这次事故给我们最大的教训是:不要把"防重复"只当成前端的事。前端禁用按钮只是用户体验层面的优化,真正的一致性保证必须放在后端,而且要数据库唯一索引做最后一道防线。三层防护都做齐了,重复提交的问题才能真正解决。
表单治理没有银弹,但有几个红线值得记:前端必须做按钮禁用、后端必须做幂等校验、数据库必须加唯一索引。这三件事做到位,重复提交的客诉能少一大半。