简介:一份基于Java语言与MySQL数据库的图书馆信息管理系统完整项目,面向高校期末大作业、课程设计与毕业设计,适合需要直接运行的高分参考项目。包内共152个文件,涵盖Java后端源码、Vue前端页面、JavaScript脚本、CSS样式、SQL数据库脚本及详细文档,压缩包约11.79MB,部署简单,代码注释完整,新手也能看懂并修改。目前已有153人学习下载。系统覆盖图书信息管理、借阅归还、读者证管理、数据统计等核心功能,界面美观,操作便捷,经严格调试可稳定运行。配套文档说明系统架构和部署要点,能帮助读者从环境配置到功能实现掌握完整流程,用于答辩或展示都有较高参考价值。
1. 图书馆信息管理系统期末大作业:为什么你的代码能跑但分数不高
每到期末,图书馆信息管理系统几乎是 JAVA 和 MySQL 课程大作业的“标配选题”。学生把图书表、读者表、借阅表往 MySQL 里一建,再用 JDBC 连 Java 跑通增删改查,就以为能交差。但答辩老师翻到代码,问一句“两个学生同时借同一本仅剩的书怎么办”,很多人当场卡壳。标题里所谓“完整版项目+代码+文档说明”,本质上不是一份抄完就能用的成品,而是要求你把数据库设计、JDBC 连接、事务处理、界面交互和结课文档串成一条线。适合的读者是正在做期末大作业的本科生和高职生,也包括想快速上手 Java+MySQL 业务系统开发、却还没准备好啃 Spring Boot 全家桶的初学者。下面是我按这个题目最常见也最稳妥的实现方案,把每一步怎么做、参数怎么调、坑在哪写清楚,你可以照着落地方案,再改出自己的系统。
2. 从需求到表结构:先把“图书馆系统”拆成能落地的 ER 模型和建表 SQL
2.1 需求范围与功能清单:期末大作业该做到什么程度
很多同学一上来就想做“完整版”,于是加了预约、统计报表、微信小程序接口,结果一个月过去界面都没连上。期末答辩看的是逻辑闭环,不是功能数量。按常见课程考核要求,一套图书馆信息管理系统至少覆盖:管理员登录、图书管理(增删改查)、读者管理(增删改查)、借书、还书、逾期罚款、借阅历史查询。围绕这些需求,数据表最少只需要四张:图书表、读者表、借阅记录表、管理员表。如果你还能加一张图书分类表,属于锦上添花,但别因此把 JOIN 复杂度拉高,导致自己写不动。
| 功能模块 | 数据表 | 核心字段 | 考核关注点 |
|---|---|---|---|
| 管理员登录 | admin | id, username, password | 密码不能明文存储 |
| 图书管理 | book | isbn, title, author, total_cnt, stock | 库存非负、重复录入拦截 |
| 读者管理 | reader | card_no, name, max_borrow | 借阅额度限制 |
| 借书/还书 | borrow_record | book_id, reader_id, borrow_time, due_time, return_time, status | 事务边界、状态流转 |
| 逾期罚款 | borrow_record | due_time, return_time, fine_amount | 计算规则正确 |
这里我建议借阅记录表不要单独拆罚款表,把 fine_amount 字段放在 borrow_record 里,理由留到第 4 章说。功能清单列出来后,你要做成一张“已完成 vs 待完善”的检查表,写进文档开头,答辩老师扫一眼就知道你做了需求分析,而不是拿着代码现场讲。
2.2 核心数据表设计与字符集/引擎选型
使用 MySQL 8.0 时,建库字符集统一用 utf8mb4,不要用 utf8。MySQL 的 utf8 实际只支持最多 3 字节,存不了 emoji 和部分生僻字;utf8mb4 才是真正的完整 Unicode 实现。排序规则选 utf8mb4_unicode_ci,它在校对规则上比 _general_ci 更规范,虽然略微慢一点,但四张表的量级根本感觉不到。引擎选 InnoDB 而不是 MyISAM,核心在于 InnoDB 有行级锁、外键约束、崩溃恢复能力,MyISAM 全表锁且不支持外键。期末作业虽不至于高并发,但借书要保证“stock 扣减不超卖”,行锁是底线。
| 对比项 | InnoDB | MyISAM |
|---|---|---|
| 锁粒度 | 行锁(配合索引才生效) | 表锁 |
| 外键 | 支持 | 不支持 |
| 事务 | 支持 | 不支持 |
| 全文索引 | 8.0 支持 | 支持 |
| 适用场景 | 业务写入频繁 | 只读/数仓备份 |
再说字段类型。isbn 为什么要用 VARCHAR(20) 而不是 INT?因为国际标准书号里可能出现字母 X,数字前也可能有前导零,INT 会把这些全部破坏。书名和读者名用 VARCHAR 而不是 TEXT,因为 TEXT 不能设默认值,且做模糊查询时不如 VARCHAR 高效。罚款金额用 DECIMAL(10,2),千万别用 FLOAT/DOUBLE,浮点算钱会出精度玄学,比如 0.05 * 3 在二进制里并不严格等于 0.15。DECIMAL 在 MySQL 里是按字符串存储的十进制,金额计算才是精确的。
另外,建表时显式声明外键是加分项。外键能让数据库层拒绝插入不存在的 book_id 和 reader_id,避免 Java 层漏校验造成脏数据。但要注意:外键字段与引用字段的列类型必须完全一致,book.id 是 INT UNSIGNED,borrow_record.book_id 也必须是 INT UNSIGNED,否则 MySQL 会报“无法创建外键(errno: 150)”。这属于典型的不看详情就抓瞎的坑,我会在第 5 章里再次提到。
2.3 用一段完整的建表 SQL 跑通 MySQL 8.0
下面这段 SQL 是“少表但能覆盖全部业务”的典型写法,直接在 MySQL 命令行、Navicat 或 MySQL Workbench 里执行即可。如果你的环境是 MySQL 5.7,除了个别注释外的写法也通用。
CREATE DATABASE IF NOT EXISTS library_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE library_system; CREATE TABLE admin ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT '存SHA-256摘要,不存明文', PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE book ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) DEFAULT '', total_cnt INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '总册数', stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前可借数', PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reader ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, max_borrow INT UNSIGNED NOT NULL DEFAULT 3, PRIMARY KEY (id), UNIQUE KEY uk_card_no (card_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE borrow_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, book_id INT UNSIGNED NOT NULL, reader_id INT UNSIGNED NOT NULL, borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, due_time DATETIME NOT NULL, return_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0=借出中,1=已归还,2=逾期', fine_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), KEY idx_book (book_id), KEY idx_reader (reader_id), KEY idx_status (status), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book (id), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:admin 表存管理员凭证,password 字段用 COMMENT 直接标明“存摘要”,提醒自己在 Java 侧不要写明文。book 表里 total_cnt 是这本书的总复本量,stock 是当前可借库存;还书时只能加 stock,不能动 total_cnt,这样统计总藏书量时不会出错。borrow_record 表把 fine_amount 放进业务表,还书时一条 UPDATE 就能同时改状态和算罚款,省去事务里操作两张表的麻烦。
参数说明:所有与数量、ID 有关的字段都用 INT UNSIGNED,明确拒绝负数。DATETIME 设置为 CURRENT_TIMESTAMP 默认值,让数据库生成借书时间,避免 Java 端和 MySQL 时钟不一致。BIGINT 给 borrow_record 的主键是因为借阅历史会持续增长,虽然期末数据量小,但体现设计习惯。status 字段用 TINYINT 不用 ENUM,因为 ENUM 改枚举值需要 ALTER TABLE,而 TINYINT 可以随时加值。外键名如 fk_borrow_book 是自定义的,方便以后撤销约束。注意 MySQL 8.0 里“int(11)”这种显示宽度写法已无意义,别再照抄网上老教程。
建表成功后,用SHOW CREATE TABLE book;检查一遍 DEFAULT CHARSET 和外键,确认无误再往下走。如果你想在空库里直接跑整段脚本,建议把“借阅记录表”的创建放在最后,因为它外键依赖前两张表。这是初学者最容易翻车的点:先建子表,再建父表,外键直接报错。
3. 用 Java JDBC 还是 MyBatis?选型理由与一个能跑的最小数据访问层
3.1 期末作业里的三种数据访问方案对比
Java 连接 MySQL 的主流方案有三类:原生 JDBC、Spring JDBC Template、MyBatis。很多人纠结用什么,其实决定因素有三个:本学期课程讲没讲、答辩老师认不认、你的环境能不能离线跑。原生 JDBC 的优点是完全透明:从 Class.forName 到 ResultSet,每一行代码都能在答辩时讲清是干什么的;缺点是模板代码多。Spring JDBC Template 帮我们省掉大量 try-with-resources,但它要求项目引入了 spring-jdbc 和 spring-context,对没学过 Spring 的同学是额外负担。MyBatis 把 SQL 和 Java 解耦,动态 SQL 写起来舒服,延时加载和缓存是加分项,但配置过程相对复杂,万一 XML 没写对,报错信息也绕。
| 方案 | 上手成本 | 代码量 | 答辩友好度 | 适用课设 |
|---|---|---|---|---|
| 原生 JDBC | 低 | 多 | 高(每行都可讲) | 数据库/Java 基础课 |
| Spring JDBC Template | 中 | 中 | 中(需解释 IOC 容器) | 学过 Spring 的班 |
| MyBatis | 较高 | 少 | 高(动态 SQL+XML) | JavaEE 课设 |
我的建议是:如果学校要求必须手写 JDBC,那就用原生 JDBC;如果课程已经教过 MyBatis,或你本就有基础,直接用 MyBatis,给答辩留“会企业级框架”的印象。原生 JDBC 在运行时只需一个 mysql-connector-j 的 jar 包,放到项目 lib 目录下;MyBatis 还需要 mybatis 的 jar 包。如果网不好,直接到 Maven 仓库下载对应版本离线导入。这里不展开依赖坐标版本,因为要根据自己的 JDK 和 MySQL server 版本选择,强行给一个坐标反而可能误导。
3.2 手写 JDBC 工具类:连接、增删改查、防止 SQL注入
先解决环境匹配问题。Java 8 配 mysql-connector-java 8.0.x,驱动类名是 com.mysql.cj.jdbc.Driver。很多老教程写 com.mysql.jdbc.Driver,在 MySQL 8.0 驱动里虽然兼容但已废弃。URL 里必须指定 serverTimezone,否则高版本驱动在解析日期时抛“The server time zone value”异常。我们用一个 DBUtil 类集中管理连接参数。
package util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/library_system" + "?useUnicode=true&characterEncoding=utf8mb4" + "&useSSL=false&serverTimezone=Asia/Shanghai" + "&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; // 换成你自己的密码 static { try { // 加载 MySQL 驱动,只需一次 Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("找不到JDBC驱动,请检查mysql-connector-java.jar是否在classpath"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }逻辑说明:URL 里的 useUnicode=true 和 characterEncoding=utf8mb4 是中文不乱的第一个关口。useSSL=false 关闭 SSL,本地开发不必要,也避免证书验证报错。allowPublicKeyRetrieval=true 是针对 MySQL 8.0 caching_sha2_password 认证插件的,不加这个参数,非 SSL 连接时驱动拒绝自动获取公钥,直接报“Public Key Retrieval is not allowed”。serverTimezone=Asia/Shanghai 是为了让 Java 读取 DATETIME 时使用东八区,否则如果你的系统时区是 UTC,恰好差 8 小时。
这里要特别提醒:PASSWORD 不要硬编码在源码里。期末作业可以硬编码,但文档里写一句“生产环境应改为配置文件或环境变量”能加分。接下来是登录方法,重点演示 PreparedStatement 防注入:
public boolean login(String username, String password) { String sql = "SELECT COUNT(*) FROM admin WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { return rs.next() && rs.getInt(1) == 1; } } catch (SQLException e) { e.printStackTrace(); return false; } }代码里为什么用 PreparedStatement 而不是 Statement?因为把用户输入直接拼进 SQL 字符串会让登录名变成代码:若密码框输入' OR '1'='1,拼接后 SQL 变成SELECT COUNT(*) FROM admin WHERE username='xxx' AND password='' OR '1'='1',WHERE 恒真直接绕过登录。PreparedStatement 先让 MySQL 解析 SQL 骨架,再把问号参数当成纯值传递,从机制上杜绝拼接注入。try-with-resources 负责自动关闭 Connection、PreparedStatement、ResultSet,防止连接泄漏。
随后可以封装一个通用的增删改方法:
public int executeUpdate(String sql, Object... params) { try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // 把可变参数依次绑定到问号 for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("数据库操作失败", e); } }参数说明:Object... params 是可变长参数,使得不管加了几本书、删了哪个读者都能复用;setObject 的类型会根据值自动匹配 JDBC 类型,INT/VARCHAR/DECIMAL 都能处理。这个工具方法适合单条 SQL 的简单操作;第 4 章的借还书涉及多步 SQL,不能直接用它,必须用同一个 Connection 管理事务。
3.3 用 MyBatis 生成复杂查询:图书检索与借阅历史
如果你已经决定用 MyBatis,最值得展示的复杂查询就是图书检索:三个可选条件,一个动态 SQL。Mapper 接口定义如下:
@Mapper public interface BookMapper { List<Book> searchBooks(@Param("title") String title, @Param("author") String author, @Param("isbn") String isbn); }对应的 XML 映射文件:
<select id="searchBooks" resultType="com.example.entity.Book"> SELECT id, isbn, title, author, total_cnt, stock FROM book <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="author != null and author != ''"> AND author LIKE CONCAT('%', #{author}, '%') </if> <if test="isbn != null and isbn != ''"> AND isbn = #{isbn} </if> </where> ORDER BY id DESC </select>逻辑说明:<where>标签很重要,它会在至少一个条件成立时把整段 WHERE 包起来,并智能去掉第一个多余的 AND。如果什么都不填,就等价于SELECT ... FROM book,不会出错。LIKE 为什么用 CONCAT('%', #{title}, '%') 而不是'%' + #{title} + '%'?因为 CONCAT 写在 SQL 里更一目了然,也防止传入的 title 本身带 % 破坏语义。参数说明:title 和 author 做模糊查,isbn 用精确查,因为 ISBN 一旦模糊就失去唯一性,可能查出相邻书号。这种动态 SQL 在期末文档里放一小段,足够让答辩老师认为你不只会 JDBC 模板。
MyBatis 的全局配置也很轻量:在 mybatis-config.xml 里指定数据库连接池和 mapper XML 路径。很多同学漏掉 resultType 与实体类字段的映射,导致查询返回全 null。解决办法是开启mapUnderscoreToCamelCase=true,让库里的 total_cnt 自动映射到 Java 字段 totalCnt。这又是一个常见的隐蔽坑,写在这里给你提个醒。
4. 核心业务逻辑实现:借书、还书、逾期罚款的状态机与事务边界
4.1 借还书的状态设计与 SQL 更新顺序
借书与还书是这套系统里最有技术含量的一对操作。借书最少要两步:book 表 stock-1,borrow_record 插入一条 status=0 的新记录。还书最少要把 borrow_record 的 status 改成 1、return_time 设为当前时间、算出罚款,再把 book 表 stock+1。如果其中某一步失败,整个操作就不能算完成。很多人在这一步翻车,因为他们把所有操作写进两个 DAO 方法,一个失败,另一个已经提交。所以我们要用事务把它们绑成一个原子操作。
状态机的状态转移并不复杂:
- 借书:读者和书都存在 → 产生一条记录,status=0,借出中。
- 正常还书:status=1,return_time 不为空,fine_amount=0。
- 逾期还书:status=1,return_time 不为空,fine_amount 根据实际超期天数计算。
- 如果还书时发现当前时间晚于 due_time,还书当天就把罚款算出来,不要等定时任务。
一个隐藏条件是:同一读者能不能借出中又借同一本书?业务上一般允许,只要库存足够,系统会生成第二条借阅记录。如果你的系统不允许,就要用唯一索引(book_id, reader_id, status)去限制“同一个人同一本书未归还不能重复借”,但会增加状态更新的复杂度。期末我建议不限制,反而更好讲:只要库存够,重复借自动生成两条记录,历史查询也更完整。
借书的 SQL 骨架:
BEGIN; SELECT stock FROM book WHERE id = ? FOR UPDATE; -- 在 Java 代码里判断 stock > 0 UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0; INSERT INTO borrow_record (book_id, reader_id, due_time) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY)); COMMIT;第二条SELECT ... FOR UPDATE是这一章的核心。它在 InnoDB 下给该行加排他锁,第二个事务想再 SELECT 同一行 FOR UPDATE 就必须等第一个事务 COMMIT。若不加锁,两个事务同时读到 stock=1,然后各自扣减,库存就变成 -1。UPDATE 里自带AND stock > 0是防御式兜底:如果某种原因锁没挡住,这条 UPDATE 影响 0 行,Java 代码可以识别并回滚。BEGIN 和 COMMIT 是事务边界,实际在 Java 里要用 conn.setAutoCommit(false) 和 conn.commit() 实现,而不是直接执行 BEGIN。
4.2 事务控制在 Java 代码里的正确写法
继续用原生 JDBC,把上面的骨架变成完整方法:
public void borrowBook(int bookId, int readerId, int days) { String lockSql = "SELECT stock FROM book WHERE id = ? FOR UPDATE"; String deductSql = "UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0"; String insertSql = "INSERT INTO borrow_record (book_id, reader_id, due_time) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? DAY))"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务,多步SQL要么全成功要么全回滚 try (PreparedStatement psLock = conn.prepareStatement(lockSql)) { psLock.setInt(1, bookId); try (ResultSet rs = psLock.executeQuery()) { if (!rs.next() || rs.getInt("stock") <= 0) { throw new IllegalStateException("库存不足"); } } } try (PreparedStatement psDeduct = conn.prepareStatement(deductSql)) { psDeduct.setInt(1, bookId); if (psDeduct.executeUpdate() == 0) { throw new IllegalStateException("并发扣减失败,请重试"); } } try (PreparedStatement psInsert = conn.prepareStatement(insertSql)) { psInsert.setInt(1, bookId); psInsert.setInt(2, readerId); psInsert.setInt(3, days); psInsert.executeUpdate(); } conn.commit(); } catch (Exception e) { if (conn != null) { try { conn.rollback(); // 任一步出错,撤销前面的所有操作 } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException("借书失败: " + e.getMessage(), e); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这个写法的要点是三个 PreparedStatement 都使用同一个 Connection,且这个 Connection 是手动提交模式。如果检测库存不足,抛异常前的 INSERT 还没执行,rollback 会撤销一切。如果最后一步 INSERT 失败,rollback 也会把前面扣掉的库存恢复。finally 里 close Connection,但注意 close 不会自动提交,因为你手动 commit 过了;close 只是释放连接。
关于“锁的分类”,这里有必要多说一句:很多人一看到锁就以为是 Java 的 synchronized,其实在 MySQL 层面这是 InnoDB 的行锁(Record Lock)。FOR UPDATE 锁住的是主键 id 对应的索引记录,不是整张表,这也呼应了前面选 InnoDB 的理由。如果 SELECT 不是走主键索引,比如你用 isbn 去查,锁的范围可能扩大为多个记录或间隙锁,实际开发要避免这种退化。另外,MySQL 默认隔离级别是 REPEATABLE READ。对本系统来说,默认隔离级别加 FOR UPDATE 已经足够解决超借问题,不需要显式改隔离级别,答辩时能说清这一点就是加分。
事务还有一个容易忽略的边界:借书方法里不要嵌套调用另一个同样开了事务的方法。比如 borrowBook 里又去调用一个本类方法调整借阅额度,由于 Spring 才做事务代理,原生 JDBC 环境下会造成连接交错,锁和提交的时机全乱。建议用一个专门的 Service 层集中管理事务,DAO 只做单表操作。
4.3 罚款计算的两种常见做法与参数设计
期末阶段,罚款用“还书时实时计算”就够了。SQL 可以在还书事务中一步完成状态更新和罚款计算:
public void returnBook(int recordId, int bookId) { String updateBorrowSql = "UPDATE borrow_record SET return_time = NOW(), status = 1, " + "fine_amount = GREATEST(DATEDIFF(NOW(), due_time), 0) * 0.05 " + "WHERE id = ? AND status = 0"; String updateStockSql = "UPDATE book SET stock = stock + 1 WHERE id = ?"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(updateBorrowSql); PreparedStatement ps2 = conn.prepareStatement(updateStockSql)) { ps1.setInt(1, recordId); int rows = ps1.executeUpdate(); if (rows == 0) { throw new RuntimeException("记录不存在或已归还"); } ps2.setInt(1, bookId); ps2.executeUpdate(); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } } catch (SQLException e) { throw new RuntimeException("还书失败", e); } }参数说明:DATEDIFF(NOW(), due_time) 返回超期天数,GREATEST(...,0) 把负数归零,0.05 是每天罚款金额,可以抽成常量供配置。WHERE status = 0 保证“已归还的记录不能被重复还”,避免影响行数为 0 的异常被误判成记录不存在。还书事务里必须同步执行 book 表的 stock+1,否则罚款算完了,书还躺着没入库,这是最常见的半成品 bug。
另一种常见做法是写 MySQL 存储过程,把所有逻辑放进数据库端,Java 只调 CALL。存储过程适合演示“数据库逻辑封装”,对期末也是一项能力证明。但我不推荐把主要业务写进存储过程,因为答辩老师会追问“Java 里事务有什么用”,如果你说没有,分数就丢了。存储过程可以当作扩展点写进文档:CALL borrow_book(?, ?, ?) 包含 BEGIN...COMMIT,但 Java 端仍需处理连接超时和异常映射。
还有一个容易踩的点:如果还书时due_time离现在只有几小时,DATEDIFF 会取整数天,可能返回 0,导致该收的罚款没收。如果你的业务规则要求“超过一小时也算一天”,需要改用 TIMESTAMPDIFF(HOUR, due_time, NOW()) / 24 并向上取整。期末答辩一般不会抠到这个粒度,但你在文档里主动写清“按自然日计算,不足一天按一天算”的规则,老师会觉得你考虑周全。罚款金额请用ROUND(..., 2)包一层,避免 DECIMAL 与乘法运算后出现多余的尾数。
5. 期末大作业避坑指南:连接、编码、事务回滚的 5 条血泪经验
5.1 现象:中文乱码全表变问号
很多项目在本地 MySQL 查询正常,发给老师验收时对方的库显示“图书馆”变成“????”。原因通常是三个环节至少一个没设 utf8mb4:建库语句、JDBC URL、MySQL 服务端默认字符集。如果库已经建成 latin1,再在 Java 里指定 UTF-8 也救不回来。解决:把建库语句改成DEFAULT CHARACTER SET utf8mb4,JDBC URL 加上 characterEncoding=utf8mb4;同时检查 MySQL 配置文件(Windows 下是 my.ini,Linux 下是 /etc/my.cnf)里的 character-set-server=utf8mb4,改完重启 mysql 服务。顺手说一下,如果用的是 Navicat 建库,勾选字符集 utf8mb4 后,表对象的默认字符集也会继承,但老表字段级别的 charset 可能被带乱,所以宁可全用 CREATE TABLE 语句重建,别依赖图形工具的可视化修改。
5.2 现象:连接 MySQL 8.0 报 Public Key Retrieval is not allowed
这个问题在课堂演示时特别刺眼。现象:第一次启动 Java 程序,抛 SQLNonTransientConnectionException,错误提示 “Public Key Retrieval is not allowed”。原因:MySQL 8.0 的 caching_sha2_password 在非 SSL 连接中需要向服务器请求公钥加密密码,而 JDBC 驱动默认 allowPublicKeyRetrieval=false。解决:URL 上加allowPublicKeyRetrieval=true&useSSL=false。如果你用图形化工具,也可以在连接高级设置里找到同名的开关。某些公司生产环境要求必须 SSL,那就反过来,保留 useSSL=true 并配置证书,但期末完全不需要。另外,你也可以在 MySQL 里把账号插件改成 mysql_native_password,但这样反而掩盖了驱动配置问题,不值得。
5.3 现象:重复提交借书按钮,库存变负数
现象:页面快速点两次借书,图书库存从 1 变成 -1,还出现两条借阅记录。原因有两层:前端没做防重复点击;后端 SQL 没有锁保护。有的同学只在 Java 层用synchronized (this)包住 borrowBook 方法,这在单实例部署下可以挡住一个 JVM 内的多线程,但两个浏览器进程同时请求对应两个 JVM 时,synchronized 就管不住了。解决:数据库层用SELECT stock FROM book WHERE id=? FOR UPDATE,配合UPDATE book SET stock=stock-1 WHERE id=? AND stock>0。前者加锁,后者兜底。MySQL 的锁分类里还有表锁、间隙锁、意向锁,期末不需要全讲,但你要能解释“为什么不用 synchronized”,这才显得你是真懂而不是背概念。前端防重复点击是最后一道防线,不要依赖它来保证数据正确性。
5.4 现象:密码明文存储被老师当场指出
现象:admin 表里 password 字段存的是 123456,老师查一条就能看到。原因:图省事,插入时直接写死密码。解决:在 Java 侧用 SHA-256 + 固定盐对密码做摘要,或者至少用 SHA-256 裸摘要。以下是注册时生成摘要的参考写法:
private static String sha256(String password) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(password.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); // 返回64位十六进制小写串 }注意 password 字段的长度至少 64 字符,SHA-256 摘要十六进制表示正好 64 位。如果字段是 VARCHAR(32),摘要会被截断,登录时永远无法匹配。登录时用同样的 sha256 函数对输入值做哈希,再和库里比对,而不是把明文密码传上去比对。用 BCrypt 当然更正确,但期末能写出 SHA-256 已经证明你有安全意识了。如果老师追问“什么是加盐”,你就说“盐是随机生成的,存在 admin 表的 salt 字段中,计算时拼接”,这个点写进文档即可。
5.5 现象:导出的 SQL 脚本用新版 MySQL 跑不起来
现象:把同学的 SQL 文件 import 到自己电脑的 MySQL 8.0,报错 “Unknown collation: 'utf8mb4_0900_ai_ci'” 或者 “Table doesn't exist”。原因:对方的 SQL 文件通常是 Navicat 导出,里面夹带了与版本绑定的排序规则,或者外键的引用顺序错了——先创建子表,后创建父表。解决:不要直接使用图形化工具的导出 SQL,改用手动维护的 schema 版本 SQL。排序规则统一成 utf8mb4_unicode_ci,兼容 MySQL 5.7 和 8.0。建表顺序按 admin → book → reader → borrow_record,子表外键依赖的父表必须先存在。如果已经混入外来 SQL,执行前先查看表头是否有 SET NAMES、DROP TABLE IF EXISTS,并确认没有禁用外键检查。另有一个 MySQL 8.0 特有坑:严格模式下不能插入 '0000-00-00 00:00:00' 这种零日期。如果你在老版本中用过 DATETIME DEFAULT '0000-00-00',导入 8.0 会失败,建议把字段设为 NULL 或去掉默认值。另外,在 Windows 上安装 MySQL 8.0 后初次连接,记得在命令行用net start mysql启动服务,否则就算 JDBC URL 写对也只是提示“服务没有运行”。
6. 答辩前必做:数据验证脚本与性能优化的三个小技巧
6.1 用 SQL 脚本给借还书逻辑做体检
建好表和 Java 代码后,先用一组 SQL 模拟业务闭环。在 MySQL 里插入测试数据,执行借书、查库存、还书后查罚款:
INSERT INTO book(isbn, title, author, total_cnt, stock) VALUES('9787111558422', 'Java编程思想', 'Bruce Eckel', 5, 5); SET @bookId = LAST_INSERT_ID(); -- 模拟借书:减库存 + 插入借阅记录 UPDATE book SET stock = stock - 1 WHERE id = @bookId AND stock > 0; INSERT INTO borrow_record(book_id, reader_id, due_time) VALUES(@bookId, 1, DATE_ADD(NOW(), INTERVAL 0 DAY)); -- 验证库存=4,记录 status=0 SELECT stock FROM book WHERE id = @bookId; SELECT id, status FROM borrow_record WHERE book_id = @bookId; -- 模拟还书:加库存 + 更新状态 UPDATE book SET stock = stock + 1 WHERE id = @bookId; UPDATE borrow_record SET status = 1, return_time = NOW() WHERE book_id = @bookId AND status = 0;如果最后查出来 stock 又回到 5、borrow_record.status=1,说明基本流程没破。重点是把这条脚本留在文档附录里,答辩老师问“怎么证明你的系统对”正好用。
6.2 索引、分页和连接池的最省事配置
图书检索常用的 title、author 字段不要盲目加普通索引,因为 LIKE '%关键字%' 前缀有百分号,普通索引帮不上忙,反而拖慢插入。期末方案里对 title 建索引只是为了应付“有索引”这个考核点,真正说明边界时,你要能解释“前导 % 会导致索引失效”。分页查询记得用WHERE id > lastId ORDER BY id LIMIT 20,而不是 LIMIT offset, size,后者越翻越慢。如果有余力,把连接改成 HikariCP,最小连接数 5、最大 20、连接超时 3 秒,在文档里写清楚参数含义即可,不必真的引依赖。
6.3 文档说明的最优结构
文档不要按代码逐行讲,按四层写:需求分析(谁用、有哪些功能)、数据库设计(ER 图、四张表、索引和外键理由)、核心流程(借还书时序图、事务边界、罚款规则)、测试记录(插入测试数据后的查询结果和遇到的 Bug)。把 SELECT FOR UPDATE 单独作为一小节,解释为什么能防止超借,这是整篇项目最有价值的技术点。写完文档后,我从头读一遍,经常发现自己漏掉“还书后忘记把 status 改成 1”这种低级错误。多做几次验证,你的系统才真的能拿去答辩。希望帮到你。
本文还有配套的精品资源,点击获取