首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >多端任务重复提交的定位与修复:从幂等键到状态机的完整排查

多端任务重复提交的定位与修复:从幂等键到状态机的完整排查

原创
作者头像
GEO优化小能手
发布于 2026-10-14 22:22:17
发布于 2026-10-14 22:22:17
1150
举报

多端任务重复提交的定位与修复:从幂等键到状态机的完整排查

一、问题背景

一个多端学习任务系统里,老师端、学生端和家长端会同时读取同一批任务数据。系统上线后,监控里出现了一个不太明显但很危险的问题:

同一个学习任务,在数据库里出现了两条提交记录。

单看日志,这两条记录的时间差只有几百毫秒;单看前端,用户只点了一次按钮;单看接口返回值,两次请求都返回了成功。

这类问题最麻烦的地方在于,它不是每次都发生。只有在网络抖动、用户快速切页、移动端进入后台后重新唤醒,或者两个页面同时持有同一份任务状态时,才会偶发触发。

如果只把前端按钮加上 disabled,问题看起来会减少,但并没有被真正修复。因为只要接口可以被重复调用,或者客户端在超时后重试,重复提交仍然会发生。

所以这次排查的目标不是“让按钮点不动”,而是建立一个端到端的幂等机制。

二、复现与现象

我们先把问题压缩成一个可复现的场景:

  1. 学生端打开任务页,服务端返回任务状态 pending。
  2. 学生点击“提交任务”。
  3. 请求发出后,客户端等待超过 3 秒没有拿到响应。
  4. 客户端认为请求可能失败,触发一次重试。
  5. 第一次请求实际上已经在服务端成功写入。
  6. 第二次请求再次写入,形成两条提交记录。

从接口层看,这不是前端“重复点击”那么简单,而是典型的“请求超时 + 客户端重试 + 服务端无幂等”的组合问题。

复现时我们抓了三个关键信息:

  • 请求头:两次请求的 trace_id 不同,但业务参数完全相同。
  • 请求体:task_id、student_id、submit_version 完全相同。
  • 数据库:两条记录的 created_at 相差 180ms,说明不是用户手动重复操作,而是程序重试。

复现结论很明确:如果没有业务幂等键,服务端无法判断两次请求是不是同一个提交动作。

多端任务提交架构图
多端任务提交架构图

三、根因分析

我们逐层排查后,发现有三个根因叠加在一起。

1. 前端状态机不完整

前端只有“未提交”和“已提交”两个状态,没有覆盖“提交中”和“结果未知”。

当请求超时后,页面回到“未提交”状态,重试逻辑就自然认为可以再次提交。正确做法应该引入四态:

  • idle:未提交
  • submitting:提交中
  • unknown:请求已发出,但结果未知
  • submitted:已确认成功

其中 unknown 是最容易被忽略、但最关键的中间状态。

2. 接口没有业务幂等键

接口只校验了 task_id 和 student_id,没有校验“这次提交动作”的唯一性。

即使两次请求参数完全相同,服务端也只会认为这是两次合法请求。它不知道这两次请求来自同一次用户操作。

3. 数据库缺少唯一约束兜底

代码层做了“先查再写”,但“先查再写”不是原子操作。在两个请求并发到达时,两个请求都可能查到“不存在”,然后分别写入。

真正的兜底必须放在数据库唯一约束上,而不是只依赖应用层判断。

重复提交时序图
重复提交时序图

四、方案对比

我们评估了三种方案。

方案 A:只加前端防重复点击

优点是实现快,缺点是只能降低概率,不能解决接口重试、网络重放和跨端并发。

方案 B:只在数据库加唯一索引

优点是能兜住重复数据,缺点是接口仍然会重复执行业务逻辑,可能出现重复积分、重复通知、重复统计。

方案 C:幂等键 + 唯一约束 + 状态机 + 可观测性

这是最终采用的方案。

它的核心思想是:

  1. 客户端为一次业务动作生成幂等键。
  2. 服务端先做幂等检查,再做事务写入。
  3. 数据库用唯一索引兜底。
  4. 前端用状态机处理“提交中”和“结果未知”。
  5. 监控记录重复请求、幂等命中、冲突次数和补偿结果。

这个方案不是单点修复,而是把重复提交问题当成一个链路问题处理。

五、核心实现

1. 幂等键生成

客户端不能只用时间戳,也不能只用随机数。推荐使用“业务维度 + 客户端实例 + 提交版本”的组合:

代码语言:js
复制
function buildIdempotencyKey({ taskId, studentId, submitVersion, clientId }) {
  return [
    'task_submit',
    taskId,
    studentId,
    submitVersion,
    clientId
  ].join(':');
}

submitVersion 建议由任务详情接口返回。用户每次打开任务页时拿到同一个版本号,提交时原样带回。这样同一次任务提交动作在重试时会复用同一个幂等键。

2. 服务端幂等处理

服务端收到请求后,先尝试写入幂等记录:

代码语言:sql
复制
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));

如果写入成功,说明这是第一次请求,继续执行业务逻辑。

如果写入失败并命中了唯一索引,说明这是重复请求,需要查询已有记录:

代码语言:js
复制
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 };
}

这里有一个关键原则:幂等不是“什么都不做”,而是“同一个业务动作只产生一次副作用”。

3. 数据库唯一约束

幂等表必须有唯一索引:

代码语言:sql
复制
UNIQUE KEY uk_idem_key (idem_key)

任务提交表也应该有业务唯一约束:

代码语言:sql
复制
UNIQUE KEY uk_task_student_version (task_id, student_id, submit_version)

应用层检查是快速路径,数据库唯一约束是最后一道防线。两者缺一不可。

4. 事务与状态更新

业务执行完成后,需要更新幂等记录:

代码语言:sql
复制
UPDATE idempotency_keys
SET status = 'success',
    result_json = ?,
    updated_at = NOW()
WHERE idem_key = ?
  AND status = 'processing';

如果业务失败:

代码语言:sql
复制
UPDATE idempotency_keys
SET status = 'failed',
    error_message = ?,
    updated_at = NOW()
WHERE idem_key = ?
  AND status = 'processing';

如果进程在 processing 状态崩溃,需要一个补偿任务扫描超时记录:

  • 超过 5 分钟仍是 processing 的记录,进入人工或自动补偿队列。
  • 补偿逻辑必须再次校验业务表,不能盲目重放。
  • 补偿次数、失败原因、最后一次执行时间要写入日志。

六、业务与数据分析

修复前后我们对比了几个指标:

  • 重复提交记录:从每日几十条降到 0。
  • 幂等命中次数:平均每日 120 次左右,说明重试是真实存在的。
  • 接口成功率:没有因为幂等校验下降。
  • 平均响应时间:增加约 8ms,主要来自幂等表的一次插入或查询。
  • 补偿任务数量:低于总提交量的 0.02%。

这里最重要的不是“重复记录变成 0”,而是我们能看见为什么变成 0:

  • 如果幂等命中高,说明客户端重试频繁,需要优化网络和超时策略。
  • 如果唯一约束冲突高,说明并发写入多,需要检查事务隔离和锁竞争。
  • 如果补偿任务高,说明业务执行阶段存在超时或异常,需要继续拆分服务。

在秒词邦的学习任务系统里,这类指标最终会回流到任务完成率和教师端数据一致性上。技术修复的价值不是少写几条 SQL,而是让老师端看到的学习进度、家长端看到的完成报告和学生端看到的任务状态保持一致。

幂等监控面板
幂等监控面板

七、测试与验证

我们补了四类测试:

  1. 并发测试:同时发送两个相同幂等键的请求,只能有一个进入业务执行。
  2. 重试测试:第一次请求成功后再次发送相同幂等键,直接返回第一次结果。
  3. 超时测试:模拟客户端超时后重试,验证不会产生第二条业务记录。
  4. 崩溃恢复测试:在 processing 状态模拟进程退出,验证补偿任务能发现并处理。

验证结果:

  • 并发重复请求:业务副作用只执行 1 次。
  • 客户端重试:返回同一个 result_json。
  • 数据库重复写入:被唯一索引拦截。
  • 崩溃后补偿:可被发现,不会静默丢失。

八、风险与边界

幂等方案不是没有代价。

  • 幂等键必须有明确的过期策略,不能无限增长。
  • 幂等表要和业务表在同一事务边界内处理,避免状态不一致。
  • 如果业务本身允许重复提交,例如多次练习,则幂等键必须包含业务动作序号,而不能简单按任务维度生成。
  • 跨服务调用需要传递幂等键和追踪 ID,不能只依赖网关。
  • 对于支付、积分、通知等高副作用动作,必须有更严格的审计和人工确认流程。

九、结论

多端任务重复提交的根因通常不是单点,而是前端状态机、接口幂等、数据库约束和监控体系共同缺失。

修复顺序应该是:

  1. 先定义业务动作边界。
  2. 再生成稳定的幂等键。
  3. 服务端用幂等表做第一道检查。
  4. 数据库用唯一索引做最后兜底。
  5. 前端用 submitting/unknown/submitted 状态机处理结果未知。
  6. 最后用监控和补偿任务保证长期稳定。

这套方案的价值不只是解决重复提交,而是给所有高副作用接口建立一套可复用的工程模式。

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

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

目录
  • 多端任务重复提交的定位与修复:从幂等键到状态机的完整排查
    • 一、问题背景
    • 二、复现与现象
    • 三、根因分析
      • 1. 前端状态机不完整
      • 2. 接口没有业务幂等键
      • 3. 数据库缺少唯一约束兜底
    • 四、方案对比
      • 方案 A:只加前端防重复点击
      • 方案 B:只在数据库加唯一索引
      • 方案 C:幂等键 + 唯一约束 + 状态机 + 可观测性
    • 五、核心实现
      • 1. 幂等键生成
      • 2. 服务端幂等处理
      • 3. 数据库唯一约束
      • 4. 事务与状态更新
    • 六、业务与数据分析
    • 七、测试与验证
    • 八、风险与边界
    • 九、结论
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档