
每年到选题季教务处的表格满天飞学生群里“谁帮我看看还有哪个题能选”刷屏老师们在邮箱里翻学生发来的选题申请。我做过好几个版本的选题系统这个基于 Java Python MySQL 的 Web 版是最满意的一个。不是说它功能多华丽而是它把三方分工做得特别清楚Java 扛住 Web 交互和业务规则Python 负责批量数据处理和自动化任务MySQL 存住所有状态。整套东西跑下来稳定、省心而且源码不复杂改起来很快。这套系统适合几类人参考正在做课程设计或者毕业设计的计算机专业学生想给院系搞一个内部选题工具但不想买商业系统的教务老师以及刚接触 Web 全栈、想看看 Java 和 Python 怎么在一个项目里配合的开发者。我会把整体设计思路、数据库怎么建模、核心业务怎么落地、踩过哪些坑全部拿出来说。1. 内容整体设计与思路拆解1.1 为什么要用 Java Python MySQL 三个技术栈很多人在做一个 Web 系统时习惯一套技术走到底比如全 Java 或者全 Python。我这次刻意没用单一语言原因是选题系统这个场景天然分成两条线。第一条线是在线业务学生登录、浏览题目、提交选题、教师审核、管理员管理。这条线要求稳定、并发可控、事务可靠Java 的 Spring Boot 生态在这块非常成熟尤其是事务控制和权限框架写起来心里踏实。第二条线是离线数据处理学期开始时要导入大量学生名单和教师信息通常是以 Excel 形式存在学期结束时要把选题结果导出成各种统计报表还要给没选上题的学生发通知。这种工作 Python 的 pandas、openpyxl 处理起来太顺手了而且写一些自动化脚本定时统计、生成报表比 Java 轻量太多。MySQL 则是整个系统的数据底座不管是 Java 写入的业务数据还是 Python 处理后的结果数据都统一落在 MySQL 里。三者各司其职没有谁在硬扛自己不擅长的活。1.2 角色权限与核心业务流程我在设计这个系统时没有一上来就写代码而是先认认真真梳理了业务场景里的角色和流程。选题系统实际涉及三类角色管理员维护基础数据包括学期设置、专业班级、教师账号、学生账号审核教师提交的题目监控选题进度手动处理异常情况。教师申报题目包括题目名称、方向简介、可选人数、面向专业、审核学生选题、查看自己名下的选题名单、录入评分或评语。学生浏览可选题目、查看每个题目的剩余名额实况、提交选题通常有一轮正选和一轮补选、查看最终结果。核心流程是这样一条链教师申报题目 → 管理员审核开放 → 学生在规定时间内选择 → 教师确认或拒绝 → 管理员协调调剂。这个流程看着简单落到系统里就会牵扯出一个关键问题状态机设计。每个题目有状态草稿、待审核、已开放、已满员、已关闭每条选题记录也有状态待确认、已通过、已拒绝、已调剂如果状态设计不好后面查数、统计全是坑。我最终用的状态流转方式是题目的所有状态变化都记录在日志表里JS 端只允许按照预定义的流转方向操作后端再校验一次。这样可以避免“教师把已满员的题目又打开”这种逻辑混乱。1.3 为什么选 MySQL 而不是别的数据库关于 MySQL 的选择网上讨论很多。我的实际感受是对于这种中等规模几百个题目、几千个学生的 Web 应用MySQL 是性价比最高的选择。它上手容易、文档丰富、主从部署也不复杂而且 Java 的 MyBatis/JPA 和 Python 的 SQLAlchemy/PyMySQL 都原生支持得很好。相比 PostgreSQLMySQL 在安装、运维和容器化部署上对新手更友好相比 NoSQL选题系统的数据关联性强、事务要求高根本不适合用文档型或 KV 型数据库。2. 核心细节解析与实操要点2.1 数据库表结构设计先想清楚再动手这套系统的表结构我迭代过两版。第一版把教师和学生的信息全塞在一张 user 表里用一个 role 字段区分结果后期要为不同角色加字段时表变得非常臃肿。第二版拆成了多表设计清爽了很多。核心表如下用户相关user主账号表id, username, password_hash, role, status, created_at统一存登录凭据student_profile学生扩展表user_id, student_no, name, major, class_name, grade, email存学生学籍信息teacher_profile教师扩展表user_id, teacher_no, name, department, title, email存教师信息业务相关topic题目表id, teacher_id, title, description, capacity, selected_count, status, audit_status, created_at, deadline——selected_count是已选人数capacity是上限selection选题记录表id, student_id, topic_id, status, selection_time, confirm_time, notesemester学期设置表id, semester_name, start_time, end_time, is_currentoperation_log操作日志表id, user_id, action, target_type, target_id, detail, created_at加粗的字段都是查询和统计的高频字段在真实环境里必须建索引。我吃过一次亏一开始表数据量不大没在意后面学生选课时一拥而入系统开始变慢才发现少了联合索引。哪些地方需要建立索引我在实践中的规则很简单高频查询条件字段如 student_no、topic_id外键字段如 selection 表的 student_id 和 topic_id状态字段加上时间字段的联合查询如 index(status, created_at)索引不是越多越好写多读少的表比如日志表就尽量少建索引避免写入变慢。2.2 把并发控制在数据库这一层回到刚才提到的selected_count字段。这个字段听起来简单但它是整个系统并发问题的核心。给学生开放选题那一刻会出现一个非常典型的场景某个题目只剩最后一个名额同时有 10 个学生在点“选这个题”如果系统写的代码是先查数量、判断没满、然后再更新那这 10 个请求有可能全部通过判断最后把名额爆掉。我采用的方案是数据库乐观锁 事务补偿。具体做法分三步在topic表增加一个version字段每次更新时带上版本号。更新语句写成原子操作在一条 UPDATE 中同时判断capacity是否还有富余UPDATE topic SET selected_count selected_count 1, version version 1 WHERE id #{topicId} AND selected_count capacity AND status OPEN通过受影响行数判断是否抢到名额。受影响行数为 0说明题目已满或状态不对。在selection表插入记录时用数据库唯一约束防止同一学生同学期重复选课ALTER TABLE selection ADD UNIQUE KEY uk_student_semester (student_id, semester_id);这个组合拳打完再也没有出现超选或者一人选多题的情况。很多网上代码喜欢在 Java 层用 synchronized 或者分布式锁控制我强烈建议你把锁下沉到数据库这样不仅代码简单而且天然支持多实例部署。2.3 Java 后端接口设计尽量贴合业务流转因为我做的是选题系统不是纯 CRUD接口设计要对应业务动作。我在这个项目里没有把一个实体简单暴露成五个接口而是按业务动作命名比如POST /api/topic/create教师申报题目POST /api/topic/{id}/audit管理员审核POST /api/student/selectTopic学生提交选题内部走事务POST /api/student/cancelSelection学生在未确认前可取消POST /api/teacher/confirmSelection教师确认或者拒绝GET /api/student/availableTopics学生查看可选题目并且实时显示剩余名额每个接口都做三件事参数校验、权限校验、业务处理。对了权限校验这里有个经验教训千万不要只在 Controller 里写一套 if-else 判断角色。项目里我用的是 Spring Security 自定义注解直接在 Controller 方法上标注PreAuthorize(hasRole(TEACHER))代码干净得多。还有参数的校验选题系统的参数不算复杂但是容易踩坑的是一些“数字边界”问题比如教师填了一个负的容量、忘记填截止时间等。我引入了javax.validation在实体字段上用NotNull、Min(1)这类注解省下大量冗余判断代码。2.4 Python 脚本批量导入导出与自动通知说句实话选题系统中不少让人头疼的活都是靠 Python 这个副手搞定的。我的项目里有三个 Python 脚本非常实用。第一个是批量导入脚本import_data.py。每学期开始管理员手里是教务处的 Excel 名单如果靠人工往系统里录几千个账号录到怀疑人生。脚本用 pandas 读 Excel把学号、姓名、专业、班级、邮箱逐行校验然后批量生成加密密码用 werkzeug 库的 generate_password_hash保证和 Java 端存储格式兼容最后批量插入 MySQL。几千条数据几十秒就搞定而且脚本里做了重复学号检查不会把数据库搞脏。第二个是截止自动处理脚本close_selection.py。选题窗口一到系统要自动关闭未确认的题目给未选题的学生发提醒邮件。Python 端用 APScheduler 定时任务每天调用一次查询当前学期所有处于开放状态的题目如果超过截止时间就更新状态并且找出还没有有效选题记录的学生调用邮件接口发提醒。第三个是结果统计脚本export_report.py。学期末管理员要交一份全校选题统计表各专业选题率、各教师题目热度、未选题学生名单这个脚本直接用 SQL 跑几个聚合查询pandas 处理成 DataFrame再 openpyxl 写入 Excel格式、字体、列宽都调好了导出就能写报告。很多做 Java 的人一听到要写 Python 就反感其实这种配合完全可以做成进程外的方式Java 系统只负责业务操作Python 脚本独立部署在定时任务里两边不直接函数调用而是通过数据库和文件系统协作。这样互不干扰Java 挂了也不影响 Python 清数据。3. 实操过程与核心环节实现3.1 环境搭建与项目结构我的标准开发环境是三件套JDK 17、Python 3.10、MySQL 8.0。项目是标准的 Spring Boot 多模块结构我习惯把前后端干干脆脆地分开course-select-system/ ├── backend-java/ # Spring Boot 后端 │ ├── src/main/java/com/example/courseselect │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务层 │ │ ├── mapper/ # MyBatis Mapper 接口 │ │ ├── entity/ # 实体类 │ │ ├── config/ # 安全配置、跨域配置 │ │ └── common/ # 统一返回体、异常处理 │ └── src/main/resources/mapper/ # XML SQL ├── frontend-web/ # 前端我是用的 Vue3 ├── scripts-python/ # Python 辅助脚本 │ ├── import_data.py │ ├── close_selection.py │ ├── export_report.py │ └── requirements.txt └── docs/ # 数据库设计文档和部署文档前端用 Vue3 Element Plus 开发通过 axios 调后端接口JWT 做登录态。前端不是这篇文章的重点但如果想快速跑起来可以让 Java 后端直接返回模板页面用 Thymeleaf牺牲一点交互体验但开发和部署会简单很多。3.2 数据库脚本建表 DDL 参考因为篇幅有限我挑最重要的三张表给你看一眼完整 DDL 我放到项目文档里。user 表CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 登录账号, password_hash varchar(255) NOT NULL COMMENT 密码哈希, role varchar(20) NOT NULL COMMENT STUDENT / TEACHER / ADMIN, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;topic 表CREATE TABLE topic ( id int NOT NULL AUTO_INCREMENT, teacher_id int NOT NULL, title varchar(200) NOT NULL, description text, capacity int NOT NULL DEFAULT 5 COMMENT 可选人数上限, selected_count int NOT NULL DEFAULT 0 COMMENT 已选人数, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, status varchar(20) NOT NULL DEFAULT DRAFT COMMENT DRAFT/PENDING/OPEN/FULL/CLOSED, audit_status varchar(20) NOT NULL DEFAULT PENDING COMMENT 待审核/通过/驳回, semester_id int NOT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_teacher (teacher_id), KEY idx_semester_status (semester_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;selection 表CREATE TABLE selection ( id int NOT NULL AUTO_INCREMENT, student_id int NOT NULL, topic_id int NOT NULL, semester_id int NOT NULL, status varchar(20) NOT NULL DEFAULT PENDING COMMENT PENDING/APPROVED/REJECTED/REASSIGNED, selection_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, confirm_time datetime DEFAULT NULL, note varchar(500) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_student_semester (student_id, semester_id), KEY idx_topic (topic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表的时候有两个细节值得提醒第一所有表都用utf8mb4字符集避免生僻字、数学符号存进去变成问号。第二外键约束我故意没有加物理外键在读写频繁场景下会影响插入性能业务层去保证数据一致性就够了。3.3 Java 核心逻辑事务内完成选题选题接口是全系统最核心的代码我贴一下 Service 层的关键逻辑Transactional(rollbackFor Exception.class) public SelectionResult selectTopic(SelectTopicRequest request) { Long studentId request.getStudentId(); Long topicId request.getTopicId(); Long semesterId request.getSemesterId(); // 1. 校验学生是否已经选过 int count selectionMapper.countByStudentAndSemester(studentId, semesterId); if (count 0) { throw new BusinessException(400, 你本学期的选题已经提交不能重复选择); } // 2. 原子抢占名额 int rows topicMapper.decrCapacityIfAvailable(topicId); if (rows 0) { throw new BusinessException(400, 该题目名额已满或已关闭); } // 3. 插入选题记录 Selection selection new Selection(); selection.setStudentId(studentId); selection.setTopicId(topicId); selection.setSemesterId(semesterId); selection.setStatus(PENDING); selectionMapper.insert(selection); // 4. 记录操作日志 logMapper.insert(STUDENT_SELECT, studentId, topic, topicId, 学生提交选题); return SelectionResult.success(selection); }这段代码最重要的就是Transactional和update ... where selected_count capacity配合。没有事务前面 UPDATE 成功了后面 INSERT 万一失败名额就被白白扣掉了。没有原子更新并发场景直接超卖。3.4 Python 脚本Excel 数据清洗与入库再贴一个 Python 导入脚本的核心逻辑你感受一下它做数据清洗有多顺手import pandas as pd from sqlalchemy import create_engine, text from werkzeug.security import generate_password_hash engine create_engine(mysqlpymysql://root:passwordlocalhost:3306/course_select?charsetutf8mb4) def import_students(excel_path, semester_id): df pd.read_excel(excel_path, dtype{学号: str}) default_password 123456 # 校验学号必须唯一18位 if df[学号].duplicated().any(): raise ValueError(Excel中存在重复学号) rows [] for _, row in df.iterrows(): student_no row[学号].strip() name row[姓名].strip() major str(row.get(专业, )).strip() class_name str(row.get(班级, )).strip() # 统一处理邮箱缺失的生成默认邮箱 email str(row.get(邮箱, )).strip() if not email or email nan: email f{student_no}school.edu.cn rows.append({ username: student_no, password_hash: generate_password_hash(default_password), role: STUDENT, status: 1, student_no: student_no, name: name, major: major, class_name: class_name, email: email, semester_id: semester_id, }) # 事务插入 with engine.begin() as conn: # 先插 user 表拿 id for r in rows: result conn.execute(text( INSERT INTO user (username, password_hash, role, status) VALUES (:username, :password_hash, :role, :status) ), r) user_id result.lastrowid conn.execute(text( INSERT INTO student_profile (user_id, student_no, name, major, class_name, email, semester_id) VALUES (:user_id, :student_no, :name, :major, :class_name, :email, :semester_id) ), {**r, user_id: user_id}) print(f成功导入 {len(rows)} 名学生) if __name__ __main__: import_students(students_2025_spring.xlsx, semester_id1)有个小细节pandas 读 Excel 时学号这种数字列很常被读成 float比如20210001变成20210001.0所以读取时一定要指定dtype{学号: str}否则后期匹配账号全是坑。4. 常见问题与排查技巧实录4.1 并发选课出现“超选”和“回滚失败”我第一版上线的时候没有用乐观锁结果第三天就出事了。某个抢手题目容量是 3最后实际选了 5 个学生把老师和学生全搞炸了。第一次修复我加的是 Java 层的synchronized但只对单机有效而且锁粒度太大体验很差。后来才改成数据库原子更新 版本号。这里有个排查小技巧你可以通过慢查询日志看到多条UPDATE topic SET selected_count selected_count 1的语句在同一秒执行如果发现UPDATE影响了多行正常应该最多一行那就说明原子条件没起作用。4.2 中文乱码字符集问题从根源上解决中文乱码几乎每个新人都会遇到。我之前排查一个 bugJava 后端写入数据库的名字正常但 Python 读出来是乱码或者反过来Python 导入的数据在 Java 界面显示乱码。最后定位到三个环节必须保持一致数据库连接Java 的 JDBC URL 加characterEncodingutf8mb4Python 的 SQLAlchemy URL 加charsetutf8mb4建表字符集统一CHARSETutf8mb4HTTP 响应头Content-Type: application/json; charsetutf-8只要这三处统一乱码基本绝迹。还有一个侧面试探法如果乱码表现为“???”这种问号说明字符在传输过程中直接丢了如果表现为“温æ±åº¦”这种乱码形态说明字符编码被错误转换了往往是 Connector 的编码参数问题。4.3 MySQL 8.0 认证插件和 SQL 模式差异用 MySQL 8.0 时caching_sha2_password认证插件经常让旧版客户端连不上Python 的 PyMySQL 如果版本太旧会遇到Authentication plugin caching_sha2_password cannot be loaded。这个问题可以在连接串里指定mysqlpymysql://然后升级 PyMySQL 版本解决也可以创建一个使用mysql_native_password的账号。另外 8.0 的默认sql_mode比 5.7 严格常见的情况是ONLY_FULL_GROUP_BY导致聚合查询报错。如果遇到Expression #N of SELECT list is not in GROUP BY clause不要只想着加ANY_VALUE()先把 SQL 逻辑调整对再考虑放宽模式。4.4 选题时间窗口控制失败系统上线时我用 Java 判断当前时间是否在选题窗口内但测试发现总有学生能在非窗口时间提交成功。排查后发现是管理员把服务器时区设成了 UTC导致时间偏移了 8 小时。我后来做了两处修正统一所有环境数据库、Java JVM、服务器的时区为Asia/Shanghai并且不在代码里写死时区而是从配置中心读取关键的时间判断逻辑必须在后端校验不能只在前端做。定时任务脚本close_selection.py也加了一次兜底检查发现非窗口时间的提交直接回滚。4.5 数据库连接池耗尽选题开始时流量突增某天下午系统突然出现大量Connection pool exhausted报错。我检查了配置默认的 HikariCP 最大连接数是 10对一个小系统来说平时够用但在高并发窗口期完全扛不住。我把maximum-pool-size调到了 50同时给接口层加了简单的限流针对单用户一分钟最多选 5 次双管齐下后没有再出现这个问题。排查连接池情况有一条命令很实用SHOW STATUS WHERE Variable_name LIKE Threads_connected;如果这个数字长期接近最大连接数说明池子不够用或者有连接泄漏。连接泄漏往往是事务里执行业务代码太慢或者异常分支没走完释放连接导致的排查时重点看日志里是否有大量长时间未提交的事务。4.6 前后端点不了名跨域问题本地开发时前端跑在 5173后端跑在 8080每次请求都被浏览器拦腰截断报CORS policy错误。我一开始在 Controller 上加CrossOrigin注解解决后来觉得过于零散就统一加了一个 CORS 配置类允许指定前端域名。上线后我直接把跨域配置去掉用 Nginx 把前后端放在同一个域名下面彻底避免跨域。如果你用 Nginx在 server 块里加一段反向代理配置location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }4.7 问题速查表症状原因解决思路选课超名额并发未控制采用数据库原子更新 乐观锁 唯一约束页面显示中文问号字符集未统一统一库表、连接、响应头为 utf8mb4Python 连 MySQL 报认证错误8.0 默认认证插件升级 PyMySQL 或换 mysql_native_password定时脚本不执行时区或任务配置错误统一时区、查看调度器日志高并发时接口变慢连接池耗尽调大连接池大小并检查连接泄漏前端请求被拦截跨域开发期用 CORS 配置上线用 Nginx 同域代理5. 一些部署和运维层面的经验这个话题本来不在原计划里但每次部署这套系统都会遇到同样的问题打包、启动、守护进程。Spring Boot 的 jar 包打出来就能跑Python 脚本扔到 crontab 里就能定时执行但怎么把 MySQL 配置好、怎么管理环境变量、怎么做好日志这些细节往往决定系统的稳定性。我自己的部署方案是服务器用 UbuntuJava 服务通过 systemd 注册成守护进程Python 脚本用虚拟环境隔离依赖MySQL 用 Docker 配合数据卷管理。三个服务都配了独立的日志输出文件日志滚动策略统一按天切割。这样即使系统出问题查日志也很快就能定位。文件上传路径比如教师上传的题目附件不要写在项目源码里应该放到独立的目录或对象存储Java 服务的配置里只写相对路径启动时通过环境变量注入绝对路径。Python 脚本的数据库连接信息也不能硬编码我从环境变量读取这样不同环境开发、测试、生产切换不用改代码。还有一个容易忽略的点Java 服务启动脚本里要设置JAVA_OPTS-Xms256m -Xmx102m不然默认堆大小会根据物理机内存自动分配小内存机器上容易出现内存抖动。Python 定时任务如果处理的数据量大建议使用分批次读取和提交避免内存暴涨。6. 一点总结和实操心得做到这里这套选题系统的轮廓已经完全清晰了。它不是一个纯 Java 项目也不是纯 Python 项目而是一个“Java 负责在线业务 Python 负责离线数据 MySQL 统一存储”的组合型 Web 应用。如果你打算照着做一个我的建议是先把业务规则列清楚谁在什么时间能做什么事再设计表结构最后再编码。因为表格和规则一旦定错后面改起来成本极大。我第一版就是没想清楚状态流转数据库都建好才开始梳理角色结果推翻了小半个数据库设计。我个人在实际操作中最大的体会是不要把技术栈当成包袱每个环节用最适合的工具。以后如果再扩展积分制选题、跨专业选课或者移动端适配这套架构依然有足够的扩展余地Java 有丰富的生态Python 有灵活的数据处理能力MySQL 有可靠的存储和事务保障三者配合起来是一个很稳定的组合。最后再分享一个小技巧如果你在开发时经常被“账号密码忘了”“测试数据不对”这些琐事干扰可以让 Python 脚本顺带生成一批模拟数据学生、题目、选择记录用真实数据量的百分之一做压测这比手动造数高效得多。系统上线后这批脚本收敛好权限交给管理员使用就是一套很实用的运维工具。