MongoDB建模实战避坑:100个开发者的血泪教训,教你把数据库跑起来

说实话,我第一次在MongoDB上栽跟头的时候,是在凌晨三点。生产环境的查询时间从200ms飙到12秒,监控报警把我从床上拉起来,结果发现原因就一个——我把所有字段都塞进了一个文档里,然后试图用$text去搜索其中某个数组字段。那种绝望感,只有真正踩过的人才懂。

这篇文章里没有教科书式的”引言-一二三-结语”,就纯粹聊聊我这些年摸爬滚打攒下来的经验,顺便把其他开发者踩过的坑也一并摆出来。


MongoDB到底是什么?先搞懂这个再谈建模

很多人学MongoDB建模之前,脑子里装的是关系型数据库的思维定式。他们习惯性地去想”表”“外键”“JOIN”,然后发现MongoDB里根本没有这些概念,就开始困惑了。

MongoDB本质上是一个文档数据库。什么意思呢?你可以把它想象成一个装满抽屉的柜子,每个抽屉里放一个文档(document),文档的内容是一张JSON风格的纸,上面可以写任何东西。不像MySQL那样严格要求每张表的结构都一样,MongoDB的文档结构可以非常灵活。

但灵活是有代价的。

关系型数据库的约束很强,你不能随意改变表结构,这种约束反而帮你避免了设计错误。MongoDB没有这种约束,所以你写进去的东西是什么样,数据库就长什么样。如果一开始设计得不好,后面改起来会非常痛苦。

我记得有个团队,他们的产品上线半年后发现查询性能很差,排查之后发现,他们把所有的订单数据、用户信息、商品详情都塞在了同一个集合里,用嵌套数组来关联。随着数据量增长,每次查询都要扫描几十兆的数据,索引根本帮不上忙。

所以,建模这一步,真的不能跳过。


第一个坑:滥用嵌套数组

这是新手最常犯的错误。

想象一下,你正在设计一个博客系统的评论功能。很多人会这么想:

// 典型的错误做法
{
  _id: ObjectId("..."),
  title: "MongoDB建模指南",
  content: "这是一篇关于MongoDB的文章...",
  comments: [
    {
      author: "张三",
      content: "写得真好!",
      replies: [
        { author: "李四", content: "同意", created_at: ISODate("2024-01-01") },
        { author: "王五", content: "学习了", created_at: ISODate("2024-01-02") }
      ]
    },
    {
      author: "赵六",
      content: "还有后续吗?",
      replies: []
    }
  ]
}

这个结构看起来非常合理,符合人对”文章”和”评论”的自然认知。但实际用起来,问题就出来了。

问题一:文档大小限制。MongoDB单个文档最大不能超过16MB。如果某个热门文章的评论和回复非常多,这个文档很容易就超过限制。我之前遇到过一篇教程,评论加回复到了3000多条,文档大小接近15MB,写入直接报错。

问题二:数组修改效率低。当你需要往comments数组里插入一条新评论时,MongoDB需要移动数组中后面的所有元素。数组越大,这个操作越慢。如果你的评论数组有10万条,每插入一条都要移动大量数据,这在生产环境是灾难性的。

问题三:查询嵌套数组困难。如果你想查询”所有包含李四评论的文章”,你需要用$elemMatch,而且这个查询无法有效利用索引。如果嵌套层数再多一层,查询性能会更差。

正确的做法:分离存储

把评论单独放在一个集合里,通过article_id来关联:

// 文章集合
{
  _id: ObjectId("..."),
  title: "MongoDB建模指南",
  content: "这是一篇关于MongoDB的文章...",
  comment_count: 128  // 缓存评论数量,避免每次都count
}

// 评论集合
{
  _id: ObjectId("..."),
  article_id: ObjectId("..."),
  author: "张三",
  content: "写得真好!",
  parent_id: null,  // 顶级评论为null,子评论指向父评论
  reply_count: 2,   // 缓存回复数量
  created_at: ISODate("2024-01-01")
}

// 回复可以直接存在评论集合里,用parent_id关联
{
  _id: ObjectId("..."),
  article_id: ObjectId("..."),
  author: "李四",
  content: "同意",
  parent_id: ObjectId("评论A的_id"),
  reply_count: 0,
  created_at: ISODate("2024-01-02")
}

这样设计之后,每个文档都很小,插入和查询都非常快。而且你可以给article_id和parent_id建立索引,查询效率大幅提升。


第二个坑:该Embed还是不Embed,这是个问题

这是MongoDB建模中最核心的问题:数据应该内嵌(embed)还是引用(reference)?

没有标准答案,但有判断标准。我给你总结了一套实用的决策框架:

用”读多写少”还是”读少写多”来判断

如果一个数据关系是”查询时几乎总是需要一起取出来,但很少单独更新”,那就内嵌。

如果一个数据关系是”经常单独更新,或者数据量会增长到很大”,那就引用。

举几个具体的例子:

应该内嵌的场景:

// 用户信息 + 最近订单(内嵌)
{
  _id: ObjectId("..."),
  username: "alice",
  email: "alice@example.com",
  // 最近订单只保留最新的5条,用于展示
  recent_orders: [
    { order_id: "ORD-2024-001", amount: 299.00, status: "已完成", date: ISODate("2024-01-15") },
    { order_id: "ORD-2024-002", amount: 158.50, status: "配送中", date: ISODate("2024-01-20") }
  ]
}

这个场景为什么适合内嵌?因为用户查看个人资料时,几乎一定会看最近订单。而且我们只保留最近几条,文档大小可控。如果每次都要单独查询订单集合再拼接,性能反而更差。

应该引用的场景:

// 用户和订单完全分离
// 用户集合
{
  _id: ObjectId("..."),
  username: "alice",
  email: "alice@example.com"
}

// 订单集合
{
  _id: ObjectId("..."),
  user_id: ObjectId("..."),
  order_id: "ORD-2024-001",
  items: [
    { product_id: ObjectId("..."), name: "鼠标", price: 99.00, quantity: 1 },
    { product_id: ObjectId("..."), name: "键盘", price: 199.00, quantity: 1 }
  ],
  total_amount: 298.00,
  status: "已完成",
  created_at: ISODate("2024-01-15")
}

订单数据为什么要单独存储?因为一个用户可能有成百上千个订单,内嵌进用户文档会让文档变得巨大。而且订单的查询模式很复杂——有时候按用户查,有时候按时间范围查,有时候按状态筛选,分开存储更灵活。

判断流程图

你可以用这个思路来做决策:

  1. 这个数据会被单独查询吗? 如果是 → 引用
  2. 这个数据会增长到很大吗? 如果是 → 引用
  3. 这个数据更新频率高吗? 如果是 → 引用
  4. 这个数据和父数据总是作为一个整体被读取吗? 如果是 → 内嵌
  5. 内嵌后文档会超过几MB吗? 如果是 → 引用

第三个坑:索引建错位置,查询效率相差百倍

索引是MongoDB性能的核心,但很多开发者对索引的理解非常浅。他们知道要建索引,但不知道建在哪里、怎么建。

复合索引的顺序很重要

假设有这样一个查询场景:用户经常按status和created_at来筛选订单,并且经常按created_at排序。

// 这个索引顺序是对的
db.orders.createIndex({ status: 1, created_at: -1 })

// 这个索引顺序是错的
db.orders.createIndex({ created_at: -1, status: 1 })

为什么顺序重要?因为MongoDB的索引是B树结构,查询时从左到右匹配索引字段。如果你先按status筛选,再按created_at排序,那索引就应该先放status再放created_at。

我记得有个真实案例,一个电商平台的订单查询接口响应时间从50ms变成了3秒,排查后发现,他们的复合索引字段顺序和实际查询的过滤条件完全相反。调整顺序之后,查询时间恢复到50ms。

不要盲目建索引

每个索引都会占用磁盘空间,并且会降低写性能。因为每次插入、更新、删除数据时,MongoDB都需要同时更新所有相关的索引。

我给开发者的建议是:先查后建。也就是说,先看看生产环境的查询日志,找出真正被频繁使用的查询模式,再针对性地建立索引。不要凭感觉建一堆索引,最后既占空间又拖慢写入。

覆盖索引(Covered Query)

如果一个查询只需要返回索引中已有的字段,MongoDB不需要去查实际的文档数据,这种查询叫做覆盖查询,性能非常高。

// 假设我们有一个复合索引
db.orders.createIndex({ user_id: 1, status: 1, amount: 1 })

// 这个查询可以用覆盖索引,不需要读取文档本身
db.orders.find(
  { user_id: ObjectId("..."), status: "已完成" },
  { amount: 1, _id: 0 }
)

这种查询方式在生产环境中非常有用,尤其是那些需要批量读取特定字段的场景。


第四个坑:大字段不要放主文档

这个坑我见过太多人踩了。

有些人觉得”反正MongoDB是Schema-free的,我把所有数据都塞一个文档里最省事”。然后他们把大段的文本内容、图片的Base64编码、甚至整个PDF文件都存进了文档里。

这种做法的后果是:

  1. 文档膨胀:每次更新一个小字段,整个文档都要重新写入磁盘
  2. 内存压力:MongoDB的working set(工作集)会包含这些大文档,占用大量内存
  3. 备份变慢:大文档让备份和恢复时间成倍增加

正确的做法

对于大字段,有两种处理方式:

方式一:单独存储

// 主文档只存引用
{
  _id: ObjectId("..."),
  title: "产品说明书",
  content_ref: "fs_uploads/product_manual_v2.pdf"
}

// 大文件存在GridFS或对象存储(如AWS S3)

方式二:如果必须存在数据库里,用单独的集合

// 文章主文档
{
  _id: ObjectId("..."),
  title: "MongoDB建模指南",
  summary: "本文详细介绍MongoDB数据建模的最佳实践...",
  tag_ids: [ObjectId("..."), ObjectId("...")]
}

// 文章内容单独存储
{
  _id: ObjectId("..."),
  article_id: ObjectId("..."),
  content: "很长的文章内容...",
  version: 3
}

这样设计之后,主文档保持轻量,只有需要详细内容的查询才会去加载大字段。


第五个坑:忽视数据倾斜问题

数据倾斜是指某些文档或集合的数据量远超其他文档或集合。在MongoDB中,这会导致严重的性能问题。

典型场景:热点文档

想象一个社交媒体平台,某个大V用户发了热门内容,成千上万的人同时访问这个内容。如果这个内容的所有数据都存在一个文档里,那这个文档就变成了”热点文档”。

MongoDB的sharding机制是按文档的shard key来分配数据的,但如果所有请求都指向同一个文档,分片也就帮不上忙了。

解决方案:数据分片粒度要合适

// 不好的分片策略:以用户为shard key
// 大V用户的所有数据都在一个分片上,造成热点

// 好的分片策略:以用户+时间戳组合为shard key
{
  shard_key: { user_id: ObjectId("..."), timestamp: ISODate("2024-01-15T10:00:00Z") },
  content: "大V发布的热门内容..."
}

这样即使是大V的内容,也会分散到不同的分片上,避免单点热点。

另一个倾斜场景:集合数据量不均匀

有些集合可能只有几百条数据,有些集合可能有几亿条。这种不均匀会导致运维复杂度增加,备份策略、索引策略都需要差异化处理。

我的建议是:在设计阶段就预估每个集合的数据量级,不要等到数据量上来之后才发现某个集合成了性能瓶颈。


第六个坑:聚合管道写得太长

MongoDB的聚合框架非常强大,但很多开发者过度依赖它,把本来可以在应用层做的事情全塞进了聚合管道里。

// 一个冗长且难以维护的聚合管道
db.orders.aggregate([
  { $match: { status: "已完成", created_at: { $gte: startDate, $lte: endDate } } },
  { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user" } },
  { $unwind: "$user" },
  { $lookup: { from: "products", localField: "items.product_id", foreignField: "_id", as: "products" } },
  { $unwind: "$products" },
  { $group: { _id: "$user._id", total_amount: { $sum: "$amount" }, order_count: { $sum: 1 } } },
  { $sort: { total_amount: -1 } },
  { $limit: 10 }
])

这个聚合管道有几个问题:

  1. 可读性差:复杂的管道让人难以理解和维护
  2. 调试困难:出问题的时候,很难定位是哪一步出错了
  3. 性能不佳:MongoDB的聚合引擎对复杂管道不是特别优化,尤其是多个$lookup的情况
  4. 无法利用应用层缓存:每次都重新计算,没有缓存机制

更好的做法:分层处理

// 第一层:在数据库层面做基本筛选和聚合
const basicData = await db.orders.aggregate([
  { $match: { status: "已完成", created_at: { $gte: startDate, $lte: endDate } } },
  { $group: { _id: "$user_id", total_amount: { $sum: "$amount" }, order_count: { $sum: 1 } } },
  { $sort: { total_amount: -1 } },
  { $limit: 100 }  // 先取多一些,应用层再筛选
]).toArray()

// 第二层:在应用层做关联和最终处理
const userIds = basicData.map(item => item._id)
const users = await db.users.find({ _id: { $in: userIds } }).toArray()
const userMap = new Map(users.map(u => [u._id.toString(), u]))

const result = basicData.map(item => ({
  ...item,
  user: userMap.get(item._id.toString())
})).slice(0, 10)

这样做的优点:

  • 数据库只做它擅长的事:筛选和聚合
  • 应用层做它擅长的事:关联数据和业务逻辑
  • 代码更易读、更易测试
  • 可以在应用层加缓存,避免重复计算

第七个坑:忽视事务的使用场景

MongoDB从4.0版本开始支持多文档事务,但很多开发者要么完全不用事务,要么滥用事务。

什么时候应该用事务?

事务适用于需要保证多个操作原子性的场景。比如:

// 转账场景:A给用户B转账100元
const session = client.startSession()
try {
  await session.withTransaction(async () => {
    // 从A的账户扣100
    await db.accounts.updateOne(
      { _id: accountA },
      { $inc: { balance: -100 } }
    )
    // 给B的账户加100
    await db.accounts.updateOne(
      { _id: accountB },
      { $inc: { balance: 100 } }
    )
    // 记录交易日志
    await db.transactions.insertOne({
      from: accountA,
      to: accountB,
      amount: 100,
      timestamp: new Date()
    })
  })
} finally {
  await session.endSession()
}

如果没有事务,可能出现A扣了钱但B没收到,或者B收到了钱但A没扣钱的情况。

什么时候不应该用事务?

  1. 简单的单文档操作不需要事务。MongoDB的单文档写入本身就是原子的,不需要额外的事务开销。
  2. 大量写入操作不适合事务。事务有开销,大量写入时用事务会显著降低性能。
  3. 跨分片的写入要谨慎。虽然MongoDB支持跨分片事务,但性能开销比单分片事务大得多。

第八个坑:没有合理处理数据淘汰

很多开发者设计系统时只考虑了写入和查询,完全没想过数据淘汰的问题。结果数据库越用越大,性能越来越差,最后不得不做数据迁移或者清理,代价巨大。

几种数据淘汰策略

TTL索引:MongoDB内置的TTL(Time-To-Live)功能可以自动删除过期的文档。

// 日志数据:只保留最近30天的记录
db.logs.createIndex({ created_at: 1 }, { expireAfterSeconds: 2592000 })  // 30天 = 2592000秒

应用层淘汰:对于需要复杂淘汰逻辑的数据,可以在应用层定期执行清理。

// 每天凌晨清理7天前的会话数据
async function cleanupSessions() {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
  await db.sessions.deleteMany({ last_active: { $lt: sevenDaysAgo } })
}

版本化淘汰:对于配置类数据,可以用版本号管理,旧的配置自动被新配置替代。

// 配置数据按版本存储,只保留最新的3个版本
{
  _id: ObjectId("..."),
  config_key: "payment_gateway",
  version: 3,
  config: { gateway_url: "https://new-gateway.example.com", timeout: 5000 },
  created_at: ISODate("2024-01-15")
}

// 淘汰策略:只保留version最大的3条
db.configs.deleteMany({
  config_key: "payment_gateway",
  version: { $lt: (maxVersion - 2) }
})

第九个坑:命名规范混乱

这个听起来很小,但实际上影响非常大。我见过太多项目,集合名称有的用复数(users),有的用单数(order);字段名有的用下划线(user_name),有的用驼峰(userName);日期字段有的叫created_at,有的叫createTime,有的叫createdAt。

这种混乱在团队协作中会造成巨大的沟通成本,而且会让查询脚本难以维护和复用。

我建议的命名规范

集合命名:使用复数、小写、下划线分隔

// 好的命名
db.users
db.order_items
db.user_sessions

// 不好的命名
db.User          // 大写字母
db.user          // 单数
db.OrderItems    // 驼峰命名

字段命名:统一使用下划线分隔的小写字母

// 好的命名
{
  user_id: ObjectId("..."),
  created_at: ISODate("..."),
  order_status: "completed",
  payment_method: "alipay"
}

// 不好的命名
{
  userId: ObjectId("..."),      // 驼峰
  CreatedAt: ISODate("..."),   // 大写
  orderStatus: "completed",    // 驼峰
  PaymentMethod: "alipay"      // 大写
}

特殊字段:

  • 对象ID统一叫_id(MongoDB默认)或xxx_id
  • 日期字段统一叫xxx_at或xxx_time,且统一使用ISODate格式
  • 布尔字段用is_xxx或has_xxx开头
  • 状态字段用xxx_status或xxx_state

第十个坑:忽视了数据校验

MongoDB从3.6版本开始支持JSON Schema校验,但这个功能经常被忽视。没有数据校验,意味着你可以往数据库里插入任何格式的数据,包括字段缺失、类型错误、格式不对的数据。

// 创建集合时定义Schema校验
db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["user_id", "total_amount", "status"],
      properties: {
        user_id: {
          bsonType: "objectId",
          description: "用户ID,必填"
        },
        total_amount: {
          bsonType: "number",
          minimum: 0,
          description: "订单金额,必须是非负数"
        },
        status: {
          enum: ["pending", "paid", "shipped", "completed", "cancelled"],
          description: "订单状态"
        },
        items: {
          bsonType: "array",
          minItems: 1,
          items: {
            bsonType: "object",
            required: ["product_id", "quantity", "price"],
            properties: {
              product_id: { bsonType: "objectId" },
              quantity: { bsonType: "int", minimum: 1 },
              price: { bsonType: "number", minimum: 0 }
            }
          }
        },
        created_at: {
          bsonType: "date",
          description: "创建时间"
        }
      }
    }
  }
})

有了这个校验之后,任何不符合规则的写入操作都会被拒绝,从源头上保证了数据质量。


实战案例:电商订单系统的数据建模

让我用一个具体的例子,把前面的知识串起来。

假设你要设计一个电商订单系统,主要需求:

  • 用户可以下单购买多个商品
  • 订单状态会流转:待支付 → 已支付 → 已发货 → 已完成
  • 需要支持按用户查询订单历史
  • 需要支持按时间范围查询订单统计
  • 需要支持订单搜索(按订单号、商品名称)

错误的建模方式

{
  _id: ObjectId("..."),
  order_no: "ORD-2024-001",
  user: {
    user_id: ObjectId("..."),
    username: "alice",
    email: "alice@example.com",
    address: "北京市朝阳区xxx街道xxx号",
    phone: "13800138000"
  },
  items: [
    {
      product_id: ObjectId("..."),
      product_name: "无线鼠标",
      product_image: "https://cdn.example.com/images/mouse.jpg",
      price: 99.00,
      quantity: 1,
      category: {
        category_id: ObjectId("..."),
        category_name: "电脑配件"
      }
    },
    // ...可能还有更多商品
  ],
  total_amount: 298.00,
  discount: 20.00,
  payment_amount: 278.00,
  status: "已支付",
  payment: {
    method: "alipay",
    transaction_id: "ALI20240115001",
    paid_at: ISODate("2024-01-15T10:30:00Z")
  },
  shipping: {
    courier: "顺丰快递",
    tracking_no: "SF1234567890",
    shipped_at: ISODate("2024-01-15T14:00:00Z"),
    address: {
      province: "北京",
      city: "北京",
      district: "朝阳区",
      detail: "xxx街道xxx号",
      longitude: 116.407,
      latitude: 39.904
    }
  },
  created_at: ISODate("2024-01-15T10:00:00Z"),
  updated_at: ISODate("2024-01-15T14:00:00Z")
}

这个设计有什么问题?

  1. 用户信息冗余:每个订单都存了一份完整的用户信息,用户修改地址后,历史订单的地址也不会更新,但新订单会更新。这会导致数据不一致。
  2. 商品信息冗余:商品名称、图片、分类都存进了订单,商品信息变更时历史订单的数据会过时。
  3. 文档过大:如果一个订单包含10个商品,加上嵌套的地址信息,文档大小可能超过100KB。
  4. 状态流转困难:订单状态更新时,整个文档会被重写,如果有大字段,性能会受影响。

正确的建模方式

// 订单主集合:轻量级,只包含核心信息
db.orders
{
  _id: ObjectId("..."),
  order_no: "ORD-2024-001",
  user_id: ObjectId("..."),           // 引用,不冗余
  total_amount: 298.00,
  discount: 20.00,
  payment_amount: 278.00,
  status: "paid",                      // 用英文枚举,便于程序处理
  payment_method: "alipay",
  transaction_id: "ALI20240115001",
  shipping_address_id: ObjectId("..."), // 引用收货地址
  created_at: ISODate("2024-01-15T10:00:00Z"),
  updated_at: ISODate("2024-01-15T14:00:00Z")
}

// 订单项集合:每个商品一行
db.order_items
{
  _id: ObjectId("..."),
  order_id: ObjectId("..."),
  product_id: ObjectId("..."),
  product_name_snapshot: "无线鼠标",    // 快照,记录下单时的商品名
  price_snapshot: 99.00,               // 快照,记录下单时的价格
  quantity: 1,
  category_name_snapshot: "电脑配件"
}

// 用户集合
db.users
{
  _id: ObjectId("..."),
  username: "alice",
  email: "alice@example.com",
  phone: "13800138000"
}

// 收货地址集合
db.shipping_addresses
{
  _id: ObjectId("..."),
  user_id: ObjectId("..."),
  receiver_name: "Alice",
  province: "北京",
  city: "北京",
  district: "朝阳区",
  detail: "xxx街道xxx号",
  phone: "13800138000",
  is_default: true,
  created_at: ISODate("2024-01-01T00:00:00Z")
}

// 订单状态变更日志:用于追踪和审计
db.order_status_logs
{
  _id: ObjectId("..."),
  order_id: ObjectId("..."),
  from_status: "pending",
  to_status: "paid",
  operator: "system",                 // system表示系统自动变更
  note: "用户通过支付宝完成支付",
  created_at: ISODate("2024-01-15T10:30:00Z")
}

这个设计的优点

  1. 用户信息不冗余:用户修改地址只会影响新订单使用的地址,历史订单通过shipping_address_id引用当时的地址快照(可以在下单时把地址信息存一份快照到订单里,如果历史订单的地址也需要保留的话)。
  2. 商品信息用快照:product_name_snapshot和price_snapshot记录了下单时的商品信息,商品后续怎么改都不影响历史订单。
  3. 文档大小可控:主订单文档很轻量,商品信息在单独的集合里,可以自由扩展。
  4. 状态变更可追溯:order_status_logs集合记录了每一次状态变更,方便审计和问题排查。
  5. 查询灵活:
    • 按用户查订单:db.orders.find({ user_id: ... })
    • 按时间范围查统计:db.orders.aggregate([{ $match: { created_at: { $gte: start, $lte: end } } }, { $group: { _id: "$status", count: { $sum: 1 } } }])
    • 按订单号搜索:db.orders.find({ order_no: "ORD-2024-001" })

高并发场景下的特殊考虑

前面讲的都是常规场景,高并发场景有一些特殊的建模考量。

计数器问题

想象一个微博点赞功能,每次点赞都要更新点赞数。如果所有点赞都更新同一个文档,高并发下会出现严重的锁竞争。

// 错误做法:所有点赞更新同一个文档
// 文档:{ _id: postId, like_count: 10000 }
// 每次点赞:db.posts.updateOne({ _id: postId }, { $inc: { like_count: 1 } })
// 1000个并发请求同时更新同一个文档,性能极差

解决方案:分片计数器

// 把计数器分散到多个文档
// 分片文档1
{ _id: "postId_shard_0", count: 333 }
// 分片文档2
{ _id: "postId_shard_1", count: 333 }
// 分片文档3
{ _id: "postId_shard_2", count: 334 }

// 读取时聚合所有分片
const shards = await db.like_shards.find({ post_id: postId }).toArray()
const total = shards.reduce((sum, s) => sum + s.count, 0)

分布式锁的替代方案

在高并发场景下,用数据库做分布式锁是非常低效的。但如果必须用MongoDB,可以用以下方式:

// 使用原子操作实现简单的分布式锁
async function tryAcquireLock(lockName, ownerId, ttlMs = 30000) {
  const lockDoc = {
    _id: lockName,
    owner: ownerId,
    acquired_at: new Date(),
    expires_at: new Date(Date.now() + ttlMs)
  }
  
  // upsert,只有不存在或已过期时才能获取
  const result = await db.locks.updateOne(
    { _id: lockName, $or: [{ owner: { $ne: ownerId } }, { expires_at: { $lt: new Date() } }] },
    { $set: lockDoc },
    { upsert: true }
  )
  
  return result.modifiedCount > 0
}

写放大问题

在高并发写入场景下,频繁更新同一个文档会导致严重的写放大。MongoDB每次更新文档都会写整个文档到磁盘,即使你只改了一个字段。

解决方案:使用更新日志+定期合并

// 不直接更新主文档,而是写更新日志
db.update_logs.insertOne({
  target_collection: "posts",
  target_id: postId,
  operation: { $inc: { like_count: 1 } },
  created_at: new Date()
})

// 定时任务(比如每分钟)合并更新日志到主文档
async function mergeUpdates() {
  const oneMinuteAgo = new Date(Date.now() - 60000)
  const logs = await db.update_logs.find({ created_at: { $gte: oneMinuteAgo } }).toArray()
  
  // 按目标文档分组
  const grouped = logs.reduce((acc, log) => {
    const key = `${log.target_collection}:${log.target_id}`
    if (!acc[key]) acc[key] = []
    acc[key].push(log.operation)
    return acc
  }, {})
  
  // 批量合并
  for (const [key, operations] of Object.entries(grouped)) {
    const [collection, id] = key.split(":")
    const combined = operations.reduce((acc, op) => {
      for (const [opType, field] of Object.entries(op)) {
        if (!acc[opType]) acc[opType] = {}
        for (const [field, value] of Object.entries(field)) {
          if (!acc[opType][field]) acc[opType][field] = 0
          acc[opType][field] += value
        }
      }
      return acc
    }, {})
    
    await db[collection].updateOne({ _id: id }, combined)
    await db.update_logs.deleteMany({ target_collection: collection, target_id: id, created_at: { $gte: oneMinuteAgo } })
  }
}

这样把高频的写操作变成了批量的合并操作,大幅减少了写放大。


建模检查清单

每次设计MongoDB数据模型之前,建议你过一遍这个清单:

  • [ ] 是否明确了每个集合的主要查询模式?
  • [ ] 是否评估了每个集合的数据量级和增长趋势?
  • [ ] 内嵌的数据是否有大小限制风险(16MB)?
  • [ ] 是否避免了在大数组上做频繁的位置更新?
  • [ ] 是否为目标查询建立了合适的索引?
  • [ ] 是否考虑了数据淘汰策略?
  • [ ] 是否定义了数据校验规则?
  • [ ] 是否统一了命名规范?
  • [ ] 是否考虑了高并发下的锁竞争问题?
  • [ ] 是否设计了数据一致性的保障机制?
  • [ ] 是否评估了备份和恢复的时间成本?
  • [ ] 是否考虑了分片策略(如果需要)?

最后说几句

MongoDB建模没有银弹。每个项目都有自己的业务场景和性能要求,最好的模型是那些最贴合业务需求的模型。我的建议是:先做一个简单的原型,上线后根据实际性能数据不断调整。

我见过太多团队在项目初期把模型设计得过于复杂,试图涵盖所有可能的场景,结果反而限制了业务的灵活性。MongoDB的Schema-free特性本意是让你能够快速迭代,但如果一开始就把结构定死了,那就失去了这个优势。

另一个建议是:多看看生产环境的慢查询日志。MongoDB的db.currentOp()和profile功能可以帮助你找到性能瓶颈。很多模型问题不是在设计阶段能发现的,而是在实际运行中暴露出来的。

最后,记住一句话:建模不是一劳永逸的事情,它是一个持续优化的过程。随着业务的发展,你的数据模型也需要不断调整。不要因为”改模型很麻烦”就放任错误的设计蔓延下去。及时的调整比事后的重构成本低得多。

希望这些经验能帮你在MongoDB建模的路上少踩一些坑。如果你在实际项目中遇到了具体问题,欢迎随时交流——毕竟每个项目都有自己的独特性,纸上谈兵终究不如实战经验丰富。