简介:这份基于MongoDB的图书管理系统面向Java与数据库课程设计学习者及刚接触NoSQL的开发者,围绕图书信息管理场景实现增删改查等基础功能,运行时会自动创建数据库并写入一条初始数据,适合用作课设原型和MongoDB入门练习。压缩包共38个文件,包含22个class、12个java源码及jar依赖、工程配置等,整体约1.3MB,源码与编译产物齐全,便于导入IDE后阅读和二次修改。目前已有4525人学习,说明其作为Java+MongoDB课设参考具有一定关注度。读者可据此了解Java连接MongoDB的实现方式、基础CRUD流程与简单工程结构;作者也提示查询结果目前仅输出到控制台、尚未回显到界面,这为后续完善交互和排错留下了明确的练习空间,适合在复现基础上继续改进。
1. 需求切入:图书管理系统为什么不该先写建表 SQL
做 MongoDB 课设的人里,十个有八个选题是图书管理系统,但真正动手时,最先卡住的地方是“没有建表语句,从哪儿下手”,代码本身倒不是问题。图书、读者、借阅记录这三类数据天生带嵌套:一本书有多个副本、一条借阅记录要带上书名和读者名做快照,很难直接套进关系型数据库的三范式,勉强拆成七八张表,写起来要连表,答辩时还得画一堆外键线;反过来把所有东西塞进一个文档,又会遇到数组无限增长、单文档上限撑不住的问题。这套方案落在这个中间区域:Java + Spring Boot 配 MongoDB,把图书、读者、借阅、检索、统计五条链路跑通,集合控制在四个,索引不超过八条,单机跑得动,加个副本集还能演示事务。适合正在交 MongoDB 课设、数据库课设,或者毕设选题偏文档数据库应用的同学,也适合想拿一个完整项目对照着改的开发者。
2. 总体架构设计:三层结构与五个模块的职责划分
2.1 从借还书动作反推模块边界
很多人划分模块的习惯是照着集合划分:books 一个模块、readers 一个模块、borrow_records 一个模块。这样划分的好处是目录看起来整齐,坏处是借书这个动作要同时改两个集合,代码会散落在两个 Service 里,最后谁都不知道库存到底在哪儿扣。
我一般按业务动作划分模块,因为动作才是会被演示、被答辩追问、被写进验收用例的东西:
| 模块 | 负责的动作 | 主要读写的集合 |
|---|---|---|
| 图书管理 | 新增、编辑、下架、调整馆藏册数 | books |
| 读者管理 | 办证、挂失、注销、查看在借列表 | readers、borrow_records |
| 借阅管理 | 借书、还书、续借、逾期判定 | books、borrow_records |
| 检索与统计 | 多条件查书、借阅排行、月度报表 | books、borrow_records |
| 系统管理 | 管理员登录、操作日志 | admin_users |
划分到这一步,一条重要的边界就清楚了:库存字段availableCopies只允许借阅模块写,图书管理模块改馆藏总数时只能改totalCopies,并且要顺带调整availableCopies的差值。把这条规则写进代码注释,比写十页设计文档都管用,因为答辩时老师最爱问的就是“你库存会不会扣错”。
统计模块单独拉出来也有实际意义。它只读不写,可以走单独的只读连接,将来真要上量了,把聚合查询挪到从节点上,其他模块一行代码都不用动。
2.2 分层落地:Controller / Service / Repository 的职责与包结构
分了模块不等于分了层。课设最常见的写法是 Controller 里直接注入MongoTemplate写查询,功能能跑,但一到要改需求就全线返工。我通常按下面这个结构铺开,工程打开的第一眼就能看出边界:
src/main/java/com/example/library ├── controller # 只做参数校验和返回体包装,不碰 MongoTemplate ├── service # 业务编排:库存扣减、状态流转、逾期计算都在这层 ├── repository # 继承 MongoRepository,只放声明式查询和简单聚合 ├── domain # Book / Reader / BorrowRecord / AdminUser 四个文档类 ├── config # Mongo 配置、索引初始化、全局异常处理 └── common # 统一返回体、业务异常、分页参数对象Repository 用MongoRepository应付简单查询足够了,比如“按 ISBN 查一本书”“查某个读者的全部借阅记录”。但涉及条件更新、聚合管道、分页排序的组合查询,我一般直接上MongoTemplate,因为MongoRepository的派生查询方法名一长就没法看,findByCategoryAndAvailableCopiesGreaterThanOrderByCreatedAtDesc这种签名,自己隔一周都读不懂。
Controller 层保持极薄,一个典型的方法长这样:
@RestController @RequestMapping("/api/borrow") public class BorrowController { private final BorrowService borrowService; public BorrowController(BorrowService borrowService) { this.borrowService = borrowService; } // 借书:bookId 与 readerId 都由前端传入,业务规则全部下沉到 Service @PostMapping public Result<BorrowRecord> borrow(@RequestBody @Valid BorrowRequest req) { return Result.ok(borrowService.borrow(req.getBookId(), req.getReaderId())); } // 还书:只需要记录 id,罚金由服务层按应还时间和实际归还时间算 @PostMapping("/{recordId}/return") public Result<BorrowRecord> giveBack(@PathVariable String recordId) { return Result.ok(borrowService.giveBack(recordId)); } }@Valid加在请求体上,是为了把“bookId 不能为空”这类校验挡在进入业务逻辑之前,而不是让 Service 里堆一堆if (bookId == null)。Result是统一的返回包装,code、message、data三字段就够,别在这一层搞花活,答辩演示时前端解包越简单越好。
Service 层的方法签名要能自我说明。borrow(bookId, readerId)返回完整的借阅记录而不是布尔值,前端拿到记录就能直接渲染“应还日期是几号”,不用再发一次请求。
2.3 技术选型与部署拓扑
选型这块没什么玄学,课设的约束是“两周内能跑通、答辩电脑上能演示、老师问得出所以然”。按这个标准定下来的组合是:
| 层面 | 选择 | 理由 | 什么时候换 |
|---|---|---|---|
| 运行环境 | Java 17 + Spring Boot 3.x | 长期支持版本,spring-boot-starter-data-mongodb开箱即用 | 学校机房只有 JDK 8 就降到 2.7 |
| 数据访问 | MongoTemplate 为主,MongoRepository 为辅 | 条件更新和聚合管道必须手写 | 全是单表增删改查可以只用 Repository |
| 数据库 | MongoDB 6 以上 | 支持$jsonSchema校验和部分索引 | 只能用 4.x 时移除$merge相关写法 |
| 前端 | Thymeleaf 服务端渲染,或 Vue 静态页 | 课设演示不需要构建链太复杂 | 需要交前后端分离的选题再上 Vue |
| 部署 | 本机 mongod 单机,演示事务时切副本集 | 单机启动快,副本集一条命令就能起 | 拿不出多端口就直接讲设计不演示事务 |
部署拓扑上,最小可用形态是“一个 Spring Boot 进程 + 一个 mongod 实例”,全部跑在 127.0.0.1。需要演示多文档事务时,用一条命令起一个单节点副本集,--replSet参数不能省:
# 起一个带副本集参数的单节点实例,端口保持默认 27017 docker run -d --name mongo-rs -p 27017:27017 mongo:6 --replSet rs0 --bind_ip_all # 进容器初始化副本集,单节点也要走这一步,否则事务直接报错 docker exec -it mongo-rs mongosh --eval 'rs.initiate({_id:"rs0", members:[{_id:0, host:"127.0.0.1:27017"}]})'初始化完成后,连接串要带上replicaSet=rs0,否则驱动仍然按单机模式连,@Transactional一样失效。这一步踩过的人不少,现象是应用启动正常、查询正常,只有带事务的那个接口 500。
3. 文档模型设计:四个集合、两处冗余与六条索引
3.1 集合划分与字段设计
四个集合:books、readers、borrow_records、admin_users。前三个是业务主体,第四个只为登录存在,字段能少就少。
books的关键在于把“总册数”和“可借册数”拆成两个字段,而不是靠借阅记录实时算。实时算的问题是列表页要为每一本书发一次 count,二十本书就是二十次查询,翻页一次能把连接池打满。
| 字段 | 类型 | 说明 |
|---|---|---|
_id | ObjectId | 主键,Java 侧用 String 接收十六进制串 |
isbn | string | 唯一索引,业务上的“书号” |
title/author | string | 书名与作者,参与全文索引 |
category | string | 分类,用于筛选和统计分组 |
totalCopies | int | 馆藏总册数,只在图书管理模块改 |
availableCopies | int | 可借册数,只在借阅模块改,恒 ≤ totalCopies |
tags | array | 标签,长度受控,用于检索 |
createdAt/updatedAt | date | 统一存 UTC 的Instant |
borrow_records是这套设计里唯一会持续增长的集合,字段要多留一点余地:
| 字段 | 类型 | 说明 |
|---|---|---|
bookId/readerId | string | 引用两个主集合的_id |
bookTitle/readerName | string | 冗余快照,列表页不用回查 |
borrowedAt/dueAt | date | 借出时间与应还时间,dueAt默认借出后 30 天 |
returnedAt | date | 未归还时为 null |
status | string | BORROWED/RETURNED/OVERDUE |
active | boolean | 是否属于“未结清”状态,专供部分索引用 |
renewCount | int | 续借次数,上限 2 次 |
fine | decimal | 逾期罚金,归还时固化,避免事后改规则导致历史数据变动 |
active这个字段看着冗余,其实是整套索引方案的地基。只用status做部分索引条件的话,$in: ["BORROWED","OVERDUE"]在老版本上不被部分索引支持,写出来要么报错要么退化成全集合索引。加一个布尔字段,条件就变成等值匹配,任何版本都稳。
3.2 内嵌还是引用:两处冗余的取舍标准
文档数据库设计里最常被问的一句是“为什么不把借阅记录内嵌到读者文档里”。答案取决于三个判断:
第一,数组会不会无界增长。一个活跃读者四年下来借几百本书很正常,内嵌数组会一路涨到几千个元素,每次更新都要重写整个文档,写放大非常明显。所以借阅记录独立成集合,用readerId引用。
第二,读的时候需不需要跨集合。图书列表页要显示“当前借出几本”,如果每次都去borrow_records里 count,一页二十条就是二十次查询。所以books里存availableCopies,用一次条件更新换掉所有读时的计算,这是典型的“写时多花一点、读时省一大截”。
第三,历史数据要不要跟着主数据变。借阅记录里冗余bookTitle和readerName,是因为书名改名、读者改手机号之后,历史借阅单不该跟着变。这条冗余不是偷懒,是把“当时的快照”固化下来,罚金计算和历史查询都依赖它。代价是主数据改名后要决定是否回刷历史记录,我的做法是不回刷,在文档里注明“快照字段,写入后不再更新”。
反过来说,tags这种长度可控、永远跟着书走的字段就该内嵌,为它单开一个集合纯属给自己找麻烦。
3.3 索引、唯一约束与文档校验
索引不是越多越好,每加一条写操作就多一次维护成本。这套系统我只建六条:
// books:ISBN 全局唯一,防止同一个书号录两遍 db.books.createIndex({ isbn: 1 }, { unique: true, name: "uk_books_isbn" }) // books:书名、作者、标签走全文索引,替代 %keyword% 这种全表扫 db.books.createIndex( { title: "text", author: "text", tags: "text" }, { name: "idx_books_text", weights: { title: 5, author: 3, tags: 1 } } ) // books:分类 + 可借状态,列表页最常见的筛选组合 db.books.createIndex({ category: 1, availableCopies: -1 }, { name: "idx_books_category" }) // borrow_records:读者查自己的在借列表 db.borrow_records.createIndex({ readerId: 1, status: 1 }, { name: "idx_borrow_reader" }) // borrow_records:同一读者同一本书,未结清的记录只能有一条 db.borrow_records.createIndex( { readerId: 1, bookId: 1 }, { unique: true, name: "uk_borrow_active", partialFilterExpression: { active: true } } ) // borrow_records:逾期巡检任务按应还时间扫 db.borrow_records.createIndex({ status: 1, dueAt: 1 }, { name: "idx_borrow_due" })第 5 条是最值钱的一条。重复借书这种并发问题,靠应用层“先查再插”是防不住的:两个请求同时查到“没借过”,然后都插入成功。部分唯一索引把这条规则交给数据库,应用层只要捕获DuplicateKeyException转成友好提示就行。partialFilterExpression: { active: true }的含义是只对未结清记录生效,归还之后active置为 false,同一个读者就能再借同一本书,历史记录也不受唯一约束影响。
文档校验用$jsonSchema兜底,防止脚本直接往库里塞脏数据:
db.runCommand({ collMod: "books", validator: { $jsonSchema: { bsonType: "object", required: ["isbn", "title", "totalCopies", "availableCopies"], properties: { isbn: { bsonType: "string", description: "必填,唯一" }, totalCopies: { bsonType: "int", minimum: 0 }, availableCopies: { bsonType: "int", minimum: 0 } } } }, validationLevel: "strict", validationAction: "error" })注意availableCopies只能校验到“非负”,校验不到“不超过总数”,这类跨字段规则还得放在 Service 层。校验级别选strict而不是moderate,是为了让初始化脚本写脏数据时立刻失败,而不是悄悄放过。
3.4 初始化数据与配置
应用配置里有一项必须关掉:auto-index-creation。Spring Data 会根据实体上的@Indexed自动建索引,听着省事,实际在多实例启动时会互相抢锁,日志里一片警告。
spring: data: mongodb: uri: mongodb://app:app_pwd@127.0.0.1:27017/library?authSource=admin&replicaSet=rs0 auto-index-creation: false # 索引统一由 init/01-indexes.js 建,避免启动期抢锁 uuid-representation: standard初始化数据不要写成@PostConstruct,否则每次重启都会插一遍测试数据,跑到第三轮演示时书库里全是重复书名。做法是单独一个init目录放三个脚本,按序号执行:建集合与校验规则、建索引、灌种子数据。种子数据控制在二十本书、五个读者、三条历史借阅记录,够演示分页和统计,又不至于让答辩现场翻页翻半天。
4. 核心链路实现:借书、还书、检索与统计的落代码
4.1 借书:条件更新扣库存,唯一索引挡重复
借书这条链路要同时满足三件事:库存不能被扣成负数、同一本书不能重复借、并发下结果要正确。三件事里只有第一件能靠单条命令解决,所以顺序很重要。
@Service public class BorrowService { private final MongoTemplate mongoTemplate; public BorrowService(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } public BorrowRecord borrow(String bookId, String readerId) { // 1. 条件更新:把"可借数大于 0"写进查询条件,扣减与判定合成一次原子操作 Query stockQuery = Query.query( Criteria.where("_id").is(bookId).and("availableCopies").gt(0)); Update stockUpdate = new Update() .inc("availableCopies", -1) .set("updatedAt", Instant.now()); Book book = mongoTemplate.findAndModify( stockQuery, stockUpdate, FindAndModifyOptions.options().returnNew(true), Book.class); if (book == null) { // 查不到有两种可能:书不存在,或者被借光了,分开提示更友好 boolean exists = mongoTemplate.exists(Query.query(Criteria.where("_id").is(bookId)), Book.class); throw new BizException(exists ? "该书已全部借出" : "图书不存在"); } // 2. 写借阅记录,active=true 会命中部分唯一索引,重复借书在这里被数据库挡下 BorrowRecord record = new BorrowRecord(); record.setBookId(bookId); record.setBookTitle(book.getTitle()); record.setReaderId(readerId); record.setBorrowedAt(Instant.now()); record.setDueAt(Instant.now().plus(30, ChronoUnit.DAYS)); record.setStatus("BORROWED"); record.setActive(true); record.setRenewCount(0); try { return mongoTemplate.insert(record); } catch (DuplicateKeyException e) { // 3. 补偿:记录插入失败必须把刚扣掉的库存加回去,否则这本书永远少一册 mongoTemplate.updateFirst( Query.query(Criteria.where("_id").is(bookId)), new Update().inc("availableCopies", 1), Book.class); throw new BizException("这本书你还没还,不能重复借"); } } }三个参数值得说清楚。Criteria.where("availableCopies").gt(0)是整段代码的核心,它把“判断够不够”和“扣减”压进同一次文档级原子操作,MongoDB 单文档更新本身就是原子的,所以两个并发的借书请求里只有一个能扣成功。returnNew(true)让返回值是更新后的文档,这样拿到的book.getTitle()用于写快照才是最新的。捕获DuplicateKeyException后的补偿更新不能省,这是没有事务时的“后悔药”,少这一句,库存就会越借越少。
4.2 还书与逾期罚金:把规则固化在归还那一刻
罚金计算看着简单,实际上是时区和精度两个坑的集合。我的处理原则是:时间全部用Instant存 UTC,天数计算用ChronoUnit.DAYS对“应还时间”和“实际归还时间”取差,罚金规则写在配置里,算完立刻固化到记录上。
private static final BigDecimal FINE_PER_DAY = new BigDecimal("0.50"); public BorrowRecord giveBack(String recordId) { BorrowRecord record = mongoTemplate.findById(recordId, BorrowRecord.class); if (record == null || !Boolean.TRUE.equals(record.getActive())) { throw new BizException("借阅记录不存在或已归还"); } Instant now = Instant.now(); // 逾期天数按自然日向上取整,晚还 1 小时也算一天,规则要在文档里写死 long overdueDays = Math.max(0, ChronoUnit.DAYS.between(record.getDueAt(), now.plus(1, ChronoUnit.HOURS))); Update update = new Update() .set("returnedAt", now) .set("status", "RETURNED") .set("active", false) // 关键:释放部分唯一索引,读者可以再次借这本书 .set("fine", FINE_PER_DAY.multiply(BigDecimal.valueOf(overdueDays))); // 条件里带上 active=true,避免同一个记录被重复还书导致库存多加 BorrowRecord updated = mongoTemplate.findAndModify( Query.query(Criteria.where("_id").is(recordId).and("active").is(true)), update, FindAndModifyOptions.options().returnNew(true), BorrowRecord.class); if (updated == null) { throw new BizException("该记录已被处理,请刷新"); } // 归还成功后再加库存,顺序不能反:先加库存后改状态,失败时会多出一册"幽灵书" mongoTemplate.updateFirst( Query.query(Criteria.where("_id").is(record.getBookId())), new Update().inc("availableCopies", 1), Book.class); return updated; }now.plus(1, ChronoUnit.HOURS)是一处经验补偿。ChronoUnit.DAYS.between是截断取整,晚还 23 小时会算出 0 天,用户会觉得“明明逾期了却没罚钱”。加一小时做容差,比精确到分钟再四舍五入更好解释。active置 false 必须在同一次更新里完成,否则会出现“状态已改、索引没释放”的中间态。
4.3 多条件检索、分页与聚合统计
检索接口的写法决定了这个系统在一千条数据时会不会卡。先用正则拼关键字是最容易踩的坑,因为title上的普通索引对.*keyword.*完全无效。
public Page<Book> search(String keyword, String category, int page, int size) { List<Criteria> conditions = new ArrayList<>(); // 走全文索引,权重让书名优先命中;不用 $regex,避免全集合扫描 if (StringUtils.hasText(keyword)) { conditions.add(Criteria.where("$text").is(new Document("$search", keyword))); } if (StringUtils.hasText(category)) { conditions.add(Criteria.where("category").is(category)); } Query query = new Query(); if (!conditions.isEmpty()) { query.addCriteria(new Criteria().andOperator(conditions)); } // 先 count 再取当页,两个查询用同一个条件对象,保证总数和列表口径一致 long total = mongoTemplate.count(query, Book.class); query.with(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); return new PageImpl<>(mongoTemplate.find(query, Book.class), PageRequest.of(page, size), total); }统计部分用聚合管道比在 Java 里 for 循环强得多,借阅排行一条语句就能出结果:
// 近 30 天借阅排行前十,$first 取快照书名,避免再回查 books db.borrow_records.aggregate([ { $match: { borrowedAt: { $gte: new Date(Date.now() - 30 * 24 * 3600 * 1000) } } }, { $group: { _id: "$bookId", title: { $first: "$bookTitle" }, times: { $sum: 1 } } }, { $sort: { times: -1 } }, { $limit: 10 } ])$match必须放在管道最前面,这样能命中borrowedAt上的索引,否则整个集合先分组再过滤,数据量一大就明显慢。$group里用$first拿书名,依赖的是同一bookId的快照书名一致这个前提,如果中途改过书名,取到的是第一条记录里的值,展示用途完全够。
4.4 事务边界与降级方案
@Transactional在 MongoDB 里不是加了就生效的,它要求部署形态是副本集或分片集群,单机 mongod 会直接抛异常。所以我一般把事务限制在一处:借书和还书这两个既改库存又改记录的操作。
// 只在副本集部署时启用,单机演示走上面 4.1 的条件更新 + 补偿 @Transactional(rollbackFor = Exception.class) public BorrowRecord borrowInTx(String bookId, String readerId) { Book book = mongoTemplate.findAndModify( Query.query(Criteria.where("_id").is(bookId).and("availableCopies").gt(0)), new Update().inc("availableCopies", -1), FindAndModifyOptions.options().returnNew(true), Book.class); if (book == null) { throw new BizException("该书已全部借出"); } BorrowRecord record = buildRecord(book, readerId); return mongoTemplate.insert(record); // 抛异常时上面的 $inc 会一起回滚 }降级方案要提前想好,因为答辩机房的 MongoDB 十有八九是单机装的。做法是在配置里加一个开关library.tx.enabled,Service 用策略模式选实现:开关打开走borrowInTx,关闭走 4.1 的条件更新加补偿。两条路的对外行为完全一致,答辩时无论环境是什么形态都能演示,也不会出现“换台电脑就跑不起来”的尴尬。
5. 避坑与排查:这类课设最容易翻车的五个现场
5.1 应用起来了,第一次请求 500:连接还没握手完
现象是控制台打印Connection refused或者Timed out after 30000 ms while waiting for a server,刷新两次又好了。
原因是容器编排里 Spring Boot 启动只要几秒,mongod 首次初始化数据目录要十几秒,应用先连上了端口但服务还没 ready。
解决办法是给连接串加上超时参数,并在部署脚本里加健康检查等待:uri后面补&connectTimeoutMS=5000&serverSelectionTimeoutMS=10000,启动脚本先轮询mongosh --eval 'db.runCommand({ping:1})'直到返回成功再拉起应用。本地开发嫌麻烦,至少在application-dev.yml里把serverSelectionTimeoutMS调小,让错误早点暴露,别让人对着一个转圈的页面等半分钟。
5.2 前端拿到的 id 是一坨对象,按 id 删书删不掉
现象是接口返回的 JSON 里id字段变成{"timestamp":1712...,"counter":...},前端拼出来的删除请求后端一律查不到记录。
原因是把ObjectId直接当字段类型暴露给了 JSON 序列化器,Jackson 找不到合适的转换器,就把内部结构整个拆开输出。
解决办法两条:实体类主键统一声明成String,让 Spring Data 自动做ObjectId与十六进制串的转换;如果确实需要保留ObjectId类型,就注册一个自定义序列化器,把它输出成toHexString()。这件事要在项目第一天定下来,等到前端页面全写完再改,改动面会扩散到每一个接口。
5.3 书架页越用越慢,文档体积悄悄涨到几 MB
现象是老读者的详情页打开要两三秒,db.readers.stats()一看平均文档大小是别人的几十倍。
原因是最初把借阅历史内嵌成了读者文档里的数组,每借一次 push 一条,四年下来几千条,每次更新都要重写整个文档,读的时候还要整体加载。
解决办法是立刻把借阅历史拆成独立集合,读者文档里最多保留最近 5 条做展示,用$slice或者写入时$push配$slice: -5控制长度。判断标准很直接:数组长度有没有业务上限?没有上限的一律独立成集合,这条经验在文档数据库里几乎没有例外。
5.4 逾期天数总差一天,本地测试全对上了线就错
现象是同一笔借阅记录,本地算出来逾期 3 天,换台机器算出来 2 天,罚金对不上。
原因是字段用了LocalDateTime存本地时间,不同机器的默认时区不同,存进去的绝对时刻就不一样;聚合查询里又用new Date()做边界,两边基准不统一。
解决办法是全链路统一Instant(或Date)存 UTC,只在返回给前端的那一刻做时区格式化,格式化成yyyy-MM-dd HH:mm并显式指定时区。写进设计文档的一句话是:数据库里不存本地时间,一天的分界线由展示层决定。
5.5 分页翻到后面越来越慢,总数和列表还对不上
现象是前几页秒开,翻到第五十页要等两三秒;偶尔还会出现列表里 20 条、总数显示 19 条。
原因是用了skip加limit,跳过的文档也要被扫描一遍,页码越大越慢;总数和列表是两次独立查询,中间有人插了一条新数据,两边的口径就分叉了。
解决办法是课设规模下先接受skip,但把size限制在 50 以内,超过就报错提示;总数和列表尽量放在同一个请求里顺序执行,别并发发出。数据量再大就换成游标分页,用上一页最后一条的_id或createdAt做$lt条件,翻页耗时就不再随页码增长。这条改进写进答辩的“优化点”里,比空谈高并发有说服力。
6. 验收与答辩:一份自检清单加一次数据演练
到这一步功能基本齐了,真正决定分数的是怎么证明它是对的。我通常准备两样东西:一张验收清单、一段能在三分钟内跑完的演示脚本。
验收清单按“能不能被追问”来设计,每一条都要落到一个可观察的结果:
| 验收项 | 判定标准 | 演示方式 |
|---|---|---|
| 库存一致性 | 任意时刻availableCopies≥ 0,且 ≤totalCopies | 连点十次借书按钮,看报错与库存 |
| 重复借阅拦截 | 同一读者同一本书未还时再次借出被拒 | 手动调两次接口,第二次返回业务提示 |
| 还书幂等 | 同一条记录还两次,库存只加一次 | 重复点还书,看库存数字不变 |
| 逾期计算 | 应还日期改到 5 天前,罚金 = 5 × 单价 | 直接改库演示,不用等 30 天 |
| 检索性能 | 一千条数据下全文搜索 < 200ms | 打开执行计划看是否命中索引 |
| 事务回滚 | 借书时故意让插入失败,库存回到原值 | 临时把readerId设为超长字符串触发校验 |
| 数据校验 | 直接插入availableCopies: -1被拒 | 在 mongosh 里执行一次非法写入 |
演示脚本要提前跑通三遍以上,顺序是:先展示书库列表和分页、再展示一次完整的借还闭环、最后展示统计图表。中间穿插一次故意的失败操作,比如借一本可借数为 0 的书,让老师看到异常处理而不是只看到顺利路径。这一步的加分幅度比多加两个功能页面大得多。
答辩提问里出现频率最高的三个问题,答案要背下来:为什么选文档数据库——因为图书数据的字段是可变长的(标签、副本、快照),关系型要为每种组合开表;库存并发怎么保证——条件更新加单文档原子性,副本集下用事务兜底;能不能换成 MySQL——能,但要接受连表和更复杂的并发控制,这时可以顺手画出三范式下的表结构和连接查询,反而显得方案是权衡出来的,不是随手选的。
有个血泪经验:我见过太多的项目在答辩前一晚才发现初始化脚本没跑,数据库是空的,现场临时灌数据又漏了索引,第一个查询就卡住。从那以后我每次演示前都强制走一遍“清库 → 跑 init 三个脚本 → 执行演示脚本”,把这三步写成一行命令,宁可多花两分钟,也不要在讲台上跟黑匣子较劲。这套检查习惯比任何功能都更能保住分数,希望帮到你。
本文还有配套的精品资源,点击获取