☰
基于MongoDB的图书管理系统:文档建模与并发借阅控制
2026/11/5 5:38:30 网站建设 项目流程

简介:这是一份面向Java与MongoDb初学者的课程设计参考包,主题为图书管理系统,涵盖图书信息增删改查等基础功能,适合用于数据库课程设计、小型项目练手或MongoDb入门实践。压缩包共38个文件,约1.3MB,以12个java源码和22个class编译文件为核心,另含项目配置、Eclipse工程文件及mongo-java-driver依赖jar,便于直接导入IDE查看与调试。系统运行时会自动创建数据库并插入一条初始数据,无需预先建库,但需保证MongoDb服务已开启;作者也说明查询结果目前仅输出在控制台、尚未反馈到界面,为后续改进留下明确切入点。该资源已有4525人学习,适合希望理解Java连接MongoDb、完成基础CRUD与课程设计结构的学习者参考。

1. 一套用 MongoDB 存书的课设,看点不在界面

解压“基于MongoDB的图书管理系统.rar”之后,大多数人第一反应是找启动类、配好数据库、把页面跑起来看一眼,然后就没下文了。可真跑通你会发现,它的价值不在前端长什么样,而在于图书、读者、借阅这三类数据被塞进了文档型数据库,借书还书没有走关系库那套外键约束和 JOIN 查询。做数据库课设、Java Web 课设的人,可以拿它当骨架直接改业务;写过 MySQL 版图书管理、想知道文档模型怎么落地的人,它更像一次模型迁移练习:哪些字段该内嵌、哪些必须拆集合、并发借阅怎么保证不超借,这些在关系库里靠事务和唯一索引解决的事,换到 MongoDB 得重新想一遍写法。下面按我实际拆包、重建、跑通的顺序讲,重点放在数据模型、Java 接入和并发控制上。

2. 数据模型先行:三个集合怎么切、索引怎么建

2.1 环境对齐:JDK、Spring Boot 和 MongoDB 的版本线

拿到这类 Java + MongoDB 的课设包,第一步不是急着导进 IDE,而是先把三样东西的版本关系捋顺,否则后面一启动就是NoSuchMethodError或者驱动不认服务端。资源本身没有写死具体版本,常见的组合是 Spring Boot 2.7 配 MongoDB 4.x 驱动、JDK 8 或 11;如果要用 Spring Boot 3.x,JDK 必须 17 起,驱动也升到 4.11 以上。我一般的做法是先在本机把服务端和客户端跑起来,确认数据库这一层没问题,再去动 Java 工程。

先用几条命令确认本机现状,别猜:

# 三个关键组件的版本,先记下来,后面配 pom 要对照 java -version mvn -v mongod --version mongosh --version

java -version的输出决定你能用哪个大版本的 Spring Boot;mongod --version决定你用的驱动下限,服务端 6.0 以上基本要求驱动 4.6 以上;mongosh是官方新版 shell,比老的mongo命令多支持不少语法,建议直接装它。如果你的机器 JDK 是 8,就把工程锁在 Spring Boot 2.7.x,别硬上 3.x,那一步的报错信息非常不直观。

版本对应关系可以粗记成下面这样,具体小版本以官方兼容矩阵为准:

组件保守选择激进选择影响
JDK8 / 1117决定 Spring Boot 大版本
Spring Boot2.7.x3.x3.x 要求 JDK17 且包名从 javax 换 jakarta
MongoDB 服务端4.4 / 5.06.0 / 7.05.0 起要求 CPU 支持 AVX
驱动由 Boot 自带手动升 4.11+低版本驱动连不上新服务端

提示:4.4 之后的 MongoDB 服务端对 CPU 指令集有要求,老机器或某些虚拟机镜像上mongod会直接退出,日志里写 illegal instruction。这一条在避坑章节还会展开。

2.2 三个集合的结构:把一对多关系拍平成引用

关系库转文档库最容易犯的错,是把借阅记录做成图书文档里的一个数组。图书借阅记录是只增不减的,一本书被借几百次,文档就会一直膨胀,最终撞上 16MB 的单文档上限,而且每次追加都要重写整个文档,写放大非常严重。正确的切法是三集合:books存书目和库存计数,readers存读者信息,loans存每一条借阅流水,用bookId和readerId做引用。这样库存字段是唯一需要高频更新的地方,刚好可以用原子操作扛住并发。

在mongosh里建库建集合,顺便把校验规则挂上:

// 切库,MongoDB 是延迟创建的,第一条写入时才真正落盘 use library; // 图书集合:校验器保证必填字段和类型,脏数据直接拒绝写入 db.createCollection("books", { validator: { $jsonSchema: { bsonType: "object", required: ["isbn", "title", "author", "total", "available"], properties: { isbn: { bsonType: "string", description: "ISBN 必填" }, title: { bsonType: "string" }, author: { bsonType: "string" }, category: { bsonType: "string" }, total: { bsonType: "int", minimum: 0 }, available: { bsonType: "int", minimum: 0 }, // 可借数量,借出时原子递减 tags: { bsonType: "array", items: { bsonType: "string" } } } } }, validationLevel: "strict", // 新增和更新都校验 validationAction: "error" // 不合法直接报错,不静默丢弃 }); // ISBN 唯一索引,防止同一个书重复入库 db.books.createIndex({ isbn: 1 }, { unique: true }); // 列表页常用组合条件:按分类筛选 + 按书名排序 db.books.createIndex({ category: 1, title: 1 }); // 关键词搜索走文本索引,注意中文分词能力有限,后面会讲 db.books.createIndex({ title: "text", author: "text" });

validationLevel设成strict意味着插入和更新都校验,设成moderate只校验新插入的文档和已通过校验的文档更新,课设阶段用strict更省心。validationAction用error会直接拒绝写操作并返回错误码,如果你希望坏数据落库但要留日志,才改成warn。三个索引各有用途:唯一索引是业务约束的最后一道防线,组合索引服务列表页,文本索引服务模糊搜索,三者不要互相替代。

读者和借阅记录两个集合同样用校验器兜底:

db.createCollection("readers", { validator: { $jsonSchema: { bsonType: "object", required: ["cardNo", "name"], properties: { cardNo: { bsonType: "string" }, // 借书证号 name: { bsonType: "string" }, phone: { bsonType: "string" }, status: { enum: ["ACTIVE", "FROZEN"] } // 冻结状态不允许借书 } }} }); db.readers.createIndex({ cardNo: 1 }, { unique: true }); // 借阅流水:一条记录对应一次借出,归还时改 status 而不是删文档 db.createCollection("loans"); db.loans.createIndex({ readerId: 1, status: 1 }); // 查某读者当前未还 db.loans.createIndex({ bookId: 1, status: 1 }); // 查某本书的借阅历史

loans里status用枚举值而不是布尔值,是为了将来能扩展出“逾期”“续借”这些状态,布尔值一旦加上第三种状态就得改表结构。索引建在(readerId, status)上,是因为最常见的查询是“这个读者手上还有几本没还”,这个组合能直接走索引覆盖,不需要回表扫描全集合。

2.3 种子数据:先灌几十条再谈功能

空库跑系统,页面上什么都没有,你根本判断不出分页、排序、搜索这些逻辑对不对。我习惯先灌一批种子数据,量不用大,三十到五十本书、十几个读者、二十来条借阅流水就够把所有查询路径走一遍。种子数据最好写成脚本,方便反复重置。

// 批量插入书目,total 和 available 初始相等 db.books.insertMany([ { isbn: "9787000000001", title: "数据结构与算法分析", author: "某作者", category: "计算机", total: 5, available: 5, tags: ["算法"] }, { isbn: "9787000000002", title: "数据库系统概论", author: "某作者", category: "计算机", total: 3, available: 3, tags: ["数据库"] }, { isbn: "9787000000003", title: "操作系统导论", author: "某作者", category: "计算机", total: 2, available: 2, tags: ["系统"] } ]); // 留一条未还记录,方便页面上的"在借"逻辑有数据可显示 db.loans.insertOne({ bookId: db.books.findOne({ isbn: "9787000000002" })._id, readerId: db.readers.findOne({ cardNo: "R0001" })._id, borrowAt: new Date(), dueAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), // 借期 30 天 status: "BORROWED" }); // 库存要跟着减,种子数据也要保持一致,否则一上来就对不上账 db.books.updateOne({ isbn: "9787000000002" }, { $inc: { available: -1 } });

这里有个容易被忽略的细节:插入借阅流水之后必须同步扣减库存,否则你手动造的数据和业务逻辑算出来的账对不上,后面排查并发问题时会被这批脏数据误导。种子脚本最好放在仓库的scripts/目录里,配合说明写清楚执行顺序,别人拿到包之后三步就能把环境恢复成你演示时的样子。

3. Java 侧接入:连接串、实体映射和查询分工

3.1 连接串里的参数比 host 更要紧

很多人配 MongoDB 连接只写mongodb://127.0.0.1:27017/library,本地开发确实能连上,但只要加了认证就会立刻翻车。认证失败最常见的原因不是密码错,而是authSource没指定——MongoDB 的用户是挂在某个库下面的,默认去admin库校验,而课设里通常把用户建在业务库里,两边对不上就是认证失败。

spring: data: mongodb: # authSource 显式指向建用户的库,别依赖默认值 uri: mongodb://libuser:libpass@127.0.0.1:27017/library?authSource=library # 关掉自动建索引,索引统一用脚本管理,避免启动时隐式加锁 auto-index-creation: false # 连接池上限,课设环境给 20 足够,压测时再调 connection-pool-max-size: 20

auto-index-creation这一项值得单独说:开着它,Spring Data 会在启动时扫描@Indexed注解并建索引,小数据量下没感觉,数据一多就是启动卡顿甚至锁表。我一般关掉自动创建,把索引写在 2.2 那种脚本里,环境初始化时执行一次,索引定义集中在一处,换环境也好复现。连接池默认值通常够用,但如果你要演示并发借书,把它调到 20 以上能避免大量请求排队等连接。

建用户这一步也别漏:

// 在 admin 库创建业务账号,权限只给 library 库的读写 use admin; db.createUser({ user: "libuser", pwd: "libpass", roles: [{ role: "readWrite", db: "library" }] });

密码别照抄示例,课设演示环境也建议换一个。角色用readWrite而不是dbOwner,最小权限原则在课设里同样适用,万一手滑写了删除集合的代码,权限能挡一半。

3.2 实体映射:注解和字段名必须对得上

Spring Data 的映射规则是“字段名即文档键名”,一旦 Java 侧用了驼峰而库里存的是下划线,查出来就是一堆 null,而且不报错,这种静默失败最耗时间。稳妥的做法是在实体上显式声明集合名和每个字段的键名。

@Document(collection = "books") // 显式指定集合,避免类名小写推导出错 public class Book { @Id private String id; // 对应 _id,类型用 String 接 ObjectId @Indexed(unique = true) private String isbn; private String title; private String author; private String category; @Field("total") private Integer total; @Field("available") private Integer available; // 可借数量,只允许通过原子操作修改 private List<String> tags; // getter / setter 省略 }

@Id字段用String接收,Spring Data 会自动把ObjectId转成 24 位十六进制字符串。这里埋着一个高频坑:前端传参、URL 路径参数拿到的都是字符串,如果你在查询条件里拿它和ObjectId比较,或者手动new ObjectId(str)时传了空串、长度不对的值,就会抛IllegalArgumentException。统一在服务层做一次校验和转换,后面能省很多事。

Repository 接口负责简单查询,复杂条件交给MongoTemplate:

public interface BookRepository extends MongoRepository<Book, String> { // 方法名即查询:isbn 等值匹配 Optional<Book> findByIsbn(String isbn); // 分页查询按分类,Pageable 里带页码、页大小和排序 Page<Book> findByCategory(String category, Pageable pageable); // 库存大于 0 的书,用于"可借列表" List<Book> findByAvailableGreaterThan(int min); }

方法名派生查询在字段少的时候很顺手,但条件一多方法名会长得没法看,比如findByCategoryAndAvailableGreaterThanAndTitleContaining,可读性极差。我的经验是:单条件、双条件用 Repository,三个条件以上一律走MongoTemplate手写Criteria,可读性和可调试性都更好。

3.3 查询服务:MongoTemplate 拼条件的正确姿势

Criteria链式拼接是这套代码里最需要写清楚的部分,因为它直接决定了索引能不能命中。下面这个方法覆盖列表页的筛选、排序、分页三个动作:

@Service public class BookQueryService { private final MongoTemplate mongoTemplate; public BookQueryService(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } public Page<Book> search(String category, String keyword, int page, int size) { List<Criteria> ands = new ArrayList<>(); // 分类精确匹配,能命中 category + title 组合索引的前缀 if (StringUtils.hasText(category)) { ands.add(Criteria.where("category").is(category)); } // 关键词走正则,注意这会放弃索引,只适合小数据量 if (StringUtils.hasText(keyword)) { ands.add(Criteria.where("title").regex(Pattern.quote(keyword), "i")); } // 库存大于 0 才算可借 ands.add(Criteria.where("available").gt(0)); Criteria criteria = new Criteria().andOperator(ands.toArray(new Criteria[0])); Query query = new Query(criteria) .with(Sort.by(Sort.Direction.ASC, "title")) .skip((long) page * size) // 深分页性能差,第六章会讲替代方案 .limit(size); long total = mongoTemplate.count(Query.of(query).limit(-1).skip(-1), Book.class); List<Book> rows = mongoTemplate.find(query, Book.class); return new PageImpl<>(rows, PageRequest.of(page, size), total); } }

几个参数要说明白:andOperator把所有条件包成一个隐式 AND,比链式.and()更直观也更安全,后者在动态拼接时容易生成重复键导致条件被覆盖。skip + limit是课设里最常见的分页写法,页码小的时候没问题,翻到几千页之后会变慢,因为服务端要跳过前面所有文档。count那里用Query.of(query).limit(-1).skip(-1)是为了去掉分页参数再统计总数,直接复用查询对象能少写一遍条件。正则查询带Pattern.quote是防止用户输入里的特殊字符被当成元字符,i选项表示不区分大小写,但代价是全集合扫描,数据量上千条之后就该换成文本索引了。

4. 借书还书的并发:查询更新为什么一定会超借

4.1 先查后改的经典漏洞

教科书式的写法是先findById查出这本书,判断available > 0,然后available - 1再save。单线程跑演示脚本毫无问题,一旦页面上两个人同时点“借阅”,这条链路就会超借。原因很直白:两个请求读到的available都是 1,都判断通过,都写回 0,最终库存少了 2 但只借出去 1 本,或者更糟,库存变成负数。

这不是 MongoDB 特有的问题,关系库用UPDATE ... WHERE available > 0同样能规避。区别在于 MongoDB 的写操作默认是单文档原子性的,也就是说,只要把“判断 + 递减”合并成一次写操作,就能天然获得原子性,不需要额外加锁。这是文档模型在库存扣减这种场景下的优势,也是这套课设最值得讲的一个点。

4.2 用 findAndModify 把判断和扣减合并成一步

findAndModify的语义是:按条件找到一条文档,原子地修改并返回结果。把“库存大于 0”写进查询条件,就等价于“只有库存够才扣减”。

@Service public class LoanService { private final MongoTemplate mongoTemplate; public LoanService(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } /** * 借书:先原子扣库存,扣成功再写借阅流水 * @return 借阅流水 id,返回 null 表示库存不足或书不存在 */ public String borrow(String bookId, String readerId) { // 条件里带上 available > 0,不满足时压根匹配不到文档 Query query = Query.query( Criteria.where("_id").is(bookId).and("available").gt(0)); Update update = new Update().inc("available", -1); Book updated = mongoTemplate.findAndModify( query, update, FindAndModifyOptions.options().returnNew(true), // 返回修改后的文档 Book.class); if (updated == null) { // 两种可能:书不存在,或库存已为 0,对外统一提示"暂不可借" return null; } Loan loan = new Loan(); loan.setBookId(bookId); loan.setReaderId(readerId); loan.setBorrowAt(new Date()); loan.setDueAt(DateUtils.addDays(new Date(), 30)); loan.setStatus("BORROWED"); mongoTemplate.insert(loan); return loan.getId(); } }

inc生成的是$inc操作符,服务端执行时是读改写一体的,不需要客户端回传整条文档。returnNew(true)让返回的是扣减后的新值,方便直接判断剩余库存;如果只是想确认“改没改成功”,用默认的returnOld也够。条件里用_id匹配时,别忘了传进来的字符串要能转成ObjectId,否则匹配不到会静默返回 null,和库存不足的现象一模一样,排查时会绕很多弯路。

还书用同样的思路,但方向相反,并且要防止重复归还:

public boolean giveBack(String bookId, String loanId) { // 先把流水从在借改成已还,改成功了才回补库存 Query loanQuery = Query.query( Criteria.where("_id").is(loanId) .and("bookId").is(bookId) .and("status").is("BORROWED")); // 状态条件防重复归还 Update loanUpdate = new Update().set("status", "RETURNED").set("returnAt", new Date()); Loan loan = mongoTemplate.findAndModify( loanQuery, loanUpdate, FindAndModifyOptions.options().returnNew(true), Loan.class); if (loan == null) { return false; // 流水不存在或已经还过了 } // 流水状态变更成功后,库存回补一次 mongoTemplate.updateFirst( Query.query(Criteria.where("_id").is(bookId)), new Update().inc("available", 1), Book.class); return true; }

关键在status这个条件上:把状态作为更新条件的一部分,相当于用一次原子写完成了“状态检查 + 状态变更”,重复提交同一个还书请求时,第二次匹配不到BORROWED的流水,直接返回 false,库存也就不会被多加。这种写法比先查再判断省一次往返,也彻底消除了并发下的重复归还窗口。

4.3 什么时候必须上事务

上面两个方法虽然各自原子,但“扣库存”和“写流水”是两次独立写操作,中间如果抛异常,库存就白扣了。课设演示环境里这种概率不高,但如果你要讲清楚一致性,就得知道 MongoDB 的多文档事务要求副本集模式,单机版mongod直接报错。

把单机升级成单节点副本集是最省事的做法:

# 用副本集模式启动,--replSet 指定集合名 mongod --dbpath /data/db --replSet rs0 --bind_ip 127.0.0.1 --port 27017 # 另开终端初始化副本集,单节点也要执行这一步 mongosh --eval "rs.initiate()" # 确认状态是 PRIMARY 才算可用 mongosh --eval "rs.status().myState"

初始化之后,在事务方法上标@Transactional,并把事务管理器配成MongoTransactionManager,Spring 就会把这几次写包在一个会话里,要么全成功要么全回滚。

@Configuration public class MongoTxConfig { @Bean public MongoTransactionManager transactionManager(MongoDatabaseFactory factory) { return new MongoTransactionManager(factory); } }

代价是事务有额外开销,还得保证所有读写都走同一个会话。我的建议是:课设里的借还书逻辑,用 4.2 的条件更新已经能挡住绝大多数问题,把事务留到“批量导入书目同时初始化库存”这类确实需要多文档一致的场景,讲课时讲一句“单文档原子、多文档靠事务”,比堆一堆事务注解更清楚。

5. 跑不起来时的排查清单:五条血泪经验

5.1 连接被拒:先看监听地址,再看 authSource

现象是启动即报Connection refused或Authentication failed。前者的原因通常是mongod默认只监听127.0.0.1,而你的应用跑在容器或另一台机器上,改--bind_ip 0.0.0.0或配置文件里的net.bindIp就能解决。后者多半是连接串里少了authSource,用户建在library库但默认去admin校验,必然失败。排查顺序固定为:mongosh能不能连上 → 带上账号密码能不能连上 → Java 连接串和mongosh的参数是否完全一致,三步之内基本能定位。

5.2 服务端启动即退出:老 CPU 和 AVX 指令集

现象是mongod启动几秒后进程消失,日志里出现 illegal instruction 或直接空日志。原因是 MongoDB 5.0 之后的版本用到了 AVX 指令集,某些老 CPU 和支持精简指令集的虚拟化环境跑不了。解决办法有两个:装 4.4 版本的服务端,或者换一台支持 AVX 的机器。这个问题特别容易在演示现场爆发,因为开发机是新的、演示机是旧的,建议提前在演示环境跑一次mongod --version加启动验证,别等到上台才发现。

5.3 查询结果永远是空:字符串和 ObjectId 类型不匹配

现象是findById或者按 id 查询的接口永远返回 null,日志里一条错都没有。原因是你拿字符串去和_id的ObjectId类型比对,或者手动构造ObjectId时传了非法值。判断方法很简单:在mongosh里执行db.books.findOne()看一眼_id的实际类型,如果是ObjectId("..."),Java 侧就必须用能自动转换的方式传参,或者自己显式new ObjectId(str)并捕获异常。我一般会在服务层入口统一做一次ObjectId.isValid()校验,非法直接返回参数错误,比让它静默返回 null 好排查得多。

5.4 启动报 NoSuchMethodError:Boot 版本和驱动不匹配

现象是编译通过、启动时抛java.lang.NoSuchMethodError或者ClassNotFoundException,报错指向 Spring Data 的内部类。原因基本是手工改了pom.xml里的驱动版本,但没同步调整 Spring Boot 的版本,两边对不上。解决办法是删掉手动指定的 MongoDB 驱动版本,让 Boot 的依赖管理统一决定;确实需要升驱动,就连 Boot 一起升,并且确认 JDK 版本跟得上。

5.5 事务报错:单机版不支持多文档事务

现象是给借书方法加了@Transactional之后,一调用就抛Transaction numbers are only allowed on a replica set member or mongos。原因很明确:事务依赖副本集或分片集群,你连的是单机mongod。按 4.3 的方法把单机改成单节点副本集,并确认rs.status()里当前节点是 PRIMARY。改完之后还有一个隐性坑:连接串里最好显式带上replicaSet=rs0,否则驱动可能按单机模式建立连接,事务照样不可用。

6. 收尾技巧:让演示时的查询稳定在毫秒级

系统能跑通只是及格线,答辩或者给同事演示的时候,评委最喜欢点的就是“搜索一下”和“翻到最后一页”。这两个动作恰好是最容易卡的地方。深分页用skip在数据量上千之后会明显变慢,因为服务端必须扫过前面所有文档才能定位;按关键词做正则匹配更是全集合扫描。我一般会用下面的方式先确认问题出在哪:

// 看查询走了哪个索引, winningPlan 里出现 COLLSCAN 就是全表扫 db.books.find({ category: "计算机", available: { $gt: 0 } }).explain("executionStats");

如果winningPlan里是IXSCAN说明索引命中,stage是COLLSCAN就说明条件写法和索引对不上,比如条件顺序不匹配组合索引前缀,或者对字段做了函数处理后比较。深分页的替代方案是用上次结果最后一条的_id做游标,查询条件改成_id > lastId,配合limit就能一直走索引,代价是不能随机跳到某一页,但演示场景里“加载更多”比页码跳转更好讲。

导出数据也别手写循环,直接用官方工具更稳:

# 导出为 JSON 数组,方便打包进课设压缩包 mongoexport --uri="mongodb://libuser:libpass@127.0.0.1:27017/library?authSource=library" \ --collection=books --out=books.json --jsonArray # 导入时加 --upsertFields 避免重复 ISBN 报唯一索引冲突 mongoimport --uri="mongodb://libuser:libpass@127.0.0.1:27017/library?authSource=library" \ --collection=books --file=books.json --jsonArray --upsertFields=isbn

--jsonArray让输出是一个合法的 JSON 数组,用编辑器打开能直接看;不加的话是每行一个文档的格式,适合流式处理但不适合人读。--upsertFields这个参数在课程设计交付时特别有用,别人导入你的数据不会因为 ISBN 重复直接失败,而是按 ISBN 更新已有文档。索引脚本、种子数据、导入导出命令三样东西放进同一个scripts目录,附一页执行顺序说明,别人拿到包之后照着敲就能复现你的演示环境。

从那以后我每次交付带数据库的课设,都会强制走一遍“清空库 → 跑索引脚本 → 灌种子数据 → 启动应用 → 点三次搜索和两次借还”的流程,确认从零到可用不超过十分钟。这十分钟能挡掉现场百分之八十的意外。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询