首页 / 资讯中心 / 文章详情

从零搭建编码训练系统:沙箱隔离、自动化评测与知识沉淀实践

从零搭建编码训练系统:沙箱隔离、自动化评测与知识沉淀实践 ★ FEATURED ARTICLE
这段时间我花了不少精力把一个叫t3code的项目从头到尾做了完整梳理。它并不是什么大厂开源框架更像是一套面向开发者个人和团队内部的“编码能力训练与实战复盘系统”。简单说它把日常刷题、代码片段沉淀、技术知识卡、提交记录分析这几件事揉在一起做成一个可以自己部署、自己维护的轻量平台。如果你正在带新人、做技术分享或者想给自己搞一个可持续的编码训练环境这篇文章应该能给你一些可落地的参考。先说清楚我的定位t3code 不是为了替代 LeetCode 或 Codeforces而是解决一个更实际的问题——很多人刷题时只追求 AC忽略了代码规范、边界测试和复盘沉淀。我在设计时最核心的诉求是让每一次编码练习都留下可检索的痕迹让团队新人通过“题目代码片段复盘笔记”三步快速进入状态。文章里所有方案都是我基于常见工程实践做的合理推演并没有官方文档背书但每一步都可以直接照着搭。1. 项目定位t3code到底解决什么问题1.1 从痛点出发为什么需要一个编码训练系统我先描述几个场景你看看自己遇没遇到过。第一个场景团队来了个新人语言语法没问题但让他独立写一个带异常处理的接口他会犹豫半天。你给他讲了一堆规范他当时点头一个星期后又忘得干干净净。第二个场景你自己每天刷三道题刷完就关页面到了月底回头看除了 AC 数量之外什么都没有连当时卡住的测试用例都记不清。第三个场景技术分享会上你想给大家演示一段好的写法结果翻遍聊天记录和博客收藏夹才找到一个七零八落的代码片段。t3code 的目标就是把这三个场景统一成一套闭环训练 - 反馈 - 沉淀 - 复用。它不是一个题库而是一个“编码行为管理系统”。题目只是引子重点在于你提交代码后能获得多维度的反馈包括用例通过率、执行耗时、代码风格提醒以及最重要的——自动归档。每次提交都会生成一个快照存入个人代码库后续可以通过标签、题目、状态随时检索。这个定位决定了它的架构不会太重。我们不需要大规模分布式评测集群因为核心用户是个人开发者或中小团队同时在线人数大概率不超过几十个。与其堆一堆用不上的微服务不如把精力放在两个核心难点上一个是代码执行的隔离沙箱另一个是提交记录的结构化沉淀。1.2 为什么采用“单体应用 模块化拆分”的方案早期设计时我也纠结过要不要上微服务。后来我列了一张对比表发现对于 t3code 这种体量的项目微服务纯粹是给自己找麻烦。对比维度单体应用微服务开发效率高改完直接部署低每次改接口要联调资源占用低一个进程搞定高每个服务都要独立内存故障排查相对简单日志集中需要链路追踪成本高扩展能力够用单机可扛百级并发强适合大规模公有云平台适合场景内部工具、训练平台、小型SaaS面向海量用户的开放平台最终我选择了FastAPI PostgreSQL Redis Docker的单体架构但按功能模块做了严格的分层。这种“物理单体、逻辑微服务”的做法兼顾了开发效率和未来拆分空间。举个例子沙箱执行模块虽然在同一个进程里但它是通过独立的内部 API 暴露功能将来真要拆出去只需要把这个模块接口改成 HTTP 调用其他部分不用动。1.3 目标用户与实际使用场景从用户角度t3code 主要服务三类人在校学生或转行开发者需要高频、低成本地练习编码并且希望每次练习都有记录可复盘。团队中的新人通过平台内置的“团队题目集”和“代码规范卡”快速熟悉项目上下文。技术团队的管理者或 Tech Lead通过仪表盘查看成员的提交趋势、薄弱知识点从而调整培训计划。实际使用场景我设计了三种每日一题模式系统按标签权重比如数组 40%、动态规划 30%、字符串 30%从题库选一道题推送给用户用户完成后提交系统反馈评分。团队周赛模式每周五下午固定时间从“团队题库”里抽 3 道题限时 60 分钟结束后自动汇总排名和错题集。自由训练模式用户可以按标签、难度、题型筛选题目也可以只做“最近做错的题”相当于一个私人错题本。这三种场景覆盖了从日常练习到团队管理的完整链条也让 t3code 不只是一个人自嗨的项目而是真正能嵌入团队工作流的工具。2. 核心模块解析与实操要点t3code 的功能可以拆成四块题目管理、在线沙箱、训练反馈、知识沉淀。每一块都有值得展开的细节。2.1 题目管理模块数据结构与标签体系题目管理是整个系统的基础。我设计题目表时没有把字段搞得特别复杂但有几个关键点是踩过坑后才想明白的。第一slug 必须唯一且不可变。题目内容以后可能修改但 URL 和标识不能变否则用户收藏和历史记录会失效。slug 的格式我统一用 kebab-case比如two-sum-easy-001。第二标签要区分技术标签和难度标签。难度标签属于枚举类型技术标签单独建多对多关联表。这两类混在一个字段里后面做筛选和统计会非常痛苦。第三模板代码和测试用例是分离的。模板代码是用户进入编辑器时自动填充的初始代码测试用例是系统跑用户的提交时用的。两者分开才能在不同语言之间复用题目。我实际用来建表的核心 SQL 大概长这样CREATE TABLE problems ( id SERIAL PRIMARY KEY, slug VARCHAR(120) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, difficulty SMALLINT NOT NULL DEFAULT 2, -- 1简单 2中等 3困难 content TEXT NOT NULL, -- Markdown 格式的题目描述 template_code JSONB NOT NULL, -- 形如 {python: ..., javascript: ...} test_case JSONB NOT NULL, -- 形如 {input: ..., output: ...} tags TEXT[] DEFAULT {}, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE TABLE problem_tags ( problem_id INTEGER REFERENCES problems(id) ON DELETE CASCADE, tag VARCHAR(50) NOT NULL, UNIQUE(problem_id, tag) ); CREATE TABLE submissions ( id BIGSERIAL PRIMARY KEY, user_id INTEGER NOT NULL, problem_id INTEGER NOT NULL, language VARCHAR(30) NOT NULL, code TEXT NOT NULL, status VARCHAR(20) NOT NULL, -- AC / WA / TLE / MLE / CE runtime_ms INTEGER, memory_kb INTEGER, submitted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() );这里面最关键的是test_case字段我用 JSONB 而不是单独建表。原因是测试用例格式因题目而异有的需要多组输入输出有的需要特殊参数结构用关系表管理反而麻烦。但注意JSONB 查询时不能索引内部字段所以如果题目数量特别大再考虑拆分前期没必要。2.2 在线编码沙箱选型与安全边界沙箱是整个系统最敏感的部分。用户提交的代码本质上是不受信任的你必须在隔离环境中执行否则一个os.system(rm -rf /)就能让你后悔一辈子。我对比了三种方案方案隔离级别启动速度资源消耗并发上限适用场景本地子进程进程级最快极低高只执行可信代码不推荐Docker 容器系统级中等1-3秒较高中大多数训练平台的正解云函数/Serverless虚拟化层快但冷启动不稳低高需要弹性扩缩容的场景对于 t3code 来说Docker 容器是最合适的。它的隔离性比本地进程强很多启动速度虽不算快但完全可以接受。官方评测机集群动辄上千个容器并发我们个人部署时只需要限制同时运行的容器数量比如先设个 4 个并发上限再配合队列机制不会出问题。实际操作时我写了一个 Python 脚本来负责整个执行流程关键步骤如下从请求里拿到代码、语言、测试用例。把代码和测试用例写入一个临时目录。构建镜像时使用gcc:12、python:3.11-slim、node:20-alpine这类精简镜像不装多余工具。运行容器设置--network none、--memory、--cpus、--pids-limit和超时时间。容器退出后从 stdout/stderr 收集输出与预期输出对比。最后清理临时目录和容器。核心命令大概是docker run --rm \ --network none \ --memory 128m \ --cpus 0.5 \ --pids-limit 64 \ --stop-timeout 5 \ -v /tmp/t3code_exec_xxx:/code:ro \ -w /code \ python:3.11-slim \ python /code/main.py这里有几个容易踩的坑。挂载目录一定要只读不然用户可以往容器里写文件虽然容器删了就没了但可能会占满宿主机磁盘。--stop-timeout不能省它控制着 SIGKILL 的延迟时间防止用户代码里写了忽略 SIGTERM 的逻辑。--pids-limit很重要遇到 fork 炸弹时这就是保命机制。2.3 训练反馈如何让每次提交都有意义很多人刷题之后的反馈就是“错”“对”两个字这太浪费了。t3code 在反馈方面做了三层第一层是基础判定包括 AC、WA、TLE、MLE、CE 五类状态。这一层是硬性指标没什么好说。第二层是性能指标主要是运行时间和内存占用。我在发布前会用标准答案跑一遍得到基线数据然后用户的提交会和基线对比。如果用户代码跑得比标准答案慢 3 倍以上系统会把“性能预警”打在反馈栏里这个设计倒逼用户不只是追求正确还会关注复杂度。第三层是风格提醒。这一层比较轻不做深度的静态分析只做几个简单检查有没有未使用的变量、函数名是否符合 snake_case 或 camelCase、是否存在明显的长函数超过 80 行。前端通过 Monaco Editor 的光标悬停来展示这些提醒效果就像有个小 Code Review 工具在盯着你写代码。这三层反馈加起来用户得到的就不只是一句“通过”而是一条完整的改进路径。2.4 知识沉淀团队规范与代码片段库我见过太多团队把规范写在 Wiki 里结果没有一个人看。t3code 做了两个改变第一个改变是**“代码片段即规范”**。每一条规范都附带一段可运行的正例和一段反例并且片段本身可以像代码一样被搜索。比如“异常处理规范”这条规范对应的片段是// good try { const data await api.fetch(); } catch (error) { logger.error(fetch failed, { error }); throw new BusinessError(数据加载失败, { cause: error }); } // bad try { const data await api.fetch(); } catch (error) { console.log(error); }新人遇到类似场景时在编辑器里输入 “异常处理”系统就能把这段带提示的片段拉出来。第二个改变是提交自动归档。用户每做一道题系统会生成一条结构化记录包括题目、通过的测试用例数、耗时、失败时的输入数据以及用户写的复盘笔记。这里我强制要求用户在下一次提交前必须填“这次学到了什么”这个字段哪怕一个字也行。这个设计刚开始有人嫌烦但用了一周后绝大多数人都反馈说“记录下来的问题才是真正解决的问题”。3. 从零到一实现关键环节的完整流程3.1 环境准备与依赖清单我建议的部署环境是 Linux 服务器2 核 4G 内存起步磁盘 20G 以上。操作系统用 Ubuntu 22.04 或 Debian 12这些都是常见选择。你的本地开发机也可以跑但 Docker 在 macOS 和 Windows 上会有一些文件挂载性能差异线上部署建议直接上 Linux。后端依赖我用了一个精简的requirements.txtfastapi0.115.0 uvicorn[standard]0.30.6 sqlalchemy2.0.35 psycopg2-binary2.9.9 redis5.0.8 docker7.1.0 jinja23.1.4 pydantic2.9.2前端我用的是 Vue 3 Vite Monaco Editor这部分没什么特殊依赖主要就是 Monaco 的 worker 配置后面会讲到。3.2 数据库设计与核心接口实现数据库分六张表problems、problem_tags、submissions、users、review_notes、code_snippets。重点说一下review_notes表它是复盘模块的根基CREATE TABLE review_notes ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, submission_id BIGINT NOT NULL REFERENCES submissions(id) ON DELETE CASCADE, note_text TEXT NOT NULL, tags TEXT[] DEFAULT {}, next_review_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() );next_review_at字段用来做简单的间隔重复提醒。我们可以采用一个简化的策略第一次做错的题三天内必须再次练习如果二刷通过间隔延长到七天如果连续两次通过标记为“已掌握”。这个策略比复杂的 SM-2 算法更易实现对用户也足够友好。后端接口我列出了几个核心路由POST /api/submit接收代码、题目 ID、语言触发执行判定。GET /api/problems/daily按标签权重返回每日推荐题。GET /api/submissions/me返回当前用户的提交记录。GET /api/review/overdue返回到期需要复习的题目与笔记。以POST /api/submit为例处理逻辑大致是from fastapi import APIRouter, BackgroundTasks from pydantic import BaseModel import docker router APIRouter() client docker.from_env() class SubmitRequest(BaseModel): problem_id: int language: str code: str router.post(/submit) async def submit(req: SubmitRequest, background_tasks: BackgroundTasks): # 1. 查询题目获取测试用例和模板代码结构 problem get_problem(req.problem_id) # 2. 把代码和测试用例打包成执行文件 exec_dir prepare_execution_directory(req.code, problem.test_case, req.language) # 3. 异步执行沙箱判断 background_tasks.add_task(run_docker_sandbox, exec_dir, req.language, req.problem_id) return {message: submission queued, status: PENDING}这里有个容易忽略的细节执行判断一定不要放在同步请求里否则前端会一直等着容器跑完如果一个用户提交了死循环代码整个请求线程就被拖住了。用BackgroundTasks或者 Celery 都能解决小规模项目用 FastAPI 自带的后台任务足够了。3.3 沙箱执行与结果判定详细流程run_docker_sandbox这个函数是整个系统的核心我把它写得比较谨慎。整个流程分七步拼接代码文件根据语言类型把用户代码写入main.py/main.cpp/index.js测试用例写入test_case.json。构建执行命令Python 直接用python /code/main.pyC 需要先编译所以我用了一个小技巧把编译和执行分开先g -o main main.cpp再执行./main。启动容器按前文命令启动同时记录开始时间。读取输出从容器日志里取 stdout 和 stderr。注意 Python 的 print 有缓冲我统一在命令里加-u参数禁用缓冲不然输出可能丢失。判定结果比对输出与期望输出时我会先去掉行尾空白再按精确匹配判断。部分题目需要特殊判定比如浮点数误差这种题我单独加了一个judge_type字段。记录资源用量Docker 的stats接口可以拿到峰值内存这个数据存入submissions表。清理用finally确保删除临时目录和容器。这套流程看着简单但我在写第 4 步时踩过一个坑如果用户代码故意不退出比如跑了while True容器虽然设置了--stop-timeout 5但日志读取线程会一直阻塞。解决办法是给日志读取也加超时用asyncio.wait_for(fut, timeout10)包住超时直接按 TLE 处理。3.4 前端编辑器交互与细节优化前端部分最重要的组件是 Monaco Editor。它的核心配置有两个第一是语言切换。t3code 支持 Python、JavaScript、TypeScript、C 四门语言。切换语言时我需要从题目数据里取出对应的template_code替换编辑器内容。这里有个体验细节切换语言前如果用户已经写了代码必须弹窗确认否则一失手就是几千行代码没了。第二是提交按钮的状态管理。用户在提交后按钮应该进入 loading 状态并显示排队位置。因为后台任务是异步的前端需要用轮询或 WebSocket 获取判定结果。我用了轮询每 2 秒查一次状态超过 60 秒还没 AC就提示“当前排队人数较多请稍后在提交记录中查看”。前端还有一个容易被忽略的点草稿自动保存。我把代码草稿存在 localStorage每 10 秒存一次。用户在未登录状态下写代码、不小心刷新页面回来内容还在。这个功能对用户体验的提升非常明显但很多平台没做我也不知道为什么。4. 常见问题与排查技巧实录整个开发过程中我前前后后记录了十几个问题这里挑几个印象最深的分享。这些问题不一定每个人都会遇到但遇到的时候往往很头大不仔细排查还真发现不了。4.1 沙箱执行环境不干净结果被篡改第一次测试时我发现两个用户提交了同样的 Python 代码结果一个 AC一个 RE。查了很久发现是两个容器共用了同一个挂载目录后启动的容器把前一个的代码覆盖了。原因我用了一个固定的临时目录比如/tmp/t3code_exec没有做区分。解决每次执行前生成一个带随机后缀的目录比如/tmp/t3code_exec_9f3k2并在执行结束后立即删除。另外给挂载目录加:ro只读参数避免容器内部写入影响宿主机。4.2 容器启动慢用户等待时间过长如果每次都docker run一个新的容器1 到 3 秒的启动时间是无法避免的。但如果用户连续提交 5 道题每次都等 3 秒体验就有点差了。优化方案用 Docker SDK 时先检测镜像是否存在存在则直接复用。同时预热 2 个空闲容器常驻执行任务时先复用空闲容器任务结束不销毁而是恢复到初始状态。这种方式能把平均执行时间降到 1 秒以内。代价是内存占用略高但 4G 内存的服务器完全扛得住。4.3 用户提交死循环代码把系统 CPU 打满这是我重点防的一个问题。第一次测试时我故意提交了一段while True: pass的代码结果宿主机 CPU 瞬间飙到 100%。后来我做了三重保护第一层容器启动命令里加--cpus 0.5限制容器最多使用半个 CPU 核心。第二层在代码执行流程中用 Python 的signal.alarm给宿主机的判定进程也设置一个 10 秒超时双重保险。第三层如果 30 秒内同一个用户提交了 5 次 TLE直接封禁该用户 10 分钟禁止提交。这三层下来死循环代码基本翻不出浪花。4.4 测试用例太少导致的误判本来我觉得出题时测试用例没写五六个结果误判了几次。最典型的是一个“返回最大值”的题目有用户写死了return 1而测试用例恰好都是输入只有一个正数的情况。这个问题靠代码层面没法根治我给管理后台加了一个用例推荐工具管理员输入需求描述工具自动生成边界值建议比如空数组、单元素、全负数、极大数、重复值这五类。运营人员照着建议补全用例误判率立刻降了下来。4.5 复盘数据“看起来正确但无法执行”有用户反馈昨天做错的题今天点击“再次练习”系统给出的错题记录里的输入数据是错的直接复制去本地跑根本复现不出来。排查后发现问题出在test_case字段的存储格式上。我用 JSONB 存的是字符串型的输入比如{nums: [1, 2, 3]}但其实应该整段存标准 JSON 结构。最初的导入脚本在写入时做了不必要的转义把数组变成了带引号的字符串。解决清理历史数据统一规范化 JSON 格式。同时给后台导入脚本加了一层校验如果json.loads失败就阻止写入宁可漏题也不能存坏数据。5. 部署、运维与性能优化5.1 部署拓扑与基础配置t3code 的部署拓扑非常简单一台服务器就能跑Nginx (443/80) - Uvicorn (FastAPI, 127.0.0.1:8000) - Postgres (127.0.0.1:5432) - Redis (127.0.0.1:6379) - Vue 静态文件 (由 Nginx 直接服务)我用 systemd 管理两个服务t3code-api.service和t3code-scheduler.service前者跑 FastAPI后者跑定时任务比如每日题目推荐、过期复盘提醒。Nginx 里有个细节提交判定的接口返回是异步的前端轮询频率不能太高否则日志刷得很快。我最终把轮询间隔控制在 2 秒并且在接口里对连续空轮询做了 10 次后指数退避降到 5 秒一次有效减少了无效请求。5.2 性能监控与日志归档只关注“能用”不够还得关注“好用”。我接入了三套监控基础资源监控用node_exporter Prometheus 盯 CPU、内存、磁盘 IO。应用层性能FastAPI 里加了一个中间件统计每次请求的耗时和状态码超过 3 秒的请求单独记录到slow_query.log。用户行为埋点记录“编辑器首次输入时间”“提交前停留时长”“代码修改次数”。这个数据比较小众但对优化题目难度和编辑器体验很有帮助。日志轮转也别忘了不然跑一个月磁盘会被海量日志占满。我在logrotate里配置了按天切割、保留 14 天、压缩旧日志实测每天日志量大约 200MB14 天才 2.8G完全可接受。5.3 备份与安全加固清单最后说几个安全细节。只列要点每一条都值得单独展开Postgres 备份每天凌晨 3 点用pg_dump生成 SQL 备份保留 7 天异地同步到另一台机器的指定目录。Docker 镜像加固不要使用带-slim之外的命令注入包镜像尽量精简不用 root 用户运行容器内代码自动创建nobody用户。API 鉴权所有写操作接口都要求 JWT token用户在未激活状态下只能查看题目不能提交代码。内容安全题目内容的 Markdown 渲染要做 XSS 过滤不然发一个带脚本的题目管理员一打开就中招。我在这台服务器上跑了三个月除去一次磁盘告警之外整体还算稳定。用户量超过 200 人之后并发沙箱偶尔会把 CPU 打满后来把--cpus 0.5改成0.25并限制单用户最大排队数为 2情况就好了很多。6. 我的一些经验体会做完 t3code 这个项目我最大的感受是一个工具如果能减少用户从“看到题”到“记住解法”之间的阻力它就有独立存在的价值。它不需要多华丽也不需要覆盖所有编程场景只要真正解决一小群人的痛点就值得做下去。如果你也想搭一个类似的系统我建议先不要碰题目数量先把“提交 - 沙箱判定 - 结果反馈”这条链路跑通再慢慢加复盘和题目管理功能。沙箱这块如果预算有限可以先只支持 Python把 Python 的执行和超时处理做扎实后面再扩展其他语言。最后分享一个小技巧判断用户的代码是不是自己写的不需要上什么 AI 检测模型看两个数据就够了一是首次输入前的停留时间二是提交前 30 秒内的修改次数。如果某用户每次提交前都停顿两分钟然后一次性粘贴超长大段代码那就得人工留意一下了。这个数据 t3code 一直在记录虽然不是核心功能但关键时刻真的能救场。如果你已经动手搭了类似的编码训练平台或者对某个模块的实现有更好的想法欢迎在评论区交流。技术这东西一个人闷头搞容易走偏聊一聊反而能想明白很多当时的迷惑。
阅读完成 · 觉得有帮助?
咨询建站