news 2026/9/26 9:05:23

数据库课设进阶:四张表+存储过程+事务+索引的完整借阅系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据库课设进阶:四张表+存储过程+事务+索引的完整借阅系统

简介:数据库课设——图书借阅管理系统是一份面向高校计算机/软件专业学生的数据库课程设计资料包。项目围绕图书借阅场景,涵盖数据库设计、关系模型构建、SQL编程、事务处理、安全性权限管理、性能优化及备份恢复等核心知识点,并配有可运行的Java源代码、编译后的class文件及开发环境配置。资源包共81个文件,以Java源码、class字节码、jar库文件为主,另含项目文档doc、数据库文件db/ldf/mdf以及界面预览图jpg,压缩包整体大小约11.12MB,目录结构清晰便于按模块查阅。包内文档系统阐述了从数据表设计到借阅事务实现的完整思路,代码与数据库文件可直接导入调试,帮助理解前后端交互与数据库底层逻辑。已有1544人学习,适合需要快速完成课程设计、准备答辩或进行数据库实践入门的学生参考。

1. 图书借阅管理系统:数据库课设里最该做厚的四张表

图书借阅管理系统是数据库课程设计里的高频题目,也是最容易做薄的题目——很多人交上去的东西只有几张表和几个页面,答辩时一问"借书时库存怎么扣减、并发下会不会超卖"就卡壳。这套课设资源把该有的数据库能力补齐了:四张核心表建表脚本、借书还书两个存储过程、两条审计触发器、一组多表联查视图,外加 Java 端参数化增删改查封装。

它能解决的具体问题很明确:库存不准、外键关系混乱、并发超卖、中文乱码、答辩拿不出性能数据。适合正在赶课设的在校生,也适合想快速补一遍 MySQL 事务、存储过程、索引的初级开发。

下面按"建表 → 借还流程 → 查询封装 → 排错 → 压测验证"的顺序展开,每个环节都给可直接运行的 SQL 和关键参数说明。

2. 建表与索引:四张表把借阅业务落到外键约束

2.1 需求拆分:读者、图书、借阅、罚款四个对象

很多课设翻车不是死在写不出 SQL,而是死在表设计阶段——一张表塞十几个字段,借阅记录和读者信息混在一起,删一个读者连带把历史借阅也删了。我在拆分表结构时只遵循一条原则:每个业务对象一张表,历史动作单独落表。

这个系统需要覆盖四个对象。读者(reader)记录借书证号、姓名、联系方式、最大可借册数、当前在借数量和状态;图书(book)记录 ISBN、书名、作者、分类、馆藏总数和可借数量;借阅(borrow)是核心流水表,记录谁在什么时候借了哪本书、应还日期和实际还书日期;罚款(fine)由逾期动作产生,关联到具体某条借阅记录。四张表的关系很清晰:reader 和 book 通过 borrow 建立联系,fine 再指向 borrow,形成一条完整的业务链。

这样拆有两个直接收益。第一,历史数据不丢——读者注销了,借阅流水还在,统计排行榜不受影响;第二,外键约束真正生效——你没法给一个不存在的读者插入借阅记录,也没法随便删掉还有在借图书的 book 行。

2.2 核心建表 SQL:字段类型、默认值与约束说明

CREATE TABLE book ( book_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '图书ID', isbn VARCHAR(20) NOT NULL COMMENT 'ISBN号', title VARCHAR(100) NOT NULL COMMENT '书名', author VARCHAR(50) DEFAULT NULL COMMENT '作者', category VARCHAR(30) DEFAULT '未分类' COMMENT '分类', total_count INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '馆藏总数', available_count INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '当前可借数量', price DECIMAL(8,2) DEFAULT 0.00 COMMENT '定价', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', PRIMARY KEY (book_id), UNIQUE KEY uk_isbn (isbn), KEY idx_category (category) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';

这段建表 SQL 里有三个设计点是课设答辩常问的。一是 total_count 和 available_count 分开存,而不是每次借还都去 COUNT 一遍历史流水——数据量上来之后频繁统计的开销非常明显,用冗余字段换查询速度。二是 isbn 加 UNIQUE 约束,同一本书录两遍是录入阶段最常见的脏数据,唯一索引从根上挡住。三是 category 建了普通索引,因为按分类统计是后面的高频查询。

CREATE TABLE reader ( reader_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '读者ID', card_no VARCHAR(20) NOT NULL COMMENT '借书证号', name VARCHAR(30) NOT NULL COMMENT '姓名', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', max_borrow TINYINT UNSIGNED NOT NULL DEFAULT 5 COMMENT '最大可借册数', borrowed_count TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前在借册数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', PRIMARY KEY (reader_id), UNIQUE KEY uk_card_no (card_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='读者表';

reader 表里 max_borrow 和 borrowed_count 这两个字段容易被忽略。借书时先拿 borrowed_count 和 max_borrow 比,超了就拒绝,这个额度控制是借阅系统的硬需求。status 用 TINYINT 而不是 CHAR 或 BIT,是为了留扩展位——1 正常、0 冻结,以后要加挂失、注销状态不用改表结构。card_no 是业务上的唯一标识,必须加 UNIQUE。

CREATE TABLE borrow ( borrow_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '借阅ID', reader_id INT UNSIGNED NOT NULL COMMENT '读者ID', book_id INT UNSIGNED NOT NULL COMMENT '图书ID', borrow_date DATE NOT NULL COMMENT '借书日期', due_date DATE NOT NULL COMMENT '应还日期', return_date DATE DEFAULT NULL COMMENT '实际还书日期', status TINYINT NOT NULL DEFAULT 0 COMMENT '0在借 1已还 2逾期', PRIMARY KEY (borrow_id), KEY idx_reader (reader_id), KEY idx_book (book_id), KEY idx_status (status), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader(reader_id), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(book_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅流水表'; CREATE TABLE fine ( fine_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '罚款ID', borrow_id INT UNSIGNED NOT NULL COMMENT '关联借阅ID', reader_id INT UNSIGNED NOT NULL COMMENT '读者ID', overdue_days INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逾期天数', amount DECIMAL(6,2) NOT NULL DEFAULT 0.00 COMMENT '罚款金额', paid TINYINT NOT NULL DEFAULT 0 COMMENT '0未缴 1已缴', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '生成时间', PRIMARY KEY (fine_id), KEY idx_reader (reader_id), KEY idx_borrow (borrow_id), CONSTRAINT fk_fine_borrow FOREIGN KEY (borrow_id) REFERENCES borrow(borrow_id), CONSTRAINT fk_fine_reader FOREIGN KEY (reader_id) REFERENCES reader(reader_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='罚款表';

borrow 表是整个系统的核心流水。due_date 用 DATE 而不是 DATETIME,因为借阅业务只关心到天,DATETIME 反而会在查询和格式化时多一步处理。status 用 0/1/2 表示在借、已还、逾期未还,逾期状态其实可以由 due_date 和 return_date 推导,但单独维护一个状态字段能让查询语句简单很多。两张表都声明了外键,MySQL 会保证 borrow 里不会出现不存在的 reader_id 或 book_id。

fine 表里的 overdue_days 和 amount 是我刻意留下的冗余。罚款金额虽然可以通过逾期天数 × 单价现算,但罚款规则一旦调整,历史罚款记录就会失真,所以在还书结算那一刻就把天数和金额固化成快照。这也是答辩里一个很好的加分点:你能说清楚每个冗余字段存在的原因。

2.3 初始化数据:测试读者与样例图书

初始化数据是课设里最容易偷懒、但答辩必被问的部分。我的习惯是至少准备三个读者、五本图书,覆盖正常、冻结、额度用完三种状态,这样演示借书流程时每个分支都能点到。注意不要手动指定主键,让自增生成,否则插入顺序一乱,外键关系就跟着乱。

INSERT INTO reader(card_no, name, phone, max_borrow, borrowed_count, status) VALUES ('R2024001', '张三', '13800000001', 5, 0, 1), ('R2024002', '李四', '13800000002', 5, 2, 1), ('R2024003', '王五', '13800000003', 5, 3, 0); INSERT INTO book(isbn, title, author, category, total_count, available_count, price) VALUES ('9787111213826', '深入理解计算机系统', 'Randal E.Bryant', '计算机', 3, 3, 99.00), ('9787115428028', '数据库系统概论', '王珊', '计算机', 2, 2, 45.00), ('9787020002207', '红楼梦', '曹雪芹', '文学', 4, 4, 59.70), ('9787302518367', 'MySQL技术内幕', '姜承尧', '计算机', 2, 2, 89.00), ('9787544270878', '三体', '刘慈欣', '科幻', 5, 5, 35.00);

第三个读者王五的 status 是 0,第一个读者张三的 borrowed_count 是 0,这就是给后续演示准备的边界数据:借书时分别验证"冻结拒绝"和"正常借出"两条路径。

2.4 索引策略:哪些列建索引、哪些列别建

索引不是越多越好,这是课设里最常见的误用。我在这套表里只建了四类索引:主键索引、唯一索引、外键列索引、等值查询索引。

外键列必须建索引——InnoDB 在删除父表行时要检查子表是否存在引用,没有索引的话这个检查就是全表扫描;而且按 reader_id 查某个读者的借阅历史是高频查询,索引同时服务了约束和查询。status 这类低基数列我反而没建索引:字段只有 0/1/2 三个取值,选择性太差,索引扫描和全表扫描区别不大,还多占空间。

注意:建表时只给 borrow 的 reader_id、book_id 建了单列索引,没有建(reader_id, book_id)联合索引。如果后续要查"某读者是否借过某本书",可以再加联合索引,但要靠 EXPLAIN 验证实际收益,不要想当然地加。

3. 借还书流程:存储过程、行锁与事务边界

3.1 借书存储过程:库存校验与 FOR UPDATE 行锁

借书不是一条 INSERT 能搞定的。它涉及三个动作:扣减图书可借数量、增加读者的在借册数、插入一条借阅记录。三个动作要么全成功要么全不做,这就是事务的用武之地。我用一个存储过程把整段逻辑包起来,业务逻辑收口在数据库层,Java 端只需要一次 CALL。

DELIMITER // CREATE PROCEDURE sp_borrow_book( IN p_reader_id INT UNSIGNED, IN p_book_id INT UNSIGNED, IN p_days TINYINT UNSIGNED, OUT p_result TINYINT ) BEGIN DECLARE v_avail INT DEFAULT 0; DECLARE v_borrowed INT DEFAULT 0; DECLARE v_max INT DEFAULT 5; DECLARE v_status INT DEFAULT 0; START TRANSACTION; SELECT available_count INTO v_avail FROM book WHERE book_id = p_book_id FOR UPDATE; SELECT status, borrowed_count, max_borrow INTO v_status, v_borrowed, v_max FROM reader WHERE reader_id = p_reader_id FOR UPDATE; IF v_status = 0 THEN SET p_result = -3; -- 读者被冻结 ROLLBACK; ELSEIF v_avail < 1 THEN SET p_result = -1; -- 库存不足 ROLLBACK; ELSEIF v_borrowed >= v_max THEN SET p_result = -2; -- 超出可借册数 ROLLBACK; ELSE UPDATE book SET available_count = available_count - 1 WHERE book_id = p_book_id; UPDATE reader SET borrowed_count = borrowed_count + 1 WHERE reader_id = p_reader_id; INSERT INTO borrow(reader_id, book_id, borrow_date, due_date, status) VALUES(p_reader_id, p_book_id, CURDATE(), DATE_ADD(CURDATE(), INTERVAL p_days DAY), 0); SET p_result = 0; COMMIT; END IF; END// DELIMITER ;

重点说两处。第一,两个 SELECT 都加了 FOR UPDATE,这是并发安全的关键——不加锁时两个连接同时读到 available_count=1,各自都认为能借,扣两次就把库存扣成负数;加了行锁,第二个连接必须等第一个提交后才能读到新值。第二,p_result 这个 OUT 参数是给上层判断结果用的,我约定的编码是 0 成功、-1 库存不足、-2 超出可借额度、-3 账号冻结,Java 端拿到非 0 直接弹对应提示。

提示:FOR UPDATE 必须在事务里才有意义,这也是为什么存储过程里显式写了 START TRANSACTION 和 COMMIT/ROLLBACK。如果拆成多条 SQL 在应用层执行,锁的边界很容易失控。

调用方式很简单,p_days 传借阅天数,业务上默认 30 天:

CALL sp_borrow_book(1, 1, 30, @r); SELECT @r; -- 0 表示借出成功,-1/-2/-3 分别是三种拒绝分支

3.2 还书存储过程:DATEDIFF 算逾期与罚款落表

还书的动作和借书对称:更新借阅记录状态、回补图书库存、减少读者在借数量、检查是否逾期并生成罚款。

DELIMITER // CREATE PROCEDURE sp_return_book( IN p_borrow_id INT UNSIGNED, OUT p_fine DECIMAL(6,2) ) BEGIN DECLARE v_reader_id INT UNSIGNED; DECLARE v_book_id INT UNSIGNED; DECLARE v_due_date DATE; DECLARE v_status TINYINT DEFAULT 0; DECLARE v_overdue INT DEFAULT 0; START TRANSACTION; SELECT reader_id, book_id, due_date, status INTO v_reader_id, v_book_id, v_due_date, v_status FROM borrow WHERE borrow_id = p_borrow_id FOR UPDATE; IF v_status = 1 THEN SET p_fine = 0; -- 已经还过,直接退出 ROLLBACK; ELSE UPDATE borrow SET return_date = CURDATE(), status = 1 WHERE borrow_id = p_borrow_id; UPDATE book SET available_count = available_count + 1 WHERE book_id = v_book_id; UPDATE reader SET borrowed_count = borrowed_count - 1 WHERE reader_id = v_reader_id; SET v_overdue = DATEDIFF(CURDATE(), v_due_date); IF v_overdue > 0 THEN SET p_fine = v_overdue * 0.50; -- 每天 0.5 元,可自行调整 INSERT INTO fine(borrow_id, reader_id, overdue_days, amount, paid) VALUES(p_borrow_id, v_reader_id, v_overdue, p_fine, 0); END IF; COMMIT; END IF; END// DELIMITER ;

逾期天数用 DATEDIFF 计算,这里有个写反参数的血泪经验:DATEDIFF(结束日期, 开始日期),结果是前者减后者。还书场景里就是DATEDIFF(CURDATE(), v_due_date),今天减去应还日期,正数是逾期天数,负数说明提前还了,罚款为 0。

这个存储过程同时展示了罚款的"结算"逻辑:金额在还书那一刻算好并 INSERT 进 fine 表,paid默认 0(未缴),后续做缴纳功能时 UPDATE 成 1 即可。课设演示时可以把逾期读者单独列一个"待缴罚款"清单,闭环就完整了。

3.3 隔离级别选择:为什么默认 REPEATABLE READ 够用

MySQL InnoDB 默认隔离级别是 REPEATABLE READ,课设阶段不需要去改全局配置。很多人觉得"不可重复读"听起来不安全,就想当然升到 SERIALIZABLE,结果并发度直线下降。实际上在借书场景里,真正的并发风险是"两个事务同时读到 available_count=1 然后都去扣减",这个问题单靠隔离级别解决不了——RR 下的普通 SELECT 是非锁定读,不会挡住别的事务修改同一行。正确做法就是 3.1 里的 SELECT ... FOR UPDATE,显式加行锁,让第二个事务阻塞在读取阶段。

隔离级别脏读不可重复读幻读借书场景是否够用
READ UNCOMMITTED可能可能可能否
READ COMMITTED否可能可能够用,需配 FOR UPDATE
REPEATABLE READ否否否(MVCC)够用,MySQL 默认
SERIALIZABLE否否否可用但并发差

这张表是答辩时可以直接背出来的。核心论点就一句:REPEATABLE READ 配合 FOR UPDATE 行锁,已经能覆盖借书场景的全部并发问题,课设不需要更高级的隔离级别。

3.4 触发器审计日志:借还动作自动留痕

借还记录的"谁在什么时间借了什么书"属于典型的审计需求,用触发器做最省事——它不依赖应用层每次记得写日志代码,只要对 borrow 表 INSERT 和 UPDATE,日志自动落表。

CREATE TABLE borrow_log ( log_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '日志ID', borrow_id INT UNSIGNED NOT NULL COMMENT '借阅ID', action VARCHAR(10) NOT NULL COMMENT 'BORROW 或 RETURN', reader_id INT UNSIGNED NOT NULL COMMENT '读者ID', book_id INT UNSIGNED NOT NULL COMMENT '图书ID', op_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', PRIMARY KEY (log_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅审计日志'; DELIMITER // CREATE TRIGGER trg_borrow_log AFTER INSERT ON borrow FOR EACH ROW BEGIN INSERT INTO borrow_log(borrow_id, action, reader_id, book_id) VALUES (NEW.borrow_id, 'BORROW', NEW.reader_id, NEW.book_id); END// CREATE TRIGGER trg_return_log AFTER UPDATE ON borrow FOR EACH ROW BEGIN IF NEW.status = 1 AND OLD.status = 0 THEN INSERT INTO borrow_log(borrow_id, action, reader_id, book_id) VALUES (NEW.borrow_id, 'RETURN', NEW.reader_id, NEW.book_id); END IF; END// DELIMITER ;

AFTER INSERT 触发器里 NEW 代表刚插入的行,所以直接取 NEW.borrow_id 写日志。AFTER UPDATE 触发器加了NEW.status = 1 AND OLD.status = 0的判断,避免每次 UPDATE 都记一条——只有状态从"在借"变成"已还"才记录 RETURN。

不过触发器有个要留意的边界:它把业务逻辑藏进了数据库,排错时第一反应是看应用代码,容易漏掉触发器这一层。我的习惯是触发器只做审计这种"旁路"动作,核心的扣库存、算罚款都放在存储过程里显式执行,这样逻辑链路是看得见的。

4. 查询与增删改查:视图封装、统计 SQL 与 JDBC 连接

4.1 高频统计查询:借阅排行榜、逾期名单、在借清单

数据库课设的增删改查不能只有单表 CRUD,多表联查和统计聚合才是拿分点。下面三个查询是这个系统里最高频的,直接抄:

-- 最近30天借阅排行榜 SELECT bk.title, COUNT(*) AS borrow_times FROM borrow b INNER JOIN book bk ON b.book_id = bk.book_id WHERE b.borrow_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY bk.book_id, bk.title ORDER BY borrow_times DESC LIMIT 10; -- 逾期未还名单 SELECT r.card_no, r.name, bk.title, b.due_date, DATEDIFF(CURDATE(), b.due_date) AS overdue_days FROM borrow b INNER JOIN reader r ON b.reader_id = r.reader_id INNER JOIN book bk ON b.book_id = bk.book_id WHERE b.status = 0 AND b.due_date < CURDATE(); -- 某读者的在借清单 SELECT bk.title, bk.isbn, b.borrow_date, b.due_date FROM borrow b INNER JOIN book bk ON b.book_id = bk.book_id WHERE b.reader_id = 1 AND b.status = 0;

排行榜查询有三个细节。GROUP BY 不能只写 bk.book_id 而 SELECT 里带 bk.title,严格模式下会报错,规范写法是把两个都放进 GROUP BY;DATE_SUB(CURDATE(), INTERVAL 30 DAY) 是"当前日期往前推 30 天",比先算日期再拼字符串可靠;LIMIT 10 控制了返回行数,数据量大时避免结果集过大。

逾期名单的查询条件是status = 0 AND due_date < CURDATE(),这就是 2.2 里保留 status 字段的价值——如果全靠日期推导,这条 SQL 会复杂不少。索引方面,idx_reader 和 idx_status 会分别服务"按读者查流水"和"按状态查逾期"两个方向。

4.2 视图封装:三表连接收敛成一张逻辑表

借阅详情在界面里几乎每个页面都要用,每次写三表 JOIN 太啰嗦,而且应用层能看到全部字段也不是好事。我建了一个视图把连接逻辑收口:

CREATE VIEW v_borrow_detail AS SELECT b.borrow_id, r.card_no, r.name AS reader_name, bk.title, bk.isbn, bk.category, b.borrow_date, b.due_date, b.return_date, b.status FROM borrow b INNER JOIN reader r ON b.reader_id = r.reader_id INNER JOIN book bk ON b.book_id = bk.book_id;

视图建好后,界面的"借阅记录"页面只需要一句SELECT * FROM v_borrow_detail WHERE status = 0 ORDER BY borrow_date DESC,不用关心底层表结构。要注意视图不是性能优化手段,它只是把 JOIN 逻辑封装起来了,底层照样是三条连接;课设里视图的定位是简化查询和隐藏表结构,不是加速。

4.3 JDBC 连接池:HikariCP 最小配置与参数含义

应用层连 MySQL 不能每次查询都新建 Connection,那是课设里最常见的性能问题。连接池是标准答案,Spring Boot 默认带 HikariCP,单独写课设时手动配一下也很简单:

HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/library_db" + "?useUnicode=true&characterEncoding=utf8mb4" + "&serverTimezone=Asia/Shanghai&useSSL=false"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); config.setPoolName("libraryPool"); HikariDataSource dataSource = new HikariDataSource(config);
参数取值含义
maximumPoolSize10连接池最大连接数,课设 5-10 足够
minimumIdle2空闲保底连接,避免频繁新建连接
connectionTimeout3000获取连接超时时间,超过直接报错而不是无限等
characterEncodingutf8mb4与表字符集保持一致,乱码根因之一

连接串里serverTimezone=Asia/Shanghai是给 MySQL 8 驱动的,不写会报时区错误;useSSL=false是本地开发环境关掉 SSL,省掉证书配置的麻烦。用 Druid 或 C3P0 也完全可以,核心是连接复用,不是具体哪家实现。

4.4 增删改查封装:PreparedStatement 参数化防注入

DAO 层我始终坚持用 PreparedStatement,一个分页查询的典型写法是:

public List<Book> searchBook(String keyword, int page, int pageSize) { String sql = "SELECT book_id, isbn, title, author, category, " + "total_count, available_count " + "FROM book " + "WHERE title LIKE ? OR isbn LIKE ? " + "ORDER BY book_id DESC LIMIT ?, ?"; List<Book> list = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + keyword + "%"; ps.setString(1, like); ps.setString(2, like); ps.setInt(3, (page - 1) * pageSize); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book b = new Book(); b.setBookId(rs.getInt("book_id")); b.setIsbn(rs.getString("isbn")); b.setTitle(rs.getString("title")); b.setAuthor(rs.getString("author")); b.setAvailableCount(rs.getInt("available_count")); list.add(b); } } } catch (SQLException e) { logger.error("searchBook failed, keyword={}", keyword, e); } return list; }

四个参数占位符的含义:两个 LIKE 参数值都是%关键字%,实现模糊匹配;LIMIT ?, ?是分页,第一个参数是偏移量(page-1)*pageSize,第二个是每页行数。try-with-resources 保证 Connection、PreparedStatement、ResultSet 都自动关闭,不会把连接池的连接漏掉。

为什么不用字符串拼接?因为拼接出来的 SQL 里,用户输入会被当成 SQL 代码执行,典型的注入:name = 'a' OR '1'='1'能把整张表带出来。PreparedStatement 的参数由驱动转义,输入永远只是数据不是 SQL,这是红线级别的习惯。

5. 避坑与常见问题:答辩前必须排掉的五个雷

这五个问题是我做课设辅导时被问得最多的,也是我自己当初一个个踩出来的。每条都按"现象 → 原因 → 解决"整理,答辩前对着过一遍,能少挨不少问。

5.1 外键删除被拒

现象:执行DELETE FROM reader WHERE reader_id = 2报错,提示Cannot delete or update a parent row: a foreign key constraint fails。

原因:reader_id=2 的读者在 borrow 表里存在借阅记录,外键约束不允许删除被引用的父表行,这是数据库在保护数据完整性。

解决:删除前先查引用再处理:

SELECT COUNT(*) FROM borrow WHERE reader_id = 2;

如果有在借记录,先走还书流程;如果是历史记录且确实要删读者,业界标准做法是软删除——把 reader 的 status 改成 3(注销),而不是物理删行。答"读者注销怎么办"时,标准答案就是软删除。

5.2 并发借书库存变负数

现象:两个管理窗口同时借同一本书,演示完发现 available_count 变成 -1。

原因:UPDATE book SET available_count = available_count - 1本质是"读旧值 → 计算新值 → 写回"三步,两个事务交错执行就会丢失更新,这是典型的并发场景。

解决:两种方案,看课设要求选。存储过程里 SELECT ... FOR UPDATE 先锁行,后面 UPDATE 就安全了;更省事的是带条件更新:

UPDATE book SET available_count = available_count - 1 WHERE book_id = ? AND available_count > 0;

然后检查受影响行数,为 0 说明没库存。注意后者只能防超卖这一种情况,整套借阅流程的原子性还是要靠事务。如果演示时真碰到死锁报错Deadlock found,多半是两个事务加锁顺序不一致,统一所有事务先锁 book 再锁 reader 就能解决。

5.3 中文乱码

现象:界面输入"数据库"三个字,存进库里变成???。

原因:三层字符集不一致——连接串的 characterEncoding、表的 DEFAULT CHARSET、客户端工具各自为政,MySQL 按其中一层解释中文字节就乱了。

解决:三层统一成 utf8mb4:

CREATE DATABASE library_db DEFAULT CHARACTER SET utf8mb4;

连接串加characterEncoding=utf8mb4,工具连接也显式选 utf8mb4。注意是 utf8mb4 不是 utf8,MySQL 的 utf8 是残缺的 utf8mb3,遇到 emoji 或生僻字照样翻车。

5.4 备份恢复翻车

现象:答辩前用 mysqldump 备份,恢复时报错,或者恢复后数据少一段。

原因:两个经典错误。备份没加--single-transaction,备份期间业务还在写,逻辑备份的数据在表之间对不上;恢复时没先建库,直接往不存在的库导入。

解决:

mysqldump -uroot -p --single-transaction --default-character-set=utf8mb4 library_db > library_backup.sql mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4" mysql -uroot -p library_db < library_backup.sql

--single-transaction利用 InnoDB 的 MVCC 做一致性快照,不加的话每张表独立备份,表间数据逻辑上不一致;--default-character-set不加的话,备份文件里中文注释恢复时可能乱码。

5.5 索引失效

现象:查询条件里明明有索引列,EXPLAIN 却显示 type=ALL 全表扫描。

原因:三种常见情况。索引列上套了函数,比如WHERE YEAR(create_time) = 2024;LIKE 用了前导通配符'%数据库%';字符串列被拿数字去查,触发隐式类型转换。

解决:EXPLAIN 看 type 字段,从 ALL 变成 ref 才算生效。前导通配符改成后缀匹配'数据库%',或者用全文索引;列上套函数改成范围查询:

WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31';

这条在答辩时几乎必问,能说出"函数包住索引列会导致索引失效"这句话,就比大多数人强了。

6. 验证与答辩:EXPLAIN、十万行压测与演示路径

6.1 用 EXPLAIN 把"索引生效"讲给答辩老师听

建完表、写完流程,最后一步是证明你的设计是稳的。我最喜欢用的手段是 EXPLAIN——它输出一行表,直接把优化器的执行计划摊开:

EXPLAIN SELECT b.borrow_id, r.name, bk.title FROM borrow b INNER JOIN reader r ON b.reader_id = r.reader_id INNER JOIN book bk ON b.book_id = bk.book_id WHERE b.status = 0 AND b.reader_id = 1;

看三列就够。type 列,从 ALL 到 ref 再到 eq_ref 是递进的;key 列,显示用到了哪个索引名,比如 idx_reader;rows 列,是优化器估算扫描的行数。答辩时这么讲:"这个查询驱动表 borrow 走了 idx_reader 索引,扫描 2 行,然后通过主键 eq_ref 回查 reader 和 book,总共扫描 4 行,而不是全表扫几万行。"有数字,有索引名,比空口说"我建了索引"有说服力得多。

6.2 十万行压测与演示路径设计

光有索引还不够,数据量一上来才能看出差距。我给课设准备了一个造数存储过程:

DELIMITER // CREATE PROCEDURE sp_gen_books(IN p_count INT) BEGIN DECLARE i INT DEFAULT 1; WHILE i <= p_count DO INSERT INTO book(isbn, title, author, category, total_count, available_count) VALUES ( CONCAT('978', LPAD(FLOOR(RAND() * 9999999999), 10, '0')), CONCAT('压测图书', i), CONCAT('作者', i % 500), ELT(1 + FLOOR(RAND() * 5), '计算机', '文学', '科幻', '历史', '经济'), 10, 10 ); SET i = i + 1; END WHILE; END// DELIMITER ; CALL sp_gen_books(100000);

RAND 生成随机数,LPAD 补零凑 ISBN,ELT 从列表里随机取分类。笔记本上插十万行大约一两分钟,嫌慢就改成五万。造完数据再跑 6.1 的 EXPLAIN,把扫描行数对比给老师看;如果某个查询真的慢了,开慢查询日志看现场:

SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; -- 超过1秒的记录 SHOW VARIABLES LIKE 'slow_query_log_file';

演示路径我建议固定成一条闭环:登录 → 新增一个读者 → 借书(先演示正常分支,再演示冻结拒绝分支)→ 还书 → 查罚款 → 缴纳 → 看排行榜。每一步都对应一个存储过程或一条查询,走到罚款那步时,顺手把 borrow 表的状态和 fine 表的数据一起展示,证明事务性。收尾把 EXPLAIN 结果摆出来,整套演示不超过十分钟,但覆盖了建表、约束、事务、索引、统计全部知识点。

资源包里建表脚本、存储过程、触发器、初始化数据和 Java 源码都按目录分好了,照着顺序跑就能复现整套流程。从那以后我每次做课设项目,都会强制自己走一遍"约束检查 + 十万行压测 + 边界数据演示"这三件事,能把这套流程完整走完的项目,答辩基本不会被问倒。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 9:04:45

MIPI LP TX低功耗发送模式:D-PHY时序原理与调试实战

MIPI LP TX 这个说法&#xff0c;第一次听到的人多半会愣一下——MIPI 我熟&#xff0c;CSI、DSI、DPHY 这些词天天见&#xff0c;但 LP TX 是什么&#xff1f;是某个新出的协议变种&#xff0c;还是某个芯片厂商的私有叫法&#xff1f;其实都不是。LP 是 Low-Power 的缩写&…

作者头像 李华
网站建设 2026/9/26 9:04:09

烟火检测数据集实战指南:从1000张标注图到YOLOv8可训练资产

简介&#xff1a;目标检测是计算机视觉基础任务&#xff0c;而烟火检测作为典型小目标、低对比、强干扰场景&#xff0c;对数据质量与模型适配提出严苛要求。其核心原理在于YOLO格式标签的归一化坐标约束、类别ID严格对齐及图像尺度与噪声控制。技术价值体现在提升mAP与召回率平…

作者头像 李华
网站建设 2026/9/26 9:03:34

在 VS Code 中,一键安装 MCP Server 并接入 TaoToken 的配置指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 9:00:58

开源AI Agent如何用50+Skill重构营销工作流?从原理到落地

1. 营销团队的日常&#xff0c;为什么总是卡在"工具切换"上先说个我观察了很久的现象。市场部做一次完整的投放&#xff0c;从人群洞察、创意生成、文案润色、落地页搭建、数据复盘&#xff0c;至少要打开五六个工具&#xff1a;问卷后台、素材库、ChatGPT窗口、排版…

作者头像 李华
网站建设 2026/9/26 9:00:47

嵌入式Linux驱动开发实战:设备树、固件与调试避坑指南

1. 嵌入式驱动开发到底在忙什么 很多人一听“嵌入式驱动开发”&#xff0c;脑子里浮现的画面要么是对着 datasheet 一行行啃寄存器&#xff0c;要么是抱着开发板反复插拔串口线看 log。外人看着像在“调板子”&#xff0c;自己干起来才知道&#xff0c;这活儿横跨硬件手册、内核…

作者头像 李华
网站建设 2026/9/26 9:00:16

APU内存带宽如何决定本地大模型推理速度:实测与调优指南

1. 为什么一块APU的内存带宽能决定本地大模型的生死1.1 从一次失败的模型加载说起去年年底我拿到一颗AMD Ryzen AI Max 395的工程样品&#xff0c;第一反应跟大多数人一样&#xff1a;这玩意儿核显规模都堆到40个计算单元了&#xff0c;跑个本地大模型应该很轻松吧&#xff1f;…

作者头像 李华