ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图书管理系统MySQL数据库设计:ER图、范式与事务实现

图书管理系统MySQL数据库设计:ER图、范式与事务实现 简介一份基于MySQL的图书管理系统数据库设计文档面向数据库课程设计、毕业设计及需要掌握关系型数据库建模的初学者。资源为单个docx文件大小852KB按题目概述、需求分析、概要设计、逻辑结构设计、程序设计的顺序展开配有数据流图和ER图并附数据库源代码体系非常完整。文档详细给出图书、读者、借阅记录等核心表的字段设计与关联约束涵盖图书管理、读者管理、借阅服务、统计分析、系统管理等功能模块并包含单表查询、借书操作、超期处理、还书操作、书籍状态管理等SQL示例还阐述了用户权限控制、数据完整性与备份恢复等安全设计能够帮助读者从零搭建一个可运行的图书管理数据库。目前已有5891人学习既适合作为课设参考也可供开发人员快速理解业务数据建模流程。1. 为什么图书管理系统要从数据库设计开始很多人在做图书管理系统时第一反应是先把项目建起来、画几个页面等写到“借书”这个功能才意识到一个尴尬事实书被借走之后书籍表、借阅记录、读者可借数量几个地方要同时发生变化而这些变动能不能在一个事务里完成取决于表结构和字段定义。这个基于 MySQL 的图书管理系统设计把顺序反了过来先做需求分析、ER 图、函数依赖分析再落到六张物理表最后才写借书、还书、超期罚款的 SQL。适合正在做数据库课程设计、需要提交完整设计文档的人也适合想搞明白借阅流程底层数据如何流转的读者。2. 需求向ER图映射实体联系与函数依赖分析原始设计文档把功能需求拆成了五组图书管理、读者管理、借阅管理、综合查询、统计。实际建表时真正决定表结构的不是图书管理那一组而是借阅管理里的细节——借书要记录“谁借了哪本书、什么时候借的”还书要记录归还时间超期要产生罚款挂失要影响读者状态。这些零散动作落到数据层就是实体与实体之间的关系而 ER 图是把这个关系画清楚的第一步。2.1 功能需求如何转换成数据需求把功能需求逐条翻译成“需要记录什么”是设计文档中最关键的一步。翻译结果可以直接决定字段清单。功能需求涉及实体需要记录的字段新书入库书籍、书籍类别书籍编号、名称、类别、作者、出版社、出版日期、登记日期办理借书证读者证号、姓名、性别、读者类型、登记日期借书借阅记录借书证编号、书籍编号、借书时间还书归还记录借书证编号、书籍编号、还书时间超期罚款罚款记录证号、姓名、书号、书名、金额、借阅时间这里有一个容易忽略的设计决策还书不是把借阅记录里的“借出状态”改掉而是单独建一张return_record表。原因是借阅记录代表“这本书被借过的历史”还书记录代表“这次借阅在什么时候结束”。如果直接在借阅记录上覆盖还书时间那“这本书过去一年被借了几次”“平均借期多长”这类统计就永远做不出来。所以借阅和归还是两个独立实体用书号关联。2.2 六个实体与三类联系ER 图里一共有六个实体书籍类别、读者、书籍、借阅记录、归还记录、罚款记录。实体之间的联系相对简单但值得逐个确认。书籍与类别是多对一关系一本书属于一个类别一个类别下有多本书所以类别表的编号被书籍表作为外键引用。读者与书籍是多对多关系一个读者可借多本书一本书在不同时间可被多个读者借阅这个多对多关系通过借阅记录表拆成了两个一对多。罚款记录与借阅记录是一对一关系一次借阅违规产生一条罚款所以罚款表直接用bookid作为主键。注意这个前提每本物理书有唯一编号同一时刻只有一条有效在借记录bookid才可以承担主键职责。如果一个 ISBN 对应多个副本入库时必须拆成多个bookid否则张三借走一本、李四借走另一本时借阅记录会互相覆盖。2.3 函数依赖与范式判定函数依赖分析是判断表结构是否合格的工具。文档中给出的依赖关系可以归纳成下面几组。书籍关系中bookid是候选码bookid → bookname、bookid → bookauthor、bookid → bookpub、bookid → bookpubdate构成函数依赖集。因为主键只有一个字段不存在非主属性对码的部分依赖又因为所有属性都直接依赖主键不存在传递依赖所以书籍关系满足 3NF。读者关系里readerid决定姓名、性别、类型、登记日期同样的判定逻辑也属于 3NF。借阅关系是一个值得展开说明的地方。它的主键是复合的(readerid, bookid)函数依赖为(readerid, bookid) → borrowdate。这里不存在某个非主属性只依赖readerid或只依赖bookid所以没有部分依赖3NF 成立。现实中如果允许同一读者多次借同一本书这个复合主键就需要再扩展借阅批次号或借书时间原文档的简化模型默认一次借阅对应一个唯一书号属于合理的学生设计但在第 4 章写事务时能看到它的边界在哪里。3. 建表实现六张核心表的MySQL DDL与约束校验逻辑结构设计完成后下一步是把它翻译成可执行的 MySQL DDL。这里有一个执行顺序上的硬性要求必须先建被引用的父表再建引用它的子表否则外键定义会直接报错。文档中的建表顺序是book_style、system_books、system_readers最后才是三张记录表。3.1 表结构与字段类型六张表的字段定义以原文建表 SQL 为准整理如下。表名字段类型约束说明book_stylebookstylenovarchar(30)主键类别编号book_stylebookstylevarchar(30)非空类别名称system_booksbookidvarchar(20)主键书籍编号system_booksbooknamevarchar(30)非空书名system_booksbookstylenovarchar(30)外键指向 book_stylesystem_booksbookauthorvarchar(30)—作者system_booksbookpubvarchar(30)—出版社system_booksbookpubdate / bookindatedatetime—出版/登记日期system_booksisborrowedvarchar(2)非空1 已借出0 在馆system_readersreaderidvarchar(9)主键借书证号system_readersreadernamevarchar(9)非空姓名system_readersreadersexvarchar(2)非空性别borrow_recordbookidvarchar(20)主键、外键被借书籍borrow_recordreaderidvarchar(9)外键借书人borrow_recordborrowdatedatetime非空借书时间return_recordbookidvarchar(20)主键、外键归还书籍return_recordreaderidvarchar(9)外键归还人return_recordreturndatedatetime非空还书时间reader_feebookidvarchar(20)主键、外键违规书籍reader_feereaderidvarchar(9)外键违规读者reader_feereadername / booknamevarchar(9/30)非空冗余姓名与书名reader_feebookfeevarchar(30)非空罚款金额reader_feeborrowdatedatetime非空借阅时间字段类型的选择有两个细节。varchar在 MySQL 中按字符计数varchar(30)在 utf8mb4 字符集下可以存 30 个汉字书名和姓名长度都够用。日期字段统一用datetime而不是timestamp因为timestamp的范围只到 2038 年并且会随数据库时区变化而转换对于借书、还书这类业务时间datetime更稳定。3.2 建表SQL与执行顺序下面这段 DDL 合并了原文六张表按外键依赖顺序执行。-- 1. 书籍类别表先建因为后续表要引用它的主键 CREATE TABLE book_style ( bookstyleno varchar(30) PRIMARY KEY, bookstyle varchar(30) NOT NULL ); -- 2. 书籍表引用 book_style CREATE TABLE system_books ( bookid varchar(20) PRIMARY KEY, bookname varchar(30) NOT NULL, bookstyleno varchar(30) NOT NULL, bookauthor varchar(30), bookpub varchar(30), bookpubdate datetime, bookindate datetime, isborrowed varchar(2) NOT NULL, FOREIGN KEY (bookstyleno) REFERENCES book_style(bookstyleno) ); -- 3. 读者表无外键可在书籍表之前或之后建 CREATE TABLE system_readers ( readerid varchar(9) PRIMARY KEY, readername varchar(9) NOT NULL, readersex varchar(2) NOT NULL, readertype varchar(10), regdate datetime ); -- 4. 借书记录表引用 system_books 和 system_readers CREATE TABLE borrow_record ( bookid varchar(20) PRIMARY KEY, readerid varchar(9), borrowdate datetime, FOREIGN KEY (bookid) REFERENCES system_books(bookid), FOREIGN KEY (readerid) REFERENCES system_readers(readerid) ); -- 5. 还书记录表结构与借书记录对应 CREATE TABLE return_record ( bookid varchar(20) PRIMARY KEY, readerid varchar(9), returndate datetime, FOREIGN KEY (bookid) REFERENCES system_books(bookid), FOREIGN KEY (readerid) REFERENCES system_readers(readerid) ); -- 6. 罚款记录表书号唯一一次违规一条记录 CREATE TABLE reader_fee ( readerid varchar(9) NOT NULL, readername varchar(9) NOT NULL, bookid varchar(20) PRIMARY KEY, bookname varchar(30) NOT NULL, bookfee varchar(30), borrowdate datetime, FOREIGN KEY (bookid) REFERENCES system_books(bookid), FOREIGN KEY (readerid) REFERENCES system_readers(readerid) );这段 DDL 的核心约束来自外键。system_books.bookstyleno引用book_style.bookstyleno意味着插入书籍时类别编号必须已经在类别表里存在这能防止把图书挂到一个不存在的分类下。借书记录表的外键更关键它保证借阅关系两端的读者和书籍都是真实存在的没有幽灵数据。有一点必须提醒外键约束只在 InnoDB 引擎下生效如果建表时用了 MyISAMMySQL 会静默忽略外键定义这一点在show create table时才能发现。3.3 数据初始化建表之后要初始化基础数据。类别表的数据必须最先插入否则后续图书入库时外键校验会失败。INSERT INTO book_style(bookstyleno, bookstyle) VALUES (1, 哲学宗教), (2, 文学艺术), (3, 历史地理), (4, 数理科学), (5, 生物科学), (6, 交通运输), (7, 政治法律); INSERT INTO system_books (bookid, bookname, bookstyleno, bookauthor, bookpub, bookpubdate, bookindate, isborrowed) VALUES (20161112001, 中国易学, 1, 刘正, 中央编译出版社, 2015-05-10, 2015-10-25, 1), (20161112002, 初妆张爱玲, 2, 陶舒天, 新华出版社, 2014-01-10, 2015-05-26, 1), (20161112003, 明成祖传, 3, 晁中辰, 人民出版社, 2014-08-10, 2015-05-27, 1);初始化数据有一个容易被新手忽略的点isborrowed字段设为1表示这些书当前处于借出状态这是模拟历史借阅数据不是造数据时随手填的。后面写借书操作时isborrowed从0变1还书时从1变0初始化数据必须和这个状态机保持一致否则后续做“可借数量统计”时会得到矛盾结果。4. 借书还书与超期罚款事务状态流转的SQL写法建表只是基础真正的业务逻辑集中在借书、还书、罚款这三个操作里。表面上看都是几条 SQL但这里最容易踩的坑是借书动作要同时更新书籍状态和插入借阅记录两个操作之间如果隔了一条失败语句数据就处于“书标记未借出、但有借阅记录”的中间状态。解决办法是用事务把多个语句包成一个原子操作。4.1 借书事务与并发控制借书的完整流程是先检查读者是否正常、再检查书是否在馆、然后插入借阅记录、最后更新书籍状态。检查动作可以用 UPDATE 的条件子句完成而不是先 SELECT 再 UPDATE这样能避免两个会话同时借同一本书的并发问题。START TRANSACTION; -- 关键条件里带上 isborrowed 0 -- 如果返回影响行数为 0说明书已被借走或编号不存在 UPDATE system_books SET isborrowed 1 WHERE bookid 20161112004 AND isborrowed 0; -- 影响行数为 1 才继续客户端拿到这个值后再执行 INSERT INSERT INTO borrow_record(bookid, readerid, borrowdate) VALUES (20161112004, 20230001, CURDATE()); COMMIT;这里把“检查状态”和“更新状态”合并进了一条 UPDATE 语句。在 InnoDB 默认的可重复读隔离级别下这条 UPDATE 会对命中的行加排他锁另一个会话执行相同 UPDATE 时会被阻塞等第一个事务提交后再执行此时isborrowed已经是1条件isborrowed 0不满足影响行数为 0从而拒绝借出。这就是典型的乐观扣减写法比先 SELECT 再 UPDATE 更安全。实际应用中影响行数需要在应用层判断比如在 Java 里通过int rows statement.executeUpdate()拿到返回值为 0 时执行ROLLBACK并提示用户图书已被借出。4.2 读者挂失状态与借阅上限原文档的功能需求提到了挂失、注销和借阅数量限制但六张表里没有体现。常见做法是给system_readers增加状态字段用数字代替含义模糊的字符串。ALTER TABLE system_readers ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1挂失 2注销; ALTER TABLE system_readers ADD COLUMN max_borrow TINYINT NOT NULL DEFAULT 5 COMMENT 最大借阅数量;加上状态字段后借书判断条件要多加一条限制挂失读者继续借书。借阅数量的判断则通过统计当前未还记录数来完成在事务内加一条计数查询超过max_borrow就直接回滚这种实时统计的方式在数据量不大时足够用不需要额外维护一个冗余的“已借数量”字段。4.3 还书操作与超期罚款计算还书时要做两件事归还图书、判断是否超期。超期的判断标准是借阅天数减去允许借阅天数结果大于 0 就按天计算罚款。START TRANSACTION; -- 查询当前借阅的起始时间用于计算是否超期 SELECT bookid, readerid, borrowdate, DATEDIFF(CURDATE(), borrowdate) AS borrowed_days FROM borrow_record WHERE bookid 20161112004 AND readerid 20230001; -- 归还更新书籍状态插入还书记录 UPDATE system_books SET isborrowed 0 WHERE bookid 20161112004; INSERT INTO return_record(bookid, readerid, returndate) VALUES (20161112004, 20230001, CURDATE()); -- 超期才写罚款借阅上限 30 天每日罚款 0.1 元 INSERT INTO reader_fee(readerid, readername, bookid, bookname, bookfee, borrowdate) SELECT r.readerid, r.readername, b.bookid, b.bookname, (DATEDIFF(CURDATE(), br.borrowdate) - 30) * 0.1, br.borrowdate FROM borrow_record br JOIN system_readers r ON br.readerid r.readerid JOIN system_books b ON br.bookid b.bookid WHERE br.bookid 20161112004 AND br.readerid 20230001 AND DATEDIFF(CURDATE(), br.borrowdate) 30; COMMIT;DATEDIFF(CURDATE(), borrowdate)计算的是借出到当前的自然日差值单位是天。(天数 - 30) * 0.1算出罚款金额只有差值大于 30 天时 INSERT 语句才会插入数据没有超期则影响行数为 0不会产生空罚款记录。注意这里用的是CURDATE()而不是NOW()前者只取日期后者包含时分秒如果用NOW()同一本书当天借当天还也会因为时间差被判断成已借 0 天多几个小时边界情况容易出现误判。5. 查询统计与索引综合检索和报表背后的SQL图书馆管理系统的另一半工作量在查询和统计上。普通读者关心“怎么找到某本书、我还了没有”管理员关心“哪些书借得多、哪些读者活跃”。这些需求对应的是同一批表上的 SELECT 语句但写法不同性能差异也很大。5.1 多条件图书查询需求里要求支持按书名、作者、出版社、类别、出版日期等条件组合查询。动态拼接 WHERE 是这类查询的标准做法关键点是模糊匹配的使用。SELECT bookid, bookname, bookauthor, bookpub, bookpubdate FROM system_books WHERE 1 1 AND bookname LIKE CONCAT(%, 张爱玲, %) AND bookauthor LIKE CONCAT(%, 陶, %) AND isborrowed 1;WHERE 1 1不是可有可无的写法它让应用层在拼接查询条件时不需要判断“是否为第一个条件”每条AND都可以无脑追加代码更简洁。LIKE CONCAT(%, ?, %)比直接写LIKE %张爱玲%的好处是参数化查询时可以避免字符串拼接注入风险。但要注意前导通配符%会导致这个条件无法走普通索引书籍表数据量超过几十万条时要考虑全文索引或者限制用户至少输入一个完整字段。5.2 借阅排行与统计报表统计每种图书的借阅次数核心是 GROUP BY 加 COUNT再按次数倒序取前十条。SELECT b.bookid, b.bookname, COUNT(*) AS borrow_times FROM borrow_record br JOIN system_books b ON br.bookid b.bookid WHERE br.borrowdate 2024-01-01 GROUP BY b.bookid, b.bookname ORDER BY borrow_times DESC LIMIT 10;这段 SQL 的 JOIN 把借阅记录和书籍信息关联起来只统计 2024 年的借阅数据。GROUP BY 后面的字段必须和 SELECT 中的非聚合字段保持一致MySQL 在 only_full_group_by 模式下对这点要求很严格bookname虽然在语义上依赖bookid但还是需要显式写进 GROUP BY否则会在 5.7 及以上版本直接报错。统计读者借阅情况时把分组字段换成readerid、readername即可结构完全一样。5.3 当前未归还查询“某读者现在借了哪些书没还”是综合查询里最高频的语句这里不能用简单的 JOIN因为借阅记录和还书记录是一对多的历史关系。SELECT b.bookname, br.borrowdate FROM borrow_record br JOIN system_books b ON br.bookid b.bookid WHERE br.readerid 20230001 AND NOT EXISTS ( SELECT 1 FROM return_record rr WHERE rr.bookid br.bookid AND rr.readerid br.readerid );NOT EXISTS子查询的逻辑是对每条借阅记录检查是否存在一条对应的归还记录不存在说明这本书还没还。这比NOT IN (SELECT bookid FROM return_record WHERE readerid...)更严谨因为NOT IN在子查询结果集为空时行为正常但一旦子查询包含 NULL 值整个条件就变成不确定性结果NOT EXISTS没有这个陷阱。5.4 索引设计查询性能优化不能等到数据量大了再补建表阶段就应该规划。下面这组索引覆盖了前面几个高频查询场景。CREATE INDEX idx_books_name ON system_books(bookname); CREATE INDEX idx_borrow_reader_date ON borrow_record(readerid, borrowdate); CREATE INDEX idx_return_book ON return_record(bookid, readerid);idx_borrow_reader_date是复合索引列顺序是readerid在前、borrowdate在后这对应“查某读者借了哪些书并按时间排序”的场景也对应“统计某个读者某段时间的借阅次数”。复合索引遵循最左前缀原则如果查询条件只包含borrowdate而不包含readerid这个索引就用不上。所以规划索引时要从实际查询出发不要为了索引而索引每张表的索引数量控制在三到五个以内索引过多反而拖慢写入速度。6. 初始化失败与约束报错三个可以直接套用的排查方法最后一个环节说排错。建表和数据初始化阶段最容易报错错误信息本身可能看不明白但根因无非是外键失败、字段类型不匹配、初始化顺序错误这三个问题。6.1 外键约束失败的排查报错代码 1452 表示插入的外键值在父表中不存在。比如执行借书记录 INSERT 时提示Cannot add or update a child row先用这条 SQL 找出哪条记录的引用是断的。SELECT bookid, readerid FROM borrow_record WHERE bookid NOT IN (SELECT bookid FROM system_books) OR readerid NOT IN (SELECT readerid FROM system_readers);返回空结果说明借阅表本身没有脏数据问题出在刚插入的那条语句上——大概率是应用层传入的readerid手误多了一位数字。我的处理习惯是先清掉孤儿记录再重新插入业务数据而不是反向删父表记录。6.2 字段类型与状态值边界原设计里isborrowed用的是varchar(2)能插入0、1也能插入是、否、true数据库完全不拦。这类布尔状态字段应该用TINYINT或ENUM(0,1)从类型层面限定取值范围。已经建完的表可以通过修改列类型收口。ALTER TABLE system_books MODIFY COLUMN isborrowed TINYINT NOT NULL DEFAULT 0 COMMENT 0在馆 1借出;时间字段的边界同样值得注意。datetime不随会话时区变化适合记录业务发生时刻timestamp会在写入和读取时按会话时区转换。做超期判断、罚款计算时统一使用CURDATE()避免因时区换算导致“今天还书却被判定超期一天”的边界问题。6.3 同书多副本的编号策略如果图书馆的同一本图书采购了多个副本不要在bookid上复用同一个编号。正确做法是每一本物理书分配唯一编号比如 ISBN 后加三位流水号。改法很简单副本数量由system_books表里的记录条数体现和bookid主键不冲突。统计馆藏数量时用SELECT bookstyle, COUNT(*) FROM system_books GROUP BY bookstyle统计结果自然就是每类书的物理副本总数。这个设计判断越早做后面借书、还书、罚款三张表越不需要返工。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进