☰
MongoDB图书管理系统实战:原子扣减、聚合统计与索引优化
2026/11/5 5:38:34 网站建设 项目流程

简介:面向初学MongoDb与Java桌面开发者的图书管理系统课程设计包,围绕图书信息增删改查这一典型场景,提供可直接运行参考的MongoDb入门实践,适合课程设计、数据库入门与Java项目练手。压缩包共38个文件,约1.3MB,以22个class编译文件与12个java源码为主体,并附带MongoDb Java驱动jar及Eclipse项目配置,便于查看项目结构、对照源码理解数据库连接与CRUD实现。数据库无需预先建表,程序运行时会自动创建并写入一条初始数据,但需提前启动MongoDb服务,这一设计降低了初次运行门槛。该版本已实现基础管理功能,查询结果目前仅输出到控制台、尚未回显到界面,正好留下二次开发空间,可据此练习界面联动与结果展示。已有4525人学习,适合作为Java+MongoDb课程设计、入门练习与功能完善参考。

1. 答辩前一晚解压源码包:这套 MongoDB 图书管理系统到底能跑出什么

答辩前一晚,我把一份图书管理系统的源码包解压出来,看见 dao 层只有三个类,心里先凉了半截。等真正跑起来才发现,借书、还书、按书名作者模糊检索、逾期统计、库存扣减,一样没少。这套基于 MongoDB 的 Java 图书管理系统就是典型的课设体量:不追求大而全,但把文档型数据库最该练的几件事全落到了具体代码上——嵌套文档存借阅明细、findOneAndUpdate做原子扣减、聚合管道出排行榜、复合索引兜住查询。它适合两类人:一是要交 Java + 数据库课设、想找个能改得动又不至于看不懂的底座;二是写惯了 MySQL,想看看同样的业务换文档库之后,表设计、事务边界、分页逻辑到底哪里不一样。下面按「它是什么 → 怎么跑 → 怎么改」一路拆下去。

2. 技术选型与工程骨架:从 pom.xml 到包结构看清这套系统

2.1 为什么图书管理这类业务,文档库其实更顺手

关系库做图书管理,最别扭的是「一本书」和「它的借阅历史」这两件事的关系。用 MySQL 你得拆book、borrow_record两张表,查详情的时候 JOIN 一把,或者分两次查再在 Java 里拼。MongoDB 的文档模型允许你把「借阅记录」当成一本书下面的一层数组,也可以独立成集合用bookId关联——两种都行,而课设场景里通常选后者,因为记录会一直长,塞进文档里迟早撑爆 16MB 的单文档上限,还得处理数组的无界增长。

真正让文档库占便宜的是字段不固定。图书这个实体每个来源的字段都不一样:有的是isbn、有的是ISSN,有的带出版社有的带publisher,还有的要存标签数组做分类。关系库要么加一堆可空列,要么上 EAV 模型把自己绕死;MongoDB 直接db.book.insertOne({...})就行,缺字段就是没有,Java 侧映射的时候给默认值。这套系统里Book实体就带了List<String> tags和Map<String,Object> ext两个弹性字段,答辩时被问到「异构数据怎么处理」,这地方能直接答。

反过来说,它不适合什么?强一致的库存扣减、跨多表的事务结算。这也是这套源码用findOneAndUpdate的带条件更新来兜底的原因,后面第 3 章会细说。

2.2 工程目录与四层职责:改动之前先认清哪个包不该动

解压后是个标准 Maven 工程,主干目录大致长这样(不同版本可能类名有出入,但分层基本一致):

library-mongo/ ├── pom.xml ├── data/ # 初始化用的 JSON 样例数据 │ ├── book.json │ └── borrow_record.json ├── src/main/resources/ │ └── db.properties # 连接串、库名、分页默认值 └── src/main/java/com/example/library/ ├── App.java # 入口,启动 Swing 主窗口 ├── config/Config.java # 读 properties,全局常量 ├── db/MongoConn.java # MongoClient 单例 + 集合获取 ├── db/IndexInitializer.java# 启动时建索引 ├── entity/Book.java ├── entity/BorrowRecord.java ├── entity/Reader.java ├── dao/BookDao.java ├── dao/BorrowDao.java ├── dao/ReaderDao.java ├── service/BorrowService.java # 借还业务规则 ├── service/StatService.java # 聚合统计 └── ui/ # Swing 面板:查询页、借还页、统计页

分层的关键在于dao 只碰 Document,service 只碰实体和业务规则,ui 连 dao 都不认识。这套源码里最容易翻车的地方就是有人图省事,在 Swing 的按钮回调里直接MongoConn.col("book").find(),写的时候爽,等到要加「借书前先查读者是否超限」这种规则,就得在三个按钮里各抄一遍。改造的时候我一般先把 ui 层所有对MongoConn的引用全部改到 service,这一步花不了半小时,但后面每加一个功能都能省回来。

App.java的职责被压得很薄,只做三件事:加载配置、初始化索引、SwingUtilities.invokeLater拉起主窗口。这种写法看着朴素,好处是启动失败的报错点非常明确——配置错了在第一步就炸,索引建不上在第二步炸,不会等到点按钮才给你一个空指针。

2.3 依赖、配置与连接池:三行依赖撑起整个数据层

pom.xml里真正必要的依赖就三个:MongoDB 官方同步驱动、JUnit(课设报告要贴测试截图)、以及打可执行 jar 用的 shade 插件。没有 Spring,没有 ORM,纯粹是驱动直连——这对课设是好事,因为每一行查询都是你自己写的,答辩老师问「这条语句对应什么操作」你答得出来。

<dependencies> <!-- 官方同步驱动,4.x 之后 bson 与 driver 合并成一个包,不用再单独引 --> <dependency> <groupId>org.mongodb</groupId> <artifactId>mongodb-driver-sync</artifactId> <version>4.11.1</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> </dependencies>

配置文件集中在db.properties,几个参数看着简单但每个都影响行为:

mongo.uri=mongodb://127.0.0.1:27017 mongo.db=library mongo.pool.max=20 mongo.pool.min=2 page.defaultSize=10 borrow.maxPerReader=5 borrow.loanDays=30

mongo.uri里如果换成副本集,写法是mongodb://host1:27017,host2:27017/?replicaSet=rs0,这个改动后面讲事务的时候会用到。mongo.pool.max决定并发上限,Swing 单机课设基本碰不到,但如果你把 ui 层换成 Servlet 跑 Tomcat,20 个连接够几十个并发用,设太大反而把 mongod 的连接数吃满。borrow.maxPerReader和borrow.loanDays是业务规则,写在配置里而不是硬编码,是为了答辩演示时能现场改一个数字展示效果——这种小设计老师一般会给分。

连接管理是这套代码里我唯一觉得「必须这么写」的地方:

public final class MongoConn { private static volatile MongoClient client; private MongoConn() {} public static MongoClient client() { if (client == null) { synchronized (MongoConn.class) { if (client == null) { // MongoClient 内部自带连接池,全进程只该有一个实例 MongoClientSettings settings = MongoClientSettings.builder() .applyConnectionString(new ConnectionString(Config.get("mongo.uri"))) .applyToConnectionPoolSettings(b -> b .maxSize(Config.getInt("mongo.pool.max", 20)) .minSize(Config.getInt("mongo.pool.min", 2))) .build(); client = MongoClients.create(settings); } } } return client; } public static MongoCollection<Document> col(String name) { return client().getDatabase(Config.get("mongo.db")).getCollection(name); } public static void shutdown() { if (client != null) { client.close(); // 退出时必须关,否则 mongod 那边连接会挂到超时 } } }

双检锁加volatile是老写法,但在这里够用,因为客户端本就线程安全、创建代价又高,重复建实例才是真问题。MongoClient是重量级对象,内部维护连接池和拓扑监控线程,每做一次操作就MongoClients.create()一次,十几分钟就能把 mongod 的connections.current顶到几百——这个坑第 5 章还会再提一次。shutdown()挂在窗口关闭的钩子上,用Runtime.getRuntime().addShutdownHook()注册一下,比在windowClosing里写好,因为用户直接关终端进程也能触发。

3. 核心模块拆解:一本书从入库到借出的完整链路

3.1 实体到文档:Book、Reader、BorrowRecord 的字段设计

三个集合的职责划分很清晰:book存馆藏,reader存读者,borrow_record存流转。关键决策是借阅记录独立成集合而不是嵌在 book 或 reader 里,原因是它会持续增长,而查询维度又是双向的(按书查谁借过、按人查借了什么),独立集合加两个索引就能同时满足。

Book实体的字段设计有两个点值得抄:

public class Book { private ObjectId id; private String isbn; // 业务唯一键,建唯一索引 private String title; private String author; private String publisher; private List<String> tags = new ArrayList<>(); private int total; // 馆藏总数 private int available; // 可借数量,借出减一、归还加一 private Date createAt; public Document toDoc() { return new Document("_id", id) .append("isbn", isbn) .append("title", title) .append("author", author) .append("publisher", publisher) .append("tags", tags) .append("total", total) .append("available", available) .append("createAt", createAt == null ? new Date() : createAt); } public static Book fromDoc(Document d) { Book b = new Book(); b.id = d.getObjectId("_id"); b.isbn = d.getString("isbn"); b.title = d.getString("title"); b.author = d.getString("author"); // 老数据可能没有这两个字段,给默认值而不是让 NPE 冒到界面上 b.tags = d.getList("tags", String.class, new ArrayList<>()); b.total = d.getInteger("total", 0); b.available = d.getInteger("available", 0); b.createAt = d.getDate("createAt"); return b; } }

total和available分开存,而不是每次去borrow_record里数一遍。这是典型的读多写少场景下的冗余设计:查询列表页要显示「可借 3/5」,如果每次都countDocuments({bookId, status:"BORROWED"}),一页十条就是十次额外查询。代价是必须在借还时同步维护,所以第 3.3 节的原子更新才那么重要。答辩时如果被问「这样不会数据不一致吗」,答「所有写路径都收口在 BorrowService 里,且扣减用带条件的原子更新」就够了。

BorrowRecord的字段更简单:bookId、bookTitle(冗余,避免列表页 JOIN)、readerId、readerName、borrowAt、dueAt、returnAt、status(BORROWED/RETURNED/OVERDUE)。status用字符串而不是数字枚举,是为了mongosh里直接看得懂,课设调试时这点很值。

3.2 图书检索:分页加模糊匹配,别在 Java 里 filter

列表页是这套系统里唯一会被频繁点的功能,写法直接决定手感。源码里的实现思路是:过滤条件在数据库侧完成,Java 只负责把Document转成实体。

public PageResult<Book> search(String keyword, int page, int size) { // 关键词为空时用空文档,等于全量查询 Bson filter = (keyword == null || keyword.isBlank()) ? new Document() : Filters.or( // Pattern.quote 防止用户输入 ( [ 这类正则元字符把查询搞崩 Filters.regex("title", Pattern.compile(Pattern.quote(keyword), Pattern.CASE_INSENSITIVE)), Filters.regex("author", Pattern.compile(Pattern.quote(keyword), Pattern.CASE_INSENSITIVE)), Filters.eq("isbn", keyword)); MongoCollection<Document> col = MongoConn.col("book"); long total = col.countDocuments(filter); // 总数单独查,前端要算总页数 if (total == 0) return PageResult.empty(page, size); List<Book> list = col.find(filter) .sort(Sorts.descending("createAt")) // 排序字段必须有索引,否则内存排序 32MB 上限会炸 .skip((page - 1) * size) .limit(size) .map(Book::fromDoc) .into(new ArrayList<>()); return new PageResult<>(list, total, page, size); }

几个参数值得说清。Pattern.quote()这行不是可选项——用户搜「C++」或者「(上册)」,不 quote 的话Pattern.compile直接抛PatternSyntaxException,界面上就是一个红叉。CASE_INSENSITIVE对中文没影响,但对英文书名有用,代价是放弃索引,所以数据量大时要靠$text索引而不是正则,这个在 5.2 节还会碰到。sort用的createAt必须在建索引时排在最前,否则skip + sort的组合会让 mongod 走内存排序,超过 32MB 直接报Sort exceeded memory limit。

skip的写法在课设体量(几千条)完全没问题,但它是 O(n) 的——跳过一万条要先扫描一万条。真要做大,得换成基于游标的分页:上一页最后一条的createAt或_id作为锚点,用Filters.lt("createAt", lastCreateAt)往后取。这个改法在第 6 章会给出代码,算是给这套系统留的一条升级路线。

3.3 借书与还书:一次原子更新顶掉一把锁

借书这个动作,业务上要检查三件事:书还在不在、可借数量够不够、这个读者有没有超借。前两件必须在数据库侧原子完成,第三件是读操作,允许有一点误差(课设场景完全可接受)。

public void borrow(ObjectId bookId, ObjectId readerId) { // 1. 业务校验:超借上限 long holding = MongoConn.col("borrow_record") .countDocuments(Filters.and( Filters.eq("readerId", readerId), Filters.eq("status", "BORROWED"))); if (holding >= Config.getInt("borrow.maxPerReader", 5)) { throw new BizException("该读者借阅已达上限"); } // 2. 原子扣减:过滤条件里带上 available > 0,杜绝并发下扣成负数 Document filter = new Document("_id", bookId) .append("available", new Document("$gt", 0)); Document update = new Document("$inc", new Document("available", -1)); Document after = MongoConn.col("book").findOneAndUpdate( filter, update, new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)); if (after == null) { // 返回 null 说明没匹配到:要么书不存在,要么已经借光 throw new BizException("库存不足,暂时无法借阅"); } // 3. 写借阅记录,带上书名冗余,列表页就不用再查一次 book int loanDays = Config.getInt("borrow.loanDays", 30); Document record = new Document("bookId", bookId) .append("bookTitle", after.getString("title")) .append("readerId", readerId) .append("borrowAt", new Date()) .append("dueAt", DateUtil.plusDays(new Date(), loanDays)) .append("status", "BORROWED"); MongoConn.col("borrow_record").insertOne(record); }

findOneAndUpdate这一句是整个系统的心脏。它把「判断余量」和「扣减余量」压成了一次原子操作,两个线程同时借最后一本,只有一个能拿到非 null 的返回,另一个走异常分支。相比之下,先find查available、判断、再update的写法在并发下必然出问题——两个请求都读到available=1,都判断通过,都执行$inc -1,最后变成 -1。

归还的逻辑对称,但多一步状态判断:

public void giveBack(ObjectId recordId) { Date now = new Date(); Document rec = MongoConn.col("borrow_record").findOneAndUpdate( Filters.and(Filters.eq("_id", recordId), Filters.eq("status", "BORROWED")), new Document("$set", new Document("status", "RETURNED").append("returnAt", now)), new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)); if (rec == null) { throw new BizException("该记录不存在或已归还,请勿重复操作"); } // 归还成功后再回补库存 MongoConn.col("book").updateOne( Filters.eq("_id", rec.getObjectId("bookId")), new Document("$inc", new Document("available", 1))); }

注意这里是「先改记录状态,再回补库存」,顺序不能反。如果先加库存再改状态,中间进程挂掉就会变成「书没还但库存多了一本」;按现在的顺序最坏情况是「记录已归还但库存没加」,属于账面少一本,用户会立刻发现并重试,而重复调用因为status条件不匹配会被挡掉。这就是没有事务时能做的补偿设计——把错误方向选在「可被发现且幂等」的那一侧。

提示:如果你把mongo.uri指向了副本集,这两步可以包进client.startSession()的事务里。但单机 standalone 模式下开会直接抛异常,课设演示大概率就是单机,别在这里浪费一晚。

3.4 统计报表:用聚合管道代替 Java 里的 for 循环

统计页要出三块数据:借阅次数 Top10、按状态分组的数量、逾期未还清单。新手常见的写法是把整个集合find().into(list)拉到内存里用HashMap数,几百条数据看不出问题,几千条就开始卡顿,而且显不出你懂数据库。

Top10 用聚合管道:

public List<Document> topBorrowed(int limit) { List<Bson> pipeline = Arrays.asList( // 只统计已归还和借出中的,剔除脏数据 Aggregates.match(Filters.in("status", "BORROWED", "RETURNED")), // 按书分组,每出现一条记录算一次 Aggregates.group("$bookId", Accumulators.sum("times", 1)), Aggregates.sort(Sorts.descending("times")), Aggregates.limit(limit), // 关联 book 集合把书名、作者带出来,等价于一次 LEFT JOIN Aggregates.lookup("book", "_id", "_id", "bookInfo"), Aggregates.unwind("$bookInfo"), Aggregates.project(Projections.fields( Projections.excludeId(), Projections.include("times"), Projections.computed("title", "$bookInfo.title"), Projections.computed("author", "$bookInfo.author"))) ); return MongoConn.col("borrow_record").aggregate(pipeline).into(new ArrayList<>()); }

group的_id就是分组键,Accumulators.sum("times", 1)等价于 SQL 的COUNT(*)。lookup是左连接,localField是当前管道的_id(也就是bookId的值),foreignField是book集合的_id,结果塞进bookInfo数组,所以后面必须unwind拍平才能取值。这段管道换成 SQL 就是一条带JOIN和GROUP BY的语句,答辩时这么解释最省事。

按状态分组更短,Aggregates.group("$status", Accumulators.sum("count", 1))一句完事。逾期清单则是一条普通查询:Filters.and(Filters.eq("status","BORROWED"), Filters.lt("dueAt", new Date())),因为逾期状态是时间算出来的,不适合落库成固定字段——真要落库就得写定时任务去刷,课设没必要。

4. 跑起来:从导入数据到第一个页面能点

4.1 环境清单与启动顺序,顺序错了就是白折腾

这套东西对环境的依赖很轻,但顺序有讲究。清单如下:

组件版本建议说明
JDK8 或 11源码里用了isBlank(),JDK 11 最稳,8 需换trim().isEmpty()
Maven3.6+只用它拉依赖和打 jar
MongoDB5.0 或 6.0 单机装完默认监听 27017,无密码
mongosh跟随安装包用来验证集合和索引
mongoimport随 Database Tools 单独装导入data/*.json

正确的顺序是:先起mongod,再用mongosh确认能连上,然后导数据、建索引,最后才跑 Java。反过来做的话,程序启动会卡在连接超时上,报错信息是一串com.mongodb.MongoTimeoutException,看着吓人其实只是数据库没开。

启动数据库(Windows 装成服务的话跳过这步):

# 指定数据目录启动,路径换成你自己的 mongod --dbpath /data/mongo --port 27017 --bind_ip 127.0.0.1

--bind_ip 127.0.0.1只监听本机,课设环境够了,也免得同宿舍的人连上来乱改数据。确认连通:

mongosh "mongodb://127.0.0.1:27017" --eval "db.runCommand({ping:1})"

返回{ ok: 1 }就说明通了。

4.2 数据初始化:集合、索引与样例数据三件事

MongoDB 不用建库建表,第一次insertOne会自动创建库和集合。但索引必须手动建,这是这套源码里IndexInitializer干的事,也是新手最容易漏的一步——不建索引照样能跑,只是数据一多就慢,而你根本不知道慢在哪。

导入样例数据:

# --jsonArray 表示文件里是一个 JSON 数组,不是每行一个对象 mongoimport --uri "mongodb://127.0.0.1:27017/library" \ --collection book --file data/book.json --jsonArray --drop mongoimport --uri "mongodb://127.0.0.1:27017/library" \ --collection reader --file data/reader.json --jsonArray --drop

--drop表示导入前先清空目标集合,重复导入不会产生重复数据,调试阶段很方便。但它也会把索引一起删掉,所以索引要在导入之后再建,或者干脆交给程序启动时自动建:

public static void ensureIndexes() { MongoCollection<Document> book = MongoConn.col("book"); // isbn 业务唯一,sparse 让没有 isbn 的老数据也能插进去 book.createIndex(Indexes.ascending("isbn"), new IndexOptions().unique(true).sparse(true)); // 列表页默认按 createAt 倒序,配合 skip/limit 用 book.createIndex(Indexes.descending("createAt")); // 复合索引:书名 + 作者,覆盖最常见的前缀匹配查询 book.createIndex(Indexes.compoundIndex( Indexes.ascending("title"), Indexes.ascending("author"))); MongoCollection<Document> rec = MongoConn.col("borrow_record"); // 借还两个方向都要查,各建一条 rec.createIndex(Indexes.compoundIndex( Indexes.ascending("readerId"), Indexes.ascending("status"))); rec.createIndex(Indexes.compoundIndex( Indexes.ascending("bookId"), Indexes.ascending("status"))); rec.createIndex(Indexes.descending("dueAt")); }

sparse(true)的作用是让索引跳过没有isbn字段的文档。如果不加,唯一索引会把「两条都没有 isbn」的数据判成重复键冲突,因为 MongoDB 把缺失字段当成 null,而 null 和 null 在唯一索引里算重复。这个坑我在别处踩过,导数据的时候报了一堆E11000 duplicate key error,找了半天才发现是这个原因。

建完索引用 mongosh 验一下,看indexes()输出里有没有你刚建的那几条:

use library db.book.getIndexes() db.book.countDocuments({})

4.3 首次运行的自检清单:界面起来之后点哪几下

打包运行:

mvn clean package -DskipTests java -jar target/library-mongo-1.0.jar

-DskipTests是因为源码里带了几个依赖真实数据库的测试类,没配测试库的话会先红一片,第一次跑先跳过,等环境通了再补。如果报no main manifest attribute,说明 shade 插件没配对,检查pom.xml里transformers有没有加ManifestResourceTransformer并指定mainClass。

窗口起来之后,按这五步走一遍,能全过就说明整条链路通了:

  1. 列表页应该能看到导入的样例书,翻页按钮点得动,总数和db.book.countDocuments({})对得上。
  2. 搜索框输入一个作者名,结果条数变少且都含这个作者;输入(不报错、不崩界面(验证Pattern.quote)。
  3. 挑一本available大于 0 的书借出,界面上可借数立刻减一,db.borrow_record.find({status:"BORROWED"})能查到新记录。
  4. 对同一本书连点两次借阅按钮,第二次应该提示库存不足或操作过快,不能扣成 -1。
  5. 归还之后再查,available回到原值,记录状态变成RETURNED。

第 4 步是最值得在答辩时演示的——它证明你写的是原子更新而不是「先查后改」。

5. 避坑与排查:这几个坑我在本地都踩过

5.1 查出来全是 null:字段名对不上

现象:列表页显示了一堆空行,行数对得上但书名、作者都是空白,控制台没报错。

原因:mongoimport导入的 JSON 里字段叫name或book_name,而Book.fromDoc()里取的是d.getString("title")。MongoDB 不校验 schema,所以插入和读取之间没有任何报错,只是默默给你 null。

解决:先看一眼真实文档长什么样,再决定是改数据还是改代码。

db.book.findOne() // 如果是老数据,批量改字段名 db.book.updateMany( { title: { $exists: false }, name: { $exists: true } }, { $rename: { "name": "title" } } )

更稳妥的做法是在fromDoc()里做兜底,取不到就用备选字段名,或者至少打一条 warn 日志。我现在的习惯是每接一个外部数据源,第一件事就是findOne打出来肉眼确认字段名,不做这一步后面全是猜。

5.2 搜索框一输标题就查不到:正则用错了方向

现象:完整书名能搜到,输两个字搜不到;英文书名大小写敏感,输小写查不出大写。

原因:正则匹配是全文扫描,Pattern.compile("算法")只能匹配包含「算法」两个连续字符的内容,如果数据里是「算法导论」当然能中,但如果用户想搜的是分词后的「算法 导论」就中不了。至于大小写,如果代码忘了加CASE_INSENSITIVE,那就是严格匹配。

解决:短关键词、前缀匹配的场景,把正则改成锚定开头,能吃到索引:Pattern.compile("^" + Pattern.quote(kw))。中文分词场景要么上$text索引(需要指定语言或用default_language: "none"),要么老老实实做全文扫描并接受数据量上限。课设体量下,接受扫描是最划算的选择,但要在报告里写清楚这个取舍,别装作不知道。

5.3 启动报 Transaction numbers are only allowed on a replica set

现象:把借还逻辑包进session.withTransaction()之后,一运行就抛这个异常,程序直接退出。

原因:多文档事务要求 MongoDB 运行在副本集或分片集群上。单机mongod是 standalone 模式,从设计上就不支持事务,这是特性不是 bug。

解决:两条路。要么把单机改成单节点副本集——mongod --replSet rs0启动后进 mongosh 执行一次rs.initiate(),连接串加上?replicaSet=rs0,就能用事务了;要么按第 3.3 节的补偿式写法,先改状态再回补库存,用幂等条件挡住重复操作。课设演示我建议第二条,改一个配置能让程序起不来,风险不值得。

5.4 翻到后面几页越来越慢,甚至报 Sort exceeded memory limit

现象:前几页秒开,翻到几十页之后明显卡顿;数据量上来后直接抛异常,提示内存排序超限。

原因:skip是逐条跳过的,翻到第 100 页、每页 10 条,mongod 要先扫 1000 条丢掉;同时sort字段如果没有索引,就只能把结果集全读进内存排序,超过 32MB 就报错。

解决:先确认sort字段有索引(db.book.find({}).sort({createAt:-1}).explain("executionStats")看stage是不是IXSCAN)。有索引之后skip仍然慢,就换成游标分页:把上一页最后一条的createAt传给下一页,用Filters.lt("createAt", last)定位起点,彻底去掉skip。

// 游标分页:不传 lastCreateAt 就是第一页 Bson filter = lastCreateAt == null ? new Document() : Filters.lt("createAt", lastCreateAt); List<Document> page = MongoConn.col("book").find(filter) .sort(Sorts.descending("createAt")) .limit(size + 1) // 多取一条,用来判断还有没有下一页 .into(new ArrayList<>()); boolean hasNext = page.size() > size; if (hasNext) page.remove(page.size() - 1);

limit(size + 1)这个小技巧很实用:不用额外查一次countDocuments就能知道有没有下一页,前端按钮的置灰逻辑直接用它。

5.5 程序关了但 mongod 连接数一直不掉

现象:反复启动关闭程序几十次之后,mongod 日志里连接数持续增长,最后新连接被拒。

原因:每次运行都调MongoClients.create()建新客户端,而进程退出时没有close()。客户端内部的监控线程和连接池没有释放,服务端要等到超时才会回收。

解决:客户端做成全进程单例(第 2.3 节那份代码),退出时显式close(),用 shutdown hook 注册最保险。另外开发阶段尽量别用System.exit()在中间掐断进程,finally里的关闭逻辑会被跳过。判断是否踩坑,进 mongosh 执行db.serverStatus().connections看一眼current数值,正常单机课设应该是个位数。

6. 二次开发:把它改成能讲出彩的答辩项目

这套底子最值钱的地方是分层干净,加功能不用动老代码。我给几个具体的改法,按投入产出比排序。

第一个是加「预约」功能,成本最低但故事最完整。新建reservation集合,字段bookId、readerId、createAt、status(WAITING/FULFILLED/CANCELLED)。借书时如果available为 0,不再抛异常,而是插一条预约记录;还书成功后在同一个方法里查Filters.and(eq("bookId", id), eq("status","WAITING"))按createAt升序取第一条,把状态改成FULFILLED并给读者发一条站内消息。改动集中在BorrowService.giveBack()末尾加了十几行,dao 层新增一个类,原来的代码一行不用改——这就是分层的价值。答辩时讲「为什么预约要用队列语义而不是集合」,能聊到sort + limit 1配合状态更新的原子性,比讲 CRUD 有内容。

第二个是补审计字段,成本几乎为零。给所有集合加createBy、updateAt,在 dao 层用一个统一的Document装饰方法包一层:

public static Document stamp(Document doc, String operator) { Date now = new Date(); doc.putIfAbsent("createAt", now); doc.put("updateAt", now); doc.put("updateBy", operator); return doc; }

老数据没有这些字段不影响读取(getDate返回 null),新数据自动带上,属于典型的文档库向后兼容优势。这个点在报告里写一段「为什么不用 ALTER TABLE」,很能体现你理解了两者的差别。

第三个是把统计页做成可配置的聚合。现在 Top10 是写死的,改成从配置读stat.topN,再给StatService加一个按月份分组的方法,用Aggregates.project配合$dateToString把borrowAt格式化成yyyy-MM再 group,就能出「近半年借阅趋势」。这段管道是答辩时最能拿分的部分,因为它同时体现了聚合管道的project、group、sort三个阶段。

第四个是加一层简易权限。reader集合加role字段(ADMIN/READER),service 层每个方法开头校验一下当前登录用户,ui 层根据 role 决定借阅按钮是否可见。别上 Spring Security,课设用二十行 if 就够了,重点是能讲清「权限校验放在 service 而不是 ui,因为 ui 可以被绕过」。

最后一个技巧送给要现场演示的人:把data/目录下的样例数据准备两份,一份正常数据用于主流程演示,一份故意留几条脏数据(缺isbn、available为 0、dueAt已经过期)。演示时先跑正常流程,再切到脏数据那几条,展示系统怎么兜住异常、怎么在统计页把它们筛掉。老师问「边界情况考虑了吗」,你直接点给他看,比嘴上说强十倍。

从那以后我每次交课设前,都会强制自己在一个干净目录里重新解压、重新导数据、按自检清单从头点一遍——因为「我机器上是好的」这句话,在答辩现场从来不管用。希望帮到你。

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

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

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

立即咨询