简介面向初学MongoDb与Java桌面开发者的图书管理系统课程设计包围绕图书信息增删改查这一典型场景提供可直接运行参考的MongoDb入门实践适合课程设计、数据库入门与Java项目练手。压缩包共38个文件约1.3MB以22个class编译文件与12个java源码为主体并附带MongoDb Java驱动jar及Eclipse项目配置便于查看项目结构、对照源码理解数据库连接与CRUD实现。数据库无需预先建表程序运行时会自动创建并写入一条初始数据但需提前启动MongoDb服务这一设计降低了初次运行门槛。该版本已实现基础管理功能查询结果目前仅输出到控制台、尚未回显到界面正好留下二次开发空间可据此练习界面联动与结果展示。已有4525人学习适合作为JavaMongoDb课程设计、入门练习与功能完善参考。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实体就带了ListString tags和MapString,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 只碰 Documentservice 只碰实体和业务规则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 groupIdorg.mongodb/groupId artifactIdmongodb-driver-sync/artifactId version4.11.1/version /dependency dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/scope /dependency /dependencies配置文件集中在db.properties几个参数看着简单但每个都影响行为mongo.urimongodb://127.0.0.1:27017 mongo.dblibrary mongo.pool.max20 mongo.pool.min2 page.defaultSize10 borrow.maxPerReader5 borrow.loanDays30mongo.uri里如果换成副本集写法是mongodb://host1:27017,host2:27017/?replicaSetrs0这个改动后面讲事务的时候会用到。mongo.pool.max决定并发上限Swing 单机课设基本碰不到但如果你把 ui 层换成 Servlet 跑 Tomcat20 个连接够几十个并发用设太大反而把 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 MongoCollectionDocument 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 ListString 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、statusBORROWED/RETURNED/OVERDUE。status用字符串而不是数字枚举是为了mongosh里直接看得懂课设调试时这点很值。3.2 图书检索分页加模糊匹配别在 Java 里 filter列表页是这套系统里唯一会被频繁点的功能写法直接决定手感。源码里的实现思路是过滤条件在数据库侧完成Java 只负责把Document转成实体。public PageResultBook 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)); MongoCollectionDocument col MongoConn.col(book); long total col.countDocuments(filter); // 总数单独查前端要算总页数 if (total 0) return PageResult.empty(page, size); ListBook 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的写法在并发下必然出问题——两个请求都读到available1都判断通过都执行$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 ListDocument topBorrowed(int limit) { ListBson 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只用它拉依赖和打 jarMongoDB5.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() { MongoCollectionDocument 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))); MongoCollectionDocument 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。窗口起来之后按这五步走一遍能全过就说明整条链路通了列表页应该能看到导入的样例书翻页按钮点得动总数和db.book.countDocuments({})对得上。搜索框输入一个作者名结果条数变少且都含这个作者输入(不报错、不崩界面验证Pattern.quote。挑一本available大于 0 的书借出界面上可借数立刻减一db.borrow_record.find({status:BORROWED})能查到新记录。对同一本书连点两次借阅按钮第二次应该提示库存不足或操作过快不能扣成 -1。归还之后再查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()连接串加上?replicaSetrs0就能用事务了要么按第 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); ListDocument 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、statusWAITING/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/READERservice 层每个方法开头校验一下当前登录用户ui 层根据 role 决定借阅按钮是否可见。别上 Spring Security课设用二十行 if 就够了重点是能讲清「权限校验放在 service 而不是 ui因为 ui 可以被绕过」。最后一个技巧送给要现场演示的人把data/目录下的样例数据准备两份一份正常数据用于主流程演示一份故意留几条脏数据缺isbn、available为 0、dueAt已经过期。演示时先跑正常流程再切到脏数据那几条展示系统怎么兜住异常、怎么在统计页把它们筛掉。老师问「边界情况考虑了吗」你直接点给他看比嘴上说强十倍。从那以后我每次交课设前都会强制自己在一个干净目录里重新解压、重新导数据、按自检清单从头点一遍——因为「我机器上是好的」这句话在答辩现场从来不管用。希望帮到你。本文还有配套的精品资源点击获取