这段时间我花了不少精力,把一个叫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.txt:
fastapi==0.115.0 uvicorn[standard]==0.30.6 sqlalchemy==2.0.35 psycopg2-binary==2.9.9 redis==5.0.8 docker==7.1.0 jinja2==3.1.4 pydantic==2.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.py;C++ 需要先编译,所以我用了一个小技巧,把编译和执行分开:先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, timeout=10)包住,超时直接按 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 天、压缩旧日志,实测每天日志量大约 200MB,14 天才 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 一直在记录,虽然不是核心功能,但关键时刻真的能救场。
如果你已经动手搭了类似的编码训练平台,或者对某个模块的实现有更好的想法,欢迎在评论区交流。技术这东西,一个人闷头搞容易走偏,聊一聊反而能想明白很多当时的迷惑。