ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Spring Boot的田径运动会管理系统:从表结构设计到并发排名实战

基于Spring Boot的田径运动会管理系统:从表结构设计到并发排名实战

简介:基于Java开发的田径运动会管理系统,专为高校课程设计及Java信息系统入门者准备,覆盖运动员报名、赛事安排、成绩记录、排名展示等核心流程。资源共八十一个文件,压缩包大小约二点零四兆,包含二十五份源码、四十八份编译后的类文件、数据库驱动包、SQL脚本及实验报告,源码可用Eclipse直接打开,数据库脚本便于快速初始化。已有两千一百六十八人学习下载。系统功能模块清晰,涵盖运动员管理、赛事安排、报名系统、成绩录入与处理、信息发布和权限管理;配套课设报告详细阐述设计思路、系统架构与实现过程,源码注释完整,体现了良好的软件工程实践。通过该项目可学习图形界面设计、数据库增删改查、分层架构搭建及角色权限控制,还可参考其可扩展设计来增加新的比赛项目,是一份适合课程设计、项目实战及答辩准备的完整参考。

1. 田径运动会管理系统:一个 Java 单体项目能装下多少“脏活累活”

每年校运会报名截止前,体育部的老师都会在微信群里收到一堆格式混乱的 Excel,等比赛当天又要拿着纸质检录单满操场找人;等所有成绩录完,名次和团体总分还得用计算器一个个核对。一个 java 田径运动会管理系统,最直接的价值就是把这套流程从 Excel 和纸质表搬进数据库——报名、检录、成绩录入、名次判定、团体总分汇总全部走一套在线流程,数据只录一遍,后面所有统计都从库里出。适合的场景很明确:高校、中学、企事业单位内部运动会,以及需要按项目和成绩做综合管理的比赛。对 Java 开发者来说,它又是一个能把面向对象建模、Spring Boot 集成、Java 排序算法和并发处理都串起来的练手项目,技术深度刚好处于“能写又能讲”的位置。

2. 先建模再写代码:把运动会拆成五张表和三类状态

2.1 数据模型怎么定:运动员、项目、报名、成绩、赛程

运动会管理系统和普通 CRUD 最大的区别在于“成绩”不是一个简单的增删改查,它和报名资格、项目类型、比赛轮次是绑定的。我的习惯是先跑一遍流程再做表:报名阶段需要知道谁报了哪个项目;检录阶段要确认运动员到了没有、在第几组第几道;赛后要按项目类型计算名次和得分。所以核心表一般这么分:

  • athlete 运动员表:学号、姓名、性别、班级、联系方式。
  • event 项目表:项目名称、项目类型(径赛还是田赛)、竞赛规则(取最好成绩还是取最短时间)、是否团体项目、报名人数上限。
  • enrollment 报名表:运动员 ID、项目 ID、报名状态,保证一个人在一个项目里只有一条有效报名。
  • result 成绩表:报名 ID、成绩值、成绩单位、比赛轮次、检录状态、是否有效成绩。
  • sys_user 用户表:管理员、录入员、裁判等角色权限。

这里最容易被新手跳过的是“成绩值”的设计。径赛最终比较的是耗时,田赛比较的是距离,如果直接把成绩存成字符串“12:34.56”,后面排序、比较大小都会出问题。我一般会用整数类型存“最小计量单位”,径赛存毫秒数,田赛存厘米数,展示的时候再格式化。这样排名时就是纯数字比较,这也是 Java 数据类型选择上最值得跟新人强调的一点。

对应实体类层次是 Athlete、Event、Enrollment、Result、SysUser,关系上 Enrollment 是运动员和项目之间的多对多关联表,Result 与 Enrollment 一一对应。面向对象建模时,不要把 Result 直接挂在 Athlete 下,因为同一个运动员在不同项目里的成绩要跟着项目走,挂在报名记录下才符合业务。我当时第一次做的时候把 Result 表里塞了 athlete_id 和 event_id 两个冗余外键,后来发现同一个项目同一运动员有多条无效成绩时,排名查询的过滤条件会越写越乱。成绩表只留 enrollment_id 一个业务外键,反而清爽。

2.2 用 MyBatis-Plus 根据 Java 实体类生成创建表的 SQL 语句

建表这一步很多人是手写 SQL,但手写 SQL 和实体类字段经常对不上,尤其是后期加了字段忘了改表结构。常见做法是开发环境启动时自动比对实体类和数据库表结构,不一致就自动补列。我一般会在项目里放一个 SchemaGenerator,利用 MyBatis-Plus 的 TableInfoHelper 读取实体类的表名和字段映射,拼出 CREATE TABLE IF NOT EXISTS 的 SQL,启动时执行。

@Component public class SchemaGenerator implements ApplicationRunner { private final JdbcTemplate jdbcTemplate; public SchemaGenerator(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void run(ApplicationArguments args) { List<Class<?>> entities = List.of( Athlete.class, Event.class, Enrollment.class, Result.class ); for (Class<?> entity : entities) { TableInfo tableInfo = TableInfoHelper.getTableInfo(entity); jdbcTemplate.execute(buildCreateTableSql(tableInfo)); syncColumns(tableInfo); } } private String buildCreateTableSql(TableInfo info) { StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS `") .append(info.getTableName()).append("` ("); // TableInfoHelper 的 fieldList 不含主键字段,所以手动补主键 sql.append("`id` BIGINT NOT NULL AUTO_INCREMENT, "); for (TableFieldInfo field : info.getFieldList()) { sql.append("`").append(field.getColumn()).append("` ") .append(mapType(field.getPropertyType())) .append(" DEFAULT NULL, "); } sql.append("PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); return sql.toString(); } private String mapType(Class<?> type) { if (type == String.class) return "VARCHAR(255)"; if (type == Integer.class || type == int.class) return "INT"; if (type == Long.class || type == long.class) return "BIGINT"; if (type == LocalDateTime.class) return "DATETIME"; return "VARCHAR(255)"; } private void syncColumns(TableInfo info) { String sql = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"; Set<String> exists = jdbcTemplate.query(sql, (rs, i) -> rs.getString(1), info.getTableName()) .stream().collect(Collectors.toSet()); for (TableFieldInfo field : info.getFieldList()) { if (!exists.contains(field.getColumn())) { jdbcTemplate.execute("ALTER TABLE `" + info.getTableName() + "` ADD COLUMN `" + field.getColumn() + "` " + mapType(field.getPropertyType())); } } } }

这段代码的核心逻辑分三段:TableInfoHelper.getTableInfo 返回 MyBatis-Plus 解析好的表信息,包含表名、主键和字段列表;拼 SQL 时手动补 id 主键,因为 fieldList 里不包含 @TableId 标注的字段;syncColumns 先查 INFORMATION_SCHEMA.COLUMNS,把实体里有但表里没有的列用 ALTER TABLE 补上。这样开发阶段改实体类、重启、自动改表,等到要部署生产环境,再用 Flyway 把正式的迁移脚本补上,是一条比较顺的链路。

参数说明:mapType 的映射是开发用简化版,生产环境建议把 String 长度拿到实体注解里配置,而不是固定 255;LocalDateTime 映射成 DATETIME 而不是 TIMESTAMP,可以少踩时区坑;每次启动都执行 CREATE TABLE IF NOT EXISTS 没有副作用,但 syncColumns 要加一个开关或只让它在 dev 环境执行,线上库绝不能让它自动加列。

2.3 报名与成绩的状态机:已报名、已检录、已完赛、成绩无效

运动会流程里最容易被写成一堆 if 的就是状态。报名提交后是“已报名”,检录通过是“已检录”,完赛是“已完赛”,犯规或弃权是“成绩无效”。把这几个状态放在 enrollment 和 result 表里,用数字枚举值存储,比用一个自由字符串 status 散着写要稳得多。

状态转移规则就三条,我直接贴在代码注释里让后来的人看:

  • 已报名 -> 已检录 -> 已完赛
  • 已报名 -> 已取消
  • 已完赛 -> 成绩无效

这个状态机是整个系统里最容易出 bug 的地方。比如成绩录入时如果没有校验“已检录”状态,就会出现还没检录就录了成绩的记录;如果“成绩无效”之后没有联动重新排名,团体总分就会把无效成绩算进去。所以我在 Service 层写一个 StateMachine 工具类,只暴露 check(from, to) 方法,所有状态更新都先过它。

public class StateMachine { private static final Map<Integer, Set<Integer>> RULES = Map.of( EnrollmentStatus.REGISTERED.getCode(), Set.of(EnrollmentStatus.CHECKED_IN.getCode(), EnrollmentStatus.CANCELLED.getCode()), EnrollmentStatus.CHECKED_IN.getCode(), Set.of(EnrollmentStatus.FINISHED.getCode()), EnrollmentStatus.FINISHED.getCode(), Set.of(ResultStatus.INVALIDATED.getCode()) ); public static void check(int from, int to) { if (!RULES.getOrDefault(from, Set.of()).contains(to)) { throw new BusinessException("非法的状态变更: " + from + " -> " + to); } } }

逻辑说明:状态枚举的 code 是 int,存入数据库的就是这个数字,页面上展示再映射成中文;RULES 里定义了每个状态允许跳转到哪里,getOrDefault 保证了未注册的状态不允许任何跳转。所有 Controller 在更新状态前都调用 StateMachine.check,这样排名时不用到处判断成绩有效性,因为无效成绩已经被状态机隔离在外了。参数说明:如果以后要支持“成绩申诉后恢复有效”,只需要在 FINISHED 的规则里加一个回退目标,不要动业务代码。

3. Spring Boot + MyBatis 跑通核心链路:报名、检录、成绩录入

3.1 工程结构与依赖:为什么选 Spring Boot + MyBatis 而不是 JSP 直接写

选择 Spring Boot + MyBatis 而不是传统 Java Server Pages 和 Servlet 直接写,不是炫技,而是这套组合最适合把“接口开发”和“数据访问”分开。如果学校课程设计指定 JSP + Servlet,骨架换成 JSP 也一样,核心业务逻辑和表结构可以原样搬,只把 Controller 换成 Servlet、模板换成 JSP。但自己从零做,我建议还是 Spring Boot,省去大量 XML 配置,也方便后续加拦截器和过滤器。

pom.xml 里最小依赖如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

逻辑说明:spring-boot-starter-web 提供 MVC 和内置 Tomcat;mybatis-plus-boot-starter 提供 CRUD 封装和分页插件,报名查询、成绩列表这些基础操作不需要手写 XML;thymeleaf 做服务端渲染,报名页和成绩页都直接用后端模板,不用单独部署前端工程;mysql-connector-j 是 MySQL 8 的驱动坐标,注意新版驱动类名是 com.mysql.cj.jdbc.Driver。分页插件需要单独配置一个 MybatisPlusInterceptor Bean,加 PaginationInnerInterceptor,后面成绩列表分页要用。

application.yml 里几个关键配置:

spring: datasource: url: jdbc:mysql://localhost:3306/sports_meet?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

参数说明:serverTimezone=Asia/Shanghai 必须加,不加的话连接 MySQL 8 会直接报时区错误;map-underscore-to-camel-case 打开后 create_time 才能自动映射到 createTime 属性;StdOutImpl 会在控制台打印每条 SQL,开发阶段很有用,上线前要换成 Slf4jImpl 或直接关掉,不然日志文件会涨得飞快。

3.2 报名接口:唯一索引与事务边界

报名接口是第一个并发密集的点。运动会报名截止前半小时,可能同时有几十个人在提交。如果只做“先查再插”,两个人同时提交同一运动员报同一个项目,两条请求都会查到“未报名”,然后都插入成功,重复报名就出现了。解决方式分两层:数据库加唯一索引,业务层捕获 DuplicateKeyException。

enrollment 表建表时加唯一索引:

ALTER TABLE enrollment ADD UNIQUE KEY uk_athlete_event (athlete_id, event_id);

Service 层写法:

@Override @Transactional(rollbackFor = Exception.class) public void enroll(Long athleteId, Long eventId) { Enrollment existing = enrollmentMapper.selectOne( new LambdaQueryWrapper<Enrollment>() .eq(Enrollment::getAthleteId, athleteId) .eq(Enrollment::getEventId, eventId) ); if (existing != null) { throw new BusinessException("该运动员已报名此项目"); } Enrollment enrollment = new Enrollment(); enrollment.setAthleteId(athleteId); enrollment.setEventId(eventId); enrollment.setStatus(EnrollmentStatus.REGISTERED.getCode()); try { enrollmentMapper.insert(enrollment); } catch (DuplicateKeyException e) { throw new BusinessException("报名重复,请勿重复提交"); } }

逻辑说明:先查再插不是用来防并发的,数据库唯一索引才是最后一道闸;查一遍只是为了给出友好的业务提示,真正挡并发的是 uk_athlete_event。@Transactional 的作用是保证插入失败时事务回滚,不会留下半条数据。参数说明:唯一索引的字段顺序建议把 athlete_id 放前面,因为报名查询基本都是按运动员维度查;如果项目有人数上限,加一个 event.max_players 字段,用 UPDATE event SET count = count + 1 WHERE event_id = ? AND count < max_players 这种方式做原子扣减,比先查再判更稳。

3.3 成绩录入与 Java 排序排名:从 Stream 分组到并列名次

成绩录入是裁判的操作入口。检录时裁判在终端上把运动员状态从“已报名”改成“已检录”,比赛结束录入成绩,保存时同时把状态推到“已完赛”。排名可以在所有成绩录完后统一算,也可以保存完就刷新该项目的排名。我的做法是保存成绩后触发该项目排名刷新,因为运动会现场项目是进行式的,排名刷得快,广播和大屏才跟得上。

排名算法的核心代码如下:

public List<ResultVO> rankByEvent(Long eventId, Integer eventType) { List<Result> results = resultMapper.selectList( new LambdaQueryWrapper<Result>() .eq(Result::getEventId, eventId) .eq(Result::getValid, 1) .orderByAsc(eventType == TrackEventType.RUN ? Result::getScore : Result::getScore) .orderByDesc(eventType == TrackEventType.FIELD ? Result::getScore : Result::getScore) ); // 这里故意不用 sorted + index,因为并列名次要共享名次并跳号 List<ResultVO> rankList = new ArrayList<>(); int rank = 1; Long prevScore = null; for (int i = 0; i < results.size(); i++) { Result r = results.get(i); if (i == 0 || !r.getScore().equals(prevScore)) { rank = i + 1; } prevScore = r.getScore(); ResultVO vo = new ResultVO(); vo.setRank(rank); vo.setAthleteName(r.getAthleteName()); vo.setScore(r.getScore()); rankList.add(vo); } return rankList; }

逻辑说明:径赛按耗时升序,田赛按距离降序,所以 orderBy 方向要通过 eventType 动态切换。排名时不能用简单的 index+1,因为如果两个人成绩相同,名次要一样,下一个人要跳过空名次——这就是“并列跳号”规则。这里的思路是:循环里只有成绩发生变化时才更新 rank,成绩相同则沿用上一个 rank。参数说明:这里要求 score 是 Long 类型,用 equals 比较才有意义;result 表里冗余了 athlete_name 和 event_name,排名查询可以少 join 两张表,数据录入时把冗余字段一起写进去,查询效率高很多。如果项目多、成绩量大,还可以在 result 表上加 (event_id, valid, score) 联合索引,排序直接走索引,避免 filesort。

这里不建议用冒泡排序或者手写 Comparator 复杂链,一个循环就能把名次和排序一起算完,读代码的人也好理解。

4. 避坑指南:环境配置、并发录入、排序错乱这些坑我都踩过

4.1 java 环境配置好却启动失败:先看端口、JDK 版本和控制台 SQL

现象:跑 spring-boot:run,控制台报 java.lang.OutOfMemoryError 或者端口被占用,项目起不来;还有一种是装了多个 JDK,系统 PATH 指向的是 Java 8,而项目需要 Java 17,IDEA 里编译正常,命令行启动就报 UnsupportedClassVersionError。

原因:这类问题九成出在环境变量上。多个 JDK 并存时,IDEA 用自己配置的 JDK,命令行用 PATH 指向的 java,两者不一致;另外把堆内存调得过大,比如把进程堆大小调整到 8000MB,在开发机上反而可能因为物理内存不足启动失败,不是越大越好;端口被占用则是上一次进程没杀掉,尤其是 IDE 里点停止但子进程还挂着。

解决:命令行先跑 java -version 确认当前版本,再在 IDEA 的 Project Structure 里统一 Project SDK 和模块 SDK,两处必须一致。启动命令显式指定 JDK 路径:

/usr/local/jdk-17/bin/java -Xmx512m -Xms256m -jar sports-meet.jar

端口占用用 lsof -i:8080 找到 PID 后 kill,或者直接换端口:

server: port: 8081

同时把 MyBatis-Plus 的 StdOutImpl 打开,项目启动成功但页面报错时,先看控制台最后一条 SQL,多数问题出在表名或字段名映射不上。环境变量这种问题有时候很玄学,但按这个顺序排查,基本十分钟内能定位。

4.2 成绩并发录入导致覆盖:事务和乐观锁要一起用

现象:两个裁判同时录同一个运动员的成绩,后保存的那条覆盖了前一条,成绩单上出现明显错误。

原因:result 表的更新语句是 UPDATE result SET score=#{score} WHERE id=#{id},没有版本校验。两个人各读了一条旧数据,后提交的覆盖前面的,这是典型的丢失更新。

解决:给 result 表加 version 字段,更新时带上乐观锁:

UPDATE result SET score = ?, version = version + 1 WHERE id = ? AND version = ?
int rows = resultMapper.update(null, new LambdaUpdateWrapper<Result>() .eq(Result::getId, resultId) .eq(Result::getVersion, version) .set(Result::getScore, score) .set(Result::getVersion, version + 1)); if (rows == 0) { throw new BusinessException("成绩已被其他裁判修改,请刷新后重试"); }

逻辑说明:乐观锁适合成绩录入这种并发冲突概率不高的场景,先让请求正常执行,更新时发现版本号对不上就提示重试。如果现场录入量很大,可以把 version 换成数据库行锁 SELECT ... FOR UPDATE,但运动会场景里一个项目通常只有一个裁判在录入,乐观锁已经够用。

4.3 排名错乱:字符串成绩排序和并列名次处理

现象:100 米成绩排出来,12:34 排在 2:15 前面,明显不对。

原因:成绩字段是 VARCHAR,数据库排序按字典序比较,“12”小于“2”,所以 12 分多排在 2 分多前面。这是把成绩存成字符串最典型的翻车现场,数据库按字典序排,Java 端如果用 String 类型比较结果也一样。

解决:把成绩列统一改成 BIGINT 存毫秒数,展示时格式化。格式化的工具方法:

public static String formatMilliseconds(Long ms) { long minutes = ms / 60000; long seconds = (ms % 60000) / 1000; long millis = ms % 1000; return String.format("%d:%02d.%03d", minutes, seconds, millis); }

同时排名循环处理并列:成绩相同,名次相同,后续名次跳号。这两件事合起来,排名才算正确。血泪经验是:不要在需求阶段接受“成绩随便填文本”的说法,宁可录入时多做一步格式校验,也别让脏数据进库。

4.4 时间字段差 8 小时:时区配置的一致性

现象:数据库里 create_time 是早上 8 点,页面上显示凌晨 0 点,差了 8 小时。

原因:MySQL 连接串没配 serverTimezone,驱动用了默认时区;或者 JDBC 的时区和 JVM 时区不一致,写入时 LocalDateTime 被转成 UTC 存储。

解决:连接串固定加 serverTimezone=Asia/Shanghai,同时 JVM 启动参数加 -Duser.timezone=Asia/Shanghai。实体字段如果存的是 LocalDateTime,数据库列用 DATETIME 而不是 TIMESTAMP,DATETIME 不受数据库会话时区影响,能少一类隐藏问题。

注意:MySQL 8 的驱动在连接串缺少 serverTimezone 时会直接拒绝连接,而不是打日志警告,所以这个问题通常在项目启动阶段就暴露。

4.5 检录后成绩找不到报名记录:外键约束和冗余字段

现象:检录完成,裁判录入成绩时按运动员学号查不到报名记录,只能看到报名已删除。

原因:报名表记录了运动员 ID 和项目 ID,删除报名时没有校验是否已产生成绩,成绩表里残留了悬挂数据;或者成绩查询只 join 了报名表,报名被删后 join 失败。

解决:业务上禁止删除已检录或已完赛的报名记录,删除前先查 result 表。数据库层面给 result.enrollment_id 加外键约束,或者至少加唯一索引保证一条报名只有一条有效成绩。我一般还会在成绩表里冗余 athlete_name 和 event_name 两个字段,查询排名时少两次 join,这个对报表导出也有帮助。删除操作走软删除,加一个 deleted 字段,避免误删后找不回数据,算是给自己留一颗后悔药。

5. 收尾两件事:用 EasyExcel 导出成绩册,再压一次报名接口

5.1 流式导出:Excel 成绩册不再卡内存

运动会结束要交纸质成绩册,几十个项目、几千条成绩,用 POI 一次性写入很容易堆内存溢出。EasyExcel 的流式写解决这个问题,边读边写,内存占用稳定。

List<ResultExcelRow> rowList = buildExcelRows(eventId); EasyExcel.write(outputStream, ResultExcelRow.class) .sheet("成绩册") .doWrite(rowList);

ResultExcelRow 用 @ExcelProperty 注解列名,比如“项目名称”“运动员姓名”“班级”“成绩”“名次”。导出时只查需要的列,不要在循环里逐条查数据库。导出的顺序和页面排名一致,直接用排名刷新后的列表,不要重新排序。如果数据量大,可以分批每次 5000 条循环写入,用 EasyExcel 的 WriteSheet 复用同一个 sheet。

5.2 用 JMeter 压一次报名接口:看慢 SQL 出在哪里

上线前我会用 JMeter 模拟 100 个并发同时报名,线程数 100,循环 1 次。跑完看三件事:接口平均响应时间、错误率、数据库里有没有重复报名数据。如果响应时间超过 2 秒,去 MySQL 开慢查询日志:

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;
参数值说明
线程数100模拟 100 个运动员同时提交
Ramp-up0所有线程立即启动,制造并发峰值
循环次数1不重复提交,避免干扰唯一索引判断

压测目的不是追求高并发,而是验证两层东西:唯一索引确实挡住了重复报名,以及 SQL 有没有走索引。我习惯把压测结果截个图存在项目 docs 目录下,验收时直接拿给体育部老师看,比嘴上说“很稳”有说服力得多。

这半年多经手的运动会系统,最容易挂的地方永远是成绩排名和并发报名,界面好看不好看反而是次要的。我现在的习惯是拿到任何运动会需求先问三句:成绩是径赛还是田赛、存储单位用什么、要不要并列名次,这三个问题不定下来,我不会动第一行代码。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进