从零搭建高并发在线测评系统:判题沙箱、防作弊与架构实战 简介这份PDF资料面向准备阿里巴巴集团及支付宝在线测评的应届毕业生与实习生聚焦技术岗招聘中的在线测试环节帮助使用者在有限时间内熟悉题型分布与考察重点。内容覆盖编程语言、数据结构、算法、数据库、操作系统、计算机网络等多个技术方向可作为测评前的自测与查漏补缺参考。资源包共1个PDF文件大小约1.14MB页面中穿插「可编辑修改」的排版标记便于按需摘录与整理笔记。目前已有81人学习下载属于小众但针对性较强的备考材料。读者可借助其中的试题样例了解测评难度层级与常见考点结合自身薄弱模块制定复习计划同时提前感受阿里系招聘流程中在线测评的节奏与要求为后续笔试与面试环节积累实践经验。1. 一份 PDF 引发的排查在线测评系统里到底藏了什么很多人第一次看到「阿里巴巴在线测评.pdf」这个文件名第一反应是去找这份 PDF 本身。但真正在一线做过招聘系统、在线判题系统的人会告诉你这个标题背后指向的不是一份文档而是一整条技术链路在线测评系统。它要解决的核心问题是——如何让成千上万的候选人在浏览器里同时答题系统自动判分、自动防作弊、自动出报告而且不能崩。我做过类似的在线测评平台从题库管理、判题沙箱、前端防切屏到成绩回传每一环都有坑。这份 PDF 如果存在大概率是某次测评的题目存档、系统说明或者成绩报告。但不管它是什么你真正需要掌握的是怎么从零搭一套能扛住并发、判得准、防得住作弊的在线测评系统。这篇文章面向的是需要落地这套系统的后端、全栈和运维工程师也会讲到前端防作弊的具体参数。新手能跟着步骤跑通最小闭环熟手能看到并发判题和沙箱隔离的边界。2. 在线测评系统的三层架构与判题沙箱选型在线测评系统看起来只是「出题-答题-判分」但真正上线后你会发现最脆弱的是判题环节。一道编程题提交上来代码要在服务器上编译、运行、限制时间和内存还要防止恶意代码读取其他候选人数据。这一章把架构拆开重点讲判题沙箱的选型理由和最小实现。2.1 为什么不能直接在应用服务器上跑用户代码最常见的翻车方式把用户提交的 Python 或 Java 代码直接exec或subprocess跑在 Web 服务器上。结果就是有人写一个while True或者os.system(rm -rf /)整个测评服务直接挂掉。血泪经验是判题必须隔离。隔离方案有三档方案隔离级别启动速度适用场景子进程 资源限制低毫秒级内部小规模测评Docker 容器中秒级大多数在线测评微虚拟机如 Firecracker高百毫秒级大规模公有云测评我一般会选 Docker 方案因为生态成熟、镜像好管理配合--network none、--memory、--cpus就能挡住大部分恶意行为。如果并发量上万再考虑微虚拟机。2.2 用 Docker 跑通一个最小判题沙箱下面是一个可复现的最小判题脚本。它接收一段 Python 代码和测试输入在受限容器里运行返回输出和耗时。# judge.py import subprocess import tempfile import os import time def run_judge(code: str, test_input: str, time_limit2, memory_limit128m): # 把用户代码写入临时文件 with tempfile.NamedTemporaryFile(modew, suffix.py, deleteFalse) as f: f.write(code) code_path f.name # Docker 运行命令无网络、限内存、限CPU、只读挂载代码 cmd [ docker, run, --rm, --network, none, # 禁止网络访问 --memory, memory_limit, # 内存上限 --cpus, 0.5, # CPU 上限 -v, f{code_path}:/app/main.py:ro, # 只读挂载 -i, # 允许 stdin 输入 python:3.11-slim, timeout, str(time_limit), python, /app/main.py ] start time.time() try: result subprocess.run( cmd, inputtest_input, capture_outputTrue, textTrue, timeouttime_limit 1 # 外层再兜底一秒 ) elapsed time.time() - start return { stdout: result.stdout, stderr: result.stderr, time: round(elapsed, 3), status: ok if result.returncode 0 else runtime_error } except subprocess.TimeoutExpired: return {stdout: , stderr: timeout, time: time_limit, status: timeout} finally: os.unlink(code_path) # 清理临时文件逻辑说明这段代码做了四件事——把用户代码落盘、用 Docker 起一个无网络容器、通过 stdin 喂测试输入、收集输出和耗时。关键参数有三个--network none切断外联--memory 128m防止内存爆炸--cpus 0.5防止单次判题吃满 CPU。timeout命令在容器内限制运行时间外层subprocess.run的timeout再兜底避免 Docker 卡死导致判题队列堵塞。参数怎么改如果题目是 Java基础内存要调到256m以上因为 JVM 本身占内存。如果测试用例很大time_limit可以放宽到 5 秒但要在题目配置里单独标记不能全局放开。2.3 判题队列与并发控制单机判题跑通后下一步是并发。常见做法是用 Redis 做队列Worker 进程从队列里取任务。这里有一个容易忽略的点判题任务要分优先级。正式测评的提交优先级高于练习模式否则练习流量会把正式测评堵死。# 用 Redis 列表模拟优先级队列 # 高优先级正式测评 LPUSH judge:queue:high {submission_id: 1001, code: ..., lang: python} # 低优先级练习模式 LPUSH judge:queue:low {submission_id: 1002, code: ..., lang: python} # Worker 先消费 high再消费 low BRPOP judge:queue:high judge:queue:low 5BRPOP会按顺序检查列表高优先级队列有数据就先取高优先级。5是阻塞超时秒数避免 Worker 空转。Worker 数量建议按 CPU 核数的 1.5 倍配置因为判题是 IO 和 CPU 混合型留一点超卖空间。3. 前端防作弊与答题状态同步的落地参数判题只是后端的一半另一半是前端。在线测评最怕的是候选人切屏搜答案、多开页面、或者用脚本自动答题。这一章讲前端能做什么、不能做什么以及参数怎么设才不误伤正常用户。3.1 切屏检测与 visibilitychange 的误判边界浏览器里检测切屏最常用的是visibilitychange事件。但这里有一个玄学问题某些浏览器在切换标签页时触发时机不一致有的在切走时触发有的在切回时触发。如果只监听一次可能漏记。// anti-cheat.js let switchCount 0; let lastHiddenTime 0; document.addEventListener(visibilitychange, () { if (document.hidden) { // 页面被隐藏记录时间 lastHiddenTime Date.now(); switchCount; // 上报服务端但不要立即判作弊 reportEvent(tab_hidden, { count: switchCount }); } else { // 页面重新可见计算离开时长 const awayMs Date.now() - lastHiddenTime; if (awayMs 5000) { // 离开超过5秒标记为可疑 reportEvent(long_away, { duration: awayMs }); } } }); function reportEvent(type, payload) { // 用 sendBeacon 保证页面关闭也能发出 navigator.sendBeacon(/api/anticheat, JSON.stringify({ type, payload, timestamp: Date.now() })); }逻辑说明visibilitychange在页面隐藏和显示时都会触发用document.hidden区分方向。sendBeacon是关键普通fetch在页面关闭时可能发不出去sendBeacon由浏览器保证投递。参数上awayMs 5000才标记可疑因为正常用户可能只是看一眼时间或接个电话直接判作弊会引发大量申诉。注意切屏检测只能作为辅助证据不能作为唯一判罚依据。我见过有系统因为切屏一次就自动交卷结果候选人只是弹了个系统通知直接投诉到平台。建议策略是切屏次数和离开时长累计超过阈值才告警最终由人工复核。3.2 答题状态同步本地缓存与断线重连在线测评另一个高频问题是网络抖动导致答案丢失。候选人答了半小时一断网全没了这是最严重的体验事故。解决方案是本地缓存 增量同步。// answer-sync.js const CACHE_KEY exam_answers; // 每次答案变更先写本地 function saveAnswer(questionId, answer) { const cache JSON.parse(localStorage.getItem(CACHE_KEY) || {}); cache[questionId] { answer, updatedAt: Date.now() }; localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); // 再尝试同步到服务端 syncToServer(questionId, answer); } // 断线重连后把本地未同步的答案推上去 async function flushPending() { const cache JSON.parse(localStorage.getItem(CACHE_KEY) || {}); const pending Object.entries(cache).filter(([_, v]) !v.synced); for (const [qid, item] of pending) { await fetch(/api/answer, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ questionId: qid, answer: item.answer }) }); item.synced true; } localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } // 监听网络恢复 window.addEventListener(online, flushPending);逻辑说明localStorage做第一层缓存每次答案变更先落本地再发请求。synced标记用来区分已同步和未同步断线重连时只推未同步的部分。online事件触发flushPending保证网络恢复后自动补传。参数上localStorage一般有 5MB 限制纯文本答案足够存几千道题但如果题目带图片附件要改用IndexedDB。3.3 防复制与防粘贴的取舍很多测评系统会禁用右键、禁用复制粘贴。但实际落地时过度禁用会误伤。比如候选人想复制题目里的公式到草稿纸或者用输入法的粘贴功能都会被拦。我的建议是只禁用答题区域的粘贴不禁用全局。// 只对答题输入框禁用粘贴 document.querySelectorAll(.answer-input).forEach(el { el.addEventListener(paste, (e) { e.preventDefault(); alert(答题区域禁止粘贴请手动输入); }); });这样题目区域、草稿区域不受影响只有答案输入框拦截粘贴。参数上不需要额外配置但要注意如果题目本身是代码题候选人可能需要粘贴自己写的代码片段这时候要单独放开代码编辑器的粘贴权限。4. 避坑与排查在线测评系统最常见的五类故障这一章是我踩过的坑和帮别人排查过的故障按「现象 → 原因 → 解决」写。每一条都是真实场景里反复出现的不是理论推演。4.1 判题结果不稳定同一份代码有时通过有时超时现象候选人提交同一份代码第一次判通过第二次判超时第三次又通过。原因判题容器的 CPU 配额是共享的如果宿主机上同时跑了多个判题任务某个任务可能抢不到 CPU 时间片导致实际运行时间超过限制。另外Docker 的--cpus 0.5是软限制不是硬隔离高负载时仍会互相影响。解决给判题容器加--cpu-shares并配合--cpuset-cpus绑定核心或者把判题 Worker 部署到独立节点。更彻底的做法是判题时取多次运行的最快值而不是单次值。参数上time_limit可以设成题目预期耗时的 3 倍留出抖动空间。4.2 候选人反馈答案丢失但服务端日志显示已保存现象候选人说答完题提交后成绩里少了几道题的答案但服务端日志显示这些答案都收到过。原因前端用了fetch发答案但没等响应就跳转页面请求被浏览器取消。或者用了localStorage缓存但提交时只读了服务端数据没合并本地未同步的数据。解决答案提交用sendBeacon或keepalive: true的fetch保证页面跳转时请求不中断。提交前先调一次flushPending把本地缓存全部推上去。服务端收到答案时用upsert而不是insert避免重复提交报错。4.3 切屏检测误报率过高候选人集体申诉现象一场测评结束后超过 30% 的候选人被标记切屏异常申诉量爆炸。原因切屏阈值设得太敏感比如离开 1 秒就记一次。实际上浏览器弹通知、输入法切换、甚至某些安全软件弹窗都会触发visibilitychange。解决把阈值调到 5 秒以上并且只累计「离开时长」而不是「离开次数」。同时把切屏数据作为参考分不直接判作弊。最终判罚要结合答题速度、答案相似度等多维度数据。4.4 Docker 镜像拉取失败导致判题全部阻塞现象判题队列突然堆积Worker 日志显示docker: Error response from daemon: pull access denied。原因判题脚本里用了python:3.11-slim这种公共镜像如果宿主机网络波动或者镜像仓库限流拉取就会失败。而判题脚本没有做镜像预拉取每次判题都尝试拉。解决在 Worker 启动时预拉取所有需要的镜像判题时用--pull never禁止拉取。镜像统一存在本地仓库定期更新。参数上docker run加--pull never后如果本地没有镜像会直接报错所以预拉取步骤不能省。4.5 大规模并发时 Redis 队列丢任务现象测评高峰期部分提交没有判题结果Redis 里也找不到对应任务。原因用了LPUSHRPOP的非阻塞模式Worker 取任务后如果崩溃任务就丢了。或者 Redis 内存满了触发了淘汰策略把队列数据清了。解决改用BRPOPLPUSH或 Redis Stream 的消费者组模式任务取出后先放到「处理中」列表判题完成再删除。Redis 配置里把maxmemory-policy设为noeviction避免队列被淘汰。同时给队列做持久化AOF 开everysec。5. 从单机到集群在线测评系统的压测与扩容技巧前面四章把单机闭环跑通了但真正上线要面对的是并发。这一章讲怎么压测、怎么扩容以及一个我常用的验证技巧。5.1 用 k6 做判题接口的压测压测不是随便发请求要模拟真实提交模式。下面是一个 k6 脚本模拟 100 个候选人同时提交代码。// loadtest.js import http from k6/http; import { check, sleep } from k6; export const options { stages: [ { duration: 30s, target: 50 }, // 30秒内爬到50并发 { duration: 1m, target: 100 }, // 维持100并发1分钟 { duration: 30s, target: 0 } // 30秒内降为0 ] }; export default function () { const payload JSON.stringify({ questionId: 1, language: python, code: print(sum(map(int, input().split()))) }); const res http.post(http://your-judge-api/submit, payload, { headers: { Content-Type: application/json } }); check(res, { submit accepted: (r) r.status 202, has submission id: (r) JSON.parse(r.body).submission_id ! undefined }); sleep(1); // 模拟候选人思考间隔 }逻辑说明stages定义了三段式压测先爬坡再维持再降坡。sleep(1)模拟真实用户提交间隔避免压测流量过于集中。check验证接口返回 202 和 submission_id确保任务真的进了队列。参数上target: 100是并发用户数不是请求数k6 会自动管理虚拟用户。跑完压测后重点看三个指标判题队列长度是否持续增长、判题平均耗时是否超过题目限制、Worker 的 CPU 和内存是否打满。如果队列长度一直涨说明 Worker 不够要加机器。如果判题耗时涨了但队列没涨说明单次判题变慢要查是不是宿主机负载太高。5.2 扩容时先扩 Worker 还是先扩 Redis这是一个常见纠结。我的经验是先扩 Worker再扩 Redis。因为判题瓶颈通常在 CPU 和容器启动不在队列读写。Redis 单节点能扛几万 QPS判题 Worker 才是瓶颈。只有当 Redis 内存使用超过 70% 或者出现明显延迟时才考虑上 Redis 集群。扩容 Worker 时注意一点Docker 容器启动有开销如果每个判题任务都起一个新容器启动时间可能比判题时间还长。优化方法是维护一个容器池提前起好一批容器判题时直接投递代码判完清理容器内文件而不是销毁容器。这个优化能把判题吞吐提升 3 到 5 倍。5.3 一个验证判题正确性的小技巧判题系统最怕的是「判错了但没人发现」。我习惯在题库里放几道「哨兵题」——这些题的正确答案和错误答案都是已知的每次系统更新后先跑一遍哨兵题确认判题逻辑没变。具体做法建一个内部测试账号提交预先准备好的代码检查判题结果是否和预期一致。哨兵题要覆盖正确解法、超时解法、内存超限解法、编译错误解法、输出格式错误解法。每次改判题脚本或升级 Docker 镜像后先跑哨兵题通过了再放量。这个习惯帮我挡过好几次事故。有一次升级 Python 版本input()的行为变了导致所有读输入的题目判错哨兵题第一时间就报警了。5.4 我踩过的最大的坑最后说一个我自己的教训。早期做在线测评时我把判题和 Web 服务部署在同一台机器上觉得省事。结果一次测评中有人提交了一个死循环代码虽然容器限制了时间但容器启动本身消耗了大量 CPU把 Web 服务拖垮了所有候选人页面都打不开。后来我把判题 Worker 完全独立部署Web 服务和判题服务之间只通过 Redis 队列通信物理隔离。再后来加了容器池和优先级队列才算稳定。这件事让我明白在线测评系统的稳定性不取决于判题有多快而取决于判题出问题时会不会影响答题。答题和判题必须解耦这是底线。如果你正在搭这套系统建议先把答题链路和判题链路分开部署哪怕初期只有一台机器也用两个进程隔离。等量上来了再拆机器。希望帮到你。本文还有配套的精品资源点击获取