纸舟书店已经用 $inc 安全修改一本书的库存,也用 100 次并发更新验证了单文档原子性。但一次完整下单要同时修改 books 和 orders:库存扣减后订单写入失败,会出现“少了库存却没有订单”;订单写入后扣库存失败,则会出现超卖。
多文档事务用来保护这个跨集合不变式:扣库存和写订单要么都提交,要么都撤销。我们会先在 mongosh 中看清提交与回滚,再把已验证的逻辑加进 Node.js 官方驱动 7.5 API。
MongoDB 保证针对单个文档的一次写入是原子的。同一个 updateOne() 中的 $inc、$set 和 $addToSet 不会向其他请求暴露一个“只更新了一半字段”的状态。筛选条件也会在写入时检查,所以库存扣减可以写成:
db.books.updateOne(
{ _id: "book-node", stock: { $gte: 2 } },
{ $inc: { stock: -2 } }
)这条写入本身不需要事务。它只修改一个文档,并且把“至少还有 2 本”放进了同一个原子写入的筛选器。
事务是在业务不变式跨过文档边界时才出场:
事务不是“并发安全”的通用开关。能用单文档原子更新表达的业务,优先保持简单;只在不变式确实跨过文档时使用事务。
上一阶段已经给 book-web 临时设置 viewCount: 0,再同时发送 100 次 $inc: { viewCount: 1 }。每个 $inc 都在服务器端基于当时的文档值原子增加,所以不会出现两个请求读到同一旧值后相互覆盖的问题。
mongosh --eval 不接受裸的顶层 await,因此并发脚本要包在异步立即执行函数中。实验后删除临时字段,不改变后续的书籍模型。
docker exec paperboat-mongo mongosh --quiet bookstore --eval '
(async () => {
db.books.updateOne(
{ _id: "book-web" },
{ $set: { viewCount: 0 } }
);
const results = await Promise.all(
Array.from(
{ length: 100 },
() => db.books.updateOne(
{ _id: "book-web" },
{ $inc: { viewCount: 1 } }
)
)
);
print("CONCURRENT_INC " + JSON.stringify({
operations: results.length,
modified: results.reduce(
(sum, result) => sum + result.modifiedCount,
0
),
finalValue: db.books.findOne({ _id: "book-web" }).viewCount
CONCURRENT_INC {"operations":100,"modified":100,"finalValue":100}
CLEANUP {"hasViewCount":false}100 次更新都修改了文档,最终值正好是 100。第二行证明 viewCount 已清理。这个结果证明单文档 $inc 的原子性,它没有把另一个 orders 文档纳入同一个提交边界。
MongoDB 的多文档事务必须运行在副本集或分片集群上。课程从启动时就为 mongod 加了 --replSet rs0,并把 paperboat-mongo:27017 初始化为单节点副本集。
单节点配置足以学习事务语义,但它没有冗余。这个节点停止后,没有第二个成员可以接管。
docker exec paperboat-mongo mongosh --quiet --eval '
const hello = db.hello();
print(EJSON.stringify({
isWritablePrimary: hello.isWritablePrimary,
setName: hello.setName,
hosts: hello.hosts
}));
'{"isWritablePrimary":true,"setName":"rs0","hosts":["paperboat-mongo:27017"]}setName: "rs0" 证明它不是独立服务器,isWritablePrimary: true 表示当前节点可以接受事务写入。
事务中的每次读写都必须使用同一个 session。在 mongosh 中,session.getDatabase("bookstore") 返回与 session 绑定的数据库句柄。如果事务中间又改用普通的 db.books,那次操作就不在这个事务里。
下面购买 2 本《Node.js 项目开发》。课程起点中它的库存是 15,事务提交后应为 13,同时多出 order-2001。新订单继续使用允许的 paid 状态,并把第 7 节引入的 schemaVersion 写为 BSON int 1。
请先确认没有重复执行过本步。如果 order-2001 已经存在,回到种子数据的可确认起点后再继续,不要用新的随机 ID 绕过基线检查。
use bookstore
const session = db.getMongo().startSession();
const sessionDatabase = session.getDatabase("bookstore");
const stockBefore = db.books.findOne({ _id: "book-node" }).stock;
session.startTransaction();
try {
const stockResult = sessionDatabase.books.updateOne(
{ _id: "book-node", stock: { $gte:
COMMIT {"stockBefore":15,"stockAfter":13,"orderExists":true}库存与订单同时可见。订单中保留了书名和单价快照,因此这次事务同时遵守了前面建立的数据模型。
回滚试验会先对《数据建模的艺术》扣减 1 本,再写入一张订单,然后主动抛出“模拟支付失败”。两次写入都已在事务内执行,但没有提交;abortTransaction() 之后,库存应回到 8,临时订单数应为 0。
这个错误是为了观察回滚而主动制造的。正式业务中,可能触发回滚的是库存不足、唯一键冲突、校验失败或其他不能完成整个操作的条件。
继续使用上一步的 session 与 sessionDatabase:
const rollbackStockBefore = db.books.findOne({
_id: "book-design"
}).stock;
session.startTransaction();
try {
sessionDatabase.books.updateOne(
{ _id: "book-design" },
{ $inc: { stock: -1 } }
);
sessionDatabase.orders.insertOne({
_id: "order-rollback",
schemaVersion: NumberInt(1),
customerId:
ROLLBACK_REASON 模拟支付失败
ROLLBACK_CHECK {"stockBefore":8,"stockAfter":8,"orderCount":0}事务内曾经执行过的库存与订单写入都没有留下半成品。
验收事务不能只看“抛出了错误”。必须再读取每一个受影响的文档:成功路径要同时看到库存与订单,失败路径要同时确认两者都没有半成状态。
mongosh 实验显式调用 startTransaction()、commitTransaction() 和 abortTransaction(),方便看清每个边界。Node.js 官方驱动提供 session.withTransaction(callback),它会在回调正常返回时提交,在回调抛错时中止,并按驱动规则处理某些短暂事务或提交错误。JavaScript 的整数 1 会被这个驱动编码为满足当前校验器的 BSON int,所以 API 也显式写入 schemaVersion: 1。
驱动可能重新执行事务回调,所以回调内不要发送邮件、扣外部支付或触发其他不可重复的副作用。这类外部动作通常需要幂等键、outbox 或独立消息流程。
事务中每个驱动操作都要传入 { session }。少传一次不会报出“这条语句忘了加事务”这种友好错误;它可能直接在事务外提交,破坏原子边界。
事务正确不代表 HTTP 边界自动安全。请求体需要大小上限,畸形 JSON 应返回 400,标识符应限制类型、长度和字符范围;否则一个很小的下单路由也可能无限累积输入,或把客户端错误伪装成 500。
打开第 10 节的 server.js,在 books 下方增加订单集合:
const orders = database.collection("orders");在 readPositiveInteger() 后增加 JSON 请求体读取函数:
function httpError(statusCode, message) {
return Object.assign(new Error(message), { statusCode });
}
function readJson(request, maximumBytes = 16 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes
在 return sendJson(response, 404, ...) 之前增加下单路由:
if (request.method === "POST" && url.pathname === "/orders") {
const body = await readJson(request);
if (body === null || typeof body !== "object" || Array.isArray(body)) {
return sendJson(response, 400, { error: "invalid order" });
}
const { orderId, customerId
最后,把请求处理器末尾的 catch 改为保留明确业务状态,并对未预期错误继续返回 500:
} catch (error) {
console.error(error);
const duplicateOrder = error?.code === 11000;
const status = error.statusCode ?? (duplicateOrder ? 409 : 500);
const message = duplicateOrder
? "order already exists"
: status < 500
? error.message
: "internal error";
return
扩展后的路由同时保护 HTTP 输入与数据库原子边界:
books.findOne()、books.updateOne() 和 orders.insertOne() 都收到同一个 session。$inc 位于同一次更新中,modifiedCount !== 1 会抛错并中止事务。withTransaction() 完成后才返回 HTTP 201;库存不足和重复订单分别转换为可解释的 409。server.js 已经复制进镜像,因此修改源文件后要重新构建。旧容器仍在使用旧镜像,需要停止并删除,然后用同一容器名加入 paperboat-net。
重建不会删除 paperboat-mongo-data。MongoDB 与 API 是两个容器,数据保存在 MongoDB 的命名卷中。
在 paperboat-api 项目目录执行:
docker stop paperboat-api
docker rm paperboat-api
docker build -t paperboat-api:course .
docker run -d \
--name paperboat-api \
--init \
--network paperboat-net \
-p 127.0.0.1:3000:3000 \
-e 'MONGODB_URI=mongodb://paperboat-mongo:27017/bookstore?replicaSet=rs0' \
paperboat-api:course等日志出现监听消息后,再发送请求:
docker logs paperboat-apipaperboat-api listening on 3000这只证明新进程已启动。事务是否正确,还要分别验证成功与失败路径。
输入校验也要走失败路径。一个只有左花括号的请求不是合法 JSON,应在进入事务前返回 400;一个超过 16 KiB 的完整 JSON 即使语法正确,也应返回 413。两种请求都不应创建 session、读取库存或写订单。
先发送畸形 JSON:
printf '{' | curl -sS \
-X POST 'http://127.0.0.1:3000/orders' \
-H 'content-type: application/json' \
--data-binary @- \
-w '\nHTTP %{http_code}\n'再让 Node 生成一个超过 16 KiB 的合法 JSON,并通过管道发送:
docker exec paperboat-api node -e \
'process.stdout.write(JSON.stringify({ padding: "x".repeat(17000) }))' \
| curl -sS \
-X POST 'http://127.0.0.1:3000/orders' \
-H 'content-type: application/json' \
--data-binary @- \
-w '\nHTTP %{http_code}\n'{"error":"invalid JSON"}
HTTP 400
{"error":"request body too large"}
HTTP 413错误状态与消息都来自统一的外层 catch。两次请求都在数据库操作之前结束,因此不会改变后面的库存与订单基线。
本次 API 下单购买 2 本《现代 Web 基础》。它的库存从 30 开始,事务应把库存改为 28,并写入 order-node-1。单价快照是 59,总额应为 118。
HTTP 201 只说明 API 声称已创建资源。端到端验收还要直接查数据库,确认库存和订单都已提交。
curl -sS \
-w '\nHTTP %{http_code}\n' \
-X POST \
-H 'content-type: application/json' \
--data '{"orderId":"order-node-1","customerId":"customer-lin","bookId":"book-web","quantity":2}' \
http://127.0.0.1:3000/orders{"id":"order-node-1","status":"paid","total":118}
HTTP 201返回体只包含稳定的业务字段。createdAt 由服务器在事务中生成,具体时间取决于请求执行时刻,不把它伪写成固定输出。
失败路径请求 999 本同一图书。books.updateOne() 中的 stock: { $gte: quantity } 无法匹配,modifiedCount 为 0,路由抛出带 statusCode: 409 的错误。
这个错误发生在 withTransaction() 回调内,因此事务不会写入 order-node-fail。已经成功的上一张订单也不会受这次失败影响,库存仍是 28。
curl -sS \
-w '\nHTTP %{http_code}\n' \
-X POST \
-H 'content-type: application/json' \
--data '{"orderId":"order-node-fail","customerId":"customer-lin","bookId":"book-web","quantity":999}' \
http://127.0.0.1:3000/orders再从 MongoDB 同时核对库存、成功订单和失败订单:
docker exec paperboat-mongo mongosh --quiet bookstore --eval '
const successOrder = db.orders.findOne({ _id: "order-node-1" });
print("DB_CHECK " + JSON.stringify({
stock: db.books.findOne({ _id: "book-web" }).stock,
successOrders: db.orders.countDocuments({ _id: "order-node-1" }),
failedOrders: db.orders.countDocuments({ _id: "order-node-fail" }),
total: successOrder.total
}));
'{"error":"库存不足"}
HTTP 409
DB_CHECK {"stock":28,"successOrders":1,"failedOrders":0,"total":118}成功订单与库存变化同时存在,失败订单没有进入集合,库存也没有被第二次请求扣减。这才是 201 与 409 背后的完整验收证据。
事务持续时间越长、修改文档越多,对快照、缓存、写冲突和超时的压力就越大。事务回调应只包含必须一起提交的数据库操作,不要在其中等待用户输入、调用慢速外部 API 或生成大型报表。
事务也不会弥补错误的业务条件。库存仍要使用 { stock: { $gte: quantity } } 过滤,不能因为外面有事务,就先读取库存再无条件地 $set 新值。事务保证一组操作的提交边界,操作自身仍要正确表达并发前提。
多文档事务不能取代好的数据建模。如果一组字段永远一起读写且大小有界,先考虑是否应嵌入同一文档。只有在实体需要独立生命周期且又存在跨文档不变式时,事务才是合适工具。
当前的 rs0 只有 paperboat-mongo 一个成员。它具备事务所需的 oplog、session 和提交机制,所以我们已经能验证原子提交与回滚。但它没有第二份数据,也没有可以参与选举的第二个成员。
下一节会把视野从“一组写入能否一起提交”扩展到“一个节点停止后服务能否继续”。我们会建立三成员副本集,观察复制、多数派确认、Primary 选举和驱动重连。那是高可用性的问题,不是单节点事务自动提供的能力。
到这里,纸舟书店已经完成从单文档原子更新到跨集合事务的过渡。事务的成功路径和失败路径都有数据库证据,Node.js 驱动 7.5 也已在同一 API 中返回 201 与 409。下一步是为 rs0 增加成员,理解复制、选举与一致性取舍。