☰
Java+Swing+MySQL运动会管理系统:表结构设计、线程避坑与排名算法实战
2026/10/5 3:35:08 网站建设 项目流程

简介:这是一套面向高校计算机与数据库课程学习者的Java课程设计资源,以田径运动会管理系统为完整案例,适合正在准备数据库课设、Java实训或需要参考Swing桌面应用开发的学生。系统基于Java Swing与MySQL实现,覆盖运动员、参赛团体、比赛安排与裁判信息的录入查询,支持按编号或姓名模糊检索,并能统计团体金牌数量、关联查询运动员个人信息与上轮成绩,全能赛成绩录入后可自动计算总分并排序,同时对数据合法性、查询无结果及数据库连接异常均给出提示。资源包共74个文件,约2.2MB,包含12个java源码、48个class文件、5个xml配置、sql建库脚本以及一份docx课程设计报告,另附jar依赖与项目配置文件,结构完整可直接导入IDEA或Eclipse运行。目前已有188人学习下载,适合作为课程设计模板与开发排错参考。

1. 运动会管理系统:Java+Swing+MySQL 这套组合拳到底能跑多远

做过校园项目的人都有一个共识:运动会管理系统是那种“看起来简单、写起来全是坑”的典型。报名要分项目、分性别、分年龄组,检录要按时间轴排,成绩要支持预赛决赛多轮次,最后还要算团体总分、破纪录加分、名次并列处理。用 Java+Swing+MySQL 来搭这套东西,好处是技术栈足够经典,JDK 装好就能跑,不需要前端框架、不需要 Node 环境、不需要配一堆中间件;坏处也明显——Swing 的事件分发线程(EDT)一旦被数据库查询阻塞,界面直接假死,这是新手最容易翻车的地方。

这篇文章面向三类人:正在做课程设计需要交报告的学生、想用桌面端练手 Java 数据库编程的入门者、以及需要一套离线可运行的运动会管理工具的小型赛事组织方。我会把表结构设计、Swing 界面与 JDBC 的线程配合、成绩排名算法、课程报告里该写什么这几个关键环节拆开讲,每一步都给出可复现的代码和参数说明。你照着做,能跑通一个具备报名、检录、成绩录入、排名统计、报表导出能力的完整系统。

2. 数据库表结构:从报名到成绩的 7 张核心表怎么设计

2.1 为什么不能把所有信息塞进一张表

运动会管理系统的数据关系天然是分层的:一次运动会包含多个比赛项目,一个项目有多个参赛者,一个参赛者可以报多个项目,每个项目有多轮比赛,每轮比赛产生一组成绩。如果把这些全塞进一张宽表,会出现大量冗余——同一个运动员报三个项目,姓名、学号、班级就要重复存三遍;更麻烦的是,当某个运动员因伤退赛,你要在多个位置同步修改状态,漏一处就产生脏数据。

规范做法是拆成 7 张表:athlete(运动员)、sport_event(比赛项目)、registration(报名记录)、heat(轮次/分组)、result(成绩)、team(院系/班级)、score_rule(计分规则)。其中registration是运动员和项目的多对多桥接表,result挂在heat下面而不是直接挂registration,因为一个报名记录可能对应预赛、半决赛、决赛多条成绩。

-- 运动员表:以学号作为业务主键,避免重名歧义 CREATE TABLE athlete ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号,业务唯一标识', name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL COMMENT '1=男 2=女', team_id INT NOT NULL, birth_date DATE COMMENT '用于年龄组划分', phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1=正常 0=退赛', INDEX idx_team (team_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 报名表:运动员与项目的多对多关系,带审核状态 CREATE TABLE registration ( id INT PRIMARY KEY AUTO_INCREMENT, athlete_id INT NOT NULL, event_id INT NOT NULL, reg_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_status TINYINT DEFAULT 0 COMMENT '0=待审 1=通过 2=驳回', lane_no INT COMMENT '道次,径赛项目用', UNIQUE KEY uk_athlete_event (athlete_id, event_id), INDEX idx_event (event_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_athlete_event这个唯一索引是关键——它在数据库层面阻止了同一个人重复报同一个项目,比在 Java 代码里查一遍再插入可靠得多。并发场景下(比如两个管理员同时操作),代码检查存在竞态窗口,唯一索引不会。

2.2 成绩表的设计要预留多轮次和破纪录标记

成绩表最容易设计错的地方是把“成绩”和“名次”混在一起。名次是根据成绩算出来的派生数据,不应该和原始成绩存在同一列。另外径赛(按时间)和田赛(按距离/高度)的成绩单位不同,需要分开存储。

CREATE TABLE result ( id INT PRIMARY KEY AUTO_INCREMENT, heat_id INT NOT NULL, athlete_id INT NOT NULL, raw_score DECIMAL(10,3) COMMENT '径赛存秒数,田赛存米数', score_unit VARCHAR(10) DEFAULT 's' COMMENT 's=秒 m=米', rank_in_heat INT COMMENT '小组名次', final_rank INT COMMENT '最终名次,决赛后回填', is_record TINYINT DEFAULT 0 COMMENT '1=破纪录', remark VARCHAR(200) COMMENT '犯规、弃权、DNS 等', INDEX idx_heat (heat_id), INDEX idx_athlete (athlete_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

raw_score用DECIMAL(10,3)而不是FLOAT,因为浮点数比较在排名时会产生玄学问题——两个成绩理论上相同,FLOAT存储后可能一个略大一个略小,导致名次错乱。DECIMAL精确存储,比较时不会出意外。is_record单独标记,方便后续统计“本次运动会共破纪录 N 项”。

提示:MySQL 5.7 和 8.0 在utf8mb4默认排序规则上有差异,5.7 默认utf8mb4_general_ci,8.0 默认utf8mb4_0900_ai_ci。如果从 5.7 迁移到 8.0,中文排序结果可能变化,建表时显式指定COLLATE=utf8mb4_general_ci可以保持一致。

2.3 计分规则表让团体总分可配置

团体总分不能写死在代码里,因为不同赛事的分值规则不同:有的比赛前八名分别计 9、7、6、5、4、3、2、1 分,有的只计前六名。把规则抽成表,改规则不用重新编译。

CREATE TABLE score_rule ( id INT PRIMARY KEY AUTO_INCREMENT, event_type TINYINT NOT NULL COMMENT '1=径赛 2=田赛 3=团体', rank_no INT NOT NULL COMMENT '名次', score DECIMAL(5,1) NOT NULL COMMENT '该名次得分', is_record_bonus TINYINT DEFAULT 0 COMMENT '是否适用于破纪录加分', UNIQUE KEY uk_type_rank (event_type, rank_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 初始化常见分值 INSERT INTO score_rule (event_type, rank_no, score) VALUES (1,1,9),(1,2,7),(1,3,6),(1,4,5),(1,5,4),(1,6,3),(1,7,2),(1,8,1), (2,1,9),(2,2,7),(2,3,6),(2,4,5),(2,5,4),(2,6,3),(2,7,2),(2,8,1);

这样设计后,团体总分的计算就是一条JOIN加SUM的 SQL,不需要在 Java 里写一堆if-else判断名次对应多少分。

3. Swing 界面与 JDBC 的线程配合:别让查询卡死 EDT

3.1 为什么你的 Swing 界面一点按钮就白屏

Swing 的所有界面更新都必须在事件分发线程(EDT)上执行。如果你在按钮的actionPerformed里直接写stmt.executeQuery(),这个查询耗时 2 秒,EDT 就被阻塞 2 秒,期间界面无法重绘、无法响应任何点击,用户看到的就是白屏或“未响应”。这是 Swing 新手最常见的翻车现场。

正确做法是把数据库操作放到SwingWorker的doInBackground()里,查询结果通过done()回到 EDT 更新界面。SwingWorker是 JDK 自带的,不需要额外依赖。

// 查询运动员列表并填充到 JTable,不阻塞 EDT public class AthleteLoadWorker extends SwingWorker<List<Athlete>, Void> { private final DefaultTableModel tableModel; private final String keyword; public AthleteLoadWorker(DefaultTableModel tableModel, String keyword) { this.tableModel = tableModel; this.keyword = keyword; } @Override protected List<Athlete> doInBackground() throws Exception { // 后台线程执行数据库查询,EDT 不受影响 String sql = "SELECT a.id, a.stu_no, a.name, a.gender, t.team_name " + "FROM athlete a JOIN team t ON a.team_id = t.id " + "WHERE a.name LIKE ? OR a.stu_no LIKE ?"; List<Athlete> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setString(2, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Athlete a = new Athlete(); a.setId(rs.getInt("id")); a.setStuNo(rs.getString("stu_no")); a.setName(rs.getString("name")); a.setGender(rs.getInt("gender")); a.setTeamName(rs.getString("team_name")); list.add(a); } } } return list; } @Override protected void done() { // done() 在 EDT 上执行,可以安全操作 tableModel try { List<Athlete> list = get(); tableModel.setRowCount(0); for (Athlete a : list) { tableModel.addRow(new Object[]{ a.getId(), a.getStuNo(), a.getName(), a.getGender() == 1 ? "男" : "女", a.getTeamName() }); } } catch (Exception e) { JOptionPane.showMessageDialog(null, "加载失败:" + e.getMessage()); } } }

doInBackground()里用PreparedStatement而不是Statement,一是防 SQL 注入,二是预编译语句在循环执行时性能更好。done()里调get()会抛出ExecutionException,必须捕获,否则后台异常会被吞掉,界面什么都不显示,你连报错都看不到。

3.2 连接池要不要上:桌面端的取舍

很多教程一上来就让你配 HikariCP 或 Druid。对于运动会管理系统这种单机桌面应用,我的建议是:如果只是课程设计、并发用户不超过 5 个,直接用DriverManager每次获取连接就够了,代码简单,不需要额外 jar 包。但如果你要做成多人同时使用的 C/S 架构(多个客户端连同一个 MySQL),那就必须上连接池,否则每个查询都新建 TCP 连接,MySQL 的max_connections很快被打满。

// 简易连接池:用 ThreadLocal 保证每个线程复用连接,适合桌面端 public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/sports_meet" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; private static final ThreadLocal<Connection> CONN_HOLDER = new ThreadLocal<>(); public static Connection getConnection() throws SQLException { Connection conn = CONN_HOLDER.get(); if (conn == null || conn.isClosed()) { conn = DriverManager.getConnection(URL, USER, PASSWORD); CONN_HOLDER.set(conn); } return conn; } public static void close() { Connection conn = CONN_HOLDER.get(); if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} CONN_HOLDER.remove(); } } }

连接串里的useSSL=false是必须的,MySQL 8.0 默认要求 SSL 连接,不加这个参数会报SSL connection error。serverTimezone=Asia/Shanghai也是必须的,否则DATETIME字段读写会出现 8 小时时差。characterEncoding=utf8mb4保证中文和 emoji 不乱码。

注意:ThreadLocal方案在SwingWorker的线程池场景下,线程会被复用,连接不会自动关闭。需要在done()里显式调用DBUtil.close(),否则连接泄漏,跑一天下来 MySQL 报Too many connections。

3.3 表格排序与筛选:用 TableRowSorter 而不是重查数据库

Swing 的JTable自带排序功能,只要给TableRowSorter设上去,点击表头就能排序,不需要重新查数据库。筛选也可以用RowFilter在内存里过滤,比每次输入关键字都发 SQL 快得多。

// 给 JTable 加排序和正则筛选 TableRowSorter<DefaultTableModel> sorter = new TableRowSorter<>(tableModel); athleteTable.setRowSorter(sorter); // 关键字筛选:只显示姓名或学号匹配的行 searchField.getDocument().addDocumentListener(new DocumentListener() { private void filter() { String text = searchField.getText().trim(); if (text.isEmpty()) { sorter.setRowFilter(null); } else { // (?i) 表示忽略大小写 sorter.setRowFilter(RowFilter.regexFilter("(?i)" + Pattern.quote(text), 1, 2)); } } public void insertUpdate(DocumentEvent e) { filter(); } public void removeUpdate(DocumentEvent e) { filter(); } public void changedUpdate(DocumentEvent e) { filter(); } });

RowFilter.regexFilter的第二个参数是列索引,1, 2表示只匹配学号和姓名两列。Pattern.quote(text)防止用户输入的正则特殊字符(比如.*)导致意外匹配。这种内存筛选在数据量小于 1 万行时体验很好,超过 1 万行建议还是走数据库LIKE查询加分页。

4. 成绩排名与团体总分:算法实现和三个容易算错的地方

4.1 径赛成绩排名:为什么不能直接 ORDER BY raw_score

径赛成绩排名看起来简单——按raw_score升序排就行。但实际比赛中有几个特殊情况:弃权(DNS)、犯规(DQ)、未完赛(DNF)。这些选手不应该参与排名,但他们的记录还在result表里。如果直接ORDER BY raw_score ASC,弃权选手的raw_score可能是NULL或 0,会排到最前面,名次全乱。

正确做法是在 SQL 里过滤掉无效成绩,并且用remark字段区分状态。

-- 计算某个项目决赛的最终名次(径赛,成绩越小越好) SELECT r.athlete_id, a.name, r.raw_score, r.remark, CASE WHEN r.remark IN ('DNS','DQ','DNF') THEN NULL ELSE RANK() OVER (ORDER BY r.raw_score ASC) END AS final_rank FROM result r JOIN athlete a ON r.athlete_id = a.id JOIN heat h ON r.heat_id = h.id WHERE h.event_id = ? AND h.round = 'FINAL' ORDER BY final_rank IS NULL, final_rank;

RANK()窗口函数是 MySQL 8.0 才有的,如果你用的是 5.7,需要用变量模拟:

-- MySQL 5.7 兼容写法:用用户变量计算名次 SET @rank = 0; SET @prev_score = NULL; SELECT athlete_id, name, raw_score, CASE WHEN remark IN ('DNS','DQ','DNF') THEN NULL WHEN raw_score = @prev_score THEN @rank ELSE @rank := @rank + 1 END AS final_rank, @prev_score := raw_score AS dummy FROM result r JOIN athlete a ON r.athlete_id = a.id WHERE heat_id IN (SELECT id FROM heat WHERE event_id = ? AND round = 'FINAL') ORDER BY raw_score ASC;

这里有个细节:RANK()遇到相同成绩会给出相同名次,下一个名次跳号(1,1,3)。如果赛事规则要求并列后不跳号(1,1,2),要用DENSE_RANK()。这个区别在团体总分计算时影响很大——跳号会导致后续名次对应的分值错位。

4.2 田赛成绩排名:降序和试跳次数的处理

田赛(跳高、跳远、铅球)成绩越大越好,排序方向相反。另外田赛通常有多次试跳,取最好成绩。如果每次试跳都存一条result记录,排名时要先按运动员分组取MAX(raw_score)。

-- 田赛:每个运动员取最好成绩,再排名 SELECT athlete_id, name, MAX(raw_score) AS best_score, RANK() OVER (ORDER BY MAX(raw_score) DESC) AS final_rank FROM result r JOIN athlete a ON r.athlete_id = a.id WHERE heat_id IN (SELECT id FROM heat WHERE event_id = ? AND round = 'FINAL') AND remark NOT IN ('DNS','DQ','DNF') GROUP BY athlete_id, name ORDER BY best_score DESC;

GROUP BY里必须包含name,否则 MySQL 的ONLY_FULL_GROUP_BY模式会报错(8.0 默认开启)。如果不想改 SQL,可以临时SET sql_mode='',但不推荐,因为这会掩盖其他潜在问题。

4.3 团体总分:破纪录加分和名次并列的叠加计算

团体总分 = 各项目名次得分之和 + 破纪录加分。破纪录加分通常是固定值(比如加 5 分)或者翻倍。计算时要注意:如果同一运动员在同一个项目破纪录,只加一次;如果接力项目破纪录,每个队员都加。

-- 团体总分统计:按院系汇总 SELECT t.team_name, SUM(sr.score) AS base_score, SUM(CASE WHEN r.is_record = 1 THEN 5 ELSE 0 END) AS bonus_score, SUM(sr.score) + SUM(CASE WHEN r.is_record = 1 THEN 5 ELSE 0 END) AS total_score FROM result r JOIN athlete a ON r.athlete_id = a.id JOIN team t ON a.team_id = t.id JOIN score_rule sr ON sr.rank_no = r.final_rank AND sr.event_type = ( SELECT event_type FROM sport_event WHERE id = ( SELECT event_id FROM heat WHERE id = r.heat_id ) ) WHERE r.final_rank IS NOT NULL GROUP BY t.team_name ORDER BY total_score DESC;

这个查询嵌套了三层子查询,在数据量大时性能很差。优化方式是在result表里冗余一个event_id字段,避免每次通过heat反查。这是典型的“反范式换性能”取舍——课程设计数据量小无所谓,真实赛事几千条成绩时,这个优化能把查询从 3 秒降到 0.1 秒。

提示:final_rank为NULL的记录(弃权、犯规)在JOIN score_rule时会被自动排除,因为NULL不等于任何rank_no。这正好符合“无效成绩不计分”的规则,不需要额外写WHERE条件。

5. 避坑与排查:从环境配置到数据一致性的 5 个血泪教训

5.1 现象:程序启动报No suitable driver found for jdbc:mysql://...

原因:JDBC 驱动 jar 包没有加到 classpath,或者 MySQL 8.0 用了旧版com.mysql.jdbc.Driver类名。MySQL 8.0 的驱动类是com.mysql.cj.jdbc.Driver,旧版是com.mysql.jdbc.Driver。用 Maven 的话,mysql-connector-java的版本要和 MySQL 服务端版本匹配。

解决:Maven 依赖写mysql:mysql-connector-java:8.0.33(或你实际使用的版本),并且确认pom.xml里 scope 不是provided。如果手动加 jar,在 IDEA 里要File → Project Structure → Modules → Dependencies → +把 jar 加进去,光放在lib目录不生效。

5.2 现象:中文姓名存进数据库变成???

原因:连接串没有指定characterEncoding,或者数据库/表的字符集不是utf8mb4。MySQL 5.7 默认latin1,8.0 默认utf8mb4,但老版本升级上来的库可能还是latin1。

解决:连接串加characterEncoding=utf8mb4,建库时CREATE DATABASE sports_meet DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci,建表时也显式指定DEFAULT CHARSET=utf8mb4。三处都对了才不会乱码。

5.3 现象:SwingWorker的done()里更新表格报NullPointerException

原因:done()里调get()返回null,通常是因为doInBackground()里抛了异常但被吞掉了。SwingWorker的doInBackground()抛出的异常会包装在ExecutionException里,get()时抛出,如果不捕获,done()后续代码不执行,表格没更新,看起来像“没反应”。

解决:done()里用try-catch包住get(),把异常信息打到日志或弹窗。排查时可以在doInBackground()里加e.printStackTrace(),先确认异常类型。

5.4 现象:两个管理员同时审核报名,同一条记录被审核两次

原因:审核逻辑是“先查状态,再更新”,两个线程同时查到状态为“待审”,然后都执行了更新。这是典型的竞态条件。

解决:用数据库的原子更新代替“查了再改”。UPDATE registration SET audit_status = 1 WHERE id = ? AND audit_status = 0,根据executeUpdate()返回的影响行数判断是否审核成功。返回 1 表示审核成功,返回 0 表示已被别人审核过。这个技巧在秒杀、抢票场景也通用。

5.5 现象:成绩录入后排名不更新,要重启程序才生效

原因:排名是在程序启动时算好缓存在内存里的,录入新成绩后没有刷新缓存。或者JTable的tableModel更新了但没调fireTableDataChanged()。

解决:每次成绩录入后,重新执行排名 SQL 并刷新表格模型。如果用了TableRowSorter,更新tableModel后排序会自动重算,不需要手动干预。但如果是自定义的排名缓存,必须显式失效。

6. 课程报告怎么写才不像流水账:把设计决策讲清楚

课程报告最容易写成“需求分析→技术选型→数据库设计→界面截图→总结”的模板文,老师一看就知道是套的。想让报告有说服力,核心是把“为什么这么选”讲清楚,而不是只列“选了什么”。

比如数据库表设计那一章,不要只贴CREATE TABLE语句,要写:为什么registration表要加唯一索引?因为报名是并发操作,代码层面的“查重再插入”有竞态窗口,唯一索引把约束下沉到数据库,这是更可靠的方案。再比如 Swing 线程那一章,不要只写“用了 SwingWorker”,要写:最初直接在按钮事件里查数据库,界面卡死 3 秒,用户以为程序崩溃;改用 SwingWorker 后,查询期间界面仍可拖动,体验提升明显。这种“遇到问题→分析原因→选择方案→验证效果”的叙述,才是工程思维的体现。

报告里可以放一张对比表,把关键设计决策和替代方案列出来:

设计点选用方案替代方案选它的理由
数据库连接ThreadLocal 复用每次新建 / HikariCP桌面端并发低,ThreadLocal 零依赖且够用
排名计算SQL 窗口函数Java 内存排序数据在数据库侧算完再传,减少网络传输
界面刷新SwingWorker手动开 Thread + invokeLaterSwingWorker 封装了异常传递和生命周期
成绩存储DECIMAL(10,3)FLOAT / DOUBLE精确比较,避免浮点误差导致名次错乱

最后,报告里一定要有“测试与验证”一节,写清楚你测了哪些边界情况:成绩相同怎么排名、弃权选手是否计分、破纪录加分是否重复计算、并发审核是否冲突。这些测试用例比界面截图有价值得多。

我自己做这套系统时,最大的教训是:不要等到界面全写完再连数据库。先用一个main方法把 JDBC 连通、把增删改查跑通,再往上套 Swing。否则一旦界面报错,你分不清是 SQL 写错了还是 EDT 线程问题,排查成本翻倍。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询