上个月我们支撑了一场 3000 人同时在线的职业资格模拟考,考前压测一切正常,正式开考第 40 分钟却差点出大事:交卷接口 RT 从 200ms 飙到 8 秒,一批考生点了交卷一直转圈,有人情急之下刷新页面,发现最后十几分钟的答案没存上。那天我们是一边在群里安抚监考老师,一边热修兜底逻辑扛过来的。事后复盘,问题不在某一行代码,而在我们一开始把"答题保存"想简单了——以为就是个定时调接口。这篇把整条可靠性链路拆开讲:自动保存怎么设计、交卷怎么做幂等、弱网下怎么重试才不丢不重,以及五个当时踩得生疼的坑。
很多团队第一版都是这样写的:前端起个定时器,每隔 30 秒把整张答卷 POST 一次,用户点交卷再 POST 一次。压测时人少,一切正常,人一多问题全冒出来。
我们事后把丢答案的路径捋成了三类:
想清楚这三条,后面的设计都是对着它们来的:本地先落一份、服务端按版本收敛、交卷走幂等。
我们改后的第一原则是答案先写本地,再异步同步服务端。考生每填一道题,先写进 IndexedDB,这一步是同步且不依赖网络的,哪怕下一秒断网,刷新后本地草稿还在。
前端用"脏标记 + 防抖"控制同步频率,而不是机械地每 30 秒全量提交一次:
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;
}
}服务端不能"来什么覆盖什么",而是按版本号收敛,只接受比当前版本新的数据,从根上杜绝旧请求覆盖新答案:
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}页面加载时先读服务端草稿,再用本地草稿按版本合并,刷新、换设备都能续上。
交卷和自动保存最大的区别是:自动保存可以失败很多次,交卷一次都不能错,也绝不能重复计分。弱网下用户连点交卷、或者前端超时自动重试,都可能让同一个交卷请求到达服务端两次。
我们给每次交卷生成一个客户端幂等号,服务端用这个号去重,重复请求直接返回第一次的结果:
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}前端侧,点下交卷的瞬间按钮立刻置灰并锁定,配合本地状态机(未交 / 提交中 / 已交),从交互上就堵住"连点"。即便如此服务端幂等也不能省——前端状态是可以被刷新绕过的。
一开始我们的重试写得很天真:失败了就立刻再发,结果弱网时一堆请求堆在网关,反而把交卷通道挤爆。后来改成指数退避 + 单队列串行,自动保存的请求排着队走,前一个没结束后一个不发:
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 才值得退避重试。
在线考试这类场景,可靠性的本质不是"接口平时能通",而是在弱网、高并发、用户误操作同时发生时依然不丢不重。落地顺序建议是:先做本地草稿保证"刷新不丢",再做服务端版本收敛保证"并发不盖",最后用幂等交卷和指数退避把"重复和雪崩"摁住。这三层补齐,交卷才真正算有了底线。下一篇我们打算讲讲监考端的异常行为检测,也是这次考试里冒出来的新课题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。