MongoDB的文档模型天然支持把数组作为字段值嵌入到文档内部,这种设计让一对多关系的数据组织变得非常直观。但数组一旦存进文档,如何在服务端高效地修改它就成了一个绕不开的话题。如果每次修改都把整个文档读到应用层再写回,既浪费带宽又容易产生并发冲突。MongoDB为此提供了一组数组更新操作符,能在数据库端直接对数组元素进行增删改,本文重点讲解其中三个最常用的操作符:$addToSet、$pull和$pop。

$addToSet:向数组追加去重元素
$addToSet的作用是把一个值追加到数组末尾,前提是这个值当前不在数组中。如果数组里已经存在相同的值,操作会被静默忽略,文档不会发生任何变化。这个行为非常适合用来维护标签集合、权限列表、收藏记录等需要保证唯一性的场景。
需要特别注意的是,$addToSet判断元素是否存在的依据是严格相等。对于基本类型比如字符串和数字,这很好理解;但对于对象类型,必须整个对象的所有字段都完全一致才会被视为重复。这意味着{a:1, b:2}和{a:1, b:3}会被当作两个不同的元素同时存在于数组中。如果你的业务对象包含时间戳或自增字段,几乎不可能触发去重,这时应该考虑用其他字段做唯一约束。
下面是一个基础用法示例,向用户文档的tags数组中追加一个标签:
db.users.updateOne(
{ _id: 1 },
{ $addToSet: { tags: "mongodb" } }
)
如果一次性要追加多个元素,直接传数组是行不通的,因为整个数组会被当作一个对象元素追加进去。正确的做法是配合$each修饰符,让MongoDB逐个判断每个候选元素:
db.users.updateOne(
{ _id: 1 },
{ $addToSet: { tags: { $each: ["mongodb", "redis", "mysql"] } } }
)
$addToSet和$push最大的区别就在这里:$push是无条件追加,不管数组里有没有都会加进去,而$addToSet会先做存在性检查。如果你的业务允许重复,用$push性能更好,因为它省去了扫描数组的开销;如果必须去重,$addToSet就是首选。不过当数组规模非常大时,$addToSet的扫描成本会线性增长,这种情况下建议把唯一性约束拆到独立集合里用索引来保证。
$pull:按条件批量移除元素
$pull是删除数组元素的主力操作符,它接受一个查询条件作为参数,把数组中所有满足该条件的元素一次性移除。与$addToSet只处理单个值不同,$pull天生支持批量删除,而且条件表达式可以非常复杂,几乎能复用所有MongoDB查询操作符。
最简单的用法是按字面值删除,比如把tags数组中所有的mysql标签移除:
db.users.updateOne(
{ _id: 1 },
{ $pull: { tags: "mysql" } }
)
当数组元素是对象时,$pull的威力才真正体现出来。假设有一个scores数组,每个元素是{subject: "数学", score: 85}这样的对象,现在要把所有不及格的成绩删掉,可以这样写:
db.users.updateOne(
{ _id: 1 },
{ $pull: { scores: { score: { $lt: 60 } } } }
)
这条语句会遍历scores数组中的每个对象,检查其score字段是否小于60,满足条件的对象会被整体移除。条件里还可以嵌套逻辑操作符,比如$or、$and,甚至可以引用其他字段的值。需要提醒的是,$pull的条件是针对数组元素本身做匹配,而不是针对外层文档,初学者容易把外层文档的查询条件和$pull内部的删除条件搞混。外层{ _id: 1 }用来定位要修改哪个文档,$pull内部的{ score: { $lt: 60 } }用来决定删哪些数组元素,两者作用域完全不同。
另外有一个容易踩的坑:$pull会移除所有匹配的元素,而不是只移除第一个。如果你只想删一个,需要换用其他方案,比如先$pop再调整,或者用聚合管道配合$slice。在元素可能重复出现的数组上使用$pull时,务必确认批量删除符合业务预期。
$pop:按位置弹出首尾元素
$pop的行为非常简单直接:根据传入的数字正负,从数组的头部或尾部移除一个元素。传入1表示移除末尾元素,传入-1表示移除头部元素。这个操作符不关心元素的具体内容,只关心位置,因此常用于实现队列、栈等数据结构。
下面是一个从tags数组末尾弹出一个元素的例子:
db.users.updateOne(
{ _id: 1 },
{ $pop: { tags: 1 } }
)
如果把1改成-1,则会移除数组第一个元素。需要强调的是,$pop只能传1或-1,传0在某些版本中会被当作1处理,但这是未定义行为,不应该依赖。如果数组为空,$pop不会报错也不会创建空数组,文档保持原样。
$pop最常见的应用场景是限制数组长度。比如记录用户最近浏览的商品,每次浏览都$push一条记录,当数组长度超过阈值时用$pop把最早的记录挤出去。不过更优雅的做法是配合$slice修饰符,在$push的同时直接截断数组:
db.users.updateOne(
{ _id: 1 },
{ $push: { history: { $each: ["商品A"], $slice: -10 } } }
)
这条语句会把新元素追加到history数组末尾,并保留最后10个元素,多余的从头部丢弃。相比先push再pop的两步操作,$slice是原子的,不会出现中间状态导致数组临时超长,在高并发场景下更安全。
三个操作符的对比与组合使用
这三个操作符各有侧重:$addToSet负责去重新增,$pull负责条件删除,$pop负责位置删除。在实际业务中,它们经常需要组合使用。比如维护一个去重的标签集合,同时要支持批量删除某些标签,就可以先用$addToSet追加新标签,再用$pull清理过期标签。
下表对三者的关键特性做了归纳:
| 操作符 | 作用 | 是否支持批量 | 判断依据 |
|---|---|---|---|
| $addToSet | 追加元素并去重 | 配合$each | 元素严格相等 |
| $pull | 按条件移除元素 | 是 | 查询表达式 |
| $pop | 移除首尾元素 | 否,每次一个 | 数组位置 |
组合使用时有一个重要原则:同一条更新语句中,多个数组操作符作用于同一个数组字段时,MongoDB会按特定顺序处理,先处理$pull这类删除操作,再处理$addToSet、$push这类添加操作。理解这个顺序能避免一些诡异的结果。比如你想先删掉某个标签再添加一个新标签,如果两者在同一语句中,删除会先生效,添加后生效,最终结果符合预期。但如果添加的标签恰好和被删除的标签相同,就会出现先删后加的无效往返,这种情况下应该拆成两条语句或调整逻辑。
最后要提醒的是,所有数组更新操作符都受文档最大16MB的限制约束。当数组无限膨胀时,不仅更新变慢,整个文档的读写性能都会下降。对于增长不可控的数据,比如日志、评论列表,更合理的做法是拆分到独立集合,用引用关联而不是内嵌数组,这样既能利用索引加速查询,也能避免单文档过大带来的性能瓶颈。
MongoDB数组操作addToSetpull修改时间:2026-08-23 08:31:28