C/C++与MySQL图书管理系统设计与实现:从表结构到事务处理

发布时间:2026/10/24 8:31:06
C/C++与MySQL图书管理系统设计与实现:从表结构到事务处理

又到了课程设计扎堆交作业的时候,不知道你有没有发现,图书管理系统几乎是数据库课的“保留题目”。我第一次拿到这个题目是在数据库原理的下半学期,要求说得很直接:数据必须用 MySQL 存,界面和逻辑用 C/C++ 写。一开始我也觉得“不就是增删改查嘛”,等真正动手才发现,表结构怎么设计、连接 API 怎么封装、借书还书怎么保证数据不错乱、编译环境怎么配置,每一个环节都有值得琢磨的地方。这篇文章就围绕“MySQL + C/C++ 实现图书管理系统”这个组合,把完整的设计思路、表结构、工程分层、核心业务代码、编译踩坑和调试经验一次讲清楚。

先亮个底:这不是一个花哨的项目,但它是把数据库原理和 C/C++ 两门课串起来最直接的训练场。你要是正在被这个题目折磨,或者想找一个能放进简历的课程设计,这篇可以当一份完整参考路线看。

1. 为什么是C/C++配MySQL:选型背后的真实考量

1.1 这个组合到底解决了什么问题

图书管理系统的数据模型很典型:读者、图书、借阅记录,天然就是一对多关系。用关系型数据库存储这些数据,再用 C/C++ 写一个控制台客户端,通过 MySQL 官方提供的 C API 和服务器交互,能让你把大学里两门核心课的知识完整落在同一个项目里。

相比 Java 那一整套框架,C/C++ 方案更“裸”,也更考验基础。你要自己管理连接、执行 SQL、处理结果集、释放内存。这个过程中理解的连接生命周期、事务边界、SQL 语句怎么被服务器执行,都是后面做其他后端开发时随时要用的底层知识。

我做这个项目时最直接的感受是:数据库课上学的那套“范式、事务、索引”,如果不在真实代码里用一次,永远只是背概念。而图书管理系统正好把这些概念全部逼着你用一遍。

1.2 和其他方案差在哪

很多同学会犹豫要不要换别的技术栈,这里我把自己对比过的方案列出来:

方案优点缺点适合人群
C/C++ + MySQL底层链路清晰,能真实理解 SQL 执行和 API 调用开发量略大,编译环境要折腾学过 C/C++ 和数据库原理的学生
Java + JDBC生态成熟,面向对象封装好需要额外理解框架,工程体积重系统学完 JavaWeb 的同学
Python + SQLite写起来最快,代码量小SQLite 是嵌入式数据库,没有独立数据库服务只想快速跑通演示
纯文件存储不依赖数据库完全偏离题目要求,数据一致性难保证不推荐

不是说 Java/Python 不好,而是回到题目本身:老师想看的是你会不会用数据库解决实际问题。C/C++ + MySQL 这套组合,把“客户端/服务端结构”“数据库连接”“SQL 设计与事务处理”全部显式地暴露给你,项目做完你能说清楚每一行关键代码在干什么。

1.3 动手前需要垫的三块基础

如果你基础偏弱,先把这三件事过一遍再动工,会顺畅很多:

  • 关系型数据库核心概念:表、主键、外键、事务、索引。不要求多深,但要知道这些工具各自解决什么问题。
  • MySQL C API 的基本用法:mysql_init、mysql_real_connect、mysql_query、mysql_store_result、mysql_fetch_row、mysql_free_result,这是整个项目的调用主线。
  • C++ 封装思想:把连接管理和数据库访问包成类,避免每个功能都裸写一遍。

这三块准备到位后,剩下的就是把业务需求一步步翻译成表格和 SQL。

2. 数据建模先行:四张表怎么定、边界卡在哪

2.1 四张核心表的字段设计

很多初学者上手就写代码,结果写到一半发现缺字段、表关系理不清,又回来改表结构,非常浪费。正确顺序是先建模再写功能。我的做法是四张表:图书表、读者表、借阅表、管理员表。

图书表(book)

字段类型说明
book_idINT 自增主键图书唯一标识
isbnVARCHAR(20) 唯一ISBN 编号,一本书的不同副本共享
titleVARCHAR(100)书名
authorVARCHAR(50)作者
publisherVARCHAR(80)出版社
categoryVARCHAR(30)分类
total_countINT总册数
available_countINT当前可借册数
shelf_locationVARCHAR(50)馆藏位置
create_timeDATETIME 默认当前时间入库时间

这里有个关键设计我要单独解释:available_count 为什么单独存一个字段,而不是每次对借阅明细做 SUM 统计。原因是高频查询场景是“这本书还剩几本”,如果每次都去借阅表聚合,数据量一大性能就明显下降。单独维护一个冗余字段,借书时减一、还书时加一,查询直接读字段,这是典型的“冗余换性能”思路,课程设计里用得上,面试也常问。

读者表(reader)

字段类型说明
reader_idINT 自增主键读者唯一标识
reader_noVARCHAR(20) 唯一读者证号
nameVARCHAR(50)姓名
phoneVARCHAR(20)联系电话
statusTINYINT 默认 11 正常 0 冻结
max_borrowINT 默认 5最大可借数量
register_dateDATETIME 默认当前时间注册日期

max_borrow 放在读者表而不是写死在代码里,这样以后要给不同等级读者开放不同额度,直接改字段就行,不用动逻辑。

借阅表(borrow)

字段类型说明
borrow_idINT 自增主键借阅记录唯一标识
reader_idINT 外键读者
book_idINT 外键图书
borrow_dateDATETIME借出时间
due_dateDATETIME应还时间
return_dateDATETIME NULL实际归还时间
statusTINYINT 默认 11 借出 2 已归还 3 逾期归还
fineDECIMAL(6,2) 默认 0逾期费用

借阅表是整个系统的核心流转表。注意一个重要原则:借阅记录是流水,还书后不能删除记录,只改状态。这样保留历史借阅轨迹,后面要做借阅排行榜、罚款统计、图书热门度分析都有数据支撑。

管理员表(admin)

字段类型说明
admin_idINT 自增主键管理员标识
usernameVARCHAR(30) 唯一登录名
password_hashVARCHAR(64)密码哈希值,不存明文

2.2 外键、索引与约束怎么取舍

外键在课程设计阶段我建议加上,不要嫌它麻烦。数据库层面保证不能插入一个不存在的读者或图书,比你在应用层写一堆判断要可靠得多。

索引方面,借阅表是查询压力最大的表。实际项目里主要两种查询:查“某读者当前借了哪些书”、查“某本书的借阅历史”。所以我在 borrow 表建立了 (reader_id, status) 联合索引,以及单独的 book_id 索引。图书表的 isbn 和读者表的 reader_no 本身有唯一约束,查询时会自动走唯一索引,不需要额外处理。

还有一个小提醒:建表时给 book 表加 CHECK 约束,保证 available_count 不小于 0 且不超过 total_count。MySQL 8.0 之前 CHECK 约束不强制,但我建议还是写上,配合应用层校验双保险。

2.3 建库建表 SQL 参考

下面这组 SQL 是我调过一遍后可以直接落地的版本,字符集统一用 utf8mb4,避免中文乱码:

DROP DATABASE IF EXISTS library_db; CREATE DATABASE library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library_db; CREATE TABLE book ( book_id INT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50) NOT NULL, publisher VARCHAR(80) DEFAULT NULL, category VARCHAR(30) DEFAULT NULL, total_count INT NOT NULL DEFAULT 1, available_count INT NOT NULL DEFAULT 1, shelf_location VARCHAR(50) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CHECK (available_count >= 0), CHECK (available_count <= total_count) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reader ( reader_id INT AUTO_INCREMENT PRIMARY KEY, reader_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, max_borrow INT NOT NULL DEFAULT 5, register_date DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE borrow ( borrow_id INT AUTO_INCREMENT PRIMARY KEY, reader_id INT NOT NULL, book_id INT NOT NULL, borrow_date DATETIME NOT NULL, due_date DATETIME NOT NULL, return_date DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, fine DECIMAL(6,2) DEFAULT 0.00, FOREIGN KEY (reader_id) REFERENCES reader(reader_id), FOREIGN KEY (book_id) REFERENCES book(book_id), INDEX idx_reader_status (reader_id, status), INDEX idx_book_id (book_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE admin ( admin_id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

导入方式:mysql -uroot -p < library.sql,搞定后直接进库验证一下表结构。注意这里所有表都指定了 InnoDB,原因后面会讲——MyISAM 不支持事务,借书流程会出大问题。

3. 工程代码怎么组织才不烂尾

3.1 目录与模块划分

课程设计虽然只是一个控制台程序,但如果所有代码堆在 main.cpp 里,写到后面你会心态爆炸。我用的是简单三层组织:连接管理、DAO 数据访问、界面主逻辑。

library_system/ ├── include/ │ ├── db.h │ ├── book_dao.h │ ├── reader_dao.h │ └── borrow_dao.h ├── src/ │ ├── db.cpp │ ├── book_dao.cpp │ ├── reader_dao.cpp │ └── borrow_dao.cpp ├── sql/ │ └── library.sql ├── main.cpp └── Makefile

main.cpp 只负责菜单循环和基本的输入输出,所有 SQL 逻辑都收进 DAO 层。这样以后想把控制台换成 Qt 界面,或者改成一个简单的网页后端,DAO 层和数据库连接层不需要大改。我实际改过一次,深有体会。

3.2 连接管理:用类把 MYSQL 资源的生命周期管住

直接用 C API 时最容易犯的错误是:某个分支提前 return,忘了调用 mysql_close,连接泄漏。我这个项目里用一个 MyDB 类把连接生命周期管起来,析构函数保证释放:

// include/db.h #pragma once #include <mysql/mysql.h> #include <string> class MyDB { public: MyDB() : conn_(nullptr) {} ~MyDB() { close(); } bool connect(const std::string& host, const std::string& user, const std::string& pwd, const std::string& db, unsigned int port = 3306); void close(); bool execute(const std::string& sql); std::string escape(const std::string& input); MYSQL* getConn() { return conn_; } private: MYSQL* conn_; };
// src/db.cpp #include "db.h" #include <cstdio> bool MyDB::connect(const std::string& host, const std::string& user, const std::string& pwd, const std::string& db, unsigned int port) { conn_ = mysql_init(nullptr); if (conn_ == nullptr) return false; mysql_options(conn_, MYSQL_SET_CHARSET_NAME, "utf8mb4"); if (mysql_real_connect(conn_, host.c_str(), user.c_str(), pwd.c_str(), db.c_str(), port, nullptr, 0) == nullptr) { fprintf(stderr, "MySQL 连接失败: %s\n", mysql_error(conn_)); close(); return false; } // 统一使用 utf8mb4,避免中文乱码 execute("SET NAMES utf8mb4"); return true; } void MyDB::close() { if (conn_) { mysql_close(conn_); conn_ = nullptr; } } bool MyDB::execute(const std::string& sql) { if (mysql_query(conn_, sql.c_str()) != 0) { fprintf(stderr, "SQL 执行失败: %s\nSQL: %s\n", mysql_error(conn_), sql.c_str()); return false; } return true; } std::string MyDB::escape(const std::string& input) { if (input.empty()) return input; std::string out; out.resize(input.size() * 2 + 1); unsigned long len = mysql_real_escape_string( conn_, &out[0], input.c_str(), input.size()); out.resize(len); return out; }

这里有两个细节值得说。第一,连接成功后立刻执行SET NAMES utf8mb4,这一步不做,中文乱码几乎是必然的。第二,连接对象不要跨线程共享,MySQL 的 MYSQL* 指针不是线程安全的。课程设计一般是单线程菜单,但面试问到多线程架构时,这个点很重要。

3.3 DAO 层:把 SQL 从业务逻辑里抽出来

以图书查询为例,我封装一个 BookDAO,外部调用者只传关键词,不接触任何 SQL:

bool BookDAO::queryByTitle(MyDB& db, const std::string& keyword, std::vector<Book>& out) { std::string esc = db.escape(keyword); char sql[4096]; snprintf(sql, sizeof(sql), "SELECT book_id, isbn, title, author, publisher, category, " "total_count, available_count, shelf_location " "FROM book WHERE title LIKE '%%%s%%' ORDER BY book_id LIMIT 50", esc.c_str()); if (mysql_query(db.getConn(), sql) != 0) { fprintf(stderr, "查询图书失败: %s\n", mysql_error(db.getConn())); return false; } MYSQL_RES* res = mysql_store_result(db.getConn()); if (res == nullptr) return false; MYSQL_ROW row; while ((row = mysql_fetch_row(res)) != nullptr) { Book b; b.book_id = atoi(row[0]); b.isbn = row[1] ? row[1] : ""; b.title = row[2] ? row[2] : ""; b.author = row[3] ? row[3] : ""; b.publisher = row[4] ? row[4] : ""; b.category = row[5] ? row[5] : ""; b.total_count = atoi(row[6]); b.available_count = atoi(row[7]); b.shelf_location = row[8] ? row[8] : ""; out.push_back(b); } mysql_free_result(res); return true; }

三个重点提醒:

  • 用户输入必须转义。我调用了 db.escape,内部是 mysql_real_escape_string。如果你直接把用户输入的 keyword 拼进 SQL,遇到书名里带单引号的内容,轻则查询报错,重则被构造出 SQL 注入语句。
  • 字段可能为 NULL。mysql_fetch_row 返回的是 char* 数组,NULL 字段对应 nullptr,转 string 之前先判断一下,不然程序直接崩。
  • 结果集用完记得 mysql_free_result。这个函数我见过太多人忘写,内存泄漏在课程设计里短时间看不出,但养成习惯后面对更大项目就不会踩。

4. 核心功能实现细节:借书还书的完整链路

4.1 图书查询:从模糊匹配到结果解析

图书查询是使用频率最高的功能。模糊匹配用LIKE '%keyword%',但关键词里的特殊字符必须先转义,这个前面已经说过了。

实际操作时我建议加一个分页逻辑。控制台程序虽然不像网页一样需要真正分页,但数据量一大,一次查出几百条堆在屏幕上根本没法看。简单做法:

SELECT book_id, title, author, available_count FROM book WHERE title LIKE '%xx%' ORDER BY book_id LIMIT 10 OFFSET 0;

每页显示 10 条,按回车加载下一页。解析结果集时,先用 mysql_num_rows 判断是不是空结果,再循环 mysql_fetch_row。如果返回空结果,不要直接访问 row[0],会越界。

4.2 借书:状态检查、库存扣减、借阅记录三步一体

借书是整个项目里最有含金量的部分。这不仅是写几条 SQL 的事,而是要把“多步操作必须同时成功”这个事务思想落实到位。

借一本书要完成四件事:

  1. 确认读者存在且状态正常;
  2. 确认这本图书还有可借库存;
  3. 扣减图书 available_count;
  4. 插入一条借阅记录。

这四步之间如果一步步裸写,第三步成功但第四步失败时,库存少了却没有借阅记录,数据就永久不对了。正确做法是把它们包在一个事务里,任何一步失败整体回滚。

START TRANSACTION; -- 1. 检查读者状态(应用层先查一次,这里再查一次兜底) SELECT status FROM reader WHERE reader_id = 10001; -- 如果 status != 1 或查不到,ROLLBACK -- 2. 锁定图书行,防止并发超借 SELECT available_count FROM book WHERE book_id = 1 FOR UPDATE; -- 如果 available_count <= 0,ROLLBACK -- 3. 扣减库存,必须检查影响行数 UPDATE book SET available_count = available_count - 1 WHERE book_id = 1 AND available_count > 0; -- affected_rows == 0 说明没扣到,ROLLBACK -- 4. 写入借阅记录 INSERT INTO borrow(reader_id, book_id, borrow_date, due_date, status) VALUES(10001, 1, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 1); COMMIT;

这里最容易被忽略的是FOR UPDATE。如果没有它,两个管理员同时操作时,可能两个人都查到 available_count 为 1,然后都执行 UPDATE,结果库存变成 -1。加锁以后,第二个人必须等第一个人提交后才能继续,而且 UPDATE 语句里的available_count > 0条件会保证第二个人扣减失败,从而触发回滚。

C++ 侧配合的代码逻辑大概是:

if (!db.execute("START TRANSACTION")) return false; // 查读者状态并检查 if (!db.execute("SELECT status FROM reader WHERE reader_id = ...")) { db.execute("ROLLBACK"); return false; } // 解析结果,状态不对就回滚 // 查库存并加锁 if (!db.execute("SELECT available_count FROM book WHERE book_id = ... FOR UPDATE")) { db.execute("ROLLBACK"); return false; } // 解析结果,库存不足就回滚 // 扣减库存 if (!db.execute("UPDATE book SET available_count = available_count - 1 " "WHERE book_id = ... AND available_count > 0")) { db.execute("ROLLBACK"); return false; } // 检查 affected_rows if (mysql_affected_rows(db.getConn()) == 0) { std::cout << "库存不足,本次借书取消" << std::endl; db.execute("ROLLBACK"); return false; } // 插入借阅记录 if (!db.execute("INSERT INTO borrow(...) VALUES(...)")) { db.execute("ROLLBACK"); return false; } db.execute("COMMIT");

上面每一条 execute 失败后都做 ROLLBACK,这是事务代码的基本素养。少写一个回滚,下一次测试时数据就可能卡在中间状态。

4.3 还书:逾期计算与状态回写

还书流程和借书反过来:更新借阅记录状态、计算逾期费用、恢复图书库存。核心 SQL:

START TRANSACTION; UPDATE borrow SET return_date = NOW(), status = CASE WHEN NOW() > due_date THEN 3 ELSE 2 END, fine = CASE WHEN NOW() > due_date THEN ROUND(TIMESTAMPDIFF(DAY, due_date, NOW()) * 0.5, 2) ELSE 0.00 END WHERE borrow_id = 50001 AND status = 1; -- 受影响行数为 0 说明这条借阅记录不存在或已归还,回滚 UPDATE book SET available_count = available_count + 1 WHERE book_id = 1; COMMIT;

TIMESTAMPDIFF(DAY, due_date, NOW()) 计算逾期天数,乘 0.5 元得到违约金。这里我把逾期状态和正常归还区分开,status 分别用 2 和 3,便于后面统计“有多少本书是逾期归还的”。

借阅记录本身不删除,只改状态和回填 return_date。这是“流水留痕”原则,对账和统计全靠它。

4.4 规则边缘情况:重复借、超量借、冻结读者

课程设计演示时最容易翻车的不是“正常路径”,而是这些边界场景。我把测试时遇到过的情况列一下:

  • 同一读者反复借同一本书:如果这本书还没还,应该禁止。可以在借书逻辑里加一句SELECT COUNT(*) FROM borrow WHERE reader_id=? AND book_id=? AND status IN (1,3),结果大于 0 就拒绝。
  • 超过最大借阅数:借书前统计当前在借数量,和 reader 表的 max_borrow 比较。这个字段设计初期放在读者表的好处这时候就体现了。
  • 读者被冻结:status 为 0 的读者不允许借书。我一开始忘了这个判断,测试时创建了一个冻结读者还能正常借书,翻车翻得很难看。
  • 图书可借数为 0:查询时要能查到这本书(馆藏还在),只是借书时提示无库存。
  • 还一本不存在的借阅记录:UPDATE 的 WHERE 条件带上 status = 1,防止重复归还带来库存多扣。

这些判断不需要很复杂的代码,都是简单的 COUNT 或状态检查,但能把系统从“能跑的 demo”变成“被挑不出毛病的作业”。

5. 编译与运行:让程序在 Linux 和 Windows 上都跑起来

5.1 Linux 下 g++ 编译

我这边的环境是 Ubuntu,需要先装 MySQL 客户端开发库:

sudo apt install build-essential libmysqlclient-dev mysql-client

装完以后用 mysql_config 获取头文件和库路径参数:

g++ main.cpp src/db.cpp src/book_dao.cpp src/reader_dao.cpp src/borrow_dao.cpp \ -Iinclude -o library $(mysql_config --cflags --libs)

如果提示找不到 mysql_config,先which mysql_config确认路径;找不到就把参数写死,比如-I/usr/include/mysql -L/usr/lib/mysql -lmysqlclient。

编译通过后,运行报error while loading shared libraries: libmysqlclient.so的话,说明动态库路径没找到。临时解决:

export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu ./library

长期方案就是写完 Makefile 后,把export LD_LIBRARY_PATH加进启动脚本,或者使用 root 权限执行 ldconfig。课程设计阶段临时 export 就够。

5.2 Windows 下 Visual Studio 的配置

Windows 上我用的是 Visual Studio 创建控制台项目。需要的步骤:

  1. 安装 MySQL Connector/C,注意版本和位数(x64/x86);
  2. 项目属性 → VC++ 目录 → 包含目录,加上 Connector 的 include 目录;
  3. 库目录,加上 lib 目录;
  4. 链接器 → 输入 → 附加依赖项,添加 libmysql.lib;
  5. 运行时把 libmysql.dll 放到 exe 同级目录,新版 Connector 可能还依赖 libssl,一并拷过来。

最容易踩的坑是位数不一致:项目是 x64 却链接了 x86 的库,编译时会报 LNK2019,提示找不到 mysql_init 这类函数符号。先检查平台是不是对齐了,不要先怀疑代码。

5.3 运行期必踩的乱码和字符集问题

中文乱码是这类项目出现频率最高的运行期问题,而且很多同学找半天不知道问题在哪。乱码本质是字符集不一致,要检查三个地方:

  • 建库建表字符集:SQL 文件里已经指定了 utf8mb4,如果你的库是默认 latin1,中文一定乱;
  • 连接字符集:程序连接后执行SET NAMES utf8mb4,这一步必须放在第一次查询之前;
  • 控制台显示编码:Windows 控制台默认中文代码页是 GBK,在 main 函数开头setlocale(LC_ALL, ""),让 C 标准库按本地化方式输出多字节字符串。

三个环节都对齐了,中文才能正常显示。我见过一个同学的代码,数据库和连接都正常,就是控制台输出乱码,最后发现 main 里没加 setlocale。排错时先按这个顺序查。

6. 复盘:调试过程中最有价值的几个问题

6.1 数据库连接失败的排查顺序

如果你遇到“连接失败”,不要盯着代码猜,按顺序查:

  1. 先命令行验证:mysql -ulib_user -p -h127.0.0.1 -P3306 library_db,能连说明网络和服务没问题,问题在代码参数;
  2. 服务有没有启动:systemctl status mysql,没启动就先启动;
  3. 账号能不能从当前主机连:MySQL 的账号是“用户名 + 主机”绑定的,程序里要单独建账号并授权:
    CREATE USER 'lib_user'@'localhost' IDENTIFIED BY '你的密码'; GRANT ALL ON library_db.* TO 'lib_user'@'localhost'; FLUSH PRIVILEGES;
  4. 代码参数别传反:mysql_real_connect 的参数顺序是 host、user、password、dbname、port,我曾经把 dbname 和 user 传反,查了半小时才醒悟;
  5. 端口默认是 3306,如果你的 MySQL 改过端口,代码里要对应改。

6.2 事务没生效的典型原因

借书流程里我一开始用 MyISAM 引擎测试,发现一个问题:开始事务后,两条 SQL 执行完,其中一条报错时,前面的数据并没有回滚。当时第一反应是代码写错了,查了半天发现是表引擎不对。

MyISAM 不支持事务,它执行每条语句都是自动提交,根本没有 ROLLBACK 这回事。解决办法:所有业务表改成 InnoDB。你可以用SHOW TABLE STATUS查引擎,建表 SQL 里指定 ENGINE=InnoDB 最直接。

另一个常见问题是“忘记 COMMIT”。有些同学用 START TRANSACTION 但事务结束后没有显式 COMMIT,程序退出后数据看起来像丢了一部分。实际上数据在事务里,只是没提交。写代码时,每个事务分支结束后要想清楚:这个分支到底该 COMMIT 还是 ROLLBACK。

还有个容易被误会的点:InnoDB 的事务回滚后,自增主键不会回退。比如刚才插入到 100,事务失败回滚,下一条记录还是从 101 开始。这是正常行为,不要当成 bug。

6.3 做完这个项目之后,还能往哪走

如果时间和精力允许,这个项目有几个不错的扩展方向,也比较加分:

  • 加预约功能:一本书全部借出时,读者可以预约,书归还得下一顺位通知。需要新增预约表,把借书流程变成“预约校验 + 借出闭环”;
  • 把借书事务封装成存储过程:客户端只调用CALL borrow_book(reader_id, book_id),进一步把业务逻辑和调用层解耦;
  • 界面升级:控制台换成 Qt 或简单网页前端,DAO 层可以整体复用,这是验证你分层设计好不好的机会;
  • 定时任务提醒逾期:每天扫描 borrow 表中 due_date 小于当前时间且 status 为借出的记录,生成逾期列表;
  • 备份策略:mysqldump + crontab 定期备份数据库,养成运维意识。

最后再分享一个我做项目时的习惯:功能写完别急着交,给自己列一份测试清单,把借书、还书、重复借、超量借、冻结读者、无库存借书这些场景挨个跑一遍。很多演示翻车就是只测了正常路径。图书馆系统这类业务,用户操作错得越离谱,越能暴露设计上的短板。把表结构和核心事务流程扎扎实实走一遍,这个课程设计不会差到哪里去。