毕业设计实战:从零构建一个高可用的刷题平台后端架构
摘要:许多学生在毕业毕业设计实战:从零构建一个高可用的刷题平台后端架构
摘要:许多学生在毕业设计中选择开发刷题平台,却常因缺乏工程经验而陷入性能瓶颈、接口混乱或数据一致性问题。本文基于真实毕业设计场景,详解如何使用 Spring Boot + MyBatis Plus + Redis 构建具备题目管理、用户提交、判题回调等核心功能的后端系统。通过引入消息队列解耦判题服务、利用 Redis 缓存热点题目、设计幂等性提交接口,显著提升系统吞吐量与稳定性。读者将获得一套可直接复用的模块化代码结构与部署 checklist。
1. 背景痛点:学生项目常见“三座大山”
毕业设计里做“刷题平台”听起来简单,落地时却常被以下问题卡住:
判题阻塞:同步判题导致线程长时间挂起,并发一上来整站 504。
重复提交:前端连点两下“提交”,数据库里出现两条记录,用户一脸懵。
冷启动延迟:题目列表接口每次全表扫描,首页打开 3 s 起步,答辩现场直接翻车。
这些痛点本质上是“学生项目”与“工程系统”之间的鸿沟:功能代码能跑,但缺容错、缺横向扩展、缺观测手段。下文用一套最小可用、却可线性扩展的架构,带你把“玩具”升级成“产品”。
2. 技术选型:为什么不是 Django,也不是本地内存
| 维度 | Spring Boot | Django/Flask | 结论 |
|---|---|---|---|
| 依赖注入与 AOP | 原生支持 | 靠第三方 | Spring 生态对事务、幂等、重试的封装更成熟 |
| 横向扩展 | 无状态 Jar + 任意注册中心 | Python GIL 限制多进程利用率 | Java 多线程模型更适合 CPU 密集判题 |
| 社区组件 | MyBatis Plus、Spring Cloud、RocketMQ | 相对分散 | 企业级方案直接搬来即用 |
缓存方案对比:
- 本地内存:进程重启即失效,多实例时缓存漂移,无法横向扩展。
- Redis:独立进程,可集群;支持 TTL、LRU、Pub/Sub,天然适合“热点题目”与“判题结果”缓存。
综上,后端主栈锁定Spring Boot 2.7 + MyBatis Plus + Redis 6.x + RocketMQ 4.9,部署在 2C4G 单机上即可抗住毕业设计答辩并发。
3. 模块划分与核心实现
系统分三层:网关层(Nginx)、业务层(Spring Boot)、判题层(Sandbox)。本文聚焦业务层,内部再拆为:
- 题目服务(Problem Service)
- 提交服务(Submit Service)
- 判题回调(Judge Callback)
3.1 题目服务:缓存 + 分页 + 索引
热点题目(近 7 日提交量 Top 200)在 Redis 采用hash结构缓存,字段即题号,值序列化为 JSON;冷数据走 DB,分页用 MyBatis Plus 的Page对象。缓存穿透用布隆过滤器拦截,缓存雪崩加随机 60–120 s 的 TTL jitter。
3.2 提交服务:接口幂等性设计
前端提交时携带client_submit_id(UUID),后端用数据库唯一索引实现幂等:
UNIQUE KEY uk_user_submit (user_id, client_submit_id)重复请求直接返回原结果,避免重复入库。核心代码见第 4 节。
3.3 判题回调:消息队列解耦
提交服务只负责“写记录 + 发消息”,不等待判题结果;Sandbox 判完后向 MQ 发送JudgeFinishedEvent,业务层消费后更新状态。事件体例如下:
{ "submitId": 142857, "result": "AC", "time": 120, "memory": 65536 }消费端幂等:利用submitId做幂等键,更新前判断状态是否已终态(AC/WA/TLE 等),防止重复累加通过数。
4. 关键代码片段(含注释)
4.1 SubmitController——接收提交、幂等保护
@RestController @RequestMapping("/api/submit") @RequiredArgsConstructor public class SubmitController { private final SubmitService submitService; /** * 1. 幂等键:clientSubmitId * 2. 事务边界:仅落库与发消息,不等待判题 */ @PostMapping public ApiResult<SubmitDTO> submit(@LoginUser Long userId, @Valid @RequestBody SubmitRequest req) { // 重复提交直接返回 SubmitDTO exist = submitService.getByUserAndClientId(userId, req.getClientSubmitId()); if (exist != null) { return ApiResult.success(exist); } // 新提交:本地事务 = 写库 + 发 MQ SubmitDTO dto = submitService.doSubmit(userId, req); return ApiResult.success(dto); } }4.2 JudgeEventConsumer——消费判题结果,保证幂等
@Component @RocketMQConsumer(topics = "topic_judge_result") @Slf4j @RequiredArgsConstructor public class JudgeEventConsumer { private final SubmitService submitService; private final RedisTemplate<String, String> redisTemplate; @Override public void onMessage(JudgeFinishedEvent event) { Long submitId = event.getSubmitId(); String key = "judge:result:" + submitId; // 1. 利用 Redis setnx 做分布式锁,防并发重复消费 Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(absent)) { log.warn("duplicate consume, submitId={}", submitId); return; } // 2. 更新库,版本号乐观锁兜底 boolean updated = submitService.updateResult(event); if (!updated) { log.error("update submit result failed, event={}", event); } } }5. 性能与安全:并发、防刷、SQL 注入
竞争条件:
更新提交状态时使用乐观锁version字段,CAS 失败重试 3 次,仍失败则日志告警人工介入。防刷机制:
- 接口限流:基于 Redis 的令牌桶,每用户 10 次/60 s。
- 验证码:同一 IP 5 min 内提交超过 20 次弹出图形验证码。
- 代码相似度检测:引入
sim命令,重复率 > 90 % 直接判 0 分并记录。
SQL 注入:
MyBatis Plus 内置#{}预编译,杜绝拼接;动态排序用Wrapper的orderBy方法,内部白名单校验列名。
6. 生产级避坑 checklist
| 坑点 | 现象 | 解决 |
|---|---|---|
| 索引缺失 | 题目列表按difficulty + create_time查询 2 s | 联合索引(difficulty, create_time)后降至 20 ms |
| 判题超时无重试 | Sandbox 宕机,消息消费成功但结果丢失 | 消费端 ack 前检查返回码,非 200 抛异常,MQ 自动重试 16 次 |
| 日志缺失 | 线上出错无法复现 | 接入traceId透传,Controller、MQ、线程池统一 MDC 打印 |
| Redis 大 Key | 缓存整表select *导致 value 5 MB,网卡打满 | 拆分为hash分片,只缓存必要字段 |
| 大事务 | 提交接口里同步调用判题 + 写库 + 更新通过数,锁等待 3 s | 拆分为“写提交记录”与“更新通过数”两个事务,后者异步 |
7. 部署与可观测
- CI 脚本:
mvn -T 1C clean package -Dmaven.test.skip=true打出 fat-jar,配合systemd托管。 - Dockerfile仅 30 行,基于
openjdk:17-jre-slim, layers 缓存缓存依赖。 - Prometheus + Grafana:
- JVM 级:GC、线程数、内存;
- 应用级:QPS、RT、提交成功率;
- 业务级:7 日 AC 率、题目冷热分布。
- 告警:RT > 1 s 持续 2 min 或错误率 > 5 % 即刻飞书群机器人推送。
8. 后续思考:如何支持多语言判题沙箱?
当前 Sandbox 只支持 C/C++,如果后续想扩展 Java、Python、Go,需要:
- 镜像隔离:用
runsc或kata-containers替代裸docker run,防止ptrace逃逸。 - 资源配额:CPU、内存、seccomp 统一配置,不同语言复用同一套 cgroup 模板。
- MQ 路由:根据语言类型投递到不同 topic,消费端水平扩容互不干扰。
- 结果归一化:统一返回
cpu_time、memory、exit_code,业务层零改动。
整套代码已开源在 GitHub,欢迎 fork 并提交 Pull Request,一起把毕业设计项目做成能写进简历的工业级作品。