资讯动态

基于SpringBoot的竞赛系统:组卷、判分与并发控制实战

发布时间:2026/9/12 7:29:01 来源:尧图企业网站定制
简介这是一套基于Spring Boot框架开发的信息技术知识竞赛系统完整源码面向计算机类专业学生适合用于课程设计、毕业设计以及Spring Boot项目实训。压缩包内共有1025个文件包括141个Java后端代码文件、67个Vue前端组件、157个JavaScript交互脚本以及HTML页面、CSS样式、SQL数据库脚本等资源整体约28.43MB可帮助学习者快速构建前后端分离的竞赛管理系统。包内目录结构清晰后端控制层、业务层、持久层与前端管理端、用户端相互分离同时附带构建批处理文件和数据库脚本方便导入开发工具后直接运行或二次扩展。目前已有63人学习浏览项目经过严格测试代码注释详细可放心下载使用。通过学习本项目可深入理解知识竞赛系统中的用户权限管理、题库维护、在线考试、成绩统计、赛事发布等核心模块的设计思路从而有效提升Java Web项目开发与问题排查能力。1. 基于springboot的信息技术知识竞赛系统先想清楚要做什么标题挂着“信息技术知识竞赛系统”听起来像题库管理加一个答题页面真正动手会发现里面全是状态问题重复交卷怎么拦、倒计时谁说了算、随机组卷怎么保证每人不同还不慢、排行榜怎么刷才不压垮数据库。用 springboot 做这件事的价值在于自动装配把数据源、缓存、定时任务、参数绑定这些基础设施十分钟备齐把开发时间留给业务规则。适合两类人拿它做毕业设计、要快速出活并应付答辩的人把公司内部技术竞赛从问卷表迁到独立系统、要管题库赛事与成绩的人。下面按落地顺序讲先立表结构再实现组卷判分接着处理并发与防作弊最后压测并改掉三个必改配置。2. 基于springboot的竞赛系统表结构设计与Spring Data层2.1 信息与状态分开建模五个基础表设计表结构时最常见的错误是把“一场竞赛”压缩成用户表上的几个字段导致一个用户参加多场竞赛时状态互相覆盖。信息要静态化存储状态要按“用户×赛事”这个维度单独记我一般拆成下面五张表。表关键字段作用sys_userusername, password, role账号role 区分选手与管理员question_banktype, knowledge_point, difficulty, answer, score题库answer 只存服务端competitiontitle, start_time, end_time, duration_minutes, status赛事定义一场赛事对应一场考试competition_playercomp_id, user_id, status, join_time选手报名与答题状态核心状态表papercomp_id, user_id, total_score, answer_snapshot答卷快照与判分结果competition_player 的 status 要设计成小状态机0 未开始、1 答题中、2 已交卷。建表时把幂等兜底一起做掉CREATE TABLE competition_player ( id BIGINT PRIMARY KEY AUTO_INCREMENT, comp_id BIGINT NOT NULL COMMENT 赛事ID, user_id BIGINT NOT NULL COMMENT 选手ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始 1答题中 2已交卷, join_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, submit_time DATETIME NULL, UNIQUE KEY uk_comp_user (comp_id, user_id), KEY idx_comp_status (comp_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选手参赛状态表;建表留下三个信息点联合唯一键 uk_comp_user 保证同一用户在同一场竞赛只有一条参赛记录交卷幂等最后靠它兜底idx_comp_status 支撑“查某场比赛所有未交卷选手”这类后台扫描submit_time 单独存而不是覆盖 join_time是为了对账时能算出“参赛到交卷”的真实用时。status 用 TINYINT 存不要用枚举后续要加“缺考、异常交卷”状态时只改代码常量不用动表结构。另外question_bank 的 answer 字段无论如何不能随题目列表返回给前端接口层必须用 VO 把 answer 剥离。开发期最常出的泄露问题不是接口裸奔而是调试时把实体类直接序列化返回用户表密码字段同理存 BCrypt 哈希返回前一并剔除。2.2 springboot数据源配置与mybatis-plus选型持久层我选 mybatis-plus理由不是“少写 XML”而是分页插件和条件构造器能让组卷、状态流转这类动态 SQL 少踩字符串拼接的坑。先看 springboot 的最小配置spring: datasource: url: jdbc:mysql://localhost:3306/quiz?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: local_dev_only hikari: maximum-pool-size: 20 minimum-idle: 5 data: redis: host: 127.0.0.1 port: 6379 database: 2 mybatis-plus: global-config: db-config: id-type: assign_id configuration: map-underscore-to-camel-case: true三个参数要解释清楚serverTimezone 必须与服务器一致否则 LocalDateTime 查出来差 8 小时Hikari 的 maximum-pool-size 设 20 对竞赛系统够用设太大会拖慢 MySQL 启动redis 选 database 2 是给竞赛系统单独分库避免答题缓存与业务数据混在一起后清理误伤。id-type 用 assign_id 生成雪花 ID交卷时拿 ID 直接做日志关联比自增主键更适合后面拆库。这里顺带说清 springboot 自动装配原理启动时通过 AutoConfiguration.imports 加载条件装配类ConditionalOnMissingBean 决定哪些默认组件生效。竞赛系统用到的 RedisTemplate、DataSource 都走这条链所以不要手动 new 这两个对象否则自动装配出来的默认配置会被你的手工对象覆盖行为很难排查。版本选型有一个现实结论springboot 3.x 强制 JDK 17如果现场只有 JDK 1.8就固定在 2.7.x。两者差异集中在 javax 与 jakarta 包名迁移、部分自动装配类被重命名毕设环境用 2.7 能省掉大量“版本太高”带来的报错。2.3 用ConfigurationProperties管理竞赛规则参数组卷数量、交卷重试次数、倒计时提醒阈值这类参数最容易被写死在代码里。我习惯抽一个配置类统一收口Component ConfigurationProperties(prefix competition) Validated public class CompetitionProps { NotNull private Integer paperSize; // 每张试卷题目数 NotNull private Integer queuePoolFactor; // 抽题候选池倍数默认3 NotNull private Integer submitRetryLimit; // 交卷接口允许并发重试次数 // getter / setter 省略setter 必须有spring 靠它注入 }绑定原理是 springboot 的宽松绑定application.yml 里写competition.paper-size: 50会自动映射到 paperSize 字段不用 Value 逐个取值。加 Validated 后配置缺失会在启动阶段直接报错而不是运行时拿 null 出问题。竞赛规则经常按场次微调这类参数进配置后改 yml 重启即可为改一个数字重新编译整个项目是没有必要的成本。3. springboot竞赛系统的随机组卷与判分实现3.1 按知识点权重随机抽题先抽主键再取全文随机组卷第一反应是ORDER BY RAND()题库只有几千条时没问题到一两万题时这条 SQL 会把候选行全部丢进临时表排序组卷接口从几毫秒退化到几百毫秒。常见做法是分两步先在知识点维度按权重抽主键池再在内存里洗牌取需要的数量。select idselectIdPool resultTypejava.lang.Long SELECT id FROM question_bank WHERE status 1 AND knowledge_point IN foreach collectionpoints itemp open( separator, close) #{p} /foreach ORDER BY RAND() LIMIT #{poolSize} /selectpublic ListQuestion pickQuestions(Competition comp, CompetitionProps props) { ListLong idPool questionMapper.selectIdPool( comp.getKnowledgePoints(), props.getPaperSize() * props.getQueuePoolFactor()); Collections.shuffle(idPool); ListLong picked idPool.subList(0, Math.min(props.getPaperSize(), idPool.size())); if (picked.size() props.getPaperSize()) { ListLong backup questionMapper.selectEnabledIds(); backup.removeAll(picked); Collections.shuffle(backup); picked.addAll(backup.subList(0, props.getPaperSize() - picked.size())); } return questionMapper.selectBatchIds(picked); }selectIdPool 只返回 id 列扫描压力比 SELECT * 小一个量级候选池数量用 paperSize 乘 queuePoolFactor默认 3 够洗牌使用。两个注意点knowledge_point 要建精确索引否则按知识点过滤时依然全表扫描ORDER BY RAND() 的临时表排序照样发生降级逻辑必须存在单知识点题目数不足时若直接返回少题试卷对同一场比赛的选手不公平。提示pcikQuestions 里两次查询之间题库可能被后台修改竞态概率极低但不为零正式环境可以在事务里执行或者接受这种极小偏差不要为此加全局锁把组卷接口拖慢。3.2 试卷快照进Redis答题记录用hash攒着竞赛系统里试卷生成后就不能变所以题目 ID 列表和选手答案都先进缓存交卷时一次性落库。判分规则预先定死运行时只做查表题型判分规则实现方式单选题与标准答案完全一致得分StringUtils.equals多选题全对满分漏选给一半分拆开逐项比对判断题一致得分equals 处理 true/false用 StringRedisTemplate 写入快照的代码如下String paperKey comp:paper: userId : compId; stringRedisTemplate.opsForValue().set( paperKey, JSON.toJSONString(questionIds), comp.getDurationMinutes() 10, TimeUnit.MINUTES); String ansKey comp:ans: userId : compId; answerMap.forEach((qid, opt) - stringRedisTemplate.opsForHash().put(ansKey, qid.toString(), opt));每条缓存的 TTL 都设成比赛时长加 10 分钟比赛结束缓存自动过期省掉专门的清理任务。答案不直接写 MySQL 的原因选手每题一次 update交卷前会形成高并发写热点hash 写入是 O(1)判分时 hgetAll 一次读回正好匹配“交卷是低频动作”的特性。降级方案要写在 try/catch 里Redis 不可用时把题目 ID 列表与用户答案以 JSON 字符串冗余到 paper 表的 answer_snapshot 字段判分从该字段读取保证缓存抖动不丢卷。3.3 判分事务用状态机加乐观更新收口交卷判分跨了校验、算分、写 paper、刷排行榜四个动作必须在一个事务里但事务不能锁住整张 competition_player 表否则并发交卷互相阻塞。业界稳妥做法是用状态机的乐观更新代替行锁Transactional(rollbackFor Exception.class) public SubmitResult submit(Long userId, Long compId, MapLong, String answers) { String paperKey comp:paper: userId : compId; String ansKey comp:ans: userId : compId; int updated playerMapper.updateStatus( userId, compId, PlayerStatus.SUBMITTED, PlayerStatus.ANSWERING); if (updated 0) { throw new BizException(已经交卷或尚未开始答题); } Integer score scoreService.calc(paperKey, ansKey, answers); paperMapper.insert(buildPaper(userId, compId, score, answers)); rankService.refresh(userId, compId, score); return SubmitResult.of(score); }updateStatus 生成的 SQL 是UPDATE competition_player SET status 已交卷 WHERE user_id ? AND comp_id ? AND status 答题中。两个请求同时打进来时第二个会拿到影响行数 0直接被判“已交卷”不需要悲观锁。事务的意义在于判分或写 paper 抛异常时状态更新一起回滚不会出现“状态已交卷但成绩没落库”的中间态。scoreService 单独抽出来是为了方便扩展判分规则比如多选漏选给半分、主观题进人工复核队列。4. springboot竞赛系统的并发控制与防作弊边界4.1 重复交卷的幂等处理单机锁换Redis锁竞赛系统单实例部署时synchronized 包住 submit 就能挡住重复提交一旦要横向扩容单机锁在第二个实例上完全失效。三种方案对比方案适用规模主要问题synchronized 方法锁单实例集群失效且串行化所有交卷数据库唯一键 状态机所有规模正确但异常信息表达弱Redis setIfAbsent 分布式锁集群锁过期需要处理我用 Redis 锁承接大部分流量数据库唯一键做最后兜底String lockKey comp:submitLock: compId : userId; String token UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, token, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(正在提交中请勿重复操作); } try { submitService.submit(userId, compId, answers); } finally { if (token.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } }setIfAbsent 就是 NX 语义锁的 key 维度是“用户×赛事”不会阻塞其他选手。两个坑必须说明一是 TTL 5 秒要比判分最坏耗时大压测时若锁超时率上升先查 SQL 是否走了索引再看锁 TTL 是否小于慢 SQL 耗时二是 finally 删锁前要校验 token否则当前线程判分超时导致锁已过期另一个请求拿到锁后会被过期请求误删出现串锁。4.2 倒计时必须由服务端裁定前端倒计时只负责展示不承担正确性。选手改本地系统时间、开多个标签页、直接抓包调交卷接口这三种情况都要由服务端拦截。核心校验放在交卷入口public void checkDeadline(Competition comp) { LocalDateTime now LocalDateTime.now(); if (now.isAfter(comp.getEndTime())) { throw new BizException(考试已结束成绩按已作答内容计算); } long remain Duration.between(now, comp.getEndTime()).getSeconds(); if (remain 0 || remain comp.getDurationMinutes() * 60L) { throw new BizException(计时数据异常请联系管理员); } }第二层校验是关键剩余时间大于配置的比赛总时长说明服务端时钟被改动或赛事配置错误这时不能放行也不能直接判负要记录异常日志并把该选手标记为“人工复核”。配合 springboot 定时任务每 30 秒扫一遍答题中但已过 end_time 的选手强制状态流转到已交卷并按已作答题目判分Scheduled(cron 0/30 * * * * ?) public void forceSubmitExpired() { ListPlayer expired playerMapper.selectExpired(LocalDateTime.now()); expired.forEach(p - submitService.submitBySnapshot(p)); }这样选手就算中途关掉浏览器成绩依然落袋不会产生“页面关了没分”的投诉。submitBySnapshot 与正常交卷共用同一个判分事务只是答案来源从缓存 hash 换成 answer_snapshot 字段。4.3 排行榜用Redis的zset实时刷新排行榜如果走ORDER BY total_score DESC, submit_time LIMIT 100选手过千后每次查询都要做一次文件排序交卷高峰会拖慢主库。用 zset 把排序动作前移到写入时完成String rankKey comp:rank: compId; // 同分先交卷者靠前分数低位编入用时 double rankScore score * 10000 - (Math.min(durationSeconds, 9999)); redisTemplate.opsForZSet().add(rankKey, String.valueOf(userId), rankScore); SetString top100 redisTemplate.opsForZSet() .reverseRange(rankKey, 0, 99);zset 底层是跳表写入和取 TopN 都是对数级复杂度。把时间编码进分数而不是单独存字段是为了避免交卷高峰做二次排序分数低位存放用时取出来后再用rankScore / 10000还原真实成绩。zset 没有持久化所以每次交卷除了写 zset 还必须落 paper 表比赛结束后跑一次离线脚本从 MySQL 重算排行榜与 Redis 结果对账这一步能发现丢失的写入。5. springboot竞赛系统上线前的jmeter压测与三个必改配置5.1 用jmeter压组卷和交卷接口上线前至少把组卷、交卷两个接口的真实压测跑一遍jmeter -n -t quiz.jmx -l result.jtl -e -o report/线程组设为 50 并发、Ramp-up 10 秒、循环 5 次和 4.1 里 Redis 锁的 TTL 处于同一量级能真实压出锁竞争。看报告里的 TPS 与 95% 响应时间组卷接口在题库一万题时稳定在 100ms 以内、交卷 300ms 以内基本合格交卷报错集中在锁超时的话优先查判分 SQL 是否走了主键索引再看锁 TTL 是否小于慢 SQL 耗时。5.2 heapdump端点加固、定时任务防重、时区统一三个必改配置缺一个上线后都要返工。第一actuator 暴露的 heapdump 端点能在未授权时把整个 JVM 内存快照下载走里面可能包含题库答案和选手信息这就是常说的 springboot heapdump 敏感信息泄露。加固方式是在 profile 里只放行 health 和 infomanagement: endpoints: web: exposure: include: health,info endpoint: health: show-details: never第二Scheduled 在集群部署时每个实例都会执行清理过期试卷的 job 会重复跑要在任务入口抢同一个 Redis 分布式锁抢不到直接 return。第三时区统一数据库连接串、JVM 默认时区、Redis 里存的时间戳三处不一致时比赛开始与结束时间会出现偏移容器启动参数加-Duser.timezoneAsia/Shanghai数据库连接串与业务服务器保持同一时区时间字段统一用 LocalDateTime 落库避免 Date 隐式转换带来的时区漂移。压测报告、锁超时日志、时区三点都验证通过后这个基于springboot的信息技术知识竞赛系统才算真正具备接住一轮线上比赛的条件。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价