ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SSM+MySQL课堂考勤系统:数据结构在判重与统计中的实战应用

SSM+MySQL课堂考勤系统:数据结构在判重与统计中的实战应用 简介一份基于SSM框架与MySQL的数据结构课堂考勤管理系统完整源码包适合Java Web初学者、课程设计及毕业设计人员参考用于掌握SSM整合开发、考勤数据管理与前端交互流程。资源共740个文件核心包含83个Java源码与对应class文件、81个JSP页面、77个依赖Jar包以及JS、CSS、XML配置和图片素材压缩包约29.55MBconfig目录保存数据库连接等配置webroot下可见前端样式与脚本后端按controller、service、dao、entity分层结构清晰。目前已有28人学习下载可作课堂考勤、选课签到等同类管理系统的搭建蓝本。压缩包内还附带了sql初始化和PaperYY模块目录便于直接导入数据库、扩展试卷管理适合边看边练、二次开发或整合进实际项目。1. 课堂考勤管理系统为什么还带“数据结构”三个字拿到这个标题第一反应是“又一套 CRUD 毕业设计”。但把ssm、mysql、数据结构、课堂考勤四个词拼在一起会发现它并不是简单地把学生表、课程表、签到表堆起来。考勤系统最核心的难题是“同一节课内一个学生只能算一次有效签到”以及“一个班级几十号人如何在点名结束后快速算出出勤率、迟到次数、缺勤排名”。这两个问题表面上是 SQL 能解决的真正落到代码里却要处理集合判重、哈希索引、排序稳定性这些恰好就是数据结构课程里讲的那几章。这套系统用 SSM 而不是 Spring Boot 来写在 2024 年之后看像是“老技术”但在教学场景和传统企业里仍然大量存在。SSM 的分层方式Controller 接收请求、Service 写业务、Mapper 管 SQL让“考勤判定逻辑”和“数据库读写”被明确切开适合把数据结构算法嵌在 Service 层跑而不是写成一坨 SQL。对刚接触企业级开发的人或者需要在老项目里做维护的人这套东西的参考价值比一个 Spring Boot 秒开 Demo 大得多。这篇文章会顺着“数据模型怎么设计 - 判重和统计怎么做 - 怎么把系统跑起来 - 遇到报错怎么查”的顺序讲清楚一套基于 SSM MySQL 的课堂考勤系统里值得抠的细节。涉及代码都能直接抄走改改就用。2. 考勤系统的数据模型设计SSM 分层与数据结构落点2.1 考勤场景下的核心实体学生、课程、排课、签到记录课堂考勤管理系统的数据模型比普通的学生管理系统多一层“排课”概念。学生和课程是多对多关系直接做两张表加中间表是最省事的但考勤要按“某一次课”来记所以中间表不能只存 student_id 和 course_id必须带上schedule_id哪一轮课和attendance_date哪一天。我一般会设计四张核心表student学生、course课程、schedule排课表记录哪个班级在什么时间上哪门课、attendance考勤记录表。这四张表的关系是student和schedule之间通过attendance建立关联schedule再指向course。这样设计的好处是调课、补课只需要改schedule表不需要动历史考勤数据。CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 学号, name VARCHAR(32) NOT NULL COMMENT 姓名, class_name VARCHAR(64) NOT NULL COMMENT 班级, id_card CHAR(18) DEFAULT NULL COMMENT 身份证号用于查重, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生表; CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, class_name VARCHAR(64) NOT NULL, start_time DATETIME NOT NULL COMMENT 实际上课时间, end_time DATETIME NOT NULL, UNIQUE KEY uk_course_time (course_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT排课表; CREATE TABLE attendance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 2迟到 3缺勤 4请假, checkin_time DATETIME DEFAULT NULL COMMENT 实际签到时间, UNIQUE KEY uk_schedule_student (schedule_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考勤记录表;attendance表的UNIQUE KEY uk_schedule_student是这个设计的核心。数据库层面的唯一索引保证了一个学生在一节课内只能有一条考勤记录这一步把“并发重复签到”的第一道防线做掉了。有人说“我可以在 Service 里先查一遍再插入”但在并发场景下两个请求同时查不到记录就会插入两条唯一索引才是最终兜底。2.2 数据结构在考勤业务里的四个落点把数据结构硬塞进考勤系统没意义但考勤业务天然会出现四个数据结构考点。第一是判重。一次课 50 人班长用 Excel 导入名单可能重复粘贴了某一行。用HashSet过滤是最直接的HashSet底层是 HashMap查找复杂度 O(1)比List.contains()的 O(n) 在数据量上来后要快得多。第二是排序。期末统计“出勤率最低的十个学生”数据从库里查出来之后如果要做二次排序先按缺勤次数降序再按学号升序Java 里的Collections.sort配合 Comparator 链式写法比在 SQL 里嵌套子查询更直观。这里要理解Comparator.thenComparing的用法它对应的是数据结构里“多关键字排序”的概念。第三是前缀匹配。按姓名或学号前缀搜学生Trie树在理论上是最优解但实际项目里不会为考勤系统手写一棵 TrieMySQL 的LIKE abc%走索引就已经够用。知道 Trie 的原理才能理解为什么LIKE %abc会让索引失效——它相当于在 Trie 上做后缀搜索只能全树遍历。第四是哈希分桶。统计“每个时间段签到了多少人”把一天 24 小时按小时分桶Java 侧用HashMapInteger, Integer累计对应的是attendance表按HOUR(checkin_time)分组查询。两者殊途同归区别是数据库分组走的是聚合索引扫描Java 侧分桶走的是内存计算。2.3 SSM 各层职责哪一层该写算法哪一层不该写SSM 框架下Servlet 容器把请求交给 DispatcherServlet再由 HandlerMapping 定位到 Controller 方法。Controller 只做参数接收和视图转发Service 层写业务规则Mapper 层对数据库。考勤判重逻辑放在 Service 层是最合理的因为要组合多个查询结果还要调HashSet、HashMap这类集合结构。一个常见的错误是把业务规则写在 Controller 里。比如在AttendanceController里直接 new 一个HashSet去过滤请求参数里的学生 ID 列表。这样写在小 demo 里没问题但一旦要加“迟到 15 分钟算缺勤”的规则Controller 会变得越来越臃肿没法做单元测试。我一般会在 Service 层定义这样的接口public interface AttendanceService { /** * 签到核心方法 * param scheduleId 排课ID * param studentIds 学生ID集合前端可以传数组 * return 签到成功的学生ID列表 */ ListLong checkIn(Long scheduleId, ListLong studentIds); }Service 实现里要做的事包括校验排课是否存在、过滤重复学生 ID、查询已有签到记录做差集、批量插入新记录。这些步骤里前三个是纯内存操作最后一个才访问数据库。按这个顺序做能把数据库的写压力降到最低。3. 从零跑通考勤系统建库、Mapper、签到服务的可运行实现3.1 MySQL 建库与 SSM 工程结构从 zip 解压开始如果是拿别人打包好的zip工程第一步先把压缩包解压到一个没有中文和空格的路径下。很多 SSM 老项目用的是 Eclipse 或 IDEA 的.classpath文件路径带中文会导致资源文件加载失败。解压后看到的典型结构是这样的src/main/java放 Java 代码src/main/resources放 Spring 和 MyBatis 的 XML 配置src/main/webapp/WEB-INF放 JSP 和web.xml。如果没有 Maven 的pom.xml只有.jar包那这是一个传统的 Web 工程需要把 lib 目录下的 jar 包手动加入 Build Path。建库时要注意字符集。MySQL 5.7 时代很多教程用utf8但utf8在 MySQL 里是utf8mb3存不了 emoji 和生僻字。学生姓名里有“”这种字用utf8会直接报错。建库语句建议写成CREATE DATABASE attendance_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;排序规则utf8mb4_general_ci适合中文场景utf8mb4_unicode_ci在排序准确性上更好但这套系统里没有复杂的多语言排序需求general_ci性能略好。MySQL 8.0 默认就是utf8mb4_0900_ai_ci如果拿 8.0 的库连接 5.7 的旧工程连接字符串里的编码参数要写清楚。3.2 MyBatis Mapper 层的关键 SQL插入、更新、统计MyBatis 的 Mapper 接口和 XML 文件是分离的XML 里的namespace必须和接口全限定名一致这是Invalid bound statement报错最常见的来源。考勤系统里有两个 SQL 必须会写。第一个是批量插入签到记录。前端一次勾选 50 个学生后端拿到 ID 列表如果循环单条插入50 个学生就要发 50 次数据库请求非常慢。MyBatis 的foreach标签可以把列表拼成一条多值插入语句insert idbatchInsertAttendance parameterTypelist INSERT INTO attendance (schedule_id, student_id, status, checkin_time) VALUES foreach collectionlist itemitem separator, (#{item.scheduleId}, #{item.studentId}, #{item.status}, #{item.checkinTime}) /foreach /insert这里要注意 MySQL 对单条 SQL 的长度默认有限制max_allowed_packet默认是 4MB一个班 50 人的插入语句远达不到这个值但如果是全校大规模点名上千人同时签就要分批插入每批 200 条比较稳妥。另一个坑是foreach拼接的 SQL 如果参数有空值#{}会变成null直接写进表里业务上要在 Service 层先把空值过滤掉。第二个是更新出勤状态。学生先签到status1老师发现实际是迟到的要改成 status2。更新语句按主键更新是最安全的但业务上有时会按schedule_id student_id更新这种情况下要确认唯一索引存在否则一次 UPDATE 会影响多条记录。update idupdateStatus UPDATE attendance SET status #{status} WHERE schedule_id #{scheduleId} AND student_id #{studentId} /update3.3 Service 层的签到判重实现HashSet 与数据库差集现在把第 2 章提到的判重逻辑写成完整代码。签到接口收到前端传来的学生 ID 列表这个列表可能来自老师手动勾选也可能来自班长导入的 Excel。两种情况都可能携带重复数据。Override Transactional(rollbackFor Exception.class) public ListLong checkIn(Long scheduleId, ListLong studentIds) { // 第一步用 HashSet 去掉入参中的重复学生ID ListLong distinctStudentIds new ArrayList(new HashSet(studentIds)); // 第二步查询已签到的学生ID做差集 ListLong existingIds attendanceMapper.selectCheckedStudentIds(scheduleId); SetLong existingSet new HashSet(existingIds); ListAttendanceRecord toInsert new ArrayList(); Date now new Date(); for (Long studentId : distinctStudentIds) { if (existingSet.contains(studentId)) { // 已存在则跳过这里可以用日志记录重复签到尝试 continue; } AttendanceRecord record new AttendanceRecord(); record.setScheduleId(scheduleId); record.setStudentId(studentId); record.setStatus((byte) 1); record.setCheckinTime(now); toInsert.add(record); } if (toInsert.isEmpty()) { return Collections.emptyList(); } // 第三步批量插入返回成功插入的学生ID列表 attendanceMapper.batchInsertAttendance(toInsert); return toInsert.stream().map(AttendanceRecord::getStudentId).collect(Collectors.toList()); }这段代码的逻辑顺序是精心安排的HashSet去重是 O(n)existingSet.contains是 O(1)最终只有真正没签过到的学生才会走到数据库写入。Transactional保证了批量插入要么全成要么全不成不会出现插了一半、另一半报错导致数据不完整的情况。这里有一个细节值得提new HashSet(studentIds)在去重的同时会打乱顺序吗不会HashSet的迭代顺序和插入顺序不一致但这里只是去重不去期待它保持原顺序。如果业务上要求“前端选了谁就按谁的顺序插入”要用LinkedHashSet它额外维护了一条链表记录插入顺序。3.4 出勤率统计与排序SQL 聚合加 Java 二次排序出勤率统计是考勤系统另一个高频功能。每个学生的出勤率 实际出勤次数 / 应出勤次数应出勤次数来自schedule表里该学生所在班级的排课总数。SQL 里可以直接算出来SELECT s.id AS student_id, s.name AS student_name, COUNT(CASE WHEN a.status IN (1, 2) THEN 1 END) AS actual_attendance, COUNT(sc.id) AS expected_attendance, ROUND(COUNT(CASE WHEN a.status IN (1, 2) THEN 1 END) / COUNT(sc.id) * 100, 2) AS attendance_rate FROM student s JOIN schedule sc ON sc.class_name s.class_name LEFT JOIN attendance a ON a.schedule_id sc.id AND a.student_id s.id GROUP BY s.id, s.name ORDER BY attendance_rate ASC LIMIT 10;这个查询的关键点有两个。第一COUNT(CASE WHEN ...)是条件聚合的标准写法比SUM(IF(...))可读性好。第二attendance_rate在 MySQL 的ORDER BY里可以直接用别名因为 MySQL 对SELECT别名做了扩展但注意WHERE里不能用别名那是执行顺序决定的。如果统计完还要在 Java 侧按“出勤率相同则按学号升序”做二次排序可以这样ListAttendanceStatVO stats attendanceMapper.selectAttendanceStats(); stats.sort( Comparator.comparing(AttendanceStatVO::getAttendanceRate) .thenComparing(AttendanceStatVO::getStudentId) );thenComparing是稳定排序的优雅写法。底层List.sort用的是TimSort它是一种稳定的归并排序变种所以相同出勤率的学生会保持数据库返回的相对顺序再按学号正序排结果符合直觉。4. 部署与配置MySQL 版本差异、连接池参数与索引调优4.1 环境准备JDK 版本、Tomcat 与 MySQL 安装配置SSM 老项目的环境搭配有个“安全区”JDK 8 Tomcat 8.5 MySQL 5.7这是网上大量教程的默认组合。也有不少人直接上 MySQL 8.0这时要注意三个坑。第一个坑是驱动类名变了。MySQL 5.7 时代常用com.mysql.jdbc.Driver8.0 必须用com.mysql.cj.jdbc.Driver并且要配套使用mysql-connector-java8.x 版本的 jar 包。驱动包和数据库版本不匹配启动时通常会报Communications link failure。第二个坑是时区参数。MySQL 8.0 的连接字符串必须显式指定时区例如jdbc.urljdbc:mysql://localhost:3306/attendance_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai不写的话连接会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是编码显示乱码导致时区识别失败。allowPublicKeyRetrievaltrue是 MySQL 8.0 使用caching_sha2_password认证插件时必需的否则用账号密码连接会报Public Key Retrieval is not allowed。第三个坑是 MySQL 8.0 默认使用caching_sha2_password认证而 5.7 的客户端工具比如老版 Navicat可能不兼容。解决方案是在 MySQL 里把账号的认证插件改回mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY your_password;这里要说明一下这个操作不涉及数据库引擎本身的性能差异纯粹是兼容性适配生产环境要不要改取决于你的驱动版本。4.2 数据库连接池参数Druid 还是 C3P0参数怎么调SSM 工程里最常见的连接池是 Druid 和 C3P0。Druid 有监控页面适合教学演示C3P0 是老牌连接池稳定但参数繁琐。我一般用 Druid配置放在 Spring 的 XML 里。bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value20/ property namemaxWait value60000/ property namevalidationQuery valueSELECT 1/ property nametestWhileIdle valuetrue/ property nametimeBetweenEvictionRunsMillis value60000/ /bean这几个参数值得解释一下initialSize是启动时预创建的连接数考勤系统同时在线人数不多5 个够用maxActive是最大连接数20 是 MySQL 默认max_connections151的零头留出数据库管理连接的空间maxWait是获取连接的超时时间单位毫秒60 秒内拿不到连接就抛异常避免线程无限阻塞。validationQuery配SELECT 1是为了在空闲连接被回收前验证它是否还有效。数据库重启后连接池里的旧连接会变成死连接没有验证机制的话第一次请求会报Connection is not available第二次才会触发重新连接。这和 MySQL 的wait_timeout参数有关默认 8 小时但生产环境经常会被调成更短所以连接池的保活机制必须配好。4.3 MySQL 索引设计哪张表不要加索引哪个索引不要建考勤系统的索引设计有一个和其他业务系统不一样的地方attendance表是高频写入表索引越多写入越慢。因为每插入一行所有二级索引都要更新。核心索引只需要三个uk_schedule_student唯一索引建表时已建、idx_schedule_id按排课查某次课的所有签到、idx_student_id按学生查他自己的出勤历史。有人会额外建idx_status因为状态字段只有 4 个值区分度极低MySQL 优化器宁可全表扫也不会走这个索引属于典型的无效索引。student表上如果经常按姓名搜可以加idx_name。但注意LIKE %张%这种模糊查询是用不上idx_name的因为 B 树的索引结构是从左到右匹配前缀的%在前面意味着无法利用有序性。想加速后缀搜索可以反过来存一个name_reverse字段建索引但考勤系统没必要为这种查询做冗余设计。要对索引的实际使用情况做验证用EXPLAIN看执行计划EXPLAIN SELECT * FROM attendance WHERE schedule_id 100 AND student_id 200;如果key列显示uk_schedule_student说明索引生效。如果显示NULL说明全表扫描要回查 SQL 是否在索引列上做了函数运算或隐式类型转换。常见的隐式转换是把VARCHAR列和数字比较比如student_id是VARCHAR类型却传了整型参数MySQL 会把列值转成数字再比较导致索引失效。5. 进阶技巧批量签到的并行优化、zip 修复与常见报错速查5.1 大规模点名场景批量处理的参数调优前面提到单次批量插入 200 条比较稳妥但全校大规模点名比如 2000 人同时签时Service 层的一次batchInsert仍然可能超时。这时可以配置 MyBatis 的批量执行器让框架在一次会话里复用预处理语句SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH); AttendanceMapper mapper sqlSession.getMapper(AttendanceMapper.class); for (AttendanceRecord record : records) { mapper.insertSingle(record); } sqlSession.commit(); sqlSession.close();ExecutorType.BATCH的原理是复用PreparedStatement把多次单条插入攒成一次网络往返提交性能比foreach拼接大 SQL 更可控因为不需要考虑max_allowed_packet的限制。但要注意批量模式下select操作拿不到刚插入未提交的数据所以不要在同一个SqlSession里边插边查。JDBC 连接字符串里的rewriteBatchedStatementstrue也要提一下它是 MySQL Connector/J 的一个参数开了之后驱动会把多条 INSERT 语句重写成多值插入配合ExecutorType.BATCH效果最明显。不加这个参数驱动还是逐条发送批处理优势体现不出来。5.2 压缩包相关的两个实际问题解压失败与编码乱码回到标题里的.zip后缀本身。从网上下载的 SSM 项目压缩包最常见的两个问题不是代码问题而是解压层面的问题我处理过很多次值得单独说。第一个是解压时报 CRC 错误。出现error read zip archive或unexpected end of archive说明压缩包下载不完整或已损坏。不要反复重新解压先用zip -T命令测试压缩包完整性定位到具体文件后单独修复。如果压缩包是从 Windows 传到 Linux 服务器的优先怀疑 ASCII 模式上传导致二进制文件被转义。第二个是解压后 Java 文件中文乱码。老项目的源码很多是 GBK 编码打包的Windows 上解压后文件名和注释乱码可以这样处理在 IDEA 里把项目编码改为 GBK 读取再另存为 UTF-8 整体转换。快速判断文件编码的方式是看/src/main/resources里的 Spring XML 文件头声明如果有encodingGBK那整个工程大概率是 GBK 的。转换时注意web.xml、pom.xml不需要转它们通常是 ASCII 的强行转 UTF-8 反而会引入 BOM 头导致解析失败。一个实用的筛查命令是在 Linux 或 Git Bash 里批量检测编码file --mime-encoding src/main/java/com/example/*.java5.3 SSM 启动与运行期的 6 个高频报错速查收尾给一个高频报错的速查表这些都是 SSM 项目从解压到跑通的路上必然会遇到的。报错信息原因解决方向Invalid bound statement (not found)Mapper 接口和 XML 的 namespace 不匹配检查namespace是否等于接口全限定名ClassNotFoundException: com.mysql.jdbc.Driver驱动版本和连接串不匹配MySQL 8.0 把驱动类改为com.mysql.cj.jdbc.DriverToo many connections连接池maxActive过大或连接泄漏减小maxActive检查 Service 层是否关闭了 SqlSessionDeadlock found when trying to get lock多个事务按不同顺序更新同一批数据统一更新顺序比如先按student_id排序再更新Table xxx doesnt exist数据库名大小写敏感Linux 下 MySQL 表名区分大小写确认建表语句和查询语句大小写一致Cannot load driver class: com.mysql.cj.jdbc.DriverMaven 未引入驱动依赖检查pom.xml中mysql-connector-java的版本坐标最后给一个真正能提升调试效率的小技巧在 Spring 的 XML 配置里把 MyBatis 的日志级别调成DEBUG打印完整 SQL 和参数值。logger namecom.attendance.dao levelDEBUG/这样每次请求都能看到 MyBatis 实际执行的 SQL 语句和传入参数排查Invalid bound statement和 SQL 语法错误会快很多。第一次跑通考勤系统时把签到、统计、修改状态这三条链路的 SQL 日志各截一段存下来以后遇到数据对不上的问题对比日志就能定位是参数错了、SQL 错了还是数据本身错了。本文还有配套的精品资源点击获取
返回列表