在線測評系統(tǒng):判題沙箱、防作弊與架構(gòu)實(shí)戰(zhàn))
簡介這份PDF資料面向準(zhǔn)備阿里巴巴集團(tuán)及支付寶在線測評的應(yīng)屆畢業(yè)生與實(shí)習(xí)生聚焦技術(shù)崗招聘中的在線測試環(huán)節(jié)幫助使用者在有限時(shí)間內(nèi)熟悉題型分布與考察重點(diǎn)。內(nèi)容覆蓋編程語言、數(shù)據(jù)結(jié)構(gòu)、算法、數(shù)據(jù)庫、操作系統(tǒng)、計(jì)算機(jī)網(wǎng)絡(luò)等多個(gè)技術(shù)方向可作為測評前的自測與查漏補(bǔ)缺參考。資源包共1個(gè)PDF文件大小約1.14MB頁面中穿插「可編輯修改」的排版標(biāo)記便于按需摘錄與整理筆記。目前已有81人學(xué)習(xí)下載屬于小眾但針對性較強(qiáng)的備考材料。讀者可借助其中的試題樣例了解測評難度層級與常見考點(diǎn)結(jié)合自身薄弱模塊制定復(fù)習(xí)計(jì)劃同時(shí)提前感受阿里系招聘流程中在線測評的節(jié)奏與要求為后續(xù)筆試與面試環(huán)節(jié)積累實(shí)踐經(jīng)驗(yàn)。1. 一份 PDF 引發(fā)的排查在線測評系統(tǒng)里到底藏了什么很多人第一次看到「阿里巴巴在線測評.pdf」這個(gè)文件名第一反應(yīng)是去找這份 PDF 本身。但真正在一線做過招聘系統(tǒng)、在線判題系統(tǒng)的人會告訴你這個(gè)標(biāo)題背后指向的不是一份文檔而是一整條技術(shù)鏈路在線測評系統(tǒng)。它要解決的核心問題是——如何讓成千上萬的候選人在瀏覽器里同時(shí)答題系統(tǒng)自動判分、自動防作弊、自動出報(bào)告而且不能崩。我做過類似的在線測評平臺從題庫管理、判題沙箱、前端防切屏到成績回傳每一環(huán)都有坑。這份 PDF 如果存在大概率是某次測評的題目存檔、系統(tǒng)說明或者成績報(bào)告。但不管它是什么你真正需要掌握的是怎么從零搭一套能扛住并發(fā)、判得準(zhǔn)、防得住作弊的在線測評系統(tǒng)。這篇文章面向的是需要落地這套系統(tǒng)的后端、全棧和運(yùn)維工程師也會講到前端防作弊的具體參數(shù)。新手能跟著步驟跑通最小閉環(huán)熟手能看到并發(fā)判題和沙箱隔離的邊界。2. 在線測評系統(tǒng)的三層架構(gòu)與判題沙箱選型在線測評系統(tǒng)看起來只是「出題-答題-判分」但真正上線后你會發(fā)現(xiàn)最脆弱的是判題環(huán)節(jié)。一道編程題提交上來代碼要在服務(wù)器上編譯、運(yùn)行、限制時(shí)間和內(nèi)存還要防止惡意代碼讀取其他候選人數(shù)據(jù)。這一章把架構(gòu)拆開重點(diǎn)講判題沙箱的選型理由和最小實(shí)現(xiàn)。2.1 為什么不能直接在應(yīng)用服務(wù)器上跑用戶代碼最常見的翻車方式把用戶提交的 Python 或 Java 代碼直接exec或subprocess跑在 Web 服務(wù)器上。結(jié)果就是有人寫一個(gè)while True或者os.system(rm -rf /)整個(gè)測評服務(wù)直接掛掉。血淚經(jīng)驗(yàn)是判題必須隔離。隔離方案有三檔方案隔離級別啟動速度適用場景子進(jìn)程 資源限制低毫秒級內(nèi)部小規(guī)模測評Docker 容器中秒級大多數(shù)在線測評微虛擬機(jī)如 Firecracker高百毫秒級大規(guī)模公有云測評我一般會選 Docker 方案因?yàn)樯鷳B(tài)成熟、鏡像好管理配合--network none、--memory、--cpus就能擋住大部分惡意行為。如果并發(fā)量上萬再考慮微虛擬機(jī)。2.2 用 Docker 跑通一個(gè)最小判題沙箱下面是一個(gè)可復(fù)現(xiàn)的最小判題腳本。它接收一段 Python 代碼和測試輸入在受限容器里運(yùn)行返回輸出和耗時(shí)。# judge.py import subprocess import tempfile import os import time def run_judge(code: str, test_input: str, time_limit2, memory_limit128m): # 把用戶代碼寫入臨時(shí)文件 with tempfile.NamedTemporaryFile(modew, suffix.py, deleteFalse) as f: f.write(code) code_path f.name # Docker 運(yùn)行命令無網(wǎng)絡(luò)、限內(nèi)存、限CPU、只讀掛載代碼 cmd [ docker, run, --rm, --network, none, # 禁止網(wǎng)絡(luò)訪問 --memory, memory_limit, # 內(nèi)存上限 --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) # 清理臨時(shí)文件邏輯說明這段代碼做了四件事——把用戶代碼落盤、用 Docker 起一個(gè)無網(wǎng)絡(luò)容器、通過 stdin 喂測試輸入、收集輸出和耗時(shí)。關(guān)鍵參數(shù)有三個(gè)--network none切斷外聯(lián)--memory 128m防止內(nèi)存爆炸--cpus 0.5防止單次判題吃滿 CPU。timeout命令在容器內(nèi)限制運(yùn)行時(shí)間外層subprocess.run的timeout再兜底避免 Docker 卡死導(dǎo)致判題隊(duì)列堵塞。參數(shù)怎么改如果題目是 Java基礎(chǔ)內(nèi)存要調(diào)到256m以上因?yàn)?JVM 本身占內(nèi)存。如果測試用例很大time_limit可以放寬到 5 秒但要在題目配置里單獨(dú)標(biāo)記不能全局放開。2.3 判題隊(duì)列與并發(fā)控制單機(jī)判題跑通后下一步是并發(fā)。常見做法是用 Redis 做隊(duì)列Worker 進(jìn)程從隊(duì)列里取任務(wù)。這里有一個(gè)容易忽略的點(diǎn)判題任務(wù)要分優(yōu)先級。正式測評的提交優(yōu)先級高于練習(xí)模式否則練習(xí)流量會把正式測評堵死。# 用 Redis 列表模擬優(yōu)先級隊(duì)列 # 高優(yōu)先級正式測評 LPUSH judge:queue:high {submission_id: 1001, code: ..., lang: python} # 低優(yōu)先級練習(xí)模式 LPUSH judge:queue:low {submission_id: 1002, code: ..., lang: python} # Worker 先消費(fèi) high再消費(fèi) low BRPOP judge:queue:high judge:queue:low 5BRPOP會按順序檢查列表高優(yōu)先級隊(duì)列有數(shù)據(jù)就先取高優(yōu)先級。5是阻塞超時(shí)秒數(shù)避免 Worker 空轉(zhuǎn)。Worker 數(shù)量建議按 CPU 核數(shù)的 1.5 倍配置因?yàn)榕蓄}是 IO 和 CPU 混合型留一點(diǎn)超賣空間。3. 前端防作弊與答題狀態(tài)同步的落地參數(shù)判題只是后端的一半另一半是前端。在線測評最怕的是候選人切屏搜答案、多開頁面、或者用腳本自動答題。這一章講前端能做什么、不能做什么以及參數(shù)怎么設(shè)才不誤傷正常用戶。3.1 切屏檢測與 visibilitychange 的誤判邊界瀏覽器里檢測切屏最常用的是visibilitychange事件。但這里有一個(gè)玄學(xué)問題某些瀏覽器在切換標(biāo)簽頁時(shí)觸發(fā)時(shí)機(jī)不一致有的在切走時(shí)觸發(fā)有的在切回時(shí)觸發(fā)。如果只監(jiān)聽一次可能漏記。// anti-cheat.js let switchCount 0; let lastHiddenTime 0; document.addEventListener(visibilitychange, () { if (document.hidden) { // 頁面被隱藏記錄時(shí)間 lastHiddenTime Date.now(); switchCount; // 上報(bào)服務(wù)端但不要立即判作弊 reportEvent(tab_hidden, { count: switchCount }); } else { // 頁面重新可見計(jì)算離開時(shí)長 const awayMs Date.now() - lastHiddenTime; if (awayMs 5000) { // 離開超過5秒標(biāo)記為可疑 reportEvent(long_away, { duration: awayMs }); } } }); function reportEvent(type, payload) { // 用 sendBeacon 保證頁面關(guān)閉也能發(fā)出 navigator.sendBeacon(/api/anticheat, JSON.stringify({ type, payload, timestamp: Date.now() })); }邏輯說明visibilitychange在頁面隱藏和顯示時(shí)都會觸發(fā)用document.hidden區(qū)分方向。sendBeacon是關(guān)鍵普通fetch在頁面關(guān)閉時(shí)可能發(fā)不出去sendBeacon由瀏覽器保證投遞。參數(shù)上awayMs 5000才標(biāo)記可疑因?yàn)檎S脩艨赡苤皇强匆谎蹠r(shí)間或接個(gè)電話直接判作弊會引發(fā)大量申訴。注意切屏檢測只能作為輔助證據(jù)不能作為唯一判罰依據(jù)。我見過有系統(tǒng)因?yàn)榍衅烈淮尉妥詣咏痪斫Y(jié)果候選人只是彈了個(gè)系統(tǒng)通知直接投訴到平臺。建議策略是切屏次數(shù)和離開時(shí)長累計(jì)超過閾值才告警最終由人工復(fù)核。3.2 答題狀態(tài)同步本地緩存與斷線重連在線測評另一個(gè)高頻問題是網(wǎng)絡(luò)抖動導(dǎo)致答案丟失。候選人答了半小時(shí)一斷網(wǎng)全沒了這是最嚴(yán)重的體驗(yàn)事故。解決方案是本地緩存 增量同步。// 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)); // 再嘗試同步到服務(wù)端 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)); } // 監(jiān)聽網(wǎng)絡(luò)恢復(fù) window.addEventListener(online, flushPending);邏輯說明localStorage做第一層緩存每次答案變更先落本地再發(fā)請求。synced標(biāo)記用來區(qū)分已同步和未同步斷線重連時(shí)只推未同步的部分。online事件觸發(fā)flushPending保證網(wǎng)絡(luò)恢復(fù)后自動補(bǔ)傳。參數(shù)上localStorage一般有 5MB 限制純文本答案足夠存幾千道題但如果題目帶圖片附件要改用IndexedDB。3.3 防復(fù)制與防粘貼的取舍很多測評系統(tǒng)會禁用右鍵、禁用復(fù)制粘貼。但實(shí)際落地時(shí)過度禁用會誤傷。比如候選人想復(fù)制題目里的公式到草稿紙或者用輸入法的粘貼功能都會被攔。我的建議是只禁用答題區(qū)域的粘貼不禁用全局。// 只對答題輸入框禁用粘貼 document.querySelectorAll(.answer-input).forEach(el { el.addEventListener(paste, (e) { e.preventDefault(); alert(答題區(qū)域禁止粘貼請手動輸入); }); });這樣題目區(qū)域、草稿區(qū)域不受影響只有答案輸入框攔截粘貼。參數(shù)上不需要額外配置但要注意如果題目本身是代碼題候選人可能需要粘貼自己寫的代碼片段這時(shí)候要單獨(dú)放開代碼編輯器的粘貼權(quán)限。4. 避坑與排查在線測評系統(tǒng)最常見的五類故障這一章是我踩過的坑和幫別人排查過的故障按「現(xiàn)象 → 原因 → 解決」寫。每一條都是真實(shí)場景里反復(fù)出現(xiàn)的不是理論推演。4.1 判題結(jié)果不穩(wěn)定同一份代碼有時(shí)通過有時(shí)超時(shí)現(xiàn)象候選人提交同一份代碼第一次判通過第二次判超時(shí)第三次又通過。原因判題容器的 CPU 配額是共享的如果宿主機(jī)上同時(shí)跑了多個(gè)判題任務(wù)某個(gè)任務(wù)可能搶不到 CPU 時(shí)間片導(dǎo)致實(shí)際運(yùn)行時(shí)間超過限制。另外Docker 的--cpus 0.5是軟限制不是硬隔離高負(fù)載時(shí)仍會互相影響。解決給判題容器加--cpu-shares并配合--cpuset-cpus綁定核心或者把判題 Worker 部署到獨(dú)立節(jié)點(diǎn)。更徹底的做法是判題時(shí)取多次運(yùn)行的最快值而不是單次值。參數(shù)上time_limit可以設(shè)成題目預(yù)期耗時(shí)的 3 倍留出抖動空間。4.2 候選人反饋答案丟失但服務(wù)端日志顯示已保存現(xiàn)象候選人說答完題提交后成績里少了幾道題的答案但服務(wù)端日志顯示這些答案都收到過。原因前端用了fetch發(fā)答案但沒等響應(yīng)就跳轉(zhuǎn)頁面請求被瀏覽器取消?;蛘哂昧薼ocalStorage緩存但提交時(shí)只讀了服務(wù)端數(shù)據(jù)沒合并本地未同步的數(shù)據(jù)。解決答案提交用sendBeacon或keepalive: true的fetch保證頁面跳轉(zhuǎn)時(shí)請求不中斷。提交前先調(diào)一次flushPending把本地緩存全部推上去。服務(wù)端收到答案時(shí)用upsert而不是insert避免重復(fù)提交報(bào)錯(cuò)。4.3 切屏檢測誤報(bào)率過高候選人集體申訴現(xiàn)象一場測評結(jié)束后超過 30% 的候選人被標(biāo)記切屏異常申訴量爆炸。原因切屏閾值設(shè)得太敏感比如離開 1 秒就記一次。實(shí)際上瀏覽器彈通知、輸入法切換、甚至某些安全軟件彈窗都會觸發(fā)visibilitychange。解決把閾值調(diào)到 5 秒以上并且只累計(jì)「離開時(shí)長」而不是「離開次數(shù)」。同時(shí)把切屏數(shù)據(jù)作為參考分不直接判作弊。最終判罰要結(jié)合答題速度、答案相似度等多維度數(shù)據(jù)。4.4 Docker 鏡像拉取失敗導(dǎo)致判題全部阻塞現(xiàn)象判題隊(duì)列突然堆積Worker 日志顯示docker: Error response from daemon: pull access denied。原因判題腳本里用了python:3.11-slim這種公共鏡像如果宿主機(jī)網(wǎng)絡(luò)波動或者鏡像倉庫限流拉取就會失敗。而判題腳本沒有做鏡像預(yù)拉取每次判題都嘗試?yán)=鉀Q在 Worker 啟動時(shí)預(yù)拉取所有需要的鏡像判題時(shí)用--pull never禁止拉取。鏡像統(tǒng)一存在本地倉庫定期更新。參數(shù)上docker run加--pull never后如果本地沒有鏡像會直接報(bào)錯(cuò)所以預(yù)拉取步驟不能省。4.5 大規(guī)模并發(fā)時(shí) Redis 隊(duì)列丟任務(wù)現(xiàn)象測評高峰期部分提交沒有判題結(jié)果Redis 里也找不到對應(yīng)任務(wù)。原因用了LPUSHRPOP的非阻塞模式Worker 取任務(wù)后如果崩潰任務(wù)就丟了。或者 Redis 內(nèi)存滿了觸發(fā)了淘汰策略把隊(duì)列數(shù)據(jù)清了。解決改用BRPOPLPUSH或 Redis Stream 的消費(fèi)者組模式任務(wù)取出后先放到「處理中」列表判題完成再刪除。Redis 配置里把maxmemory-policy設(shè)為noeviction避免隊(duì)列被淘汰。同時(shí)給隊(duì)列做持久化AOF 開everysec。5. 從單機(jī)到集群在線測評系統(tǒng)的壓測與擴(kuò)容技巧前面四章把單機(jī)閉環(huán)跑通了但真正上線要面對的是并發(fā)。這一章講怎么壓測、怎么擴(kuò)容以及一個(gè)我常用的驗(yàn)證技巧。5.1 用 k6 做判題接口的壓測壓測不是隨便發(fā)請求要模擬真實(shí)提交模式。下面是一個(gè) k6 腳本模擬 100 個(gè)候選人同時(shí)提交代碼。// loadtest.js import http from k6/http; import { check, sleep } from k6; export const options { stages: [ { duration: 30s, target: 50 }, // 30秒內(nèi)爬到50并發(fā) { duration: 1m, target: 100 }, // 維持100并發(fā)1分鐘 { duration: 30s, target: 0 } // 30秒內(nèi)降為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)模擬真實(shí)用戶提交間隔避免壓測流量過于集中。check驗(yàn)證接口返回 202 和 submission_id確保任務(wù)真的進(jìn)了隊(duì)列。參數(shù)上target: 100是并發(fā)用戶數(shù)不是請求數(shù)k6 會自動管理虛擬用戶。跑完壓測后重點(diǎn)看三個(gè)指標(biāo)判題隊(duì)列長度是否持續(xù)增長、判題平均耗時(shí)是否超過題目限制、Worker 的 CPU 和內(nèi)存是否打滿。如果隊(duì)列長度一直漲說明 Worker 不夠要加機(jī)器。如果判題耗時(shí)漲了但隊(duì)列沒漲說明單次判題變慢要查是不是宿主機(jī)負(fù)載太高。5.2 擴(kuò)容時(shí)先擴(kuò) Worker 還是先擴(kuò) Redis這是一個(gè)常見糾結(jié)。我的經(jīng)驗(yàn)是先擴(kuò) Worker再擴(kuò) Redis。因?yàn)榕蓄}瓶頸通常在 CPU 和容器啟動不在隊(duì)列讀寫。Redis 單節(jié)點(diǎn)能扛幾萬 QPS判題 Worker 才是瓶頸。只有當(dāng) Redis 內(nèi)存使用超過 70% 或者出現(xiàn)明顯延遲時(shí)才考慮上 Redis 集群。擴(kuò)容 Worker 時(shí)注意一點(diǎn)Docker 容器啟動有開銷如果每個(gè)判題任務(wù)都起一個(gè)新容器啟動時(shí)間可能比判題時(shí)間還長。優(yōu)化方法是維護(hù)一個(gè)容器池提前起好一批容器判題時(shí)直接投遞代碼判完清理容器內(nèi)文件而不是銷毀容器。這個(gè)優(yōu)化能把判題吞吐提升 3 到 5 倍。5.3 一個(gè)驗(yàn)證判題正確性的小技巧判題系統(tǒng)最怕的是「判錯(cuò)了但沒人發(fā)現(xiàn)」。我習(xí)慣在題庫里放幾道「哨兵題」——這些題的正確答案和錯(cuò)誤答案都是已知的每次系統(tǒng)更新后先跑一遍哨兵題確認(rèn)判題邏輯沒變。具體做法建一個(gè)內(nèi)部測試賬號提交預(yù)先準(zhǔn)備好的代碼檢查判題結(jié)果是否和預(yù)期一致。哨兵題要覆蓋正確解法、超時(shí)解法、內(nèi)存超限解法、編譯錯(cuò)誤解法、輸出格式錯(cuò)誤解法。每次改判題腳本或升級 Docker 鏡像后先跑哨兵題通過了再放量。這個(gè)習(xí)慣幫我擋過好幾次事故。有一次升級 Python 版本input()的行為變了導(dǎo)致所有讀輸入的題目判錯(cuò)哨兵題第一時(shí)間就報(bào)警了。5.4 我踩過的最大的坑最后說一個(gè)我自己的教訓(xùn)。早期做在線測評時(shí)我把判題和 Web 服務(wù)部署在同一臺機(jī)器上覺得省事。結(jié)果一次測評中有人提交了一個(gè)死循環(huán)代碼雖然容器限制了時(shí)間但容器啟動本身消耗了大量 CPU把 Web 服務(wù)拖垮了所有候選人頁面都打不開。后來我把判題 Worker 完全獨(dú)立部署Web 服務(wù)和判題服務(wù)之間只通過 Redis 隊(duì)列通信物理隔離。再后來加了容器池和優(yōu)先級隊(duì)列才算穩(wěn)定。這件事讓我明白在線測評系統(tǒng)的穩(wěn)定性不取決于判題有多快而取決于判題出問題時(shí)會不會影響答題。答題和判題必須解耦這是底線。如果你正在搭這套系統(tǒng)建議先把答題鏈路和判題鏈路分開部署哪怕初期只有一臺機(jī)器也用兩個(gè)進(jìn)程隔離。等量上來了再拆機(jī)器。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取