ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java田径运动管理系统:Spring Boot+MyBatis+MySQL从建表到并发排名的工程实践

2026/9/23 1:09:41 拓冰建站 浏览量
Java田径运动管理系统:Spring Boot+MyBatis+MySQL从建表到并发排名的工程实践 简介基于Java的田径运动管理系统设计源码面向Java开发学习者和体育信息化项目人员用于解决田径赛事中运动员信息、比赛日程、成绩记录与器材管理的数字化问题。压缩包共69个文件含57个Java源文件构成核心业务逻辑9个txt文档提供比赛规则、触发器及设计说明2个pptx演示文稿展示E-R图与系统架构另有1个license文件整体仅351KB。系统运用模块化、封装、继承和多态等Java特性后端通过数据库触发器保障数据一致性并配有E-R图清晰呈现数据表关系。目前已有261人浏览学习适合作为课程设计、毕业设计或企业项目原型能直接导入IDE运行结合演示文稿快速掌握从设计到实现的全过程。通过源码与文档搭配还可学习界面交互、数据库连接、查询打印等开发技巧便于二次扩展。1. Java 田径运动管理系统到底在管什么学校运动会、区级田径赛、企业趣味运动会这类赛事的信息化常卡在三个环节报名靠 Excel 来回传赛程靠人工排表成绩靠手算。基于 Java 的田径运动管理系统设计源码就是把运动员注册、项目报名、道次分组、成绩录入、排名判定全部收拢到数据库和 Web 界面上。这套系统的常规技术栈是 Spring Boot MyBatis MySQL。真正有信息量的是业务建模田赛、径赛、全能的计分规则不同数据模型设计不好后面做排名和成绩报表会很被动。对 Java 开发者来说这个选题覆盖建表、接口、并发控制的完整链路适合课程设计也能改成中小型赛事的信息化底座。2. 设计田径运动管理系统的数据模型与技术选型2.1 为什么这套系统适合 Spring Boot MyBatis MySQL选型要对着数据量和查询特征来。田径赛事的数据量其实很小一场区级运动会报名运动员通常几千人报名记录几万条单机 MySQL 完全扛得住。真正麻烦的是查询形态排名要按项目分组排序破纪录要跨历史数据比较统计要按单位聚合这类逻辑用 MyBatis 写动态 SQL 和控制结果映射比 JPA 直观得多团队里任何人接手都能看懂。Spring Boot 的价值在生态参数校验、事务、定时任务、监控都有现成 starter课程设计里再套一层 Spring Security 做登录也不费事。前端不必一上来就拆 Vue管理端用 Thymeleaf 渲染模板配 Bootstrap 表格开发速度最快等要做成绩大屏或移动端报名再改前后端分离。2.2 运动员、赛事、报名三张核心表的建表 SQL 与字段设计先建运动员表、赛事项目表、报名表这是系统的主干。报名表把运动员和项目关联起来同时兼任成绩表避免单独拆张成绩表导致联查复杂。CREATE TABLE athlete ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32) NOT NULL, gender TINYINT NOT NULL COMMENT 1-男 2-女, phone VARCHAR(20) DEFAULT NULL, team_id BIGINT NOT NULL COMMENT 班级或单位ID, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_team_phone (team_id, phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE event_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, event_name VARCHAR(64) NOT NULL, item_type TINYINT NOT NULL COMMENT 1-径赛 2-田赛 3-全能, gender TINYINT NOT NULL COMMENT 1-男 2-女, rounds TINYINT DEFAULT 1 COMMENT 预决赛轮次, unit VARCHAR(8) DEFAULT 秒 COMMENT 成绩单位, record DECIMAL(8,2) DEFAULT NULL COMMENT 当前赛会纪录, status VARCHAR(16) DEFAULT OPEN COMMENT OPEN/CLOSED ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE enrollment ( id BIGINT AUTO_INCREMENT PRIMARY KEY, athlete_id BIGINT NOT NULL, item_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-已报名 1-已检录 2-已完赛, group_no INT DEFAULT NULL COMMENT 分组号, lane TINYINT DEFAULT NULL COMMENT 道次或出场顺序, score1 DECIMAL(8,2) DEFAULT NULL COMMENT 第一轮成绩, score2 DECIMAL(8,2) DEFAULT NULL COMMENT 第二轮成绩田赛可多轮, final_score DECIMAL(8,2) DEFAULT NULL COMMENT 最终成绩, rank_no INT DEFAULT NULL COMMENT 项目内排名, UNIQUE KEY uk_athlete_item (athlete_id, item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键字段设计说明字段用途设计要点uk_team_phone防止同单位重复注册用 (team_id, phone) 做复合唯一键比只锁 name 靠谱enrollment.status报名-检录-完赛状态机用 TINYINT 存状态码后续可扩展弃赛、取消等状态lane道次/出场顺序径赛存道次 1-8田赛存出场顺序共用一个字段减少列数final_score最终成绩径赛数值小者为优田赛数值大者为优排序方向靠 item_type 判断record赛会纪录冗余在项目表破纪录判定时少一次子查询一个容易犯的错是把成绩单独拆成 result 表。田径项目一名运动员一个项目基本只有一条最终成绩拆表只会让排名查询多一次 join只有需要记录每轮投掷、每次跳的完整过程时才拆成绩明细表。2.3 报名接口的 Java 实现事务与唯一索引双保险Service 层做业务校验数据库做主键兜底这是报名类接口的标准写法。Service public class EnrollmentService { Transactional public Long enroll(Long athleteId, Long itemId) { EventItem item itemMapper.selectById(itemId); if (item null || !OPEN.equals(item.getStatus())) { throw new BizException(项目不存在或报名已截止); } // 先查一遍给用户友好提示并发下的重复插入由唯一索引兜底 if (enrollmentMapper.existsByAthleteAndItem(athleteId, itemId) 0) { throw new BizException(该运动员已报名此项目); } Enrollment en new Enrollment(); en.setAthleteId(athleteId); en.setItemId(itemId); en.setStatus(0); try { enrollmentMapper.insert(en); } catch (DuplicateKeyException e) { throw new BizException(该运动员已报名此项目); } return en.getId(); } }逻辑说明先校验项目存在且报名开放再查重插入时捕获 DuplicateKeyException。两个并发请求同时进来时只有一条能拿到锁另一条被 uk_athlete_item 唯一索引拦下不会出现超报。Transactional 保证报名写入与后续的报名人数统计在同一事务里。参数上注意 Mapper 的 insert 必须配置 useGeneratedKeystrue否则 en.getId() 拿不到自增主键前端就无法拿报名记录做后续跳转。3. 赛程编排与成绩录入是田径系统的重头戏3.1 径赛分组编排同单位不碰面的分组算法田径编排有个硬性规则同单位的运动员不能出现在同一预赛组每组人数由跑道数决定常见 8 道。实现上我一般把报名记录按单位打散用轮转法分到各组。public void arrangeGroup(Long itemId, int maxLane) { // 按单位分组轮转取人保证同一轮里各单位均匀分布 ListEnrollment list enrollmentMapper.listByItem(itemId); MapLong, ListEnrollment byTeam list.stream() .collect(Collectors.groupingBy(Enrollment::getTeamId)); ListQueueEnrollment teamQueues byTeam.values().stream() .map(ArrayDeque::new).collect(Collectors.toList()); int group 1, lane 1; while (teamQueues.stream().anyMatch(q - !q.isEmpty())) { for (QueueEnrollment q : teamQueues) { if (q.isEmpty()) continue; Enrollment en q.poll(); en.setGroupNo(group); en.setLane(lane); if (lane maxLane) { group; lane 1; } } } enrollmentMapper.batchUpdateGroupAndLane(list); }逻辑说明外层循环每次从各单位队列各取一人相当于一轮一轮地往组里填人同单位选手天然分散到不同组。maxLane 参数对应跑道数跑道不足 8 条改成 6。这个算法只保证单位不冲突不保证随机性需要随机抽道次时先把 List 用 Collections.shuffle 洗一遍再分组。编排结果一定要允许人工微调所以 group_no 和 lane 要开放给管理后台直接改不能只做一次性自动生成。3.2 田赛与径赛成绩录入的校验差异径赛成绩来自电动计时或秒表单位是秒保留两位小数田赛成绩是米同样保留两位。录入接口按项目类型校验范围避免把 10 秒 05 录成 1005。项目类型成绩单位排序方向判优逻辑典型范围校验径赛秒数值小的优final_score ASC100 米 10.00-30.00田赛米数值大的优final_score DESC铅球 5.00-20.00PostMapping(/score) public Result saveScore(RequestBody ScoreForm form) { EventItem item itemMapper.selectById(form.getItemId()); if (item null) return Result.fail(项目不存在); BigDecimal score form.getScore().setScale(2, RoundingMode.HALF_UP); // 按项目配置上下界拦截手误产生的异常成绩 if (score.compareTo(item.getMinBound()) 0 || score.compareTo(item.getMaxBound()) 0) { return Result.fail(成绩超出合理范围); } enrollmentMapper.updateScore(form.getEnrollmentId(), score); return Result.ok(); }逻辑说明score 统一存 DECIMAL(8,2)径赛比较用小于、田赛用大于排序方向在排名时再按 item_type 判断。录入时最容易出的问题是前端把分:秒格式直接传成浮点数比如 1:23.45 转成 83.45依然能通过范围校验。所以前端要做独立的格式转换函数后端只认秒为单位的小数不要让业务层去猜字符串格式。3.3 排名计算并列名次与破纪录判定怎么写一个项目全部完赛后按项目类型取排序方向更新排名Transactional public void calcRank(Long itemId) { EventItem item itemMapper.selectById(itemId); String orderSql item.getItemType() 1 ? final_score ASC : final_score DESC; ListEnrollment list enrollmentMapper.listFinishedByItem(itemId, orderSql); int rank 1; for (Enrollment en : list) { // 并列名次成绩与前一名相同则沿用前一名名次 if (rank 1 en.getFinalScore().equals( list.get(rank - 2).getFinalScore())) { enrollmentMapper.updateRankNo(en.getId(), rank - 1); } else { enrollmentMapper.updateRankNo(en.getId(), rank); } rank; } // 破纪录第一名成绩优于当前纪录才更新项目表 Enrollment first list.get(0); if (first.getFinalScore() ! null item.getRecord() ! null (item.getItemType() 1 ? first.getFinalScore().compareTo(item.getRecord()) 0 : first.getFinalScore().compareTo(item.getRecord()) 0)) { itemMapper.updateRecord(itemId, first.getFinalScore()); } }提示田径规则里并列名次占位是 1、2、2、4名次数字会跳过不能用行号直接覆盖 rank_no。逻辑说明排名循环里比较前后两条成绩相等则沿用前一名名次这处理的是并列第三这类情况下一名自动变成第五。破纪录判定只在第一名上做因为名次按规则已经排好非第一名就算破纪录也只能算并列或无效。record 字段冗余在 event_item 表判定时少一次子查询代价是更新纪录时要保证 calcRank 和 updateRecord 在同一个事务里避免榜单刷新和纪录刷新不一致。4. Java 田径系统的并发与排错报名高峰和成绩榜单怎么稳住4.1 报名接口并发控制连接池参数与 Redis 防重报名开放那一刻几千人同时点提交数据库连接池很容易被打满。常见的做法是池子给足同时让请求快速失败。spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 redis: host: localhost port: 6379参数说明maximum-pool-size 根据 MySQL 的 max_connections 来配单机默认 151应用并发不高时 20 足够connection-timeout 设 3000 毫秒拿不到连接就快速失败而不是让请求无限堆积。报名接口是短事务事务范围越小锁持有时间越短。防重除了唯一索引还可以在 Redis 里用 SETNX 加一层快速拦截命中已报名直接返回减少对数据库的无效查询。SETNX 必须带过期时间防止 Redis 异常后 key 永久残留。4.2 成绩榜单缓存Redis 读多写少场景怎么刷新榜单页是查询量最大的页面几万人在手机上反复刷新不能每次都三表联查排序。用 Redis 缓存最终榜单成绩录入后主动删除 key。public ListRankVO getRankList(Long itemId) { String key rank:item: itemId; // 缓存命中直接返回避免每次请求都打数据库 ListRankVO cached redisTemplate.opsForValue().get(key); if (cached ! null) return cached; ListRankVO list enrollmentMapper.selectRankList(itemId); // 兜底过期时间 2 分钟防止删除 key 失败导致榜单永不更新 redisTemplate.opsForValue().set(key, list, Duration.ofMinutes(2)); return list; }逻辑说明读取侧先查缓存查不到才进数据库并写回缓存写入侧在 updateScore 之后执行 redisTemplate.delete(key)下一次请求自动重建缓存。2 分钟兜底过期是为了处理进程崩溃或删除失败的情况保证榜单最长 2 分钟一定收敛到最新。这个方案比每 5 秒轮询数据库省一个数量级的查询量课程设计里完全够用。4.3 三个运行期坑JDK 版本、Lombok 与时区错乱现象根因处理位置编译告警源发行版 17 需要目标发行版 17maven-compiler-plugin 的 source/target 与 IDE 语言级别不一致检查 pom.xml 的 java.version同步 IDE Project Structure启动报 You arent using a compiler supported by lombokJDK 17 以上配了旧版 Lombok升级 Lombok 依赖到 1.18.30 以上时间字段比本地时间差 8 小时数据库连接串缺少 serverTimezone连接串加 serverTimezoneAsia/ShanghaiJDK 版本错配是最常见的环境问题本质是 Maven 编译用的 JDK 和 IDE 里选的语言级别对不上。改完 pom.xml 后记得 clean package不要增量编译糊弄过去。Lombok 的问题比版本号提示更隐蔽升级依赖就能解决不要去改编译器参数硬绕。时区问题只在新建记录时暴露历史数据已经错的要单独写一条 UPDATE 用 DATE_ADD 修复靠应用代码逐个补是最慢的。连接串统一写成jdbc:mysql://localhost:3306/track_field?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai之后就不用在每个查询里做时间转换了。5. 从设计源码到可运行部署验证与压测清单打包和启动走标准流程mvn clean package -DskipTests java -Xms256m -Xmx512m -jar target/track-field-0.0.1-SNAPSHOT.jar \ --spring.profiles.activeprodJVM 参数按机器给1G 内存的服务器 -Xms256m -Xmx512m 足够给大反而挤占 MySQL 内存。启动后先看日志里有没有输出 Started TrackFieldApplication再用 HTTP 接口做冒烟验证# 新增运动员应返回自增 id curl -X POST http://localhost:8080/api/athlete \ -H Content-Type: application/json \ -d {name:张三,gender:1,teamId:3} # 查询项目报名人数应与 enrollment 表 group by item_id 的结果一致 curl http://localhost:8080/api/enrollment/count?itemId1curl 返回的 id 要和数据库自增主键对上报名人数口径要和 enrollment 表聚合查询一致。功能验证完再用 JMeter 压报名接口200 个线程跑 30 秒关注两个指标吞吐量保持在 50 req/s 以上、错误率低于 0.1%说明连接池和事务配置没有明显瓶颈。最后一个具体技巧给写接口加一个版本号校验。报名、成绩录入这类接口在请求头带X-API-Version: 1用 HandlerInterceptor 统一拦截public class ApiVersionInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String v request.getHeader(X-API-Version); if (!1.equals(v)) { response.setStatus(400); response.getWriter().write(api version not supported); return false; } return true; } }逻辑说明拦截器只在写接口路径上注册版本不匹配直接返回 400 和明确错误文案。这样做的好处是接口协议变化时老版本客户端能被快速识别并给出明确报错不用翻业务日志去猜脏数据来源。版本号常量收敛在拦截器里新接口升级只改常量业务 Controller 不需要动这个习惯比等出错之后再加日志定位省事得多。本文还有配套的精品资源点击获取