简介:《数据库课程设计——图书管理系统》是一份软件工程与数据库课程设计的任务书及完整设计指导文档,面向高校计算机类专业学生,用于完成图书管理系统的数据库建模与系统开发任务。文档围绕学生借阅、续借、归还、查询和管理员对图书、学生信息的管理等核心功能,系统梳理了需求分析、概念结构设计、逻辑结构设计、物理结构设计及编码五个阶段的设计思路;其中概念结构部分给出E-R图设计方法,逻辑结构部分讲解关系模式转换、主外键设计与规范化处理,物理结构部分则涉及建表、索引、视图和SQL语句编写,并附有完整的进度安排、提交材料要求、后台数据库推荐(SQL Server/Oracle)及参考文献列表。资源为单个doc文档,文件大小48KB,目前已有2737人学习使用。整体内容贴合教学实践,既可作为数据库课程设计的选题模板,也适合正在开展图书管理系统设计或有任务书撰写需求的读者直接参考。
1. 图书管理系统课设:一道把数据库基本功走通一遍的经典题
“数据库课程设计——图书管理系统”是每个学数据库的本科生都绕不过去的经典题目。它看起来只是某个老掉牙的业务系统,但完整链路恰好覆盖了数据库课程大纲里最核心的东西:需求分析、ER 图、关系模式、范式校验、建库建表、增删改查、事务与并发。每年都有不少人把这个题目做成“会插数据的 CRUD”,结果答辩时被一句“两个窗口同时借同一本书,怎么保证库存不为负”问住。这篇要说的是从表结构怎么设计、借书还书的事务怎么写,到最后的 .doc 文档怎么组织,按大多数学校课程设计的验收口径走一遍,不堆概念,只给能照着改的细节。合适正在写课设的学生,也适合带课助教拿去当验收参照。
2. 表结构设计:从借书场景反推数据模型,先把库建对
2.1 实体与关系:从借书场景反推
大多数人拿到题目第一反应是“图书管理系统要管书、管读者、管借还”,于是直接开始画表。我建议反过来,先想清楚“借一本书在数据上到底发生了什么”。
借书动作拆开之后至少有三步:校验读者状态、扣减图书库存、写入一条借阅记录。还书动作则是把这条借阅记录标记为已还、再把库存加回去。所以哪怕别的什么都不做,读者表、图书表、借阅记录表这三张表也是底线。再往下推:书总得有分类,管理员总得登录后台,于是有了分类表和管理员表;如果你还想做超期罚金,就要罚金表,但多数课设不做也不影响主流程。
这样推出来的实体一共五个:读者、图书、图书分类、管理员、借阅记录。关系也很清晰:图书和分类是多对一;读者和图书是多对多,而借阅记录正是把这对多对多拆成两个一对多的关联实体。管理员与读者、图书不直接挂关系,它只是操作者。这里有个常见错误:在图书表里放一个“录入员”字段,然后让管理员沿着这条外键去参与借阅逻辑。看着能跑,讲关系模式时却很拧巴。如果要做操作留痕,正确做法是在借阅记录表里加一个operator_id字段,而不是在图书表里体现“谁录入的”。
2.2 范式取舍:第三范式是底线,但别替范式写论文
课程设计文档里基本都要写“我们进行了范式分析”,但很多人的分析只是把三个范式抄了一遍。判断范式是否合格,看的是你有没有用真实的表去验证它。
第一范式要求字段原子。最容易踩的坑是把作者写成“张三,李四”,或者一个电话号码字段里塞两个号码。第二范式要求非主键字段完全依赖主键,只要你别把borrow_record表的主键设计成(reader_id, book_id)复合键,同时又放上借书日期这种与复合键只有部分依赖的字段,基本不会出问题。第三范式要求消除传递依赖,典型就是图书表里放category_name而不是category_id。把这三条对着自己的建表语句过一遍,范式章节就有血有肉,而不是教材复读。
但要注意,范式不是越高越好。借阅记录表里我会刻意冗余一个status状态字段,明明可以靠return_date是否为空推断“是否已还”,但加一个独立状态字段后,查“当前未还列表”直接走索引过滤,不用处理 NULL 判断。另一个更实际的冗余是:图书被下架或删除后,历史借阅记录仍然要能显示当时借的是什么书,此时借阅记录里存一份book_title快照反而合理。课程设计里你只要在文档里写一句“这里做了受控冗余,原因是历史可追溯”,评委不仅不扣分,还会觉得你真理解了范式,而不是背了概念。
2.3 六张核心表的字段清单与建表 DDL
先给最关键的借阅记录表字段设计,它是整个系统的枢纽表。
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| record_id | INT UNSIGNED | PK, AUTO_INCREMENT | 借阅记录ID |
| reader_id | INT UNSIGNED | NOT NULL, FK | 读者ID |
| book_id | INT UNSIGNED | NOT NULL, FK | 图书ID |
| borrow_date | DATE | NOT NULL | 借书日期 |
| due_date | DATE | NOT NULL | 应还日期 |
| return_date | DATE | NULL | 实际归还日期 |
| renew_count | TINYINT | DEFAULT 0 | 已续借次数 |
| status | ENUM('borrowed','returned','overdue') | DEFAULT 'borrowed' | 借阅状态 |
几个选型理由需要讲明白。record_id用自增主键而不是(reader_id, book_id)复合主键,是因为同一个读者可以反复借同一本书,复合主键既无法保证唯一,也妨碍后续加字段。due_date在插入时就算好,而不是靠“借书日期 + 30 天”的公式在查询时临时算,这样查逾期列表直接比较due_date < CURDATE()即可。status用 ENUM 而不是 TINYINT,是让 MySQL 在出现非法值时报错,而不是默默存一个没人认识的 2。
建表 DDL 如下,注意建库和建表顺序也在这个脚本里:
CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE library_db; -- 先建不依赖其他表的父表:分类表、读者表 CREATE TABLE category ( category_id INT UNSIGNED AUTO_INCREMENT COMMENT '分类ID', category_name VARCHAR(50) NOT NULL COMMENT '分类名称', PRIMARY KEY (category_id), UNIQUE KEY uk_category_name (category_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书分类表'; CREATE TABLE reader ( reader_id INT UNSIGNED AUTO_INCREMENT COMMENT '读者ID', reader_no VARCHAR(20) NOT NULL COMMENT '学号/工号', name VARCHAR(50) NOT NULL COMMENT '姓名', gender ENUM('男','女') DEFAULT '男' COMMENT '性别', dept VARCHAR(100) COMMENT '院系', phone VARCHAR(20) COMMENT '联系电话', email VARCHAR(100) COMMENT '邮箱', reg_date DATE NOT NULL COMMENT '注册日期', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可借 0停借', PRIMARY KEY (reader_id), UNIQUE KEY uk_reader_no (reader_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='读者表'; -- 图书表依赖分类表,放第二层 CREATE TABLE book ( book_id INT UNSIGNED AUTO_INCREMENT COMMENT '图书ID', isbn VARCHAR(20) NOT NULL COMMENT 'ISBN', title VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) NOT NULL COMMENT '作者', category_id INT UNSIGNED NOT NULL COMMENT '分类ID', publisher VARCHAR(100) COMMENT '出版社', publish_date DATE COMMENT '出版日期', price DECIMAL(8,2) COMMENT '定价', stock INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '可借库存', total_stock INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '总馆藏', location VARCHAR(50) COMMENT '馆藏位置', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在架 0下架', PRIMARY KEY (book_id), KEY idx_book_title (title), KEY idx_book_isbn (isbn), KEY idx_book_category (category_id), CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; -- 借阅记录表依赖读者表和图书表,最后建 CREATE TABLE borrow_record ( record_id INT UNSIGNED 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 COMMENT '实际归还日期', renew_count TINYINT NOT NULL DEFAULT 0 COMMENT '续借次数', status ENUM('borrowed','returned','overdue') NOT NULL DEFAULT 'borrowed' COMMENT '状态', PRIMARY KEY (record_id), KEY idx_borrow_reader (reader_id), KEY idx_borrow_book (book_id), KEY idx_borrow_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='借阅记录表';这个脚本里有几个点需要按你的环境调整。CHARACTER SET utf8mb4是必须的,很多老教材还在推utf8,但 MySQL 里的utf8最多只能存 3 字节,遇到中文和特殊字符混合的数据容易出问题。ENGINE一定要写 InnoDB,MyISAM 不支持事务和外键,而借书那套回滚逻辑完全依赖 InnoDB。DECIMAL(8,2)存金额而不是 FLOAT,是为了避免浮点精度误差。所有字段都带了COMMENT,后面做数据字典时可以直接从INFORMATION_SCHEMA.COLUMNS查出来生成,不用手工誊写。
表之间的关系由外键约束强制:book.category_id引用category.category_id,borrow_record的reader_id和book_id分别引用两张主表。课程设计里外键是明摆着的评分点,不要因为嫌插入数据麻烦就把外键删掉;后面单独有一节讲怎么在保留外键的情况下处理删除顺序。
2.4 数据字典与 ER 图:文档里最占分的那两张图
表建完之后,文档里的数据字典我建议直接用 SQL 生成,而不是在 Word 里手打表。手打容易跟实际库对不上,答辩演示时一旦被发现有字段对不上,很尴尬。
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = 'library_db' ORDER BY TABLE_NAME, ORDINAL_POSITION;执行结果导出成 CSV,再转成 Word 表格排版,半小时能做完整个数据字典章节,而且和库里的真实结构完全一致。
ER 图画法有约定俗成的套路:实体用矩形,属性用椭圆,关系用菱形。课程设计里最容易拿分的布局是“实体-联系图 + 关系模式”两段式:先画 ER 图,再在下方列出转换后的关系模式,例如:
- 读者(读者ID,学号,姓名,性别,院系,电话,邮箱,注册日期,状态)
- 图书(图书ID,ISBN,书名,作者,分类ID,出版社,出版日期,定价,可借库存,总馆藏,馆藏位置,状态)
- 借阅记录(记录ID,读者ID,图书ID,借书日期,应还日期,归还日期,续借次数,状态)
关系模式里下划线标主键,波浪线标外键。这一页放一张图加十个关系模式,概念结构和逻辑结构两个章节的主体内容就都有了。
3. 借书还书续借的 SQL 实现:事务、存储过程与统计报表
3.1 建库与测试数据:先父表后子表,后悔药先备好
建库建表脚本跑通之后,下一步是插入测试数据。我把测试数据单独拆成一个test_data.sql,和建表脚本分开,这样每次演示前都能把库重置回一个已知状态,不用靠记忆去清理脏数据。
-- 分类表数据 INSERT INTO category (category_name) VALUES ('计算机'), ('文学'), ('历史'), ('经济'); -- 读者数据 INSERT INTO reader (reader_no, name, gender, dept, phone, email, reg_date, status) VALUES ('2023001', '张明', '男', '计算机学院', '13800000001', 'zhangming@example.com', '2023-09-01', 1), ('2023002', '李华', '女', '外国语学院', '13800000002', 'lihua@example.com', '2023-09-02', 1); -- 图书数据,category_id 必须对应上面分类 INSERT INTO book (isbn, title, author, category_id, publisher, publish_date, price, stock, total_stock, location, status) VALUES ('9787111213826', '深入理解计算机系统', 'Randal E. Bryant', 1, '机械工业出版社', '2016-11-01', 139.00, 5, 5, 'A-01-01', 1), ('9787020002207', '红楼梦', '曹雪芹', 2, '人民文学出版社', '1996-12-01', 59.70, 3, 4, 'B-02-10', 1), ('9787101007725', '史记', '司马迁', 3, '中华书局', '1982-01-01', 126.00, 2, 2, 'C-01-03', 1);插入顺序有讲究:先插父表,再插子表。因为外键约束,插入图书数据时category_id必须已经存在于分类表里,否则会直接报“外键约束失败”。测试数据要覆盖“有较多库存”和“只剩少量库存”两种场景,方便后面验证借书事务的边界条件。如果插入失败,优先检查category_id是否对应得上一张表的自增值。
3.2 借书还书续借的事务边界:锁要加在判断之前
借书是整个系统里最核心的事务。它包含两个写操作:插入一条借阅记录、扣减一本库存。这两个操作要么都成功,要么都失败,所以必须包在同一个事务里。我这里给的是应用层分步执行的事务模式,完整带条件判断的版本会在存储过程一节给出。
START TRANSACTION; -- 1. 锁住读者行,状态判断到事务结束前不被其他事务改变 SELECT status, name FROM reader WHERE reader_id = 1 FOR UPDATE; -- 2. 锁住图书行,后面的库存判断才安全 SELECT stock, title FROM book WHERE book_id = 2 FOR UPDATE; -- 3. 判断通过后,插入借阅记录,应还日期按 30 天计算 INSERT INTO borrow_record (reader_id, book_id, borrow_date, due_date, status) VALUES (1, 2, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'borrowed'); -- 4. 扣减库存 UPDATE book SET stock = stock - 1 WHERE book_id = 2; COMMIT;这段 SQL 的关键不是那两条 INSERT 和 UPDATE,而是前面两个SELECT ... FOR UPDATE。它们的含义是“把这两行锁住,直到事务提交或回滚”。两个窗口同时借同一本书时,第二个事务会阻塞在SELECT ... FOR UPDATE上,直到第一个事务提交拿到最新库存,这样就不会出现两个人都判断“库存够”然后一起把库存扣成负数。DATE_ADD(CURDATE(), INTERVAL 30 DAY)是 MySQL 里最常用的日期计算写法,比在应用层拼日期字符串靠谱。
还书的逻辑正好相反:标记归还、把库存加回去。注意还书时不要只按record_id更新,一定要带上AND status = 'borrowed',否则一次重复点击还书会把库存加两次。
START TRANSACTION; -- 先把记录里的 book_id 取出来,后面加库存要用 SET @bid = (SELECT book_id FROM borrow_record WHERE record_id = 5); UPDATE borrow_record SET return_date = CURDATE(), status = 'returned' WHERE record_id = 5 AND status = 'borrowed'; UPDATE book SET stock = stock + 1 WHERE book_id = @bid; COMMIT;先取@bid再更新,是为了避免把“查 book_id”和“更新状态”挤在一条依赖子查询的 UPDATE 里,代码读起来绕,排障时也麻烦。如果第二步影响行数为 0,说明这条记录不存在、已经还过,或者状态异常,此时不该继续执行加库存。
续借就是“把应还日期往后推”,但必须限制次数,不然读者可以无限续借下去:
UPDATE borrow_record SET due_date = DATE_ADD(due_date, INTERVAL 30 DAY), renew_count = renew_count + 1 WHERE record_id = 5 AND status = 'borrowed' AND renew_count < 2;若影响行数为 0,原因只有三种:记录不存在、已经归还、续借次数到上限。应用层拿到影响行数为 0 后直接提示“续借失败”,不需要再去查一次库。
3.3 视图与存储过程:让评委看到你的数据库功力
视图最大的价值是把三条 JOIN 的查询固化下来,让应用层每次只查一个对象。我会在课设文档里建这样一个视图,因为它是“当前借阅详情页”和“逾期列表页”共用的数据源:
CREATE VIEW v_borrow_detail AS SELECT r.record_id, rd.reader_no, rd.name AS reader_name, b.title, b.isbn, r.borrow_date, r.due_date, r.return_date, r.status FROM borrow_record r JOIN reader rd ON r.reader_id = rd.reader_id JOIN book b ON r.book_id = b.book_id;建好之后,应用层查询借阅详情只需要SELECT * FROM v_borrow_detail WHERE status = 'borrowed',不用每次手写三条 JOIN,也不容易在拼接条件时漏掉关联字段。视图的好处在于它是逻辑表,不占物理存储,基础表结构调整后视图可以跟着重建,应用层代码不用动。
存储过程是我建议课设里一定要写一个的加分项。它能把“判断读者状态、判断库存、插入记录、扣库存、提交回滚”这一整套逻辑收进数据库端,应用层只需要一行CALL:
DELIMITER $$ CREATE PROCEDURE sp_borrow_book( IN p_reader_id INT, IN p_book_id INT, OUT p_msg VARCHAR(200) ) BEGIN DECLARE v_reader_status TINYINT; DECLARE v_stock INT; -- 捕获 SQL 异常时自动回滚,并把错误信息写到 p_msg DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_msg = '数据库异常,事务已回滚'; END; START TRANSACTION; SELECT status INTO v_reader_status FROM reader WHERE reader_id = p_reader_id FOR UPDATE; IF v_reader_status != 1 THEN SET p_msg = '读者状态异常,不可借书'; ROLLBACK; ELSE SELECT stock INTO v_stock FROM book WHERE book_id = p_book_id FOR UPDATE; IF v_stock < 1 THEN SET p_msg = '图书库存不足'; ROLLBACK; ELSE INSERT INTO borrow_record (reader_id, book_id, borrow_date, due_date, status) VALUES (p_reader_id, p_book_id, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'borrowed'); UPDATE book SET stock = stock - 1 WHERE book_id = p_book_id; COMMIT; SET p_msg = 'SUCCESS'; END IF; END IF; END$$ DELIMITER ;调用方式同样简单:
CALL sp_borrow_book(1, 2, @msg); SELECT @msg;这个存储过程里有三个设计点要能讲给评委听。第一,EXIT HANDLER FOR SQLEXCEPTION是异常处理的兜底,任何一条 SQL 报错都会自动回滚,不用应用层去猜失败原因。第二,OUT p_msg用 VARCHAR 而不是返回错误码,因为错误信息是给人看的,应用层拿到后直接弹窗展示。第三,过程中仍然保持“先锁读者行,再锁图书行”的顺序,这是下一章避坑部分会讲到的死锁问题的预防措施。
触发器也是常见的加分项。比如给借阅表加一个操作日志触发器,插入借阅记录时自动往日志表写一条留痕:
DELIMITER $$ CREATE TRIGGER trg_borrow_log AFTER INSERT ON borrow_record FOR EACH ROW BEGIN INSERT INTO operation_log (op_type, op_detail, op_time) VALUES ('借书', CONCAT('读者ID=', NEW.reader_id, ' 图书ID=', NEW.book_id), NOW()); END$$ DELIMITER ;触发器里NEW代表新插入的行,OLD代表被更新或删除前的行,这两个关键字几乎是必考。能主动在课设里用触发器并讲清楚NEW的语义,比文档里抄十遍“触发器是一种特殊的存储过程”有用得多。
3.4 三张统计报表的 SQL:查询模块不是摆设
图书管理系统除了借还书,统计报表也是演示时的亮点。我建议至少准备三张报表:热门图书、逾期未还、月度借阅量。它们分别对应 “GROUP BY 聚合”“多表 JOIN 带条件”“日期函数格式化”三个数据库常用考点。
-- 热门图书 TOP 10:按借阅次数倒序 SELECT b.book_id, b.title, COUNT(*) AS borrow_count FROM borrow_record r JOIN book b ON r.book_id = b.book_id GROUP BY b.book_id, b.title ORDER BY borrow_count DESC LIMIT 10;-- 逾期未还列表 SELECT r.record_id, rd.name AS reader_name, b.title, r.due_date FROM borrow_record r JOIN reader rd ON r.reader_id = rd.reader_id JOIN book b ON r.book_id = b.book_id WHERE r.status = 'borrowed' AND r.due_date < CURDATE();-- 月度借阅量统计 SELECT DATE_FORMAT(borrow_date, '%Y-%m') AS ym, COUNT(*) AS cnt FROM borrow_record GROUP BY DATE_FORMAT(borrow_date, '%Y-%m') ORDER BY ym DESC;第一段 SQL 的GROUP BY到底该写b.book_id还是b.book_id, b.title,取决于 MySQL 版本和ONLY_FULL_GROUP_BY模式。MySQL 8 默认开启该模式,所以我把b.title同时写进GROUP BY,保证在任意配置下都不会报错。第二段 SQL 是“逾期未还”报表的核心,注意status = 'borrowed'和due_date < CURDATE()缺一不可,前者排除已还记录,后者过滤出真正超期的。第三段DATE_FORMAT(borrow_date, '%Y-%m')是 MySQL 按年月分组的标准写法,比在程序里循环分组高效得多。
4. 应用层对接数据库:连接池参数与增删改查落地
4.1 技术栈选型:我为什么推荐 Python + PyMySQL
课程设计里常见的组合有那么几档:Java 原生 JDBC 加 Swing、Spring Boot 加 MyBatis、PHP 加 mysqli,还有 Python 加 PyMySQL。课设评委的验收口径一般很朴素:打开你的程序,走一遍借书还书流程,再打开数据库看表和数据变化。所以选型标准其实就三条:文档好写、答辩好讲、出了问题搜得到答案。
我一般建议课设用 Python 加 PyMySQL。原因不是 Java 不好,而是 Python 的代码量小,一个借书接口二十行能写明白,Java 加 JDBC 光连接管理、结果集遍历、资源关闭就要多写一倍代码。课设文档里贴代码时,Python 版也更容易让评委在几分钟内读明白业务流程,而不是纠结于语法。如果你已经学过 Spring Boot,用 MyBatis 也没问题,但务必保证你能脱稿讲清楚“一次借书请求到底经过哪些方法,最后执行了哪几条 SQL”。
下面是最小连接示例,先建立数据库连接:
import pymysql conn = pymysql.connect( host='127.0.0.1', port=3306, user='root', password='your_password', database='library_db', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor )这里charset='utf8mb4'必须和建库时的字符集保持一致,否则中文读写会出现编码错乱。DictCursor让查询结果返回字典而不是元组,代码里可以直接写row['title'],可读性好,答辩演示时也少很多“第几个下标是书名”的尴尬。
4.2 连接池参数:maxconnections、mincached、blocking 怎么设
每次查询都新建一个连接,数据库要重新握手、认证、分配内存,在高并发场景下开销很大。课程设计虽然并发量不高,但文档里写一段连接池配置是很实在的加分项,至少证明你考虑过“数据库连接”这种真实生产问题。我用的是DBUtils.PooledDB,它是和 PyMySQL 配合最顺手的连接池方案。
from dbutils.pooled_db import PooledDB pool = PooledDB( creator=pymysql, maxconnections=10, mincached=5, maxcached=8, blocking=True, ping=1, host='127.0.0.1', port=3306, user='root', password='your_password', database='library_db', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) def get_conn(): return pool.connection()连接池里这几个参数,课设按下面的表设置就够了:
| 参数 | 取值 | 说明 |
|---|---|---|
| maxconnections | 10 | 连接池最大连接数,超过后按 blocking 策略处理 |
| mincached | 5 | 启动后常驻的空闲连接数 |
| maxcached | 8 | 池中最多保留的空闲连接数 |
| blocking | True | 池满时请求排队等待,而不是直接抛异常 |
| ping | 1 | 取出连接前的存活校验,空闲超过 1 秒时自动探测 |
课设系统 QPS 很低,mincached=5已经足够日常演示。blocking=True时,并发高了请求会排队而不是直接报“连接不上”,界面表现更稳定,也避免了演示时突然弹错。ping=1防的是 MySQL 服务端空闲超时把连接断掉后,程序还在用死连接,这是很多“程序跑一会儿就报连接断开”的根源。每次用完连接后调用conn.close(),在连接池里不是真正关闭连接,而是把连接归还给池,这个点答辩时经常被问到。
4.3 核心接口的代码实现:查询、借书、还书
应用层最核心的三个接口是图书查询、借书、还书。查询接口负责首页的搜索框和结果列表,它必须参数化查询,防止 SQL 注入——这是课设里最容易丢分的安全考点。
def search_books(keyword, category_id=None): sql = """ SELECT b.book_id, b.title, b.author, b.isbn, c.category_name, b.stock, b.location, b.status FROM book b LEFT JOIN category c ON b.category_id = c.category_id WHERE b.title LIKE %s OR b.author LIKE %s OR b.isbn LIKE %s """ like = f"%{keyword}%" params = (like, like, like) if category_id: sql += " AND b.category_id = %s" params = (like, like, like, category_id) sql += " ORDER BY b.book_id DESC" conn = get_conn() try: with conn.cursor() as cur: cur.execute(sql, params) rows = cur.fetchall() return rows finally: conn.close()LIKE %s的参数化是把%keyword%作为一个整体传入,而不是拼 SQL 字符串。这样即使用户在搜索框输入' OR 1=1 --,也只会被当成普通字符去匹配。LEFT JOIN而不是INNER JOIN是因为如果某本书的分类被改成空值或没配分类,列表页仍然要显示这本书。conn.close()在finally里执行,保证即使查询报错连接也会归还给池,不会把连接池占满。
借书接口的业务逻辑和前面存储过程完全一致:先锁读者行、再锁图书行、判断后用事务包裹两个写操作。
def borrow_book(reader_id, book_id): conn = get_conn() try: with conn.cursor() as cur: cur.execute( "SELECT status, name FROM reader WHERE reader_id = %s FOR UPDATE", (reader_id,) ) reader = cur.fetchone() if not reader or reader['status'] != 1: return False, '读者不存在或已停借' cur.execute( "SELECT stock, title FROM book WHERE book_id = %s FOR UPDATE", (book_id,) ) book = cur.fetchone() if not book: return False, '图书不存在' if book['stock'] < 1: return False, f"图书《{book['title']}》库存不足" cur.execute( "INSERT INTO borrow_record " "(reader_id, book_id, borrow_date, due_date, status) " "VALUES (%s, %s, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'borrowed')", (reader_id, book_id) ) cur.execute( "UPDATE book SET stock = stock - 1 WHERE book_id = %s AND stock > 0", (book_id,) ) conn.commit() return True, '借书成功' except Exception as e: conn.rollback() return False, f'借书失败: {e}' finally: conn.close()这个 Python 版本和存储过程版的区别在于:判断逻辑放在应用层,数据库只负责锁和执行 SQL。两种方案都能用,但你要在答辩前想清楚自己讲的是哪一套,别文档里写“使用存储过程”,代码却全在应用层判断,评委对不上会产生怀疑。FOR UPDATE的顺序必须写成先reader后book,这是下一章死锁部分的预防约定,代码里也要保持同样的顺序。
还书接口相对简单,本质是一次更新,但同样要防重复还书。还书成功后再加一次库存,顺序不能反过来:
def return_book(record_id): conn = get_conn() try: with conn.cursor() as cur: cur.execute( "SELECT book_id FROM borrow_record WHERE record_id = %s AND status = 'borrowed' FOR UPDATE", (record_id,) ) row = cur.fetchone() if not row: return False, '借阅记录不存在或已归还' cur.execute( "UPDATE borrow_record SET return_date = CURDATE(), status = 'returned' " "WHERE record_id = %s", (record_id,) ) cur.execute( "UPDATE book SET stock = stock + 1 WHERE book_id = %s", (row['book_id'],) ) conn.commit() return True, '还书成功' except Exception as e: conn.rollback() return False, f'还书失败: {e}' finally: conn.close()先用一条FOR UPDATE的 SELECT 查出记录并锁住,再执行两条 UPDATE,这样即使两个窗口同时点“还书”,第二个窗口也会等第一个提交后再查,发现状态已经不是borrowed,直接返回“已归还”,不会把库存加两次。
4.4 业务逻辑别写在界面层:分层是隐形评分点
很多课设代码把 SQL 直接写在按钮回调里,点击“借书”按钮后,回调函数里连着写五条 SQL,事务、判断、异常处理全部混在一起。这种代码演示时能跑通,但答辩时几乎没法讲:评委问“如果库存不够这一步怎么处理”,你得在一坨回调代码里翻半天。
我建议哪怕 UI 只有几个按钮,也拆成三层:界面层只负责拿参数、调函数、弹结果;业务层放借书还书的完整事务逻辑;数据访问层只写单条 SQL 执行。界面层的借书按钮最终只有一行核心调用:
def on_borrow_click(self, reader_id, book_id): ok, msg = borrow_book(reader_id, book_id) messagebox.showinfo('结果', msg)真正的逻辑全在borrow_book里。这样文档里贴代码时,界面层贴一小段回调,业务层贴完整事务函数,评委一眼就能看出你懂分层。答辩时被问到任何业务细节,你都能定位到具体方法去讲,而不是在那几百行回调里现找。
5. 课程设计避坑指南:乱码、外键、死锁与连接失败的五个典型问题
5.1 中文乱码:查出来全是问号
现象:插入中文后数据库里存的是???,或者用 SELECT 查出来正常、一放到网页/桌面程序里就变问号。
原因:字符集在三个地方不一致。建库时用了utf8或没指定字符集,连接字符串没带charset=utf8mb4,以及终端工具显示用的字符集不是 UTF-8。
解决:先统一数据库字符集:
ALTER DATABASE library_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;再把连接参数补上charset='utf8mb4',最后把命令行终端的编码切到 UTF-8。Windows 的 cmd 里可以用chcp 65001切换。这三处一致后,中文乱码基本绝迹。如果还有个别表是老的,用ALTER TABLE 表名 CONVERT TO CHARACTER SET utf8mb4;转换表级字符集。
5.2 外键约束导致的删改失败:DELETE 被拒绝
现象:执行DELETE FROM category WHERE category_id = 3;时报错Cannot delete or update a parent row: a foreign key constraint fails。
原因:图书表里还有category_id = 3的书,外键约束不允许删掉仍被引用的父表记录。
解决:先处理子表记录,再删父表。常见做法是把该分类的书迁到其他分类,再删除分类:
UPDATE book SET category_id = 1 WHERE category_id = 3; DELETE FROM category WHERE category_id = 3;如果业务上允许“分类删了书还在”,可以在建外键时用ON DELETE SET NULL,但前提是book.category_id允许 NULL。这里要特别提醒:别图省事直接删外键约束。外键是课程设计里的显性评分点,答辩时聊外键设计比聊索引更容易出彩。你把约束留着,文档里写明“受保护的外键如何处理”,本身就是一个完整的知识点。
5.3 并发借同一本书:库存直接扣成负数
现象:两个窗口同时借《红楼梦》最后一本,两个窗口都提示借书成功,但stock变成了 -1。
原因:两个事务都先查stock = 1,判断“够”,然后各自执行UPDATE book SET stock = stock - 1。问题出在“判断库存”和“扣减库存”之间存在时间窗,这期间别的事务也读到了旧值。
解决:两种方案选一种即可。第一种是查库存时加FOR UPDATE,就像存储过程里写的那样,第二个事务会阻塞到第一个提交后读到最新值。第二种是把判断和扣减合并成一条原子 SQL:
UPDATE book SET stock = stock - 1 WHERE book_id = 2 AND stock > 0;这条语句自带行锁,影响行数为 0 就说明没借到。它的妙处在于stock > 0条件与stock = stock - 1的更新是原子的,无需额外加锁。但要注意,借书不止扣库存,还要插借阅记录,所以这条 UPDATE 必须和 INSERT 放在同一个事务里,否则会出现“扣了库存但没借阅记录”的不一致状态。
5.4 两个事务互相等待:死锁与锁顺序
现象:运行一段时间后偶发报错ERROR 1213: Deadlock found when trying to get lock; try restarting transaction,程序在借书或还书时随机失败。
原因:这是一个典型的交叉锁场景。事务 A 借书 1 还书 2,先锁书 1 再申请锁书 2;事务 B 借书 2 还书 1,先锁书 2 再申请锁书 1。两边各持一把锁,又都在等对方的锁,谁也等不到谁。
解决:让所有事务按固定顺序访问资源。比如约定“先锁读者行,再锁图书行”,并且借书、还书、续借三个流程全部遵守这个顺序。代码和存储过程里都保持同样顺序,死锁基本不会出现。如果仍然出现,用SHOW ENGINE INNODB STATUS查看输出里的LATEST DETECTED DEADLOCK段,那里会写明两个事务各持有哪把锁、等待哪把锁。排查死锁不是玄学,就是把两个事务的加锁顺序打印出来对比。
5.5 MySQL 8 连不上:认证插件与密码策略
现象:程序或 Navicat 连接 MySQL 时报Authentication plugin 'caching_sha2_password' cannot be loaded,或者干脆Access denied for user 'root'@'localhost'。
原因:MySQL 8.0 默认的认证插件从mysql_native_password换成了caching_sha2_password,而很多课设用的 PyMySQL 老版本、旧版图形客户端不支持这个新插件。
解决:把用户的认证插件改回兼容模式:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;如果不想改服务器的全局配置,也可以只在连接参数里指定认证插件,比如在 PyMySQL 的 connect 参数里追加auth_plugin='mysql_native_password'。另外提醒一句:密码别写死在源码里。课设里虽然不会真的泄露,但文档里写一句“配置文件通过环境变量注入,代码仓库不包含明文密码”,安全和规范的印象分会稳拿。改完认证方式后,记得把test_data.sql重新跑一遍,确认所有测试数据没有因为连接问题被污染。
6. .doc 交付物怎么组织,答辩前照着做一遍
6.1 章节骨架:这九块内容按顺序排
数据库课程设计的 .doc 文档没有统一国标,但大多数学校的验收口径大同小异。按下面的章节骨架写,基本能满足“结构完整、重点突出”的要求。
| 章节 | 核心内容 | 建议篇幅 |
|---|---|---|
| 需求分析 | 背景、功能需求、非功能需求 | 3-4 页 |
| 概念结构设计 | ER 图、实体属性说明 | 2-3 页 |
| 逻辑结构设计 | 关系模式、范式分析、数据字典 | 4-5 页 |
| 物理设计与实现 | 建库建表 SQL、索引设计 | 3-4 页 |
| 功能模块实现 | 借书、还书、续借、查询、统计的 SQL 与截图 | 8-10 页 |
| 系统测试 | 测试用例表、边界条件测试结果 | 2-3 页 |
| 总结与展望 | 遇到的问题、改进方向 | 1-2 页 |
| 参考文献 | 教材、官方文档、博客 | 1 页 |
| 附录 | 完整 SQL 脚本、test_data.sql | 不限 |
6.2 三张图让文档厚度翻倍
第一张是 ER 图,用 draw.io 或 Visio 画,实体、属性、关系缺一不可,画完对照建表 SQL 检查字段名是否一致。第二张是功能模块图,画出“图书管理、读者管理、借阅管理、统计报表”四个模块及下属功能,放在需求分析末尾。第三张是每个核心界面的截图,截图里一定要有真实查询出来的数据,不要截一个空列表。有这三张图,文档的整体观感会明显超过那些只有文字和代码的同学。
6.3 演示脚本:答辩前按这个顺序走一遍
答辩最忌讳现场现想操作步骤。我建议准备一张“操作-预期结果”对照表,按固定流程演示:登录管理员账号、搜索“计算机”、选中一本书借出、查看借阅记录确认库存减一、执行续借确认应还日期顺延、归还确认库存加一、打开统计报表查看 TOP 10。每一步对应一句“这条界面操作背后是哪条 SQL,事务怎么保证一致性”。演示前把 test_data.sql 重跑一遍,把数据库恢复到初始状态,避免上次演示留下的脏数据影响这次结果。
6.4 一个加分技巧:把操作日志表做进系统里
除了标准的五张表,我在课设里习惯加一张operation_log表,字段只需要op_id, op_type, op_detail, op_time, operator。借书、还书、续借时各写一条日志,界面甚至不需要展示日志,但答辩时打开这张表,能清楚看到“谁在什么时间借了哪本书”。这一手能让“系统留痕”从一句口号变成看得见的表数据,也正好呼应了前面 2.2 节说的受控冗余——日志表本身就是一种冗余,但它的价值远超那点存储开销。
我以前写课设时最常翻的车就是表设计没想清楚就急着建表,最后发现借阅记录查不出“是谁借的”只好回头硬改结构,连带着应用层代码全部重写。现在不管做什么题目,第一件事永远是先画实体关系图,再写建表 SQL,这个顺序救了我很多次。希望这篇笔记能帮你在数据库课程设计上少走一点弯路。
本文还有配套的精品资源,点击获取