MongoDB的文档模型天然支持数组字段,这让很多一对多的数据关系可以直接内嵌到一个文档里。不过数组操作如果只会用$push简单追加,遇到需要在中间位置插入、限制数组长度、批量写入的场景就会束手无策。本文将围绕$each与$position的组合用法,配合$slice切片操作,系统地讲清楚MongoDB数组批量操作的正确姿势。
数组插入的基础:$push与$each的组合
先准备一个测试集合,假设有一个存储文章标签的文档:
db.articles.insertOne({
title: "MongoDB入门",
tags: ["nosql", "database"]
})普通的$push只能一次推入一个元素,如果直接把数组传给它,整个数组会被当成一个元素塞进去,变成嵌套数组。想让MongoDB把数组拆开逐个插入,必须配合$each修饰符:
db.articles.updateOne(
{ title: "MongoDB入门" },
{ $push: { tags: { $each: ["index", "replica"] } } }
)执行后tags变为["nosql", "database", "index", "replica"]。$each在这里的作用是声明后面跟的是一个元素列表,MongoDB会遍历列表逐个追加。这是所有数组批量操作的基础,后面的$position和$slice都必须建立在$each之上。
需要注意一个细节:如果不加$each而直接$push一个数组,MongoDB不会报错,只会把数组整体作为一个元素插入,这种错误在开发中非常常见,排查时要留意数组是否多了一层嵌套。
$position精确定位:在数组任意位置插入元素
$position必须与$push和$each一起使用,用来指定插入的起始下标。它的值可以是非负整数,也可以是负数,两种情况的行为完全不同。
当$position为非负数时,语义与JavaScript的数组下标一致,表示从该下标处开始插入。例如在tags数组的开头插入两个元素:
db.articles.updateOne(
{ title: "MongoDB入门" },
{
$push: {
tags: {
$each: ["hot", "new"],
$position: 0
}
}
}
)执行后tags变为["hot", "new", "nosql", "database", "index", "replica"],两个新元素插到了下标0的位置,原有元素依次后移。如果要插到第二个元素后面,把$position改为2即可。
当$position为负数时,表示从数组末尾倒数计算位置,-1代表最后一个元素的位置。例如:
db.articles.updateOne(
{ title: "MongoDB入门" },
{
$push: {
tags: {
$each: ["last"],
$position: -1
}
}
}
)此时"last"会插在最后一个元素之前,而不是之后,tags变为["hot", "new", "nosql", "database", "index", "last", "replica"]。这个行为容易让人误解,-1指的是最后一个元素所在的位置,插入点在其前面。如果负数的绝对值超过数组长度,MongoDB不会报错,而是把元素插入到数组开头。
还有一个隐含规则:当$position指定的正数下标大于等于数组长度时,元素会被追加到数组末尾,相当于普通$push的行为。这给容错带来了一定的便利,但也要小心业务逻辑中本应报错的位置越界被静默吞掉。
$slice切片控制:防止数组无限膨胀
内嵌数组最大的隐患是无限增长,一个百万级元素的数组会把16MB的文档上限撑爆,查询性能也会急剧下降。$slice修饰符可以在$push的同时裁剪数组长度,同样需要配合$each使用。
例如维护一个只保留最近10条记录的日志数组:
db.devices.updateOne(
{ deviceId: "sensor-001" },
{
$push: {
logs: {
$each: [{ ts: Date.now(), temp: 26.5 }],
$slice: -10
}
}
}
)这里$slice取值为-10,表示每次推入新日志后,只保留数组末尾的10个元素,旧数据自动被裁剪掉。这种写法是实现固定长度滑动窗口的标准做法,比先查再改的两步操作高效得多,还能避免并发下的竞态问题。
$slice取值的几种情况要分清楚:正数N表示保留数组前N个元素,负数-N表示保留后N个元素,0表示清空整个数组。如果$each一次推入多个元素,裁剪发生在所有元素插入完成之后,而不是逐个插入时裁剪。
更进一步,$slice还可以和$sort联用。比如日志数组既要控制长度,又要按时间戳排序:
db.devices.updateOne(
{ deviceId: "sensor-001" },
{
$push: {
logs: {
$each: [{ ts: Date.now(), temp: 27.1 }],
$sort: { ts: -1 },
$slice: -10
}
}
}
)这条语句的执行顺序是:先用$each插入元素,再按ts降序对整个数组排序,最后裁剪保留10条。$sort、$slice、$position三者中,$position与$sort不能同时使用,因为排序会打乱位置插入的意义,MongoDB会直接报错。
批量操作实践中的常见坑与性能建议
第一个坑是索引失效问题。对数组字段建了多键索引之后,频繁在数组中间插入元素会导致索引频繁调整,写入放大明显。如果业务不需要按数组元素精确查询,可以考虑不建索引或把高频变动的数组拆分成独立集合。
第二个坑是并发安全。虽然单条update语句在文档级别是原子的,但如果业务逻辑是先读数组再计算位置然后更新,两步之间就可能被其他写入穿插,导致位置错乱。能用$position在服务端一步完成的位置计算,尽量不要在应用层算好下标再传回来。
第三个坑是错误搭配。下面总结几个修饰符的兼容性:
- $each:$push的专用修饰符,其余修饰符都依赖它
- $position:不能与$sort共存,可以与$slice共存
- $sort:必须在有$each的情况下使用,常与$slice搭配
- $slice:值为0会清空数组,务必小心
最后一个建议是批量写入时优先使用bulkWrite把多条数组操作合并提交,减少网络往返。例如同时更新多个文档的标签数组,可以显著降低延迟。掌握了$each、$position、$slice三件套,MongoDB的数组字段就能真正做到像操作普通数据结构一样得心应手。