到这里,纸舟书店已经不再是一组互不相干的命令。我们有一套连续的数据模型、可以解释的查询与索引、销售聚合、Node.js API、事务、复制与安全方案,也做过备份恢复和性能诊断。
这一节不再引入新的 MongoDB 功能,而是把前面留下的对象串成一次端到端验收:先核对数据库状态,再通过同一个 paperboat-api 验证健康检查、分页、事务成功与事务失败,最后重新生成销售摘要并清理课程创建的全部运行对象。
结课验收必须复用第 10、11 节真实创建的项目和 API 契约。本节只使用 paperboat-api、bookId + quantity 请求体、paid 订单状态和页码分页,不会突然切换到另一套目录名、请求字段或返回格式。
“服务能启动”只覆盖了很小一部分。一个数据库项目至少要留下五类证据:
成功路径与失败路径同样重要。只看到 HTTP 201,不能证明库存和订单一起提交;只看到 HTTP 409,也不能证明失败写入已经回滚。每一个应用结果都要回到数据库核对。
按顺序完成前 16 节后,主项目仍使用下面这些名称:
bookstore。paperboat-mongo。paperboat-api。paperboat-api:course。paperboat-net。paperboat-mongo-data。mongodb://paperboat-mongo:27017/bookstore?replicaSet=rs0。第 12、13、16 节创建的副本集、安全和分片实验对象,已经在各自章节清理,不应混入这次主项目验收。
课程的连续基线是 4 本书、4 位顾客和 7 张订单。5 张订单来自种子数据;order-2001 来自 mongosh 事务;order-node-1 来自 Node.js 事务路由。此时关键库存是:
如果你的数量或库存与表格不同,先查找是否重复执行过写入步骤。不要为了追求相同数字直接删除未知数据,也不要换一个随机订单 ID 绕过重复执行;应先确认状态差异来自哪一步。
体检不只发送 ping。我们还要确认服务端版本、副本集身份、可写状态、三个核心集合的数量、前面保留的索引和订单校验器。
索引名称是课程状态的一部分。第 8 节保留了 category_1_price_1 和 email_unique,第 10 节又为 API 的固定分类与不限分类分页分别增加了 published_1_category_1_price_1_id_1 和 published_1_price_1_id_1。课程从未创建 sku_1 或 categories_1_price_1,验收不能凭空期待它们。
docker exec paperboat-mongo mongosh --quiet --eval '
const d = db.getSiblingDB("bookstore");
const hello = db.hello();
const orderInfo = d.getCollectionInfos({ name: "orders" })[0];
print(EJSON.stringify({
serverVersion: db.version(),
replicaSet: hello.setName,
writablePrimary: hello.isWritablePrimary,
counts: {
books: d.books.countDocuments({}),
customers: d.customers.countDocuments({}),
orders: d.orders.countDocuments({})
},
indexes: {
books: d.books.getIndexes().map((index) => index.name),
customers: d.customers.getIndexes().map((index) => index.name),
orders: d.orders.getIndexes().map((index) => index.name)
},
orderValidator: Boolean(orderInfo?.options?.validator)
}));
'按课程顺序只执行一次时,结果是:
{"serverVersion":"8.0.26","replicaSet":"rs0","writablePrimary":true,"counts":{"books":4,"customers":4,"orders":7},"indexes":{"books":["_id_","category_1_price_1","published_1_category_1_price_1_id_1","published_1_price_1_id_1"],"customers":["_id_","email_unique"MongoDB 8.0 的补丁号可能随镜像更新,索引数组的显示顺序也不应作为业务约束。真正的验收条件是:serverVersion 以 8.0. 开头,副本集是 rs0,当前成员可写,三个集合可读,四条课程二级索引都存在,orderValidator 为 true。
第 10 节已经把 API 构建成容器,第 11 节又把事务路由写进同一个镜像。结课时不需要进入另一个目录执行 npm ci,也不需要在 3000 端口启动第二个服务。我们只要启动或确认现有的 paperboat-api 容器。
/health 会真正向 MongoDB 发送 ping。这意味着 {"ok":true} 同时证明 API 在监听、容器网络能解析 paperboat-mongo、驱动能发现 rs0,并且数据库接受命令。
docker start paperboat-api
docker ps \
--filter 'name=^/paperboat-api$' \
--format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
curl -sS http://127.0.0.1:3000/healthdocker start 对已经运行的容器也会返回容器名。容器表和健康检查应类似:
paperboat-api
NAMES IMAGE STATUS PORTS
paperboat-api paperboat-api:course Up <运行时长> 127.0.0.1:3000->3000/tcp
{"ok":true}STATUS 中的时长是动态值。验收时只要求目标容器处于 Up,端口绑定到 127.0.0.1:3000,健康响应为 {"ok":true}。
API 使用页码分页,并按 { price: 1, _id: 1 } 排序。price 决定主要顺序,_id 打破同价平局。这里验收的是第 10 节真正实现的 page + limit 协议,不会要求一个代码中从未生成的 nextCursor。
页码分页适合这组小数据。数据量很大或写入频繁时,第 5 节实践过的复合游标更合适;无论使用哪种分页,排序都必须包含确定性平局键。
先读第一页:
curl -sS -G 'http://127.0.0.1:3000/books' \
--data-urlencode 'page=1' \
--data-urlencode 'limit=2'再读第二页:
curl -sS -G 'http://127.0.0.1:3000/books' \
--data-urlencode 'page=2' \
--data-urlencode 'limit=2'第一页:
{"page":1,"limit":2,"items":[{"_id":"book-web","title":"现代 Web 基础","category":"编程","price":59},{"_id":"book-node","title":"Node.js 项目开发","category":"编程","price":69}]}第二页:
{"page":2,"limit":2,"items":[{"_id":"book-design","title":"数据建模的艺术","category":"数据库","price":79},{"_id":"book-mongodb","title":"MongoDB 从入门到实践","category":"数据库","price":89}]}两页共 4 本书,价格顺序是 59、69、79、89,没有重复。第 10 节已经用 explain 验证 { published: 1, price: 1, _id: 1 } 能为这条不限分类路径提供 IXSCAN 且不需要阻塞式 SORT;数据规模变化后仍要持续观察自然计划,而不是永久依赖 hint()。
最终成功订单使用固定 ID order-final-1,失败订单使用 order-final-fail。固定 ID 让结果可核对,也意味着写入步骤只能执行一次。
我们先确认两张订单都不存在,并读取 book-design 的库存。只有 stock: 8、successOrderCount: 0、failedOrderCount: 0 同时成立,才继续发送请求。
docker exec paperboat-mongo mongosh --quiet bookstore --eval '
print(EJSON.stringify({
stock: db.books.findOne({ _id: "book-design" }).stock,
successOrderCount: db.orders.countDocuments({ _id: "order-final-1" }),
failedOrderCount: db.orders.countDocuments({ _id: "order-final-fail" })
}));
'{"stock":8,"successOrderCount":0,"failedOrderCount":0}如果固定订单已经存在,停止本节写入验收,先回到前面的状态检查。直接重复成功请求会触发重复键错误,而且无法自动恢复上一次已经扣掉的库存。
API 接收 orderId、customerId、bookId 和 quantity。书名、单价和总额由服务端读取并生成,客户端不能提交一个自报总价。
这次购买 1 本《数据建模的艺术》。事务应把库存从 8 改为 7,同时插入状态为 paid、总额为 79 的 order-final-1。HTTP 201 只是应用层声明,后面还要直接读取数据库。
curl -sS \
-w '\nHTTP %{http_code}\n' \
-X POST \
-H 'content-type: application/json' \
--data '{"orderId":"order-final-1","customerId":"customer-lin","bookId":"book-design","quantity":1}' \
http://127.0.0.1:3000/orders再读取库存和订单:
docker exec paperboat-mongo mongosh --quiet bookstore --eval '
const order = db.orders.findOne(
{ _id: "order-final-1" },
{ _id: 1, customerId: 1, status: 1, total: 1 }
);
print(EJSON.stringify({
stock: db.books.findOne({ _id: "book-design" }).stock,
order
}));
'API:
{"id":"order-final-1","status":"paid","total":79}
HTTP 201数据库:
{"stock":7,"order":{"_id":"order-final-1","customerId":"customer-lin","status":"paid","total":79}}库存减少 1、订单存在、状态和金额正确,这四个事实共同证明成功事务已经提交。
失败请求购买 999 本同一图书。事务内的库存更新要求 stock >= quantity,因此不会命中文档,路由会抛出“库存不足”并返回 HTTP 409。
失败验收要确认三个结果:API 明确拒绝、库存仍是成功订单后的 7、order-final-fail 不存在。
curl -sS \
-w '\nHTTP %{http_code}\n' \
-X POST \
-H 'content-type: application/json' \
--data '{"orderId":"order-final-fail","customerId":"customer-lin","bookId":"book-design","quantity":999}' \
http://127.0.0.1:3000/orders再核对数据库:
docker exec paperboat-mongo mongosh --quiet bookstore --eval '
print(EJSON.stringify({
stock: db.books.findOne({ _id: "book-design" }).stock,
successOrders: db.orders.countDocuments({ _id: "order-final-1" }),
failedOrders: db.orders.countDocuments({ _id: "order-final-fail" })
}));
'{"error":"库存不足"}
HTTP 409
{"stock":7,"successOrders":1,"failedOrders":0}失败请求没有再次扣库存,也没有留下订单半成品。HTTP 与数据库证据对得上,才算完成回滚验收。
聚合报表应从订单事实推导,而不是单独维护一份难以解释的数字。最终管道只统计 paid 与 shipped,展开 items 后按书名累计销量与收入。
这次重新运行聚合,可以同时检查第 9 节的种子订单、第 11 节的两张成功事务订单和刚刚创建的最终订单。
docker exec paperboat-mongo mongosh --quiet bookstore --eval '
const rows = db.orders.aggregate([
{ $match: { status: { $in: ["paid", "shipped"] } } },
{ $unwind: "$items" },
{
$group: {
_id: "$items.title",
quantity: { $sum: "$items.quantity" },
revenue: {
$sum: {
$multiply: ["$items.unitPrice", "$items.quantity"]
}
}
}
},
{ $sort: { revenue: -1, _id: 1 } },
{
$project: {
_id: 0,
title: "$_id",
quantity: 1,
revenue: 1
}
[{"quantity":4,"revenue":356,"title":"MongoDB 从入门到实践"},{"quantity":4,"revenue":316,"title":"数据建模的艺术"},{"quantity":3,"revenue":207,"title":"Node.js 项目开发"},{"quantity":2,"revenue":118,"title"MongoDB 书的 4 本来自三张种子订单;数据建模书在种子销量 3 的基础上增加最终订单 1 本;Node.js 书包含种子 1 本和 order-2001 的 2 本;Web 书来自 order-node-1 的 2 本。待处理订单 order-1004 没有进入已结算报表。
完成主线后,还可以继续验证:
explain,确认扫描量变化可观察。每次故障演练都要提前写明影响对象、成功条件、最长持续时间和恢复动作。没有这些边界,就不要在承载真实流量的部署中临时“拔掉一个节点看看”。
MongoDB 适合数据天然以聚合对象读取、结构会演进、嵌套数据较多,并且团队愿意根据查询模式认真建模的项目。纸舟书店的商品、顾客地址和订单快照都符合这种对象边界。
它不是所有数据问题的默认答案。下面这些场景应认真比较关系型数据库或其他专用系统:
选择数据库时,拿真实数据形状、访问路径、一致性要求、增长速度和团队运维能力做比较,比问“哪一个更先进”更有用。
清理之前,逐项确认:
insertOne、find、updateOne、deleteOne 并读懂结果对象。explain("executionStats") 比较 COLLSCAN 与 IXSCAN。MongoClient、限制输入并处理错误。有任何一项说不清,就回到对应章节重新运行最小实验,并核对结果字段,不要继续堆更多配置。
paperboat-api 和 paperboat-mongo 都连接在 paperboat-net 上。必须先删除容器,再删除网络;否则 Docker 会因为仍有活动端点而拒绝删除网络。数据卷要在数据库容器删除后再移除。
API 在第 10、11 节使用同一标签重建过两次,旧版本可能已经显示为 <none>。Dockerfile 为每次构建都写入 dev.welearn.course=mongodb-paperboat 标签,因此清理时按这个元数据找出当前与旧镜像。mongo:8.0 与 node:22-bookworm-slim 可能被其他项目复用,本节不会未经检查就删除它们。
先确认名称完全匹配:
docker ps -a \
--filter 'name=^/paperboat-api$' \
--filter 'name=^/paperboat-mongo$' \
--format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker volume ls \
--filter 'name=^paperboat-mongo-data$' \
--format '{{.Name}}'
docker network ls \
--filter 'name=^paperboat-net$' \
--format '{{.Name}}'
docker image ls \
--filter 'label=dev.welearn.course=mongodb-paperboat'
确认输出只有课程对象后,依次执行:
docker rm -f paperboat-api
docker rm -f paperboat-mongo
docker volume rm paperboat-mongo-data
docker network rm paperboat-net
docker image ls -q \
--filter 'label=dev.welearn.course=mongodb-paperboat' \
| sort -u \
| while IFS= read -r image_id; do
[ -n "$image_id
第 14 节若保留了练习归档,先列出精确匹配的文件:
find backups -maxdepth 1 -type f \
\( -name 'bookstore-*.archive.gz' \
-o -name 'bookstore-*.archive.gz.sha256' \
-o -name 'latest-course-backup.txt' \) \
-print确认它们都只是练习文件后再删除,并在目录为空时移除目录:
find backups -maxdepth 1 -type f \
\( -name 'bookstore-*.archive.gz' \
-o -name 'bookstore-*.archive.gz.sha256' \
-o -name 'latest-course-backup.txt' \) \
-delete
rmdir backupspaperboat-api 源码是课程项目成果,可以保留。如果你的目标是恢复到开始课程前的文件状态,并且已经备份了需要的代码,请回到它的父目录,先确认目录身份,再删除:
test -f paperboat-api/package.json
test -f paperboat-api/package-lock.json
test -f paperboat-api/server.js
rm -rf -- paperboat-api不要把 rm -rf 改成通配符,也不要在没有通过两个 test 检查时执行。源码若还要继续开发,就保留项目目录;容器、网络和数据卷的清理不依赖删除源码。
最后重新查询所有课程对象:
docker ps -a \
--filter 'name=^/paperboat-api$' \
--filter 'name=^/paperboat-mongo$' \
--format '{{.Names}}'
docker volume ls \
--filter 'name=^paperboat-mongo-data$' \
--format '{{.Name}}'
docker network ls \
--filter 'name=^paperboat-net$' \
--format '{{.Name}}'
docker image ls \
--filter 'label=dev.welearn.course=mongodb-paperboat' \
四条命令都没有输出,说明 API 容器、数据库容器、数据卷、网络和课程专用镜像都已清理。第 12、13、16 节的独立实验对象也应已在各自章节得到同样的空结果。
现在你完成的不是一串 MongoDB 命令,而是一条可复现的项目路径:从安装与启动开始,经过数据建模、CRUD、查询、索引、聚合和 API,再进入事务、复制、安全、备份、诊断与分片,最后用成功、失败和清理结果完成交付。