基于Spring Boot的知识竞赛系统设计与实现:从数据库建模到性能优化 简介基于SpringBoot框架的信息技术知识竞赛系统设计与实现项目是一份面向Java Web学习者、毕业设计学生及后端开发者的完整工程资料。这份资料围绕竞赛平台的核心业务覆盖用户注册、题目管理、在线答题、成绩计算与排名展示等环节可帮助读者理解SpringBoot在真实项目中的分层架构、接口设计与数据库关联。压缩包大小约28.21MB收录了项目论文、开题报告、数据库SQL脚本、运行说明文档等论文详尽分析系统背景、设计思路与技术选型SQL脚本可快速初始化数据库文档则指导环境配置与启动流程。目前已有64人学习下载对希望从零开发在线竞赛系统、或者研究竞赛平台实现细节的开发者来说提供了从需求到实现再到部署的完整参照尤其适合课程设计、毕业设计或项目实训时参考借鉴。1. 竞赛系统不只是 CRUD为什么技术选型比写代码更先决定成败信息技术知识竞赛系统的标题听起来像经典的“增删改查毕业设计”但真正动手时你会发现它的复杂度藏在“竞赛”两个字的业务语义里题目与试卷的随机组合、倒计时下的并发交卷、分值判定与排名刷新以及成绩统计的实时性要求。如果把系统只做成题库表加成绩表的管理后台用户量一上来就会出现重复组卷、超时误判、成绩错乱这类问题。用 Spring Boot 实现这套系统时我一般先画出“准备比赛 — 进行比赛 — 判分排名 — 赛后分析”四条业务主线再倒推表结构、接口边界和缓存策略。这篇文章会按这个思路把每个环节的落地细节拆开讲适合正在做这类毕设或内部工具系统的人也适合刚接触 Spring Boot 项目、想知道完整链路怎么串起来的一线开发。2. 数据库设计与核心实体建模先定边界再写代码2.1 竞赛系统的业务边界与表关系推导知识竞赛系统的数据模型不只是用户表加题目表它至少要覆盖四个角色系统管理员负责维护题库与竞赛配置参赛用户可以报名和答题裁判或系统负责判分而成绩展示则面向所有人。一个常见的设计误区是让题目表直接关联竞赛表看起来简单但无法支持多人同题异卷的组卷需求。我习惯拆成这几类核心实体用户体系用户与角色、题库体系题目、选项、分类、竞赛运营竞赛活动、竞赛报名、试卷、答卷明细、成绩单以及审计类数据操作日志、分数变更历史。其中最容易忽略的是“试卷实例”这个概念。竞赛活动是一次模板用户报名后生成一张试卷实例实例里记录组卷时抽到的具体题目顺序与分数权重这样用户交卷后题目变更不会影响历史成绩。2.2 核心表的字段设计与主外键关系下面给出一个经过实践调整的简化建表脚本重点展示竞赛、试卷实例、答卷明细这三个容易设计出问题的表。CREATE TABLE contest_activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 竞赛ID, contest_name VARCHAR(128) NOT NULL COMMENT 竞赛名称, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, duration_minutes INT NOT NULL DEFAULT 60 COMMENT 答题时长(分钟), question_count INT NOT NULL DEFAULT 20 COMMENT 题目总数, max_attempts INT NOT NULL DEFAULT 1 COMMENT 允许答题次数, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已发布 2进行中 3已结束, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 竞赛活动表; CREATE TABLE exam_paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 试卷实例ID, activity_id BIGINT NOT NULL COMMENT 竞赛活动ID, user_id BIGINT NOT NULL COMMENT 参赛用户ID, paper_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始 1答题中 2已交卷 3超时, start_time DATETIME NULL COMMENT 实际开始答题时间, submit_time DATETIME NULL COMMENT 交卷时间, total_score INT NOT NULL DEFAULT 0 COMMENT 总分, answer_detail TEXT NULL COMMENT 作答明细JSON, UNIQUE KEY uk_activity_user (activity_id, user_id) ) COMMENT 试卷实例表; CREATE TABLE exam_answer_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL COMMENT 试卷实例ID, question_id BIGINT NOT NULL COMMENT 题目ID, user_answer VARCHAR(1024) NULL COMMENT 用户答案客观题存选项ID主观题存文本, is_correct TINYINT NULL COMMENT 客观题是否正确 0否 1是, score_received INT NOT NULL DEFAULT 0 COMMENT 实际得分, KEY idx_paper_id (paper_id) ) COMMENT 答卷明细表;这段 DDL 的关键决策有三处。exam_paper里加了answer_detail冗余字段是为了在成绩列表页避免连查答卷明细表直接用 JSON 解析渲染这在数据量不大时性能优势明显UNIQUE KEY uk_activity_user防止用户重复创建试卷实例answer_detail的类型用 TEXT 而不是 VARCHAR因为一份 50 题的 JSON 在 UTF-8 下可能超过 65535 字节MySQL 5.7 对 TEXT 的默认处理也更宽松。2.3 题目表设计里最容易被忽略的扩展性题目表本身是标准的分层设计但有两个字段容易被忽略difficulty_level和category_id。前者用于组卷时的难度配比后者用于按知识分类抽题。CREATE TABLE question_bank ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_type TINYINT NOT NULL COMMENT 1单选 2多选 3判断 4主观, category_id BIGINT NOT NULL COMMENT 知识分类ID, difficulty_level TINYINT NOT NULL DEFAULT 2 COMMENT 1易 2中 3难, content TEXT NOT NULL COMMENT 题干支持富文本, options JSON NULL COMMENT 选项列表单选/多选使用, answer VARCHAR(1024) NOT NULL COMMENT 标准答案, analysis TEXT NULL COMMENT 答案解析, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 题库表;options用 JSON 类型而不是单独建选项表是权衡后的选择。单独建表在管理端批量导入题目时写起来麻烦而且查询时要 JOIN对竞赛系统这种“读多写少”的场景不划算。answer字段对判断题存T或F对单选题存选项编号对主观题存一个模板答案或评分要点后续判分逻辑里按question_type走不同的分支。3. 用户认证与权限控制JWT Redis 在 Spring Boot 里的落地方式3.1 为什么不用单机 Session而用无状态令牌竞赛系统的使用场景通常分为管理端和参赛端管理端可能在后台维护题库和人参赛端随时进来比赛。如果用传统的 Session 保持登录态Spring Boot 内嵌 Tomcat 单机部署时没问题但只要将来做负载均衡或容器重启Session 就丢了。用 JWT 做身份认证把用户 ID、角色、过期时间写进令牌里服务端不需要存储会话状态天然适合微服务和前后端分离。JWT 的关键不外乎三点signature用服务端密钥保证令牌未被篡改exp声明过期时间自定义的role声明用于权限校验。但 JWT 存在一个经典问题令牌签发后无法在服务端主动失效。竞赛系统里管理员封禁某个用户后如果只改数据库状态而令牌仍然有效用户还是能继续答题。所以我习惯用 Redis 做一层二次校验具体做法是登录成功后把用户 ID 写入 Redis 的login:token:{userId}过期时间设为 2 小时每次请求时检查 Redis 中是否有该用户的键。3.2 登录接口与 JWT 工具类的完整代码下面是最小可用的 JWT 工具类与登录服务片段。Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-hours:2}) private int expireHours; public String generateToken(Long userId, String role) { Date now new Date(); Date expireAt new Date(now.getTime() expireHours * 3600 * 1000L); return Jwts.builder() .claim(uid, userId) .claim(role, role) .setIssuedAt(now) .setExpiration(expireAt) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } }Service public class AuthService { Autowired private UserMapper userMapper; Autowired private StringRedisTemplate redisTemplate; Autowired private JwtUtil jwtUtil; public String login(String username, String password) { User user userMapper.selectByUsername(username); if (user null || !password.equals(user.getPassword())) { throw new BusinessException(用户名或密码错误); } if (user.getStatus() ! 1) { throw new BusinessException(账号已被禁用); } String token jwtUtil.generateToken(user.getId(), user.getRole()); redisTemplate.opsForValue().set(login:token: user.getId(), token, jwtUtil.getExpireHours(), TimeUnit.HOURS); return token; } }注意这里有一个容易被忽略的参数选择StringRedisTemplate在存储 token 时设置的过期时间要和 JWT 的expire-hours保持一致否则会出现令牌已过期但 Redis 仍在存续的反向情况。jwt.secret通过application.yml注入避免硬编码在代码里这个值在生产环境应使用足够长的随机字符串至少 32 个字符。3.3 拦截器实现登录态校验与角色级权限有了令牌工具需要将它接入 Spring Boot 的拦截器链路。一个拦截器做不到的事不要写进拦截器里登录校验只负责“你是谁”角色权限判断我用自定义注解加 HandlerInterceptor 配合实现。Component public class AuthInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (token null || token.isBlank()) { throw new BusinessException(401, 未登录); } Claims claims Jwts.parser() .setSigningKey(secret.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token.replace(Bearer , )) .getBody(); Long uid claims.get(uid, Long.class); String redisToken redisTemplate.opsForValue().get(login:token: uid); if (redisToken null || !redisToken.equals(token)) { throw new BusinessException(401, 登录已失效请重新登录); } request.setAttribute(currentUserId, uid); request.setAttribute(currentRole, claims.get(role)); return true; } }拦截器注册时要注意排除白名单路径否则登录接口自己也会被拦截。Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/contest/public/**); } }excludePathPatterns里的/api/contest/public/**是竞赛公告等无需登录查阅的信息。实际开发时建议把“允许匿名访问”的接口统一放到/public前缀下管理不要逐个路径去排除否则新增接口时容易漏配导致误拦截。4. 竞赛核心链路从随机组卷到自动判分排名的完整实现4.1 随机组卷的方案选择与去重策略组卷是这个系统区别“玩具”和“可用”的分水岭。最简单的方式是SELECT * FROM question_bank ORDER BY RAND() LIMIT N但在题目数量超过几千条时ORDER BY RAND()会让 MySQL 做全表扫描加文件排序单表 5 万题时生成一次试卷可能需要 1 秒以上。同时这种写法在并发抽题时还可能抽到大量重复题目。我用的方案是先查出符合条件的题目 ID 列表在 Java 内存中做Collections.shuffle再取前 N 个。题目总数量级在几万以内时内存中处理几十毫秒就能完成。public ListQuestion generatePaper(Long categoryId, int questionCount, int easy, int medium, int hard) { ListQuestion all questionMapper.selectByCategoryAndDifficulty(categoryId, Arrays.asList(1, 2, 3)); Collections.shuffle(all); ListQuestion selected new ArrayList(); int needEasy easy, needMedium medium, needHard hard; for (Question q : all) { if (selected.size() questionCount) break; int difficulty q.getDifficultyLevel(); if ((difficulty 1 needEasy 0) || (difficulty 2 needMedium 0) || (difficulty 3 needHard 0)) { selected.add(q); if (difficulty 1) needEasy--; else if (difficulty 2) needMedium--; else needHard--; } } return selected; }这段代码有一个明确的参数语义easy、medium、hard分别表示各难度需要抽多少题三个参数之和应该等于questionCount。如果题库中某个难度的题目数量不足循环结束后selected.size()会小于期望值我一般会在组卷结果里抛一个业务异常提示“题库题目不足请补充难度为 XXX 的题目”。4.2 答题过程中的倒计时控制与防超时用户在答题页面通常需要倒计时提示前端的setInterval会存在网络延迟和页面休眠导致的计时不准确。最稳妥的做法是后端记录试卷开始时间和截止时间前端每次提交答案时发送remainSeconds参数后端校验这个值是否和实际服务器的剩余时间偏差过大。public void submitAnswer(Long paperId, ListAnswerItem answers, int remainSeconds) { ExamPaper paper examPaperMapper.selectById(paperId); if (paper null || paper.getPaperStatus() ! 1) { throw new BusinessException(试卷状态异常); } long maxDurationMillis 60L * 60 * 1000; long deadline paper.getStartTime().getTime() maxDurationMillis; long now System.currentTimeMillis(); if (now deadline) { paper.setPaperStatus(3); examPaperMapper.updateById(paper); throw new BusinessException(答题超时试卷已自动提交并判分); } if (remainSeconds 600 || remainSeconds -10) { throw new BusinessException(客户端时间校验失败请刷新页面); } // 保存答案明细 for (AnswerItem item : answers) { examAnswerDetailMapper.insert(...); } paper.setSubmitTime(new Date(now)); paper.setPaperStatus(2); examPaperMapper.updateById(paper); }remainSeconds的校验逻辑看起来很粗暴却能拦截绝大多数手工改前端倒计时的作弊方式。remainSeconds 600是允许客户端比服务端快 10 分钟内的误差超过就拒绝提交。这个阈值可以根据实际网络环境调整但不能不给否则前端把倒计时改长就能无限答题。4.3 客观题自动判分与成绩明细落库交卷后立即对客观题判分是竞赛系统的必备体验不能让用户等一个异步任务来算分。判分逻辑按题目类型分派到不同的处理器这里给一个结构清晰的判分服务。public int gradePaper(ListAnswerDetail details, MapLong, Question questionMap) { int totalScore 0; for (AnswerDetail detail : details) { Question q questionMap.get(detail.getQuestionId()); if (q.getQuestionType() 1) { // 单选题选项ID完全匹配算对 boolean correct q.getAnswer().equals(detail.getUserAnswer()); detail.setIsCorrect(correct ? 1 : 0); detail.setScoreReceived(correct ? q.getScore() : 0); } else if (q.getQuestionType() 2) { // 多选题选项集合完全匹配才得分错选漏选不得分 SetString standard new HashSet(Arrays.asList(q.getAnswer().split(,))); SetString user new HashSet(Arrays.asList(detail.getUserAnswer().split(,))); boolean correct standard.equals(user); detail.setIsCorrect(correct ? 1 : 0); detail.setScoreReceived(correct ? q.getScore() : 0); } else if (q.getQuestionType() 3) { // 判断题T/F 直接匹配 boolean correct q.getAnswer().equalsIgnoreCase(detail.getUserAnswer()); detail.setIsCorrect(correct ? 1 : 0); detail.setScoreReceived(correct ? q.getScore() : 0); } else { // 主观题先记0分等待人工批改 detail.setScoreReceived(0); detail.setIsCorrect(null); } totalScore detail.getScoreReceived(); } return totalScore; }这段判分逻辑里最容易踩坑的是多选题的判定策略。现实中的竞赛有时采用“漏选得半分、错选不得分”的规则所以代码里直接判断standard.equals(user)只能算一种默认策略更合理的做法是把策略配置到contest_activity表里用scoring_rule字段区分判分服务根据规则走不同分支。4.4 排行榜实时刷新的三种实现方案成绩公布后的排行榜是这类系统的刚需。最笨的办法是SELECT * FROM exam_paper ORDER BY total_score DESC然后展示前 100 条但成绩表一旦有了activity_id索引这个查询其实并不慢竞赛规模在几百人的情况下毫秒级返回。问题在于页面 “实时刷新”每次都全表排序意义不大。常见的做法有三个层次。最简单的是生成成绩后写入Redis ZSet按activityId分段存储用户交卷后zadd一次排行榜接口直接zrevrange取前 N 名性能最优。如果没有 Redis 环境可以在 MySQL 上建一个ranking_cache表每次交卷后只更新该用户在缓存表中的分数排行榜查询走缓存表减少对exam_paper大表的排序压力。如果用户量非常大则在成绩表中维护一个score字段索引配合LIMIT 100的覆盖索引查询。public ListRankItem getRanking(Long activityId, int topN) { String key rank:contest: activityId; SetZSetOperations.TypedTupleObject tuples redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1); ListRankItem rankItems new ArrayList(); int rank 1; for (ZSetOperations.TypedTupleObject tuple : tuples) { String userId (String) tuple.getValue(); Integer score tuple.getScore().intValue(); rankItems.add(new RankItem(rank, userId, score)); } return rankItems; }需要特别强调的是用 Redis ZSet 做排行榜时分数相同会导致成员的排序按字典序处理如果你希望同分用户按交卷时间先后排需要把“交卷时间戳”编码进 ZSet 的 member 里比如改成${userId}:${submitTimestamp}展示时再解析避免简单方案与业务规则不一致带来的返工。5. 系统集成与 zip 交付部署从源码打包到环境适配的完整路径5.1 Maven 打包参数与跳过测试的取舍这个标题带了.zip后缀意味着交付物通常是一个压缩包里面包含源码、数据库脚本、README 和打包后的可执行 jar。源码能编译不等于能直接部署这里最常见的坑是 Maven 默认执行测试而项目里的单元测试如果依赖本机数据库就会直接失败。打包时我一般用两个命令先mvn clean compile验证代码能编译再mvn package -DskipTests打包并跳过测试用例执行。-DskipTests只跳过测试运行仍会编译测试类如果测试代码本身有编译错误还是会失败。此时可以用-Dmaven.test.skiptrue连测试代码的编译一起跳过适合毕设项目里测试不完整、只需要出 jar 包的场景。mvn clean package -Dmaven.test.skiptrue另一个值得注意的参数是spring-boot-maven-plugin的配置。默认情况下打包得到的 fat jar 会包含所有依赖体积通常在 50-100MB网盘传输和服务器上传都不方便。如果服务器环境确定且依赖不变可以用spring-boot-maven-plugin的layertools模式把 jar 分层让更新时只传变化层。plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration layers enabledtrue/enabled /layers /configuration /plugin分层后 jar 内的结构发生变化启动命令也相应调整通常是先java -Djarmodelayertools -jar app.jar extract解压到独立目录再按层分别启动java -cp BOOT-INF/classes:BOOT-INF/lib/* com.example.ContestApplication。5.2 外部配置文件与启动参数的最佳实践Spring Boot 的application.yml里如果写了数据库账号密码打出的 jar 就不适合直接交付给他人因为存在泄露风险。我习惯把外部配置放到 jar 同级目录的config/application.yml并让 Spring Boot 自动加载外部配置覆盖内部默认值。这样打包时只保留默认配置实际交付时附带config目录和数据库初始化脚本。nohup java -Xms512m -Xmx1024m -jar contest-system.jar \ --spring.profiles.activeprod \ --server.port8080 \ app.log 21 这段命令的-Xms512m -Xmx1024m是 JVM 堆内存参数初始分配 512MB、最大 1GB。竞赛系统并发高时要适当调大但服务器物理内存小于 2GB 时不要盲目加大堆内存否则会导致操作系统内存不足而触发 OOM Killer。--spring.profiles.activeprod指定使用application-prod.yml中的数据库连接等生产配置避免本地开发配置被误带到线上。5.3 zip 压缩包常见问题与部署排错既然交付物是 zip压缩包在传输和解压过程中会出现几类问题。最常见的是 Windows 下用默认资源管理器解压含中文文件名的 zip 时乱码根源是创建压缩包的人在 Linux 或 macOS 上使用 UTF-8 编码文件名而 Windows 老版本默认按 GBK 解码。解决方案是压缩时使用 ZIP64 格式并明确指定 UTF-8 编码命令行下用zip -r配合-UNUTF8或用 7-Zip 选择“附加上传到文件名”。另一个常见问题是解压时提示error read zip archive或failed to copy spatial iop zip这通常是压缩包损坏或下载不完整导致需要重新下载或用unzip -t测试压缩包的完整性。unzip -t contest-system.zipunzip -t的输出如果显示No errors detected in compressed data of contest-system.zip就说明压缩包本身没问题部署问题可以排除在压缩包外。如果测试出错不要尝试用任何压缩包修复工具最省时间的做法是回到打包环境重新生成 zip。5.4 服务器部署完成后的一分钟体检清单部署完 jar 包后我通常会按照一个固定的检查顺序验证系统是否正常。先看进程状态ps -ef | grep contest-system.jar再检查端口占用ss -lnpt | grep 8080接着看启动日志里有没有Started ContestApplication最后用 curl 请求一个接口验证容器内部网络。这四步做完系统是不是真的活着基本就有数了。对于 Spring Boot 2.x 项目还有一个容易忽略的问题内嵌 Tomcat 的端口如果被系统占用启动会报Web server failed to start. Port 8080 was already in use此时可以换端口也可以先查占用进程再杀掉。很多刚接触的人会反复检查代码却忘记查端口冲突这类问题常常在部署阶段耗时最久。6. 压测与性能调优验证系统的真实上限再谈优化方向6.1 用 JMeter 压出真实瓶颈竞赛系统的瓶颈往往在交卷接口和排行榜查询而不是登录。交卷时要批量插入答卷明细、更新试卷状态、刷新成绩缓存涉及多张表的写操作并发高时数据库连接池会先耗尽。我一般用 Apache JMeter 建一个线程组模拟 200 并发用户交卷观察throughput和error rate两个指标。压测的线程组设置有几个经验值ramp-up period设为 10 秒让压力逐渐上升而不是瞬间打满循环次数设为 100 次确保样本量足够断言里校验响应状态码是否为 200 且响应时间低于 2 秒。如果错误率超过 1%优先检查 MySQL 的连接数配置。spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000HikariCP 的maximum-pool-size并不是越大越好。根据公式“线程数 CPU 核心数 × 2 有效存储设备数量”一台 4 核 8 线程的服务器配 20 个连接足够超过这个值反而会因为线程竞争导致性能下降。压测时如果错误日志里出现connection is not available, request timed out先看这里的配置是否合理而不是急着加连接数。6.2 排行榜缓存与数据库索引同步优化压测发现的另一个高频瓶颈是排行榜接口在缓存失效瞬间的毛刺现象。zrevrange本身是 O(log(N)M) 复杂度但在 Redis 里没有数据时会直接穿透到 MySQL而 MySQL 查询如果用不到索引会拖垮整个服务。因此排行榜表必须保证两件事exam_paper表上有(activity_id, total_score)联合索引以及 Redis 缓存的 key 在活动结束后不清理避免同一个热门竞赛在结束后还被频繁查询。ALTER TABLE exam_paper ADD INDEX idx_activity_score (activity_id, total_score);添加完这个索引后SELECT user_id, total_score FROM exam_paper WHERE activity_id ? ORDER BY total_score DESC LIMIT 100的查询会走覆盖索引MySQL 只需要扫描 100 行就能返回结果。压测时你会发现这个查询在 10 万级数据下响应时间能稳定在 50ms 以下。6.3 启动参数优化与监控命令备忘部署阶段的 JVM 参数如果只是默认值在高并发下会出现明显的GC停顿。我一般在生产环境用这套参数启动java -Xms1g -Xmx1g -XX:UseG1GC -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/app/logs/ \ -jar contest-system.jar-XX:HeapDumpOnOutOfMemoryError配HeapDumpPath是防止系统 OOM 后无法定位问题的最后防线堆转储文件会记录当时的内存快照配合 MAT 工具分析可以找出是哪个接口的哪种对象把内存撑爆。XX:MaxGCPauseMillis200是给 G1 回收器设定的目标停顿时间不代表强制 200ms只是让 JVM 优先朝这个目标优化。启动后定期执行jstat -gcutil pid 1000看 GC 频率jmap -heap pid看堆使用情况这些都是排查内存问题时最直接的手段。压测后如果发现 Full GC 次数持续增长优先考虑的优化方向不是加内存而是检查是不是有对象被全局缓存持有导致无法回收。这个项目从数据库建模到部署验证的完整链路里最容易让系统“看起来完成但经不起压”的永远是那几个没被注意的细节组卷时的随机排序、交卷时的超时校验、排行榜的索引设计以及部署时端口和内存的配置。把这些环节按上述方式逐个确认过一遍竞赛系统才能真正从毕设题目变成可承载多人同时答题的可用系统。本文还有配套的精品资源点击获取