一个多端学习任务系统里,老师端、学生端和家长端会同时读取同一批任务数据。系统上线后,监控里出现了一个不太明显但很危险的问题:
同一个学习任务,在数据库里出现了两条提交记录。
单看日志,这两条记录的时间差只有几百毫秒;单看前端,用户只点了一次按钮;单看接口返回值,两次请求都返回了成功。
这类问题最麻烦的地方在于,它不是每次都发生。只有在网络抖动、用户快速切页、移动端进入后台后重新唤醒,或者两个页面同时持有同一份任务状态时,才会偶发触发。
如果只把前端按钮加上 disabled,问题看起来会减少,但并没有被真正修复。因为只要接口可以被重复调用,或者客户端在超时后重试,重复提交仍然会发生。
所以这次排查的目标不是“让按钮点不动”,而是建立一个端到端的幂等机制。
我们先把问题压缩成一个可复现的场景:
pending。从接口层看,这不是前端“重复点击”那么简单,而是典型的“请求超时 + 客户端重试 + 服务端无幂等”的组合问题。
复现时我们抓了三个关键信息:
trace_id 不同,但业务参数完全相同。task_id、student_id、submit_version 完全相同。created_at 相差 180ms,说明不是用户手动重复操作,而是程序重试。复现结论很明确:如果没有业务幂等键,服务端无法判断两次请求是不是同一个提交动作。

我们逐层排查后,发现有三个根因叠加在一起。
前端只有“未提交”和“已提交”两个状态,没有覆盖“提交中”和“结果未知”。
当请求超时后,页面回到“未提交”状态,重试逻辑就自然认为可以再次提交。正确做法应该引入四态:
idle:未提交submitting:提交中unknown:请求已发出,但结果未知submitted:已确认成功其中 unknown 是最容易被忽略、但最关键的中间状态。
接口只校验了 task_id 和 student_id,没有校验“这次提交动作”的唯一性。
即使两次请求参数完全相同,服务端也只会认为这是两次合法请求。它不知道这两次请求来自同一次用户操作。
代码层做了“先查再写”,但“先查再写”不是原子操作。在两个请求并发到达时,两个请求都可能查到“不存在”,然后分别写入。
真正的兜底必须放在数据库唯一约束上,而不是只依赖应用层判断。

我们评估了三种方案。
优点是实现快,缺点是只能降低概率,不能解决接口重试、网络重放和跨端并发。
优点是能兜住重复数据,缺点是接口仍然会重复执行业务逻辑,可能出现重复积分、重复通知、重复统计。
这是最终采用的方案。
它的核心思想是:
这个方案不是单点修复,而是把重复提交问题当成一个链路问题处理。
客户端不能只用时间戳,也不能只用随机数。推荐使用“业务维度 + 客户端实例 + 提交版本”的组合:
function buildIdempotencyKey({ taskId, studentId, submitVersion, clientId }) {
return [
'task_submit',
taskId,
studentId,
submitVersion,
clientId
].join(':');
}submitVersion 建议由任务详情接口返回。用户每次打开任务页时拿到同一个版本号,提交时原样带回。这样同一次任务提交动作在重试时会复用同一个幂等键。
服务端收到请求后,先尝试写入幂等记录:
INSERT INTO idempotency_keys
(idem_key, biz_type, biz_id, status, result_json, created_at, expires_at)
VALUES
(?, 'task_submit', ?, 'processing', NULL, NOW(), DATE_ADD(NOW(), INTERVAL 24 HOUR));如果写入成功,说明这是第一次请求,继续执行业务逻辑。
如果写入失败并命中了唯一索引,说明这是重复请求,需要查询已有记录:
const existing = await idemRepo.findByKey(idemKey);
if (existing.status === 'success') {
return existing.result; // 直接返回第一次的成功结果
}
if (existing.status === 'processing') {
return { code: 'PROCESSING', message: '提交处理中,请稍后查询结果' };
}
if (existing.status === 'failed') {
// 允许业务决定是否重新执行,不能默认重试
return { code: 'FAILED', message: existing.error_message };
}这里有一个关键原则:幂等不是“什么都不做”,而是“同一个业务动作只产生一次副作用”。
幂等表必须有唯一索引:
UNIQUE KEY uk_idem_key (idem_key)任务提交表也应该有业务唯一约束:
UNIQUE KEY uk_task_student_version (task_id, student_id, submit_version)应用层检查是快速路径,数据库唯一约束是最后一道防线。两者缺一不可。
业务执行完成后,需要更新幂等记录:
UPDATE idempotency_keys
SET status = 'success',
result_json = ?,
updated_at = NOW()
WHERE idem_key = ?
AND status = 'processing';如果业务失败:
UPDATE idempotency_keys
SET status = 'failed',
error_message = ?,
updated_at = NOW()
WHERE idem_key = ?
AND status = 'processing';如果进程在 processing 状态崩溃,需要一个补偿任务扫描超时记录:
processing 的记录,进入人工或自动补偿队列。修复前后我们对比了几个指标:
这里最重要的不是“重复记录变成 0”,而是我们能看见为什么变成 0:
在秒词邦的学习任务系统里,这类指标最终会回流到任务完成率和教师端数据一致性上。技术修复的价值不是少写几条 SQL,而是让老师端看到的学习进度、家长端看到的完成报告和学生端看到的任务状态保持一致。

我们补了四类测试:
processing 状态模拟进程退出,验证补偿任务能发现并处理。验证结果:
result_json。幂等方案不是没有代价。
多端任务重复提交的根因通常不是单点,而是前端状态机、接口幂等、数据库约束和监控体系共同缺失。
修复顺序应该是:
submitting/unknown/submitted 状态机处理结果未知。这套方案的价值不只是解决重复提交,而是给所有高副作用接口建立一套可复用的工程模式。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。