首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一场3000人在线考试差点集体交白卷:答题自动保存、交卷幂等与弱网重试的可靠性设计

一场3000人在线考试差点集体交白卷:答题自动保存、交卷幂等与弱网重试的可靠性设计

原创
作者头像
用户12754333
发布2026-09-14 09:11:10
发布2026-09-14 09:11:10
220
举报

导读

上个月我们支撑了一场 3000 人同时在线的职业资格模拟考,考前压测一切正常,正式开考第 40 分钟却差点出大事:交卷接口 RT 从 200ms 飙到 8 秒,一批考生点了交卷一直转圈,有人情急之下刷新页面,发现最后十几分钟的答案没存上。那天我们是一边在群里安抚监考老师,一边热修兜底逻辑扛过来的。事后复盘,问题不在某一行代码,而在我们一开始把"答题保存"想简单了——以为就是个定时调接口。这篇把整条可靠性链路拆开讲:自动保存怎么设计、交卷怎么做幂等、弱网下怎么重试才不丢不重,以及五个当时踩得生疼的坑。

一、先想清楚:答题数据为什么会丢

很多团队第一版都是这样写的:前端起个定时器,每隔 30 秒把整张答卷 POST 一次,用户点交卷再 POST 一次。压测时人少,一切正常,人一多问题全冒出来。

我们事后把丢答案的路径捋成了三类:

  • 请求在半路丢了:考生在电梯、宿舍角落这种弱网环境,请求发出去 TCP 层还没来得及返回失败,定时器 30 秒后又发了一次,前一次到底到没到服务端,前端根本不知道。
  • 保存和交卷打架:自动保存的请求还在队列里,用户已经点了交卷,两个请求并发写同一份答卷,后到的旧数据把新数据覆盖了。
  • 刷新即丢:答案只存在内存里,还没等到下一次定时保存,页面崩了或者被手动刷新,这段答案就没了。

想清楚这三条,后面的设计都是对着它们来的:本地先落一份、服务端按版本收敛、交卷走幂等。

二、自动保存:本地草稿 + 增量版本 + 防抖

我们改后的第一原则是答案先写本地,再异步同步服务端。考生每填一道题,先写进 IndexedDB,这一步是同步且不依赖网络的,哪怕下一秒断网,刷新后本地草稿还在。

前端用"脏标记 + 防抖"控制同步频率,而不是机械地每 30 秒全量提交一次:

代码语言:javascript
复制
let saveTimer = null;
let dirty = false;
let localVersion = 0;

// 每答一道题:先落本地,再防抖触发同步
function onAnswerChange(questionId, answer) {
  draftStore.set(questionId, answer);      // 同步写 IndexedDB
  localVersion += 1;
  dirty = true;
  scheduleSync();
}

function scheduleSync() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(flushDraft, 1500); // 停顿1.5秒后才真正发请求
}

async function flushDraft() {
  if (!dirty) return;
  const payload = await draftStore.snapshot();
  try {
    const res = await fetchWithRetry('/api/exam/autosave', {
      method: 'POST',
      body: JSON.stringify({ version: localVersion, answers: payload })
    });
    if (res.ok) dirty = false;
  } catch (e) {
    // 不打断考生,标记待重传,下一轮再试
    dirty = true;
  }
}

服务端不能"来什么覆盖什么",而是按版本号收敛,只接受比当前版本新的数据,从根上杜绝旧请求覆盖新答案:

代码语言:python
复制
def autosave(exam_id, user_id, version, answers):
    record = db.get_draft(exam_id, user_id)
    if record and version <= record["version"]:
        # 迟到的旧包,直接丢弃但返回成功,避免前端无脑重试
        return {"code": 0, "version": record["version"], "stale": True}
    db.upsert_draft(exam_id, user_id, version, answers)
    return {"code": 0, "version": version}

页面加载时先读服务端草稿,再用本地草稿按版本合并,刷新、换设备都能续上。

三、交卷:幂等是底线,不是加分项

交卷和自动保存最大的区别是:自动保存可以失败很多次,交卷一次都不能错,也绝不能重复计分。弱网下用户连点交卷、或者前端超时自动重试,都可能让同一个交卷请求到达服务端两次。

我们给每次交卷生成一个客户端幂等号,服务端用这个号去重,重复请求直接返回第一次的结果:

代码语言:python
复制
import uuid

def submit_exam(user_id, exam_id, idem_key, answers):
    # idem_key 由前端进入答题页时生成一次,整局考试不变
    finished = cache.get(f"submit:{exam_id}:{user_id}:{idem_key}")
    if finished:
        return {"code": 0, "msg": "已交卷", "paper_id": finished}  # 幂等返回

    with db.lock(f"lock:submit:{exam_id}:{user_id}"):  # 并发兜底
        paper_id = db.create_paper(user_id, exam_id, answers)
        cache.setex(f"submit:{exam_id}:{user_id}:{idem_key}", 86400, paper_id)
    return {"code": 0, "paper_id": paper_id}

前端侧,点下交卷的瞬间按钮立刻置灰并锁定,配合本地状态机(未交 / 提交中 / 已交),从交互上就堵住"连点"。即便如此服务端幂等也不能省——前端状态是可以被刷新绕过的。

四、弱网重试:指数退避 + 队列串行,别让请求互相踩踏

一开始我们的重试写得很天真:失败了就立刻再发,结果弱网时一堆请求堆在网关,反而把交卷通道挤爆。后来改成指数退避 + 单队列串行,自动保存的请求排着队走,前一个没结束后一个不发:

代码语言:javascript
复制
async function fetchWithRetry(url, opt, retries = 4) {
  let delay = 500;
  for (let i = 0; i <= retries; i++) {
    try {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), 10000);
      const r = await fetch(url, { ...opt, signal: ctrl.signal });
      clearTimeout(timer);
      if (r.status >= 500) throw new Error('server'); // 5xx才重试,4xx直接放弃
      return r;
    } catch (e) {
      if (i === retries) throw e;
      await new Promise(res => setTimeout(res, delay));
      delay = Math.min(delay * 2, 8000); // 500ms→1s→2s→4s封顶
    }
  }
}

有个细节当时争论过:4xx(比如答卷已过考试时间)要不要重试?结论是不重试,客户端错误重试一百遍也是错,只会制造无效流量;只有网络错误和 5xx 才值得退避重试。

五、踩坑清单

  • 坑1:定时器全量提交把库打满。 3000 人每 30 秒全量 POST 一次,平均每秒 100 次大报文写库,交卷高峰直接雪崩。改成防抖 + 只传变更项后,写库量降到原来的十分之一。
  • 坑2:本地草稿和服务端草稿没做版本合并。 有考生手机答一半换电脑,电脑端旧草稿把手机新答案盖了。务必两端都带版本号,按版本取新。
  • 坑3:交卷不幂等,出现重复试卷。 弱网连点产生两张卷、计分两次。幂等号 + 分布式锁 + 按钮置灰,三层都要有,只做一层都会漏。
  • 坑4:重试不分错误类型。 对 4xx 也无脑重试,过期试卷反复打接口。区分"可重试错误"和"确定性失败",只对网络抖动和 5xx 退避。
  • 坑5:断网只给个红叉提示。 考生看到报错就慌着刷新,反而丢答案。改成顶部温和提示"网络不佳,答案已保存在本机,联网后自动同步",并把本地草稿状态实时显示出来,考生心里有底就不会乱操作。

结语

在线考试这类场景,可靠性的本质不是"接口平时能通",而是在弱网、高并发、用户误操作同时发生时依然不丢不重。落地顺序建议是:先做本地草稿保证"刷新不丢",再做服务端版本收敛保证"并发不盖",最后用幂等交卷和指数退避把"重复和雪崩"摁住。这三层补齐,交卷才真正算有了底线。下一篇我们打算讲讲监考端的异常行为检测,也是这次考试里冒出来的新课题。

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

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

目录
  • 导读
  • 一、先想清楚:答题数据为什么会丢
  • 二、自动保存:本地草稿 + 增量版本 + 防抖
  • 三、交卷:幂等是底线,不是加分项
  • 四、弱网重试:指数退避 + 队列串行,别让请求互相踩踏
  • 五、踩坑清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档