结论:用 $inc,别用 $set 读-改-写,否则高并发下计数器必然错乱;因为 $inc 是服务端原子操作,不依赖读取当前值,避免竞态,而 $set 读-改-写模式在并发时多个请求可能基于同一旧值计算并覆盖,导致更新丢失。

直接说结论:用 $inc,别用 $set 读-改-写,否则高并发下计数器必然错乱。
为什么 $inc 能解决并发计数器问题
计数器类字段(如 views、likes、attempts)在并发写入时,$set 方式极易丢失更新:两个请求同时读到 views: 100,各自加 1 后都写回 101,结果只 +1 而非 +2。$inc 不依赖当前值——它把“加多少”这个指令直接发给 MongoDB 存储引擎,由服务端原子执行。无论多少个并发请求同时对同一文档的 $inc 字段操作,最终结果都等于所有增量之和。
关键点:
- 底层不涉及“读取→计算→写入”三步,无竞态窗口
- 单文档内所有
$inc字段更新属于一次物理写入,不可分割 - 即使字段不存在,
$inc会自动初始化为指定增量值(如"new_count": 1→ 新增字段并设为 1)
$inc 多字段批量更新的写法和常见错误
你不需要为每个字段发一次 update,$inc 原生支持多字段一次性声明:
db.users.updateOne(
{ _id: ObjectId("...") },
{ $inc: { loginCount: 1, points: 5, "stats.total": 1 } }
)
容易踩的坑:
-
$inc只接受数值(int、long、double),若目标字段是字符串或null,会报错Cannot increment with non-numeric value - 字段路径中含点号(如
"stats.total")没问题,但不能是数组下标(如"tags.0")——除非配合$定位符,否则会创建嵌套对象而非更新数组元素 - 传入浮点数(如
points: 0.1)可能因 IEEE 754 精度导致意外结果,计数器场景建议统一用整数
Go 驱动中正确调用 $inc 的关键细节
用官方 mongo-go-driver 时,常见错误是把 $inc 写成顶层操作符,或漏掉 options.FindOneAndUpdate() 的返回设置:
update := bson.M{"$inc": bson.M{"score": 10, "hist_score": 5}}
result := collection.FindOneAndUpdate(
ctx,
bson.M{"_id": id},
update,
options.FindOneAndUpdate().SetReturnDocument(options.After)
)
注意:
- 必须用
bson.M{"$inc": ...}包裹,不能直接传bson.M{"score": 10} - 若需拿到更新后的完整文档,一定要显式设置
SetReturnDocument(options.After),否则默认返回旧值 - 不要在 filter 中硬编码字段当前值(如
{"score": 95})来“防覆盖”——这反而制造了条件竞争,$inc本身就不需要这种保护
什么时候 $inc 不够用,得上事务或应用层重试
$inc 解决的是单文档内多个数值字段的原子递增,但它不保证跨文档一致性。比如“扣库存 + 记订单”这种必须全成功或全失败的操作,$inc 无能为力,得用多文档事务。
另一个边界是:你需要基于当前值做条件判断再决定是否更新(例如“仅当 stock > 0 才扣减”)。这时不能只靠 $inc,必须把条件放进 filter:
db.products.updateOne(
{ _id: 123, stock: { $gt: 0 } }, // 条件必须在这里
{ $inc: { stock: -1 } }
)
如果匹配失败(matchedCount == 0),说明库存不足,应用层要处理这个失败,而不是假设更新一定成功。
真正容易被忽略的是:字段类型初始化。上线前没给 score 字段设默认值 0,第一个 $inc 可能因类型不匹配而静默失败——务必在 schema 设计阶段确保数值字段有合理初始值或使用 $setOnInsert 配合 upsert。



















