数据库写入真正困难的地方,通常不是“怎样把 19 改成 18”,而是两个请求同时到达时,怎样避免把彼此的结果覆盖掉。MongoDB 保证单文档写入的原子性:一次针对一个文档的更新,要么完整生效,要么不生效,其他操作不会看到一半完成的文档。
这并不意味着所有业务自动安全。过滤条件是否包含业务前提、更新是否使用原子操作符、多个文档是否需要事务,仍然需要我们明确设计。本节继续使用纸舟书店的 4 本书和 5 张订单。
上一节只读取数据,没有改变书籍;本节依赖 CRUD 结束时的状态:MongoDB 书库存为 19,标签为 MongoDB 与 后端,并且是精选书。
docker exec -it paperboat-mongo mongosh --quietuse bookstore
const startBook = db.books.findOne(
{ _id: "book-mongodb" },
{ _id: 1, stock: 1, tags: 1, featured: 1 }
);
print(EJSON.stringify({
bookCount: db.books.countDocuments({}),
startBook
}));{"bookCount":4,"startBook":{"_id":"book-mongodb","stock":19,"tags":["MongoDB","后端"],"featured":true}}若结果不同,先回到种子导入与 CRUD 章节恢复顺序。并发示例必须从可确认的值出发,否则无法判断是否出现丢失更新。
MongoDB 更新文档通常不需要把完整文档读出、修改后再整体写回。更新操作符直接描述变化:
$inc 对数值做增量运算。$set 设置或新增字段。$addToSet 向数组加入不存在的值。$push 向数组追加元素。把这些操作符放在同一个 updateOne() 中,它们会原子地作用于同一本书。$addToSet 的名字容易误解:它不会把数组变成无序集合,只是避免这次加入的值重复出现。
为 MongoDB 书补入 5 本库存,记录出版社,加上“畅销”标签和一条评价:
const atomicResult = db.books.updateOne(
{ _id: "book-mongodb" },
{
$inc: { stock: 5 },
$set: {
"publisher.name": "纸舟出版社"
},
$addToSet: {
tags: {
$each: ["畅销", "后端"]
}
},
$push: {
reviews: {
customerId: "customer-zhou",
rating: 5,
comment:
{"acknowledged":true,"matchedCount":1,"modifiedCount":1}读取最终状态:
$each 中同时有新标签“畅销”和已有标签“后端”。如果 $addToSet 生效正确,最终只新增“畅销”,不会得到两个“后端”。评价数组则应该新增一个元素。
const enrichedBook = db.books.findOne({ _id: "book-mongodb" });
print(EJSON.stringify({
title: enrichedBook.title,
stock: enrichedBook.stock,
tags: enrichedBook.tags,
publisher: enrichedBook.publisher,
reviewCount: enrichedBook.reviews.length
}));{"title":"MongoDB 从入门到实践","stock":24,"tags":["MongoDB","后端","畅销"],"publisher":{"name":"纸舟出版社"},"reviewCount":1}库存从 19 增加到 24;标签没有重复;嵌套路径自动创建了 publisher 文档;评价数组长度为 1。四个变化来自同一次原子更新。
订单预留 2 本库存时,最危险的写法是先 findOne() 读出 24,再在应用中计算 22 并 $set 回去。两个请求可能同时读到 24,然后都写成 22,最终少扣一次。
更可靠的方式是让服务端原子执行 $inc: { stock: -2 },并在过滤器中要求 stock >= 2。过滤器既定位文档,也承担并发时的业务保护条件。
const reserveResult = db.books.updateOne(
{
_id: "book-mongodb",
stock: { $gte: 2 }
},
{
$inc: { stock: -2 }
}
);
const reservedBook = db.books.findOne(
{ _id: "book-mongodb" },
{ _id: 0, stock: 1 }
);
print(
{"matchedCount":1,"modifiedCount":1,"stock":22}库存条件成立,所以预留成功并从 24 变为 22。下面故意请求 100 本:
库存不足不是数据库异常,而是过滤器不匹配。应用应检查 matchedCount,把 0 转换成明确的业务结果,例如“库存不足”,不能假设命令正常返回就代表预留成功。
const rejectedReserve = db.books.updateOne(
{
_id: "book-mongodb",
stock: { $gte: 100 }
},
{
$inc: { stock: -100 }
}
);
print(EJSON.stringify({
matchedCount: rejectedReserve.matchedCount,
modifiedCount: rejectedReserve.modifiedCount,
stock: db.books.findOne({ _id: "book-mongodb" }).stock
}));{"matchedCount":0,"modifiedCount":0,"stock":22}文档仍然存在,只是库存前提不成立。因为更新没有命中,库存保持 22,没有出现负数。
并发安全的第一条实用原则是:能由数据库表达的前提,尽量放进写入过滤器;能用 $inc、$set、$addToSet 等操作符描述的变化,不要先读出旧值再整体覆盖。
有些更新不是简单加减,而是用户打开编辑页、修改多个字段后提交。编辑期间文档可能已经被别人更新。常见做法是添加 version 字段:读取时带走版本号,提交时同时匹配 _id 和旧版本,成功后把版本加 1。
这叫乐观并发控制。它不锁住整段编辑时间,而是在提交时发现“我看到的版本已经过期”。
先为 Node.js 书设置版本 1,然后模拟两个编辑者都拿着版本 1 提交:
db.books.updateOne(
{ _id: "book-node" },
{ $set: { version: 1 } }
);
const firstEditor = db.books.updateOne(
{ _id: "book-node", version: 1 },
{
$set: { price: 70 },
$inc: { version: 1 }
}
);
const staleEditor = db.books.updateOne(
{ _id: "book-node"
{"firstEditor":{"matched":1,"modified":1},"staleEditor":{"matched":0,"modified":0},"final":{"price":70,"version":2}}第一个提交把版本从 1 改成 2。第二个提交仍要求 version: 1,因此无法命中,没有覆盖新价格。应用看到 matchedCount: 0 后,可以提示用户刷新、比较差异或重试,而不是静默覆盖。
恢复课程数据,避免临时版本字段影响后续内容:
演示产生的临时字段也属于项目状态。主动恢复并验证,能让后续章节继续依赖统一数据。
const restoreNodeBook = db.books.updateOne(
{ _id: "book-node" },
{
$set: { price: 69 },
$unset: { version: "" }
}
);
print(EJSON.stringify({
modifiedCount: restoreNodeBook.modifiedCount,
book: db.books.findOne(
{ _id: "book-node" },
{ _id: 1, price: 1, version: 1 }
{"modifiedCount":1,"book":{"_id":"book-node","price":69}}价格恢复为 69,结果中没有 version,说明临时字段已移除。
bulkWrite() 把多个写操作放进一次调用,可以减少客户端与服务端之间的往返。它支持 insertOne、updateOne、updateMany、replaceOne、deleteOne 和 deleteMany 等模型。
ordered: true 表示按顺序执行,某一步失败后停止后续操作。它保证执行顺序,不保证整批操作像事务一样“全部成功或全部回滚”。下面用一本文档完成插入、加库存、删除,既观察各类计数,也确保演示结束后没有残留文档。
const bulkResult = db.books.bulkWrite(
[
{
insertOne: {
document: {
_id: "bulk-demo",
title: "批量写入演示稿",
category: "草稿",
price: 0,
stock: 1,
tags: [],
published: false
}
}
},
{
updateOne: {
filter: { _id: "bulk-demo" },
{"acknowledged":true,"insertedCount":1,"matchedCount":1,"modifiedCount":1,"deletedCount":1,"upsertedCount":0}结果对应三步:插入 1 条,更新命中并修改 1 条,删除 1 条,没有 upsert。再检查临时文档:
批量结果说明操作发生过,最终计数说明清理是否完成。删除过滤器包含 published: false,避免误删一本文档已被意外上架的书。
print("bulkDemoCount=" + db.books.countDocuments({ _id: "bulk-demo" }));bulkDemoCount=0临时文档已经删除,核心书籍数量仍为 4。
为了直观看到 $inc 的原子性,我们给 book-web 临时增加 viewCount,同时发送 100 次 +1。如果采用“先读值、再把新值 $set 回去”的方式,并发请求可能覆盖彼此;服务端 $inc 会让每次操作都基于当时的文档值原子递增。
下面使用异步立即执行函数,是因为 mongosh --eval 中不能直接使用裸的顶层 await。Promise.all 等待 100 个更新都返回,再读取最终值。演示结束会 $unset 临时字段。
退出交互 shell,回到终端执行一条完整命令:
docker exec paperboat-mongo mongosh --quiet --eval '
const store = db.getSiblingDB("bookstore");
(async () => {
store.books.updateOne(
{ _id: "book-web" },
{ $set: { viewCount: 0 } }
);
const results = await Promise.all(
Array.from(
{ length: 100 },
() => store.books.updateOne(
{ _id: "book-web" },
{ $inc: { viewCount: 1 } }
)
)
);
const finalValue = store.books.findOne(
{ _id: "book-web" }
).viewCount;
print("CONCURRENT_INC " + JSON.stringify({
operations: results.length,
modified: results.reduce(
CONCURRENT_INC {"operations":100,"modified":100,"finalValue":100}
CLEANUP {"hasViewCount":false}100 次更新都修改了文档,最终值恰好是 100,没有更新被覆盖。hasViewCount: false 表明临时 viewCount 已移除。
这个实验说明的是单文档 $inc 的原子性,不是“任何并发业务都自动正确”。如果一次下单还要同时修改库存文档、订单文档和顾客积分,单文档保证无法把三个文档绑成一个整体;那时需要重新设计文档边界,或使用事务。
删除也会在执行时重新检查过滤器。前一节删除草稿时使用:
{
_id: "book-draft",
published: false,
stock: 0
}如果另一个操作在删除前把文档改成 published: true,这个过滤器就不再匹配,删除计数会是 0。相比只按 _id 删除,把必须保持为真的业务状态写入过滤器,可以减少“刚上架就被旧清理任务删掉”的竞态。
但先 find() 预览再 deleteOne() 之间仍然存在时间窗口。真正保护删除的是 deleteOne() 自己的过滤器,预览只是帮助操作者确认意图,不能替代条件约束。
updateMany() 和 deleteMany() 会让每个匹配文档的单次写入保持原子,但不保证整批文档一起提交或一起回滚。需要跨文档全有或全无时,应使用事务,并控制事务规模。
到这里,纸舟书店已经具备可靠写入的基础:组合操作符更新一个文档、用过滤器保护库存、用版本号发现陈旧提交、用 bulkWrite 减少往返,并通过 100 次并发 $inc 验证单文档原子性。下一阶段会用索引和 explain 检查这些访问路径的执行成本。