简介:面向数据库课程设计与综合实践场景的图书借阅管理系统项目包,适合高校学生、初学者及需要完成课设答辩的开发者参考。内容围绕图书信息、读者档案、借阅记录等核心实体展开,涉及数据库表结构设计、关系模型与外键约束、SQL增删改查、借阅事务一致性、权限管理及性能优化等知识点,可帮助读者理解从建库建表到业务逻辑实现的完整流程。压缩包共81个文件,约11.12MB,包含Java源码与编译后的class文件、数据库mdf/ldf数据文件、jar依赖、项目配置及界面图片和说明文档,便于直接导入运行、对照调试与二次整理。目前已有1548人学习下载,适合作为课设模板、实验参考和数据库应用开发的入门案例。
1. 从一次「超期天数算成负数」说起:图书借阅管理系统到底要卡住哪几个点
做数据库课设,图书借阅管理系统是被选得最多的题目,也是被做得最薄的一个。借书、还书听着只有两个动作,真落到数据库上,问题立刻具体起来:应还日期存成字符串之后,超期天数会算出负数;同一本馆藏副本被两个窗口同时点借,两条记录都能插进去;读者注销了,三年前的借阅历史跟着一起消失。这套方案要解决的就是这三件事——把需求拆成能画进报告的 E-R 图,把 E-R 图落成带约束和索引的 InnoDB 表,再用事务和查询把借还闭环跑通并留出验收路径。适合正在赶数据库课设、需要一份能演示能答辩的完整设计的同学,也适合拿它当模板改字段的从业者。下面按需求、表结构、业务实现、避坑、验收的顺序走一遍。
2. 需求与概念设计:三类角色、六个用例和一张能画进报告的 E-R 图
课设最容易翻车的地方不在写 SQL,而在动手建表之前没把需求收敛住。我见过不少同学上来就CREATE TABLE book,写到第三天发现「一本书有多本副本」这件事没地方放,只能回头改表、改代码、改报告,前面的 ER 图全部作废。所以这一章先把角色、用例、实体关系定死,后面所有 DDL 都是它的直接翻译。
2.1 三类角色与六个必须跑通的用例
角色只有三类,不要贪多。读者(学生、教师、校外读者)面向检索和自助;图书管理员面向柜台业务;系统管理员面向账号、参数和报表。三类角色的权限边界决定了后面staff表和reader表必须分开建,而不是塞进一张user表加个type字段——读者的可借上限、证件状态和馆员的登录凭据、角色位是完全不同的两组属性,混在一起会让字段一半是 NULL。
六个核心用例按优先级排:
- 检索图书:按书名、作者、ISBN、分类查,返回「这本书一共几本、现在可借几本」。
- 借书:扫读者证 + 扫副本条码,校验读者状态和可借上限,写借阅记录,改副本状态。
- 还书:按副本条码找到未还记录,算超期天数,生成罚金,改副本状态。
- 续借:未超期、未被他人预约时,应还日期顺延一次,最多一次。
- 预约:某书目全部副本借出时可排队,到书后保留三天。
- 罚金处理:查询、缴纳、减免,缴纳后不能重复收。
每个用例要能说清三件事:输入是什么(读者证号、副本条码)、校验什么(状态位、上限、是否超期)、输出什么(成功码或失败原因)。答辩时老师最爱追问的就是「读者证冻结了还能不能还书」——能,还书是归还义务,不受借出权限限制。这类边界在需求阶段写清楚,代码里才不会到处打补丁。
2.2 从用例到 E-R 图:四个实体、三条联系和一个关键决策
实体四个:读者、书目、馆藏副本、管理员。联系三条:读者与副本之间是多对多,属性是借阅日期、应还日期、归还日期、续借次数;读者与书目之间是多对多,属性是预约时间、保留截止时间;书目与副本之间是一对多。
这里有一个决定整个课设质量的分水岭:书目和副本必须拆成两张表。《数据库系统概论》里那句「同一本书可以有多本副本」不是一句废话,它意味着「书名、作者、定价」属于书目,「条码、架位、在架状态」属于副本。拆开之后,「这本书有几本可借」是一次带GROUP BY的统计查询;不拆,你就得在book表里塞一个stock字段,然后每次借还都要去维护它,一旦某次事务回滚没维护上,账就永远对不平。
分类做自关联,parent_id指向同表的category_id,支撑「计算机 / 数据库 / 教材」这种三级类目。这条自关联外键在课设里属于加分项,因为它能顺带讲清楚递归查询怎么写(MySQL 8 的WITH RECURSIVE)。
2.3 技术选型:为什么是 MySQL 8 + InnoDB,而不是 SQLite 或文档数据库
课设场景的约束很实在:单机演示、要交一份能跑的 SQL 脚本、答辩要讲事务和索引。按这三条筛,选择面其实很窄。
SQLite 装起来最省事,但它的写操作是库级锁,演示「两个窗口同时借同一本副本」这个并发场景时表现不出行锁;更麻烦的是外键约束默认关闭,报告里画的那些参照完整性在数据库层面根本没生效,答辩被问到「你这个外键真的起作用吗」就只能含糊过去。文档数据库的问题是另一个方向:它没有范式、没有多表 JOIN、没有存储过程,而课设的评分点恰恰集中在这些地方。
MySQL 8 是比较稳的答案:InnoDB 支持行级锁和真正的 ACID 事务,EXPLAIN输出足够详细,8.0 之后窗口函数、CTE、生成列、EXPLAIN ANALYZE都有了,做排行榜和递归分类不用绕路。字符集统一utf8mb4,避免书名里出现生僻字或特殊符号时插入失败。存储引擎在每张表上显式写ENGINE=InnoDB,不要依赖默认值——换个环境的默认引擎就变成 MyISAM,事务静默失效,这种玄学问题排查起来非常费时间。
2.4 字段清单与全局约定
先定约定,再写表,能省掉大量返工。全局约定四条:主键统一用自增无符号整型,业务字段(ISBN、条码、借阅证号)只做唯一索引不做主键,因为它们会变;金额一律DECIMAL(8,2),绝不用FLOAT,浮点误差会让罚金出现 0.30000000000000004 这种数字;状态位用TINYINT加注释,不用ENUM,因为ENUM加一个值就要ALTER TABLE;时间字段分两类,只关心日期用DATE,需要精确时刻用DATETIME并带DEFAULT CURRENT_TIMESTAMP。
| 表名 | 职责 | 关键字段 | 预估行数(课设演示) |
|---|---|---|---|
| category | 图书分类,自关联 | category_id、name、parent_id | 30 |
| book | 书目信息 | book_id、isbn、title、price | 500 |
| book_copy | 馆藏副本 | copy_id、book_id、barcode、status | 1500 |
| reader | 读者档案 | reader_id、card_no、max_borrow、status | 200 |
| staff | 管理员账号 | staff_id、account、pwd_hash、role | 5 |
| borrow_record | 借阅流水 | record_id、copy_id、reader_id、due_date | 5000 |
| fine | 罚金记录 | fine_id、record_id、amount、paid | 300 |
| reservation | 预约队列 | reserve_id、book_id、reader_id、status | 100 |
八张表,正好覆盖课设要求里常见的「不少于六张表、包含一对一/一对多/多对多三种联系」。行数按演示规模估,别一上来灌十万条,EXPLAIN的rows估算在没有统计信息时会很难看,反而讲不清索引效果。
3. 表结构落地:8 张 InnoDB 表的字段取舍、约束与 6 个索引
这一章是整篇的核心交付物。下面这套 DDL 可以直接拿去跑,也可以按自己的业务字段改。每个字段的类型选择后面都跟一句理由——答辩时被问「为什么 ISBN 不用数字类型」,你得答得出来。
3.1 建库与四张基础表:分类、书目、副本、读者
CREATE DATABASE IF NOT EXISTS library_ms DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci; USE library_ms; -- 分类:自关联,支持三级类目 CREATE TABLE category ( category_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(40) NOT NULL COMMENT '类目名', parent_id INT UNSIGNED DEFAULT NULL COMMENT 'NULL 表示一级类目', sort_no SMALLINT NOT NULL DEFAULT 0, UNIQUE KEY uk_category_name (parent_id, name), CONSTRAINT fk_category_parent FOREIGN KEY (parent_id) REFERENCES category (category_id) ) ENGINE=InnoDB COMMENT='图书分类'; -- 书目:只放"这本书是什么",不放"有几本" CREATE TABLE book ( book_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(17) NOT NULL COMMENT 'ISBN-13,可能带连字符和校验位 X', title VARCHAR(120) NOT NULL, author VARCHAR(80) NOT NULL, publisher VARCHAR(80) DEFAULT NULL, publish_date DATE DEFAULT NULL, category_id INT UNSIGNED NOT NULL, price DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '定价,罚金封顶用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_book_isbn (isbn), KEY idx_book_category (category_id), KEY idx_book_title (title(20)) COMMENT '前缀索引,支撑书名左匹配检索', CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES category (category_id) ) ENGINE=InnoDB COMMENT='书目'; -- 馆藏副本:每一本实体书一行 CREATE TABLE book_copy ( copy_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, book_id BIGINT UNSIGNED NOT NULL, barcode VARCHAR(20) NOT NULL COMMENT '贴在书脊上的馆藏条码', location VARCHAR(30) NOT NULL DEFAULT 'A-01' COMMENT '架位号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0在架 1借出 2预约待取 3维修 4遗失', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_copy_barcode (barcode), KEY idx_copy_book_status (book_id, status), CONSTRAINT fk_copy_book FOREIGN KEY (book_id) REFERENCES book (book_id) ) ENGINE=InnoDB COMMENT='馆藏副本'; -- 读者:与 staff 分开,字段集合完全不同 CREATE TABLE reader ( reader_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, card_no VARCHAR(16) NOT NULL COMMENT '借阅证号', name VARCHAR(30) NOT NULL, reader_type TINYINT NOT NULL DEFAULT 1 COMMENT '1学生 2教师 3校外', dept VARCHAR(40) DEFAULT NULL, max_borrow TINYINT UNSIGNED NOT NULL DEFAULT 5 COMMENT '可借上限', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结 2注销', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_reader_card (card_no), KEY idx_reader_status (status) ) ENGINE=InnoDB COMMENT='读者档案';几个类型选择的理由值得在报告里写一段。isbn用VARCHAR(17)而不是BIGINT,因为 ISBN-13 可能带连字符,最后一们还可能是校验位X,用整数类型会在插入时报错或者被迫截断;price用DECIMAL(8,2)是因为罚金要按「不超过书价」封顶,用浮点数比较大小会出现边界误差;status全用TINYINT是因为状态位会随需求增删,ENUM每加一个值都要改表结构,而且ENUM的隐式类型转换在WHERE里很容易踩坑。
reader和staff分开建,是 2.1 节角色划分的直接结果。合并成一张user表的话,馆员行的max_borrow、dept全是 NULL,读者行的pwd_hash、role也全是 NULL,一张表一半字段半空,第三范式已经破了一半。
3.2 三张业务表:借阅记录、罚金、预约
-- 借阅记录:只存外键和状态,书名读者名靠 JOIN 取 CREATE TABLE borrow_record ( record_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, copy_id BIGINT UNSIGNED NOT NULL, reader_id BIGINT UNSIGNED NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL COMMENT '应还日期 = borrow_date + 借期天数', return_date DATE DEFAULT NULL COMMENT 'NULL 表示尚未归还', renew_count TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '续借次数,上限 1', status TINYINT NOT NULL DEFAULT 0 COMMENT '0在借 1已还 2超期未还 3遗失', operator_id SMALLINT UNSIGNED DEFAULT NULL COMMENT '经办馆员', -- 生成列:未还时等于 copy_id,已还时为 NULL active_copy BIGINT UNSIGNED GENERATED ALWAYS AS (IF(return_date IS NULL, copy_id, NULL)) STORED COMMENT '用于唯一约束', KEY idx_br_reader_status (reader_id, status, due_date), KEY idx_br_copy (copy_id, return_date), KEY idx_br_due (status, due_date), UNIQUE KEY uk_br_active_copy (active_copy), CONSTRAINT fk_br_copy FOREIGN KEY (copy_id) REFERENCES book_copy (copy_id), CONSTRAINT fk_br_reader FOREIGN KEY (reader_id) REFERENCES reader (reader_id) ) ENGINE=InnoDB COMMENT='借阅流水'; -- 罚金:一条借阅记录最多一条罚金 CREATE TABLE fine ( fine_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, record_id BIGINT UNSIGNED NOT NULL, overdue_days SMALLINT UNSIGNED NOT NULL, amount DECIMAL(8,2) NOT NULL, paid TINYINT NOT NULL DEFAULT 0 COMMENT '0未缴 1已缴 2减免', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME DEFAULT NULL, UNIQUE KEY uk_fine_record (record_id), CONSTRAINT fk_fine_record FOREIGN KEY (record_id) REFERENCES borrow_record (record_id) ) ENGINE=InnoDB COMMENT='罚金'; -- 预约:同一读者对同一书目只允许一条有效预约(应用层校验 + 索引加速) CREATE TABLE reservation ( reserve_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, book_id BIGINT UNSIGNED NOT NULL, reader_id BIGINT UNSIGNED NOT NULL, reserve_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expire_at DATETIME NOT NULL COMMENT '到书后保留三天', status TINYINT NOT NULL DEFAULT 0 COMMENT '0排队 1待取 2已取 3过期 4取消', KEY idx_rs_book_status (book_id, status, reserve_at), KEY idx_rs_reader (reader_id, status), CONSTRAINT fk_rs_book FOREIGN KEY (book_id) REFERENCES book (book_id), CONSTRAINT fk_rs_reader FOREIGN KEY (reader_id) REFERENCES reader (reader_id) ) ENGINE=InnoDB COMMENT='预约队列';borrow_record里那个active_copy生成列是这套设计里最值钱的一处。业务规则是「一本副本同时只能有一条未归还记录」,正常情况下应用层会先查一遍再插入,但两个人同时点借同一本副本时,两个事务都查到「无未还记录」,都去插入,结果两条记录同时存在,库存直接崩。加上这个生成列和唯一索引之后,数据库层面兜住了这条规则:未还时active_copy = copy_id,唯一索引生效;归还后变成 NULL,而 MySQL 的唯一索引允许多个 NULL,历史记录不受影响。
还有一条硬规则:borrow_record里不存书名、不存读者姓名。存了就是冗余,读者改了名字要同步更新几千条历史记录,改漏一条就出现两份不一致的数据。报表要展示书名,用 JOIN 或者建视图,这是第三范式最直观的一次应用。
3.3 索引设计:6 个索引分别抵挡哪种查询
索引不是越多越好,每个索引都要能说清「它服务于哪条 SQL」。下面这张表可以直接贴进报告。
| 索引名 | 所在表 | 索引字段 | 服务的查询 | 预期 type |
|---|---|---|---|---|
| uk_br_active_copy | borrow_record | active_copy(唯一) | 防同一副本重复在借 | const |
| idx_br_reader_status | borrow_record | (reader_id, status, due_date) | 我的借阅、可借数统计 | ref |
| idx_br_due | borrow_record | (status, due_date) | 每日超期扫描 | range |
| idx_copy_book_status | book_copy | (book_id, status) | 某书可借副本数 | ref |
| idx_book_title | book | title(20) 前缀 | 书名模糊检索 | range |
| idx_rs_book_status | reservation | (book_id, status, reserve_at) | 预约队列取队首 | ref |
联合索引的字段顺序不是随便排的。idx_br_reader_status把reader_id放第一位,是因为「某读者的借阅列表」是最热的查询,等值条件在前、范围条件在后,才能走到ref而不是退化成index全扫。idx_br_due反过来,把低基数的status放前面,因为超期扫描的条件是status IN (0,2) AND due_date < CURDATE(),扫的是时间范围。
title上用前缀索引title(20),是因为书名平均长度在 20 字符左右,全字段索引会让索引体积翻几倍,而前 20 个字符的选择性已经够用。代价是ORDER BY title用不上这个索引,如果确实要按书名排序,再考虑加全文索引或者独立的排序字段。
3.4 用 EXPLAIN 验证索引是否真的被用上
建完索引不等于用上索引。写报告时至少跑三条EXPLAIN,把结果贴进去,说明优化前后的差别。
-- 慢查询:某读者当前在借的书 EXPLAIN ANALYZE SELECT br.record_id, b.title, br.due_date FROM borrow_record br JOIN book_copy bc ON bc.copy_id = br.copy_id JOIN book b ON b.book_id = bc.book_id WHERE br.reader_id = 1001 AND br.status IN (0, 2);看三个字段就够:type是ref说明走了索引,是ALL说明全表扫;key显示实际用的索引名;rows是预估扫描行数。如果key是 NULL,八成是两件事之一——条件列上套了函数(WHERE YEAR(due_date) = 2024这种写法会让索引失效),或者列的类型和参数类型不一致触发了隐式转换(字符集不同的两张表 JOIN 是最常见的隐式转换来源)。
提示:
EXPLAIN ANALYZE在 MySQL 8.0.18 之后才有,低版本用EXPLAIN加SHOW WARNINGS看优化器改写结果。演示环境版本要提前确认,现场切版本很尴尬。
4. 借还闭环实现:一次事务里的五个步骤与超期罚金的封顶算法
第 3 章把表立起来了,这一章让它动起来。借书是整个系统里唯一必须用事务的操作:校验、插记录、改状态三件事要么全成,要么全不成。只要有一处漏在事务外面,演示时就会出现「记录写进去了但书还在架上」这种一眼假的场面。
4.1 借书事务:五步操作与固定的加锁顺序
先看存储过程版本,适合把业务逻辑压在数据库里、答辩时直接演示 SQL 调用的场景。
DELIMITER $$ CREATE PROCEDURE sp_borrow_book( IN p_reader_id BIGINT UNSIGNED, IN p_copy_id BIGINT UNSIGNED, IN p_days TINYINT UNSIGNED, OUT p_code INT, OUT p_msg VARCHAR(64) ) proc: BEGIN DECLARE v_copy_status TINYINT; DECLARE v_reader_status TINYINT; DECLARE v_max TINYINT; DECLARE v_on_loan INT; -- 任何 SQL 异常都回滚,避免半截事务污染数据 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_code = -1, p_msg = '系统异常,已回滚'; END; START TRANSACTION; -- 步骤 1:锁定副本行。FOR UPDATE 是防并发的关键 SELECT status INTO v_copy_status FROM book_copy WHERE copy_id = p_copy_id FOR UPDATE; IF v_copy_status IS NULL OR v_copy_status <> 0 THEN ROLLBACK; SET p_code = 1001, p_msg = '该副本不在架'; LEAVE proc; END IF; -- 步骤 2:校验读者证状态与可借上限 SELECT status, max_borrow INTO v_reader_status, v_max FROM reader WHERE reader_id = p_reader_id; IF v_reader_status <> 1 THEN ROLLBACK; SET p_code = 1002, p_msg = '读者证状态异常'; LEAVE proc; END IF; SELECT COUNT(*) INTO v_on_loan FROM borrow_record WHERE reader_id = p_reader_id AND status IN (0, 2); IF v_on_loan >= v_max THEN ROLLBACK; SET p_code = 1003, p_msg = '已达可借上限'; LEAVE proc; END IF; -- 步骤 3:写借阅记录,唯一索引兜底重复借出 INSERT INTO borrow_record (copy_id, reader_id, borrow_date, due_date) VALUES (p_copy_id, p_reader_id, CURDATE(), DATE_ADD(CURDATE(), INTERVAL p_days DAY)); -- 步骤 4:带条件更新副本状态,用影响行数二次确认 UPDATE book_copy SET status = 1 WHERE copy_id = p_copy_id AND status = 0; IF ROW_COUNT() <> 1 THEN ROLLBACK; SET p_code = 1004, p_msg = '并发冲突,请重试'; LEAVE proc; END IF; COMMIT; SET p_code = 0, p_msg = '借阅成功'; END$$ DELIMITER ;调用方式要带上两个输出参数:
CALL sp_borrow_book(1001, 20031, 30, @code, @msg); SELECT @code, @msg;几个细节值得在报告里点出来。FOR UPDATE加在副本行上,锁的粒度是行级,两个窗口同时借同一本副本时,后到的事务会阻塞在SELECT ... FOR UPDATE上,等前一个提交后读到status = 1,走到 1001 分支返回「不在架」。UPDATE后面那个AND status = 0是第二次保险,配合ROW_COUNT()判断,即使隔离级别调到READ COMMITTED也不会重复改。加锁顺序固定为副本 → 读者 → 记录,全系统所有事务都按这个顺序拿锁,就不会出现 A 等 B、B 等 A 的死锁。
p_days做成参数而不是写死 30,是为了让教师和学生的借期能分开配置,演示时改一个参数就能说明「借期可配置」这件事。
4.2 还书与超期罚金:按天计算、按书价封顶
罚金规则先定死,再写代码:每天 0.2 元,从应还日期的次日算起,总额不超过该书定价。这条「封顶」规则是整个计算里唯一有业务含义的判断,也是最容易被忽略的地方——不封顶的话,一本 30 元的书超期一年罚 73 元,读者直接不还了。
DELIMITER $$ CREATE PROCEDURE sp_return_book( IN p_record_id BIGINT UNSIGNED, OUT p_fine DECIMAL(8,2), OUT p_days INT ) BEGIN DECLARE v_due DATE; DECLARE v_price DECIMAL(8,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 锁住未还记录,防止重复还书 SELECT br.due_date, b.price INTO v_due, v_price FROM borrow_record br JOIN book_copy bc ON bc.copy_id = br.copy_id JOIN book b ON b.book_id = bc.book_id WHERE br.record_id = p_record_id AND br.return_date IS NULL FOR UPDATE; -- 超期天数,未超期为 0,不会出现负数 SET p_days = GREATEST(DATEDIFF(CURDATE(), v_due), 0); UPDATE borrow_record SET return_date = CURDATE(), status = 1 WHERE record_id = p_record_id; UPDATE book_copy SET status = 0 WHERE copy_id = (SELECT copy_id FROM borrow_record WHERE record_id = p_record_id); IF p_days > 0 THEN SET p_fine = LEAST(p_days * 0.20, v_price); INSERT INTO fine (record_id, overdue_days, amount) VALUES (p_record_id, p_days, p_fine) AS new ON DUPLICATE KEY UPDATE overdue_days = new.overdue_days, amount = new.amount; ELSE SET p_fine = 0.00; END IF; COMMIT; END$$ DELIMITER ;GREATEST(..., 0)解决的是提前还书时DATEDIFF返回负数的问题,不写这一句,提前三天还书会算成 -3 天,罚金变成 -0.6 元,账面上凭空多出一笔退款。LEAST(p_days * 0.20, v_price)就是封顶。ON DUPLICATE KEY UPDATE配合fine表上record_id的唯一索引,让重复还书变成幂等操作——第二次调用时return_date已经不为 NULL,SELECT ... FOR UPDATE查不到行,两个变量保持 NULL,后面的DATEDIFF得到 NULL,GREATEST(NULL, 0)返回 NULL,IF NULL > 0走 else 分支,罚金返回 0,不会重复收钱。
注意:
INSERT ... VALUES (...) AS new的别名写法要求 MySQL 8.0.19 及以上。用低版本的话改回VALUES(col)形式,功能一致,只是写法旧一些。
4.3 应用层实现:什么时候该把事务写在代码里
存储过程适合答辩演示,但真实项目里业务逻辑多半写在应用层。两种都写一遍,报告里做一次对比,是很加分的做法。Python 版本用pymysql,关键点只有三个:手动开事务、异常回滚、加锁顺序和存储过程保持一致。
import pymysql class BizError(Exception): def __init__(self, code, msg): self.code, self.msg = code, msg def borrow(conn, reader_id: int, copy_id: int, days: int = 30) -> int: """借书。返回 0 表示成功,异常时抛 BizError。""" with conn.cursor() as cur: conn.begin() # 关闭自动提交,显式开事务 try: # 加锁顺序:副本 -> 读者 -> 借阅记录,与存储过程保持一致 cur.execute( "SELECT status FROM book_copy WHERE copy_id=%s FOR UPDATE", (copy_id,), ) row = cur.fetchone() if row is None or row[0] != 0: raise BizError(1001, "该副本不在架") cur.execute( "SELECT status, max_borrow FROM reader WHERE reader_id=%s", (reader_id,), ) status, limit_ = cur.fetchone() if status != 1: raise BizError(1002, "读者证状态异常") cur.execute( "SELECT COUNT(*) FROM borrow_record " "WHERE reader_id=%s AND status IN (0, 2)", (reader_id,), ) if cur.fetchone()[0] >= limit_: raise BizError(1003, "已达可借上限") cur.execute( "INSERT INTO borrow_record(copy_id, reader_id, borrow_date, due_date) " "VALUES (%s, %s, CURDATE(), DATE_ADD(CURDATE(), INTERVAL %s DAY))", (copy_id, reader_id, days), ) cur.execute( "UPDATE book_copy SET status=1 WHERE copy_id=%s AND status=0", (copy_id,), ) if cur.rowcount != 1: raise BizError(1004, "并发冲突,请重试") conn.commit() return 0 except Exception: conn.rollback() # 任何异常都回滚,不留半截数据 raiseconn.begin()之前要确保连接参数里autocommit=False,否则每条语句自己提交,rollback()什么也撤不回来——这是课设里出现频率最高的翻车点之一,后面第 5 章还会细说。cur.rowcount的判断和存储过程里ROW_COUNT()的作用一样,是给并发上的第二道锁。
4.4 状态机:把借阅记录和预约队列的生命周期画清楚
状态位不定死,代码里就会出现status = 1一会儿表示「已还」、一会儿表示「超期」的混乱。借阅记录四个状态:0 在借、1 已还、2 超期未还、3 遗失。允许的迁移只有四条:0→1(正常还)、0→2(到期未还,由定时任务批量刷)、2→1(超期后归还)、0/2→3(挂失)。其余迁移一律拒绝,落到代码里就是一个状态校验。
预约队列五个状态:0 排队、1 待取、2 已取、3 过期、4 取消。触发迁移的事件很明确:有副本归还且队列非空时,队首 0→1,同时写expire_at = NOW() + 3 天;读者来取书 1→2;超过expire_at由任务刷成 3;读者主动取消 0/1→4。定时任务用一条 SQL 就够了:
-- 每天凌晨执行:把已过保留期的预约置为过期 UPDATE reservation SET status = 3 WHERE status = 1 AND expire_at < NOW();把状态机写进报告,答辩时被问「你怎么保证数据一致」就有了具体抓手,比空谈「用了事务」有说服力得多。
5. 避坑清单:课设里最容易翻车的 5 个数据库问题
下面这五条都是我在改别人课设、以及自己踩过之后总结出来的,每一条都在演示现场真实发生过。按「现象 → 原因 → 解决」写,方便对照排查。
5.1 超期天数算出负数,或者整整差一天
现象:读者提前两天还书,罚金显示 -0.4 元;或者明明昨天到期、今天来还,系统显示「未超期」。
原因:due_date存成了VARCHAR,比较时按字符串比大小,'2024-9-5'和'2024-09-05'判等失败;更常见的是用TIMESTAMPDIFF(HOUR, due_date, NOW()) / 24算天数,除出来带小数,提前还书时直接是负数,没人做GREATEST兜底。
解决:日期字段一律用DATE,只到天,从根上消灭时分秒带来的边界问题。天数计算统一用DATEDIFF(CURDATE(), due_date),外面套GREATEST(..., 0)。到期判断写成due_date < CURDATE()而不是<=,这样「今天到期、今天还」算正常归还。视图或者报表里如果需要精确到小时,用DATETIME另存一列returned_at,别拿它去算天数。
5.2 副本状态和在架数量对不上,盘点永远差一本
现象:页面上显示某书「可借 2 本」,实际去书架只找到 1 本;或者明明全部借出,检索结果里还剩一本。
原因:在book表里冗余了一个stock字段,借书时减一、还书时加一,但某次借书事务中途异常回滚,borrow_record没写进去,stock却已经减了;也可能两个窗口同时借,stock = stock - 1各减一次,实际只借出一本。冗余字段成了两套真相。
解决:删掉book.stock,可借数量一律实时统计,用SUM(bc.status = 0)从book_copy算出来。数据量大了再加一层物化视图或者定时汇总表,但汇总表只能当缓存,出问题要能以book_copy为准重建。如果确实需要保留冗余字段,必须和状态更新放在同一个事务里,并且加一条定期对账 SQL 作为兜底。
-- 对账:找出冗余数量与真实副本数不一致的书目 SELECT b.book_id, b.title, b.total_copies, COUNT(bc.copy_id) AS real_copies FROM book b LEFT JOIN book_copy bc ON bc.book_id = b.book_id GROUP BY b.book_id, b.title, b.total_copies HAVING b.total_copies <> COUNT(bc.copy_id);5.3 借书提示成功,但记录没落库
现象:点「借阅」返回成功,刷新页面,这本书还在可借列表里,读者名下也没有记录。
原因:连接开启了自动提交,代码里conn.commit()之前抛了异常被try/except吞掉,没有rollback;或者反过来,事务开了但业务代码在commit之前就return了,后半段语句根本没执行。还有一种隐蔽情况:应用层用了连接池,上一个请求的事务没提交就归还了连接,下一个请求拿到同一条连接,把脏数据一起提交了。
解决:事务边界要画在业务方法的入口和出口,用try/except/finally保证任何路径都走到commit或rollback。连接池配置里显式设autocommit=False,用完连接前先rollback()一次清掉残留事务。日志里打上事务 ID 和关键 SQL 的入参,出问题时能顺着日志复现。测试时故意在INSERT之后抛一个异常,看数据是不是干净地回滚了,这个用例必须写进验收清单。
5.4 删一个读者,把三年的借阅历史一起删了
现象:管理员清理一个注销读者,DELETE FROM reader WHERE reader_id = ?执行完,borrow_record里这个人的记录全没了,历史借阅统计归零,罚款记录也跟着消失。
原因:建外键时顺手写了ON DELETE CASCADE,本意是「删主表时清理从表」,但借阅记录是业务流水,不是附属数据,它必须长期保留。层级搞反了,读者被删,流水跟着陪葬。
解决:业务表上的外键统一用ON DELETE RESTRICT,删不掉就说明还有关联数据,让业务层先处理。读者注销走状态位,status = 2,检索和借书时过滤掉,历史记录原样保留。只有纯配置类表(比如分类下的子分类)才考虑级联。建表之后跑一遍SHOW CREATE TABLE borrow_record,把外键规则贴进报告,说明每一处RESTRICT的理由。
5.5 两个人同时借同一本副本,两条记录都成功了
现象:并发测试时用两个会话同时调借书接口,两个都返回成功,borrow_record里出现两条copy_id相同、return_date都为 NULL 的记录。
原因:校验和插入之间没有锁。事务隔离级别是REPEATABLE READ,两个事务各自快照读都看到「无人借」,然后各自插入,快照读不加锁,谁也拦不住谁。有些实现里加了FOR UPDATE,但锁的是reader行不是copy行,锁错了对象。
解决:三层防护一起上。第一层,SELECT ... FOR UPDATE锁副本行,把并发串行化;第二层,UPDATE ... WHERE status = 0加ROW_COUNT()判断,兜住漏锁的情况;第三层,borrow_record.active_copy生成列加唯一索引,数据库层面直接拒绝第二条未还记录。第三层最硬,前两层是给用户友好提示用的。
-- 并发验证:开两个会话,先在各会话里执行 START TRANSACTION; SELECT status FROM book_copy WHERE copy_id = 20031 FOR UPDATE; -- 会话 A 执行完整借书流程并 COMMIT 后,会话 B 再执行 -- 预期:B 读到 status = 1,返回 1001「该副本不在架」提示:并发测试别用同一个客户端的两条查询窗口糊弄,很多图形工具会复用同一条连接,
FOR UPDATE看起来生效了,其实是同一个事务在跟自己玩。用两个独立连接或者两个命令行会话。
6. 验收与答辩:12 项自检清单和 20
本文还有配套的精品资源,点击获取