
简介这是一份以网络文档形式分享的数据库图书管理系统实训报告PDF面向正在学习数据库原理、准备课程设计或实训答辩的计算机专业学生。报告完整呈现了基于SQL Server与VB的图书管理系统开发全流程涵盖需求分析、可行性分析、概念模型设计、逻辑结构设计、详细设计与系统测试等环节涉及E-R图绘制、关系模式定义、规范化和视图设计、建库建表SQL语句编写。压缩包内共1个PDF文件大小约385KB便于直接阅读和打印参考。目前已有89人学习浏览适合用于数据库课程设计、实训报告撰写或毕业设计前期参考。读者可快速掌握数据库系统开发的标准步骤可行性分析方法、E-R图与关系模型设计、SQL语句建库建表以及结合Visual Studio和VB实现图书查询、借阅归还等核心功能同时报告给出完整目录框架与界面设计思路便于实训答辩时讲清逻辑与实现过程。1. 数据库图书管理系统实训报告一份PDF背后要补全的完整技术链条《数据库图书管理系统实训报告知识.pdf》这个标题在数据库学习路径里出现频率很高。点进来的人通常不是为了一份文档本身而是想找一个能写进实训报告、能跑通、能应对答辩的图书管理系统实现方案。这个题目看起来并不复杂无非图书、读者、借阅几张表再加增删改查但从我帮人排查这类项目的经验看真正拉开差距的往往是两件事一是借书还书的事务边界二是数据库连接的释放与并发控制。它适合正在做课程设计的学生、想用完整数据模型练手的初学者以及需要快速定位学生代码问题的一线老师。下面从表结构设计开始逐步拆到 JDBC 连接、借还书业务、报错排查和验收思路。2. 建库建模图书管理系统的E-R图、范式与MySQL建表脚本2.1 E-R图怎么落实体、关系与借阅中间表实训报告开篇通常要求放 E-R 图。很多人直接从网上抄一张满是圆角矩形的图但评审老师更愿意看到你解释“为什么借阅要单独建表”。图书和读者是典型的多对多关系一本书可以被多个读者借一个读者也可以借多本书。关系型数据库里多对多必须拆成中间表这张中间表就是借阅记录表。设计借阅记录时有个容易犯的错把主键设计成 (book_id, reader_id) 复合主键。这样做的后果是同一个读者第二次借同一本书时主键冲突系统直接报错。借阅记录里的每一行代表一次完整的“借书—还书”动作同一个读者完全可以分几次借同一本书所以主键应该用自增 id。实体部分至少要有三张主表管理员表、图书表、读者表。管理员与图书之间的“管理”关系不需要额外的关系表因为这是一对多的单向归属但读者借阅图书是多对多必须落成 borrow_record 实体。画 E-R 图时把实体属性和关系连线都标清楚哪怕字写得丑一点也比直接截图网上模板有说服力。2.2 用第三范式检查表结构写完 E-R 图后可以按范式逐条给表做体检这一步在实训报告里是标准化得分点。第一范式要求每列不可再分。有人把作者存成“张三/李四”这就不满足 1NF因为后续按某个作者筛选图书时只能靠 LIKE 模糊匹配索引失效且容易误匹配。正确做法是把作者拆成 author 和 co_author或者干脆建作者关联表。第二范式要求非主键列完整依赖主键。book 表的主键是 book_id所有字段都直接依赖它一般不会出问题最容易踩雷的是把分类名称冗余进来这是第三范式要解决的问题。第三范式要求消除传递依赖。假设把 category_name 直接冗余在 book 表1000 本“计算机”分类的书要改名成“计算机科学”时得 UPDATE 1000 行但如果只存 category_id改一行分类表就够了。借阅记录同理只存 book_id 和 reader_id不存书名、不存读者姓名展示时用 JOIN 还原。2.3 建库建表核心SQL与字段选型下面这套建库建表脚本可以直接在 MySQL 8.0 上执行覆盖管理员、分类、图书、读者、借阅记录五张表。CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE library_db; CREATE TABLE admin ( admin_id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL ) ENGINEInnoDB; CREATE TABLE category ( category_id INT AUTO_INCREMENT PRIMARY KEY, category_name VARCHAR(50) NOT NULL UNIQUE ) ENGINEInnoDB; CREATE TABLE book ( book_id INT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, publisher VARCHAR(100), category_id INT, total_copies INT NOT NULL DEFAULT 1, available_copies INT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES category(category_id) ) ENGINEInnoDB; CREATE TABLE reader ( reader_id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20), register_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE borrow_record ( id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未还 1已还 2逾期, KEY idx_borrow_book(book_id), KEY idx_borrow_reader(reader_id), KEY idx_borrow_status(status), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(book_id), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader(reader_id) ) ENGINEInnoDB;逐条说明关键选型。分类单独建表就是为了避免在 book 表里直接存 category_name这与前面的第三范式检查一一对应isbn 必须用 VARCHAR(20) 而不是数值型因为老版 10 位 ISBN 可能以 X 结尾数字类型存不下而且 ISBN 不做数学运算没有用 BIGINT 的理由。total_copies 和 available_copies 分开一个代表馆藏总量、一个代表当前可借数量还书时只增加 available_copiestotal_copies 不变。borrow_record.status 用 TINYINT 不用 VARCHAR取值只有 0、1、2 三种用注释写清楚含义比在代码里拼字符串更轻量。所有外键都不带 ON DELETE CASCADE故意让它挡住误删除后面第 5 章会专门讲这个约束带来的坑。所有表强制指定 ENGINEInnoDB因为 InnoDB 才有事务和行级锁支持借书功能依赖它写成 MyISAM后面第 4 章的借还书代码必然翻车。2.4 如果需求变了ALTER TABLE 的正确姿势实训过程中加字段很常见。比如借阅记录后来要支持预约功能就执行下面这条语句ALTER TABLE borrow_record ADD COLUMN reservation_id INT NULL COMMENT 预约单ID AFTER reader_id;ALTER TABLE 在 MySQL 8.0 里是 online DDL但要注意 ADD/DROP/MODIFY 各是独立操作一次只做一件事。很多人为了改一个字段把整张表 DROP 掉重建数据全丢这是一条血泪经验。如果想让报告更专业可以把这些 ALTER 语句单独放在“数据库结构变更”一节说明每次变更的目的和影响行数。对图书管理系统这个量级的数据来说改结构基本不会锁表太久真正需要谨慎的是生产环境实训环境放开改就行。3. JDBC连接与连接池先让数据库跑得起来再谈功能3.1 为什么应该从JDBC连接开始而不是从界面开始很多图书管理系统教程第一步就让你搭 Swing 窗口或者写前端结果学生卡在数据库连接上大半天界面再漂亮也没数据可显示。正确的顺序是先用一个 main 函数验证 Connection 对象能拿到连接后面所有查询都是对它的复用。JDBC 是 Java 访问各种数据库的统一接口。MySQL 对应的驱动是官方 Connector/J版本跟你本地 MySQL 的大版本保持一致即可8.x 服务器配 8.x 驱动不要在 8.0 的库里塞一个 5.1 的驱动 jar。如果走 Python 路线用 PyMySQL 也是同样的套路只不过把 PreparedStatement 换成了 cursor 对象核心问题没有变化。3.2 最小可运行连接代码下面这个 DbUtil 类是启动任何图书管理 DAO 层代码的前提。import java.sql.*; import java.util.Properties; public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/library_db ?useUnicodetruecharacterEncodingUTF-8 serverTimezoneAsia/Shanghai useSSLfalse; public static Connection getConnection() throws SQLException { Properties props new Properties(); props.setProperty(user, root); props.setProperty(password, 123456); return DriverManager.getConnection(URL, props); } public static void main(String[] args) { String sql SELECT COUNT(*) FROM book; try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { if (rs.next()) { System.out.println(图书总数: rs.getInt(1)); } } catch (SQLException e) { e.printStackTrace(); } } }这里集中说明几个关键参数。useUnicodetrue 和 characterEncodingUTF-8 必须与建库时的 utf8mb4 配套缺了其中一个即使数据库表是 utf8mb4JDBC 这一层也可能按平台默认字符集编码中文照样乱码。serverTimezone 是 MySQL 8.x 驱动的硬性要求不指定会直接报错。useSSLfalse 是本地开发选项省去 SSL 握手的时间。注意连接串里用的不是密码明文拼接而是通过 Properties 传参避免特殊字符被 URL 转义搞坏。try-with-resources 在这里不是锦上添花。conn、ps、rs 三个对象都在 try 块结束后自动 close缺了这一层后面第 5 章要讲的 Too many connections 就会准时找上门。PreparedStatement 的作用是预编译 SQL参数用 ? 占位既避免了字符串拼接 SQL 的注入风险也省去了标题或作者名里带单引号导致 SQL 语法错误的尴尬。3.3 HikariCP连接池参数既要会配也要会答纯 DriverManager 能跑通整个实训项目但如果报告想拿“数据库优化”方向的分数必须上连接池。常见做法是用 HikariCP理由很简单Spring Boot 默认集成它性能好而且配置项少、文档清晰。HikariConfig config new HikariConfig(); config.setJdbcUrl(URL); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); HikariDataSource dataSource new HikariDataSource(config);连接池的参数在实训答辩里被问到的概率很高下面这张表把每个参数的目的说清楚。参数建议值说明maximumPoolSize10不是越大越好。MySQL 默认 max_connections 是 151池子开 200 必然在并发测试时先崩掉minimumIdle2池里至少保持 2 条空闲连接避免第一个请求还要现建连接connectionTimeout30000拿不到连接的等待上限超过 30 秒抛 SQLTransientConnectionExceptionidleTimeout600000空闲连接保留时间配合 minimumIdle 控制回收maxLifetime1800000连接存活上限建议小于 MySQL 的 wait_timeout默认 28800 秒防止连接被服务端静默断开连接池的答辩一句话可以这样说初始化时先建 minimumIdle 条连接业务请求来了直接复用用完归还到池里而不是真关闭这样就省去了频繁建连、断连的开销。需要注意的是连接池必须是全局单例。如果每个 DAO 方法里都 new 一个 HikariDataSource等于每次请求都新建一个池连接数会成倍膨胀把自己写成连接泄露源。4. 从增删改查到借还书把业务写成能直接复现的事务链4.1 DAO分层先写一个能答辩的图书查询实训报告里的增删改查如果只写四个“方法体空转”的 demo答辩时一问就露馅。我一般建议学生按 DAO 分层来组织BookDAO 负责 book 表的读写BorrowDAO 负责借阅记录Service 层负责编排业务规则。先看一个能真正跑起来的模糊分页查询public ListBook searchByTitle(String keyword, int page, int pageSize) throws SQLException { String sql SELECT book_id, isbn, title, author, publisher, available_copies FROM book WHERE title LIKE ? ORDER BY book_id LIMIT ? OFFSET ?; ListBook list new ArrayList(); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setInt(2, pageSize); ps.setInt(3, (page - 1) * pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book b new Book(); b.setBookId(rs.getInt(book_id)); b.setTitle(rs.getString(title)); b.setAuthor(rs.getString(author)); b.setAvailableCopies(rs.getInt(available_copies)); list.add(b); } } } return list; }LIKE 查询用 % keyword % 是最常见的写法但它有个隐藏问题如果用户输入的关键词本身带 % 或 _会被当成通配符处理。实训数据量小这个问题通常不需要处理但答辩时能主动提一句“用 ESCAPE 子句转义通配符”或者“数据量大时改为全文索引”会明显拉开差距。LIMIT 分页在大偏移量时性能会下降比如 OFFSET 100000 就要扫描前 100000 行再丢弃实训数据量几百条无所谓但你要知道你写的分页在什么场景下会失效。4.2 借书三个SQL必须在一个事务里借书不是单条 INSERT。拆开看至少三步检查同一读者是否还有未归还的同本书、扣减 available_copies、插入借阅记录。任意一步失败前面的操作都得撤销这就是事务存在的意义。public boolean borrow(int bookId, int readerId) throws SQLException { String checkSql SELECT COUNT(*) FROM borrow_record WHERE book_id? AND reader_id? AND return_date IS NULL; String deductSql UPDATE book SET available_copies available_copies - 1 WHERE book_id? AND available_copies 0; String insertSql INSERT INTO borrow_record(book_id, reader_id, borrow_date, due_date) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY)); try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement psCheck conn.prepareStatement(checkSql); PreparedStatement psDeduct conn.prepareStatement(deductSql); PreparedStatement psInsert conn.prepareStatement(insertSql)) { psCheck.setInt(1, bookId); psCheck.setInt(2, readerId); try (ResultSet rs psCheck.executeQuery()) { rs.next(); if (rs.getInt(1) 0) { conn.rollback(); return false; } } psDeduct.setInt(1, bookId); if (psDeduct.executeUpdate() 0) { conn.rollback(); return false; } psInsert.setInt(1, bookId); psInsert.setInt(2, readerId); psInsert.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } } }这段代码的逻辑是先把连接的自动提交关掉让三条 SQL 进入同一个事务任何一步失败都 rollback成功再 commit。这套流程正好能把 ACID 里的原子性讲清楚——要么三条 SQL 全部生效要么一条都不生效。checkSql 里用 return_date IS NULL 判断同一读者是否还有未归还的同本书避免他反复借同一本书。deductSql 把 available_copies 0 放进 UPDATE 的 WHERE 条件里而不是先 SELECT 再判断原因是并发场景下两个请求同时读到“还有 1 本”的概率很高只有数据库行锁加条件更新才能保证库存不超发。UPDATE 语句执行后返回行数为 0说明当前库存已经不足直接回滚。INSERT 里的 CURDATE() 取当天日期DATE_ADD(CURDATE(), INTERVAL 30 DAY) 生成应还日期还款期限定 30 天。顺带说一句这里涉及的就是数据库并发锁的基础知识UPDATE 会对匹配的行加写锁直到事务提交或回滚才释放所以事务体应该尽量短不要在两条 SQL 之间做耗时操作。4.3 还书先定位再还原别把归还做成“新借”还书的一个常见误用是先 SELECT 出“最新一条借阅记录”拿到 id再 UPDATE 那条记录。问题是多轮借阅之后“最新”很容易选错尤其在并发操作下结果完全不可控。正确做法是直接在 UPDATE 里用业务条件定位未归还记录String updateRecordSql UPDATE borrow_record SET return_date CURDATE(), status 1 WHERE book_id? AND reader_id? AND return_date IS NULL;然后对 book 表执行 available_copies 1 的更新。这两个操作必须在同一个事务里执行不能用两个方法各取一次连接否则会出现“记录已还但库存没加回来”的数据不一致。删除图书也是一样的思路。硬删除前必须确认没有未归还的借阅记录否则外键约束会直接报错更省心的做法是给 book 表加一个 status 字段做软删除下架不删行。软删除的好处是历史借阅数据永远查得到统计报表不会断这在实训报告里也是个加分点。5. 图书管理系统实训高频翻车点5条排查记录这一章整理的是我让人跑图书管理系统实训项目时最常见的五个报错。每条按“现象 → 原因 → 解决”的顺序写方便你拿着日志直接对照。5.1 Access denied与Public Key Retrieval本地连接第一道坎现象main 方法一执行立刻抛 java.sql.SQLException: Access denied for user rootlocalhost (using password: YES)或者 MySQL 8.0 下驱动报 Public Key Retrieval is not allowed。原因前者是用户名或密码不对也可能是 root 用户当前只允许本机 socket 认证后者是 MySQL 8.0 默认的 caching_sha2_password 加密方式需要公钥而 JDBC 连接串没有允许客户端去取公钥。解决密码错了就改密码但更推荐为实训库建一个专用账号不要所有项目都拿 root 跑CREATE USER IF NOT EXISTS lib_userlocalhost IDENTIFIED BY lib_pass; GRANT ALL PRIVILEGES ON library_db.* TO lib_userlocalhost; FLUSH PRIVILEGES;同时在 JDBC URL 里加 allowPublicKeyRetrievaltrue。如果换了账号还报 Access denied检查是不是把 localhost 写成了 127.0.0.1。MySQL 对这两个主机的账号授权是分开的授权了 lib_userlocalhost 不代表 lib_user127.0.0.1 能登录。5.2 中文乱码库对了连接串不对照样问号现象book 表里 title 存的是“围城”但 Java 程序打印出来全是问号或者乱码。原因字符集链路有三层建库字符集、JDBC 连接字符集、终端显示字符集只要有一层不对就乱。很多教程只让你改数据库字符集没提连接串所以你改了 utf8mb4 还是乱。解决建库和建表都显式带 utf8mb4JDBC URL 带 useUnicodetruecharacterEncodingUTF-8Linux 终端再 export LANGzh_CN.UTF-8。如果是从旧版本库导入的数据SHOW CREATE TABLE book; 看看实际字符集再用 ALTER TABLE book CONVERT TO CHARACTER SET utf8mb4; 转一遍。这种改表结构的操作正好用上前面第 2 章讲的 ALTER TABLE。注意连接串里的 characterEncoding 写成 UTF-8不是 utf8Java 驱动识别的就是这个名字。5.3 外键挡道删除图书时报Cannot delete parent row现象执行 DELETE FROM book WHERE book_id 5;MySQL 直接报 Cannot delete or update a parent row: a foreign key constraint fails。原因borrow_record 表里还有借阅记录引用 book_id 5外键约束默认的 RESTRICT 策略拒绝删除。解决两种合规路线。一是先确认引用再删SELECT COUNT(*) FROM borrow_record WHERE book_id 5;引用数为 0 再删 book有引用就说明这本书有历史借阅记录不该删。二是走软删除给 book 表增加 status 字段1 可用、0 下架查询时默认带上 status 1下架的书不再出现在检索里。实训报告里把硬删除和软删除两种策略都写清楚答辩时能讲明白“为什么选择不物理删除”比只贴代码要好得多。5.4 Too many connections连接池被自己写破现象项目跑几分钟后开始报 HikariPool-1 - Connection is not available, request timed out或者 Communications link failure。SHOW PROCESSLIST; 一看几十个 Sleep 连接挂着不动。原因最常见的写法是 getConnection() 之后忘了 close或者把 HikariDataSource 在 DAO 里 new 了多次。忘记 close 的连接永远不会归还给连接池每次 new 一个 HikariDataSource 等于每次请求都新建一个独立连接池连接数翻倍膨胀。解决连接获取统一走 try-with-resourcesHikariDataSource 做成全局单例不要在 getConnection() 成功之后的分支里写多个 return每个 return 之前都要确认连接真的被关闭。项目已经挂了的时候后悔药是 SHOW PROCESSLIST; 定位大量 Sleep 的连接用 KILL 清掉占着不动的线程然后再回去改代码。提示DriverManager.getConnection() 拿到的连接close 后是真正断开连接池里的 conn.close() 只是“归还”连接本身还活着。所以连接池场景下更不能漏 close。5.5 Lock wait timeout exceeded事务里拿锁顺序不一致现象并发测试或者多人同时借书时抛 Lock wait timeout exceeded; try restarting transaction。原因典型死锁。借书流程是 UPDATE book 然后再 INSERT borrow_record还书流程如果写成先 UPDATE borrow_record 再 UPDATE book两个事务各拿了一把锁再去等另一把互相等不到就超时。这个问题在单用户手动点击时永远不会出现所以很多实训项目根本测不出来。解决统一全局锁顺序所有写操作先动 book 表、后动 borrow_record 表。事务体要短提交要快不要在事务里做网络请求或文件读写。如果死锁已经发生用 SHOW ENGINE INNODB STATUS; 查看 LATEST DETECTED DEADLOCK 段日志里会列出两个事务的 SQL 顺序照着调成一致即可。这也是数据库死锁相关知识在实训里最好的一次实战。6. 用并发测试与视图给实训报告收尾从“改得动”到“删不坏”数据库图书管理系统验收时评审最常问的一句话是“你凭什么说它不会超借、不会被删坏”。与其嘴上解释不如在报告里附一个并发测试方法和一个固化视图。下面这段代码用 20 个线程同时借同一本书专门用来验证事务和锁public static void main(String[] args) throws Exception { int threads 20; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch start new CountDownLatch(1); for (int i 0; i threads; i) { pool.submit(() - { try { start.await(); new BorrowService().borrow(1, 1); } catch (Exception e) { e.printStackTrace(); } }); } start.countDown(); pool.shutdown(); }跑完这个测试去看 book 表里该书 available_copies 的值。如果出现负数说明扣减时没有把 available_copies 0 写进 UPDATE 条件如果 borrow_record 的行数超过了库存上限说明检查与扣减没在同一个事务里执行。这个测试能把第 4 章借书代码里的问题直接暴露出来比口头解释“我用了事务”更有说服力。视图方面实训报告里放一个逾期查询视图很讨巧既能展示 JOIN 能力又能应对“怎么统计逾期图书”这类追问CREATE OR REPLACE VIEW v_borrowing AS SELECT r.name AS reader_name, b.title, br.borrow_date, DATEDIFF(CURDATE(), br.due_date) AS overdue_days FROM borrow_record br JOIN reader r ON br.reader_id r.reader_id JOIN book b ON br.book_id b.book_id WHERE br.return_date IS NULL;这个视图只返回未归还的记录overdue_days 大于 0 的就是逾期配合 borrow_record 里的 status 字段能回答“一共有多少本逾期书”这种统计问题。我自己最早做图书管理系统时把借书和扣库存写在两个方法里以为“先后调用就安全”直到一次还书中途程序崩了库存和记录对不上才意识到事务不是玄学是代码里必须画清楚的边界。后来养成了一个习惯凡是一个业务流程里超过两条写 SQL一律 setAutoCommit(false)并且在 finally 里确保连接归还。这个习惯帮我挡掉了后续不少线上事故希望也能帮到你。本文还有配套的精品资源点击获取