本文目录

[[toc]]

基础命令

新增数据

// 往 home 集合中插入一条数据
// home 是集合名称,用于整合同类的数据文档
db.home.insertOne({ "name": 'He110' })

// 插入多条数据
db.home.insertMany([
  { "name": 'He110' }
])

查找数据

// 单条件查询
db.movies.find({
  "year": 1976
})

// 多条件 and 查询
db.movies.find({
  "year": 1967,
  "title": "红色娘子军"
})

// 多条件 and 查询严格意义上的写法
db.movies.find({
  $and: [
    { "year": 1967 },
    { "title": "红色娘子军" }
  ]
})

// 多条件 or 查询
db.movies.find({
  $or: [
    { "year": 1967 },
    { "title": "红色娘子军" }
  ]
})

// 正则匹配
db.movies.find({
  "title": /红色/
})

// 逻辑比较
db.movies.find({
  "year": {
    // 不等于
    $ne: 1,
    // 大于
    $gt: 1967,
    // 大于等于
    $gte: 1967,
    // 小于
    $lt: 1976,
    // 小于等于
    $lte: 1976,
  }
})

// 其他条件
db.movies.find({
  // 查找不存在 year 字段的数据
  "year": {
    $exists: false
  },
})
db.movies.find({
  "year": {
    // 查找 year == 1967 或者 year == 1976
    $in: [1967, 1976],
    // 查找 year != 1978
    $nin: [1978],
  },
  // 查询 from.country 字段 === "china"
  "from.country": "china",
  // 将属性值拆出来写
  "from": {
    // 定义的属性都要匹配
    $elemMatch: {
      "country": "china",
      "city": "beijing",
    }
  }
})

// 只获取指定字段
db.movies.find(
  { "year": 1967 },
  // 不返回 _id ,只返回 title
  { "_id": false, "title": true }
)

删除数据

// 删除所有 year < 0 的数据
db.movies.remove({ "year": { $lt: 0 } })

// 删除所有数据
db.movies.remove({})

// 报错,必须给参数
db.movies.remove()

更新数据

// 更新第一条匹配的数据
db.movies.updateOne(
  // 查找哪些数据
  {
    "year": 1967
  },
  // 如何更新这条数据
  {
    $set: 1976
  }
)

db.movies.updateOne(
  {},
  {
    // 追加这条数据到底部
    $push: { "year": 1976, "title": "太阳" },
    // 追加多条数据到底部
    $pushAll: [{ "year": 1976, "title": "太阳" }],
    // 存在则不操作,不存在就插入
    $addToSet: { "year": 1976, "title": "太阳" },
    // 从指定数据中删除数据, 1 表示删除最后一个, -1 表示删除第一个
    $pop: { "tags": 1 },
    // 按值删除,而不是按序号
    $pull: { "tags": "糟粕" },
    // pull 的批量操作
    $pullAll: [{ "tags": "糟粕" }],
  }
)

// 更新所有匹配的数据
db.movies.updateMany([
  { "year": 1967 }
])

清空集合

db.movies.drop()

聚合操作

db.users.aggregate([
  // 查找条件
  { $match: { "gender": "" } },
  // 从哪一行开始
  { $skip: 100 },
  // 取多少数据
  { $limit: 20 },
  {
    $project: {
      // first_name 字段重命名为 名
      "": "$first_name",
      // last_name 字段重命名为 姓
      "": "$last_name"
    }
  }
])

上述命令等价于如下 SQL:

SELECT
  first_name as '',
  last_name as ''
FROM Users
WHERE gender=''
SKIP 100
LIMIT 20

数据展开

db.students.findOne()
// {
//   name: '张三',
//   score: [
//     { subject: '语文', score: 120 },
//     { subject: '数学', score: 120 },
//     { subject: '英语', score: 120 },
//   ]
// }

db.students.aggregate([
  { $unwind: '$score' }
])

// {
//   name: '张三',
//   score: { subject: '语文', score: 120 }
// }
// {
//   name: '张三',
//   score: { subject: '数学', score: 120 }
// }
// {
//   name: '张三',
//   score: { subject: '英语', score: 120 }
// }

分组

db.products.aggregate([
  {
    $bucket: {
      // 按照 price 字段分组
      groupBy: "$price",
      // 分组区间为 [0, 10), [10, 20), [20, 30), [30, 40)
      boundaries: [0, 10, 20, 30, 40],
      // 不在区间内的放到 Other
      default: "Other",
      // 设定输出格式为 `{ count: 总数 }`
      output: { "count": { $sum: 1 } }
    }
  }
])
// 同时统计按 价格 和按 上市时间 分组的数据总量
db.products.aggregate([
  {
    $facet: {
      price: {
        $bucket: {
          // 按照 price 字段分组
          groupBy: "$price",
          // 分组区间为 [0, 10), [10, 20), [20, 30), [30, 40)
          boundaries: [0, 10, 20, 30, 40],
          // 不在区间内的放到 Other
          default: "Other",
          // 设定输出格式为 `{ count: 总数 }`
          output: { "count": { $sum: 1 } }
        }
      },
      year: {
        $bucket: {
          // 按照 year 字段分组
          groupBy: "$year",
          // 分组区间为 [2021, 2022), [2022, 2023), [2023, 2024), [2024, 2025)
          boundaries: [2021, 2022, 2023, 2024, 2025],
          // 不在区间内的放到 Other
          default: "Other",
          // 设定输出格式为 `{ count: 总数 }`
          output: { "count": { $sum: 1 } }
        }
      },
    }
  }
])

数据计算

db.orders.aggregate([
  // 声明数据的统计范围
  {
    $match: {
      // 订单完成了才统计
      status: "completed",
      // 设置统计的时间段
      orderDate: {
        // 统计 2024 全年数据
        $gt: ISODate("2025-01-01"),
        $lte: ISODate("2024-01-01"),
      },
    }
  },
  // 聚合总金额、总运费、总数量
  {
    $group: {
      // 所有数据聚合到一起,不分组
      _id: null,
      // total 字段等于每个数据的 total 相加
      total: { $sum: "$total" },
      // shippingFee 字段等于每个数据的 shippingFee 相加
      shippingFee: { $sum: "$shippingFee" },
      // 每次都 + 1
      count: { $sum: 1 }
    }
  },
  // 声明输出格式
  {
    $project: {
      // 计算总利润
      grandTotal: {
        // 总利润 = 总金额 - 运费
        $add: ["$total", "$shippingFee"]
      },
      // count 字段保留,继续输出
      count: 1,
      // _id 字段抛弃,不输出了
      _id: 0
    }
  },
])

复制集

机制

  • 在数据写入时,将数据复制到其他独立节点上
  • 在写入节点故障时,自动通过选举产生新的替代节点

由于以上机制,所以 MongoDB 也就拥有以下能力:

  • 数据分发: 异地数据同步,减少另一个区域的读延迟
  • 读写分离: 不同类型的操作在不同的节点上执行
  • 异地容灾: 数据中心故障时可以快速切换到异地的备用数据中心

典型结构

一般 MongoDB 会组成三节点集群:

  • 主节点: 只有一个,只有主节点能写
  • 从节点: 两个或者多个,复制主节点中的数据,提供数据读操作

每个节点都可以参与选举投票,除此之外还有投票节点 Arbiter ,用于在平票时修改投票结果,但是现在已经不推荐使用了。

数据复制

  • 主节点: 写入数据后,记录到 oplog 中
  • 从节点: 获取主节点 oplog ,在本地回放主节点操作,同步更新数据

选举

具有投票权的节点之间两两发送心跳包,如果超过 5 次丢失心跳包则判断为节点失联。

如果失联的是从节点,则忽略。

如果失联的是主节点,触发选举。

选举基于 RAFT 一致性算法 实现,选举成功的必要条件是大多数投票节点存活。

复制集最多支持 50 个节点,但是具有投票权的节点最多 7 个。

被选举为主节点必须具备以下条件:

  • 能够与多数节点建立连接
  • 具有较新的 oplog
  • 具有较高优先级(看是否有配置)

复制集节点支持以下配置:

  • v: 是否有投票权,有则参与投票
  • priority: 指定节点优先级,优先级高的越优先成为主节点。 优先级为 0 无法成为主节点
  • hidden: 节点可以复制数据,但是对应用不可见,优先级必须为 0 ,可以有投票权。 主要用于备份操作。
  • slaveDelay: 手动设置同步时延,同步 N 秒之前的数据,保持时间差。 主要用于误操作时回滚。

复制集配置

# /data/db/mongod.conf
systemLog:
  destination: file:          #  MongoDB 发送所有日志输出的目标指定为文件
  path: /data/db/mongod.log   # 日志文件地址
  logAppend: true             # 当 mongod 活着 mongo 实例重新启动时, mongod 或者 mongos 会将新条目附加到现有日志文件的末尾
storage:
  dbPath: /data/db            # 数据存放的目录
net:
  bindIp: 0.0.0.0             # 监听的 IP
  port: 28017                 # 监听的端口
replication:                  # 指定为复制集模式,而不是单节点
  replSetName: rs0            # 副本集的统一标识,有相同标识的 MongoDB 才能组成集群
processManagement:
  fork: true                  # 作为独立后台进程运行,而不是在前台运行
# 使用配置文件启动 MongoDB
mongod -f /data/db/mongod.conf
# 进入创建的 mongo shell
# mongo 8 修改了命令为 mongosh ,如果是老版本使用 mongo
mongosh localhost:28017
// 初始化集群,进入集群状态
rs.initiate()

// 查看集群状态,包括有哪些从节点
rs.status()

// 加入从节点
rs.add("localhost:28018")

// 加入从节点
rs.add("localhost:28018")

通过父节点写入,从节点读取,可以验证复制集是否正常工作,需要注意的是,集群后从节点权限有所变化

// 从节点中,无法通过 shell 读取,需要先开启读权限
rs.slaveOk()

// 可以开始读操作了

常见命令

// 从节点中,无法通过 shell 读取,需要先开启读权限
rs.slaveOk()

// 锁定写入或者同步操作,不会再更新数据,相当于已经宕机
db.fsyncLock()
// 解锁写入或者同步操作,恢复后会通过 oplog 回放同步数据,需要确保 oplog 数据完整,存放了从锁定到解锁期间的全部数据
db.fsyncUnlock()

数据模型设计

模型设计需要考虑的元素:

  • 实体 ( Entity ): 描述业务的主要数据集合。比如 Person 就是一个抽象出来的实体。
  • 属性 ( Attribute ): 描述实体里面的单个信息。比如 name 就是 Person 实体的属性。
  • 关系 ( Relationship ): 描述实体与实体之间的数据规则。比如 School 与实体就是多对多的关系。

SQL 三大范式

  1. 确保所有字段为原子值,数据不可再分。
  2. 每个非主属性完全依赖于主键,不依赖于主键的内容拆分出去其他表。
  3. 每个非主属性不依赖于其他非主属性,同时被多个表依赖的单独抽一个表,通过外键、关联表联系。

MongoDB 数据模型

传统数据建模经过“概念模型” -> “逻辑模型” -> “物理模型”。

  • 在“概念模型”中,是对与用户需求的描述。
  • 在“逻辑模型”中,将用户需求拆分为可落地的逻辑。
  • 在“物理模型”中,考虑 SQL 设计范式,对数据按照范式建模。

MongoDB 可以直接跳过“物理模型”,直接对“逻辑模型”进行建模,因为 MongoDB 是允许冗余的,不需要原子性,也就没有额外的限制。

相对应的,在更新字段的时候,也需要考虑冗余字段一起更新。

MongoDB 模型设计

需要注意的是: 文档不能超过 16M ,如果嵌入后体积超了就必须进行拆分。

以联系人管理为例,进行建模。

建立基础模型

基础模型建立按照以下步骤:

  1. 找到需要存储的实体: 从“概念模型”中推导出“逻辑模型”。
  2. 明确实体间关系: 列出实体之间的关系。
  3. 确定数据内嵌方式: MongoDB 没有外键与关联表,转而通过数据冗余来处理,需要明确数据如何存放。

套用到联系人管理,即:

  • 对象: ContactsGroupsAddressAvatars
  • 关系:
    • Contacts - Avatars: 一对一,每个用户一个头像。
    • Contacts - Address: 一对多,每个用户可能有多个地址。
    • Contacts - Groups: 多对多,每个用户可以属于多个组,每个组也可以有多个用户。
  • 内嵌方式:
    • Avatars: 一对一直接整个对象作为子字段嵌入 Contacts 的数据结构即可。
    • Address: 一对多也是直接嵌入对象中,作为数组存在。
    • Groups: 作为数组内嵌对象中,用数据冗余实现多对多。

业务场景细化

明确以下需求,对模型进行调整:

  • 最频繁的数据查询模式
  • 最常用的查询参数
  • 最频繁的数据写入模式
  • 读写操作比例
  • 数据量大小

在明确业务需求后, 使用引用来避免性能瓶颈,使用冗余来优化访问性能

关联表

需求:

  • 联系人管理用于营销
  • 有千万级别的联系人信息
  • 分组会频繁修改,包括:新增分组、修改组名、修改分组描述、修改营销状态
  • 一个分组可能会有百万级别的联系人

分析:

按照之前的模型,将 Group 嵌入 Contacts 中的话,会导致数据冗余,也就是会影响写操作的性能。

当 分组名 修改时,将会是百万级别的更新,所有属于同一组的数据都需要更新。

可以将 Group 单独拆分为一个集合,通过 group_ids 关联,查找时使用 $lookup 查询多个集合

查询示例:

db.contacts.aggregate([
  {
    $lookup: {
      // 声明关联集合的名称
      from: "groups",
      // 使用本集合中哪个字段与关联集合匹配
      localField: "group_ids",
      // 使用关联集合的哪个字段与本集合匹配
      foreignField: "group_id",
      // 匹配后的 group 数据使用哪个键名储存
      as: "groups"
    }
  }
])

引用模式

需求:

  1. 用户上传高保真图片,大小为 5-10MB
  2. 上传头像一个月内不允许更换
  3. 不带头像查询与带头像查询的比例约为 9:1

分析:

由于头像本身不会被频繁读,并且内容很大,可以将其提取到单独的表中,使用引用的方式关联,让每次查询性能都能有较大的提升。

查询示例:

db.contacts.aggregate([
  {
    $lookup: {
      // 声明关联集合的名称
      from: "avatars",
      // 使用本集合中哪个字段与关联集合匹配
      localField: "avatar_id",
      // 使用关联集合的哪个字段与本集合匹配
      foreignField: "_id",
      // 匹配后的 group 数据使用哪个键名储存
      as: "avatar"
    }
  }
])

总结

需要拆分出来单独存储的情况:

  • 字段存储的数据较大,为 MB 级别或者超过 16MB
  • 内嵌对象或者数组会被频繁修改
  • 内嵌数组持续增长没有上限

$lookup 使用需要注意:

  • MongoDB 不会对 lookup 的集合进行主外键检查
  • 只支持 left outer join
  • 不能 from 分片表

模型设计模式

分桶设计

  • 场景: 记录时序数据,比如物联网、智慧城市、智慧交通
  • 痛点: 数据点采集频繁,数据量大
  • 方案: 通过内嵌文档,将碎片数据合并到一个实体中,减少索引大小与冗余数据占用

需求:

针对物联网场景下,海量无人机监控数据, 10w 架无人机,一年数据,每分钟记录一条。

{
  _id: '20160101050000:CA2790',
  icao: 'CA2790',
  callsign: 'CA2790',
  date: ISODate("2024-01-01T00:00:00.000+0000"),
  event: {
    a: 31428,
    b: 173,
    p: [115, 134],
    s: 91,
    v: 80
  }
}

记录的文档数: 100000 * 365 * 24 * 60 = 52560000000 ( 525.6 亿 ) 索引大小: 52560000000 * 130 = 6832800000000 ( 68328 亿 byte, 约 6364 GB ) 数据大小: 52560000000 * 92 = 4835520000000 ( 48355.2 亿 byte, 约 4503 GB )

也就是说,每年约 520 亿条数据,大小为 10TB

分桶设计的思路就是将数据聚合,每分钟数据聚合为每小时数据,也就是以下数据结构:

{
  _id: '20160101050000:CA2790',
  icao: 'CA2790',
  callsign: 'CA2790',
  events: [
    {
      a: 31428,
      b: 173,
      p: [115, 134],
      s: 91,
      v: 80,
      date: ISODate("2024-01-01T00:00:00.000+0000")
    },
    {
      a: 11111,
      b: 222,
      p: [-115, -134],
      s: 91,
      v: 80,
      date: ISODate("2024-01-01T00:01:00.000+0000")
    }
  ]
}

修改后记录的文档数: 100000 * 365 * 24 = 876000000 ( 8.76 亿 ) 修改后索引大小: 876000000 * 130 = 113880000000 ( 1138.8 亿 byte, 约 106 GB ) 修改后数据大小: 876000000 * 92 = 80592000000 ( 805.92 亿 byte, 约 618 GB )

总体体积从 10TB 减少为 724GB 。

由于绝大多数情况下不会单独读取每分钟数据,在读取全天数据时,反而更有好处( 1440 次读取降低至 24 次 )

列转行

  • 场景: 字段较多,每个字段都需要作为索引
  • 痛点: 索引体积很大
  • 方案: 关联字段合并为数组展示,这样索引数大幅降低

源数据结构如下:

{
  title: 'Dunkirk',
  release_USA: '2017/07/23',
  release_UK: '2017/08/01',
  release_France: '2017/08/01',
}

列转行后:

{
  title: '红色娘子军',
  release: [
    // 将列名转为 key 值
    {
      contry: 'USA',
      date: '2017/07/23'
    },
    {
      contry: 'UK',
      date: '2017/08/01',
    },
    {
      contry: 'France',
      date: '2017/08/01',
    }
  ]
}

版本字段

  • 场景: 数据结构频繁迭代,多种数据结构共存
  • 痛点: 不同版本的数据存到一起,难以维护
  • 方案: 添加版本号作为数据结构标识,针对不同数据版本做适配

近似计算

  • 场景: 统计站点访问数据、点击数据,写远大于读
  • 痛点: 频繁写,对 MongoDB 的读写分离模式不友好,压力全在 master 上
  • 方案: 近似计算,比如每次取随机数 (0, 9) ,只要等于 0 就更新,一次更新给数据 + 10 ,哪怕数据不准确也不是很重要,对于性能的需求远高于准确性

预聚合字段

  • 场景: 业绩排名、游戏排名、商品统计等精确统计
  • 痛点: 消耗资源多,聚合计算时间长
  • 方案: 额外存储统计数据,每次更新的时候,除了更新自身数据,再更新统计数据
{
  product: 'milk',
  sku: 'xxx-yyy-zzz-123456',
  quantitiy: 12345,
  // 统计的日销售额
  daily_sales: 40,
  // 统计的周销售额
  weekly_sales: 40,
  // 统计的月销售额
  monthly_sales: 40,
}

在销售的时候如下更新:

db.inventory.update(
  { _id: '123' },
  {
    $inc: {
      // 库存 -1
      quantity: -1,
      // 统计字段 + 1
      daily_sales: 1,
      weekly_sales: 1,
      monthly_sales: 1
    }
  }
)

事务

写操作事务

writeConcern

writeConcern 决定需要写入多少节点,才算写成功,发起写操作的程序会被阻塞,直到写成功。

  • 0: 发起写操作, master 写入就算写成功
  • 1-最大节点数: 需要被复制多少份,才能算写成功
  • majority: 复制到超过半数节点上,才算写成功
  • all: 写入全部节点,才算写成功

配置为 0 时可能会丢数据,在需要高性能低可靠性的场景才会使用,丢失数据的链路如下:

  1. 写入 master ,返回成功。
  2. master 挂起,剩下节点均未复制到该写操作。

配置为 1-最大节点数 时,由于 MongoDB 可能会动态扩容或者节点崩溃。

写死结点数并不是一个比较好的选择,可能由于节点挂起导致永远无法成功,也可能因为扩容后大多数节点未同步,导致数据丢失,一般不推荐设置。

配置为 majority 可以确保大多数节点均写入数据,但是对应的程序执行写操作的时间会变长。

配置为 all 可以绝对确保数据不丢失,但是对应的程序执行写操作的时间会变长,可能会成为响应瓶颈。

需要与 slaveDelay 综合考虑,避免因为手动限制延迟导致写操作时间延长

journal

MongoDB 的写操作分为两部分:

  • 写入内存
  • 写入磁盘

为了避免频繁磁盘 I/O ,写操作会先保存到内存中,在等到一定数量或者超时时间后,才会写入磁盘中持久化存储,一旦持久化存储了,即使宕机也不会丢失数据。

journal ,可以配置在何时确认写操作完成:

  • true: 必须写入磁盘才算完成。
  • false: 写入内存就算完成。

注意事项

  • 一般配置为 majority 即可,兼顾性能与一致性。
  • 尽量避免写死节点数,即使配置也不要配置全部节点,避免单点挂起导致所有写操作都失败。
  • writeConcern 只是增加了程序写操作的时间,而不会增加 MongoDB 的写操作负载
  • 普通数据可以直接配置 { w: 1 } 即可,重要数据需要 { w: 'majority' }

读操作事务

由于 MongoDB 的分布式特性,读取数据会面临以下问题:

  • 从哪个节点读?
  • 什么数据可以读?

对应的可以通过以下配置来解决:

  • readPreference
  • readConcern

readPreference

readPreference 决定了使用哪个节点进行读操作,可选值如下:

  • primary: 只选择主节点,适用于实时性要求极高的操作,只接受最新数据
  • primaryPreference: 优先选择主节点,主节点不可用则读取从节点,适用于实时性要求较高的操作,偏好最新数据
  • secondary: 只选择从节点,适用于实时性要求一般的操作,可以接受非最新数据
  • secondaryPreference: 优先选择从节点,从节点不可用则读取主节点,适用于实时性要求一般的操作,可以接受非最新数据
  • nesrest: 选择最近的节点,分布式异地集群考虑配置。
  • ${tag}: 根据节点 Tag 选择节点,可以通过给节点打 Tag 实现操作分离,需要大量读取数据的分发到性能较好的节点,普通操作可以直接使用次一级的节点,或者按照业务区分,避免业务互相影响。

readPerference 支持在初始化配置,在读之前配置等不同的配置方式:

  • 通过MongoDB的连接串参数: mongodb://host1:27107,host2:27107,h0st3:27017/?replicaSet=rs&readPreference=secondary
  • 通过MongoDB驱动程序APl: MongoCollection.withReadPreference(ReadPreference readPref)
  • Mongo Shell: db.collection.find(0).readPref("secondary") // 不管当前是什么节点,都从从节点读

注意事项:

  • 尽量使用 primaryPreference 替代 primary ,避免主节点挂起,还在选举中的时候,无节点可读。
  • 使用 Tag 需要考虑高可用问题,尽量为每个 Tag 配置多个节点
  • Tag 需要与选举权、优先级综合考虑,一般有特定功能的 Tag 不希望成为主节点,设置优先级为 0

readConcern

readPreference 选择了指定的节点后, readConcern 决定这个节点上的数据哪些是可读的,类似于关系数据库的隔离级别。可选值包括:

  • available: 读取所有可用的数据
  • local: 读取所有可用且属于当前分片的数据
  • majority: 读取在大多数节点上提交完成的数据
  • linearizable: 可线性化读取文档
  • snapshot: 读取最近快照中的数据

availablelocal

在复制集中, availablelocal 无区别,在分片集中才有区别,也就是历史数据迁移到其他 MongoDB 中存储。

分片集中存在数据所有权概念,即当前数据属于哪个 MongoDB , available 不考虑所有权问题, local 要求所有权必须是自己。

在数据迁移分片的场景下,数据迁移过程中,所有权未变更,所以 availablelocal 就会表现不一致了。

注意事项:

  • local 看起来更加可靠,但是每次读取都需要对数据进行过滤,在一些不太重要的场景下可以考虑切换为 available
  • MongoDB <= 3.6 不支持从节点配置为 local
  • 从主节点读取数据时,默认为 local ,从从节点读取数据时,为了向前兼容,默认为 available

majority

只有大多数节点都 复制 了这条数据,这里的大多数是按照节点认知的大多数来确定的。

写操作到达大多数节点之前都是不安全的,一旦主节点崩溃,而从节还没复制到该操作,刚才的写操作就丢失了;把一次写操作视为一个事务,从事务的角度,可以认为事务被回滚了。

通过 majority 配置可以避免脏读问题,等同于 SQL 中的 Read Committed

majority 判断逻辑如下:

  1. 如果自身节点写入,但是大多数节点未写入,返回历史数据
  2. 如果自身节点写入,大多数节点写入,但是自身节点未收到其他节点的写入完成消息,返回历史数据
  3. 自身节点写入,且收到大多数节点的写入消息,返回新数据

MongoDB 会维护多个版本的数据快照,通过 MVCC 机制 来实现这套机制

开启 majority 需要在配置节点时就开启:

replication:
  replSetName: rs0
  enableMajorityReadConcern: true

linearizable

主节点失联,选举出新主节点,新主节点写入,此时读取老的主节点,由于通信断开,读取的还是老的数据,也就是脏数据, majority 无法避免这种情况。

此时需要使用 linearizable ,在读取的时候会与多数节点确认数据有效性,都认可数据有效了才会返回新数据。

该配置会导致读取效率降低,建议配合 maxTimeMS 设置超时时间,避免长时间阻塞。

snapshot

最高级别一致性,操作完成前,不能读到新数据,可以达到 SQL 中的 Repeatable Read

db.tx.insertMany([
  { x: 1 },
  { x: 2 },
])
var session = db.getMongo().startSession()
session.startTransaction({
  // 设置读操作隔离性为最高级别
  readConcern: { level: 'snapshot' },
  // 设置写操作隔离性为 大多数 写入
  writeConcern: { w: 'majority' },
})
var coll = session.getDatabase('test').getCollection('tx')
// 事务内查看数据
coll.find({ x: 1 }) // { x: 1 }
// 事务外更新数据
db.tx.updateOne({ x: 1 }, { $set: { y: 1 } })
// 事务外查看数据
db.tx.find({ x: 1 }) // { x: 1, y: 1 }
// 事务内查看数据
coll.find({ x: 1 }) // { x: 1 }
// 回滚数据
session.abortTransaction()

事务开发

支持情况

事务属性支持程度
Atomocity 原子性单表文档: 1.x 支持
复制集多表多行: 4.0 支持
分片集群多表多行: 4.2 支持
Consistency 一致性writeConcernreadConcern 3.2 支持
Isolation 隔离性readConcern 3.2 支持
Durability 持久性Journal 与 Replication

使用方法

db.tx.insertMany([
  { x: 1 },
  { x: 2 },
])
var session = db.getMongo().startSession()
session.startTransaction()
var coll = session.getDatabase('test').getCollection('tx')
// 事务内更新数据
coll.updateOne({ x: 1 }, { $set: { y: 1 } })
// 事务内查看数据
coll.find({ x: 1 }) // { x: 1, y: 1 }
// 事务外查看数据
db.tx.find({ x: 1 }) // { x: 1 }
// 回滚数据
session.abortTransaction()

事务写机制

当一个事务开始后,如果事务要修改的文档在事务外部被修改过,则事务修改这个文档时会触发 Abort 错误,因为此时的修改冲突了。

这种情况下,需要将事务 abort 更新 timeline ,再重新开启事务。

如果一个事务已经开始修改一个文档,在事务以外尝试修改同一个文档,则事务以外的修改会等待事务完成才能继续进行。

注意事项

  • 保证 MongoDB 版本大于 4.2 ,才能拥有完整的事务能力。
  • 事务默认 60s 超时,超时的事务会被自动取消。
  • 涉及事务分片不能使用仲裁节点。
  • 事务会有额外性能开销,会影响 chunk 迁移效率, chunk 迁移也可能导致事务失效。
  • 多文档事务中,必须使用主节点读取。
  • readConcern 只应该在事务级别设置,不能设置在每次读写操作上。

MongoDB 最佳实践

连接 MongoDB

连接字符串中尽可能多的提供节点地址,建议全部列出,可以更有效的发现节点,完成集群,优先使用域名替代 IP

  • 连接到复制集: mongodb://节点1,节点2/database?[options]
  • 连接到分片集: mongodb://mongos1,mongos2/database?[options]

常见的连接 options 参数:

  • maxPoolSize: 连接池大小
  • maxWaitTime: 操作的最长等待时间,超出时间会被杀掉
  • writeConcern: 建议为 majority ,保障数据安全
  • readConcern: 在数据一致性要求高的情况下使用。

负载均衡

不建议在 MongoDB 之前实现负载均衡, MongoDB 本身就有负载均衡的能力。

如果提前进行负载均衡,可能有以下问题:

  • 驱动无法探测节点存活,无法自动故障恢复
  • 驱动无法判断游标在哪个节点创建,导致遍历游标错误。

游标

如果一个游标已经遍历完,则会自动关闭;如果没有遍历完,则需要手动调用 close() 方法,否则该游标将在服务器上存在 10 分钟(默认值)后超时释放,造成不必要的资源浪费。

但是,如果不能遍历完一个游标,通常意味着查询条件太宽泛,更应该考虑的问题是如何将条件收紧。

查询与索引

MongoDB 中没有进行资源隔离,如果某个操作缓慢,会影响所有的操作。

  • 每一个查询都必须要有对应的索引
  • 尽量使用覆盖索引 Covered Indexes
  • 使用 projection 来减少返回到客户端的的文档的内容

写入

  • 在 update 语句中只包含需要更新的字段
  • 尽可能使用批量插入提高写入性能
  • 使用 TTL 自动过期日志类型的数据

文档结构

  • 防止字段名过长,占用存储空间
  • 防止嵌套过深,超过 2 层就比较难以操作了
  • 不要使用中文、标点符号等非拉丁字母作为字段名。

分页查询

  • 避免使用 count 查询总页数, count 会遍历所有数据,拖慢查询速度
  • 数据量大的时候,可以通过末尾元素 id 过滤的方式提高查询效率: db.posts.find({ _id: { $gt: '上一页最后一个 _id' } }).sort({ _id: 1 }).limit(20)

事务

  • 能不用就不用,事务本身就有额外开销,尽量通过模型设计规避事务
  • 不要使用过大的事务,避免超时
  • 必须使用时尽量让文档处在同一分片上

分片集群

集群架构

分片架构将数据分为不同的 chunk ,每个 chunk 交给一套复制集管理,这样可以有效分离读写压力,降低负载,也允许横向扩展。

分片集群的架构如下:

graph TB
  subgraph Business[业务层]
    App[Application]
    Driver[MongoDB 驱动]

    App --> Driver
  end

  subgraph MongoS
    MongoS1[主 MongoS]
    MongoS2[备用 MongoS 1]
    MongoS3[备用 MongoS 2]
  end

  subgraph Config
    Config1[主 Config]
    Config2[从 Config 1]
    Config3[从 Config 2]
  end

  Driver --> MongoS
  Config --> MongoS
  MongoS --> Config

  subgraph Mongo1[Shard 1]
    Mongo1-master[主节点]
    Mongo1-slave1[从节点 1]
    Mongo1-slave2[从节点 2]
  end

  subgraph Mongo2[Shard 2]
    Mongo2-master[主节点]
    Mongo2-slave1[从节点 1]
    Mongo2-slave2[从节点 2]
  end

  subgraph Mongo3[Shard 3]
    Mongo3-master[主节点]
    Mongo3-slave1[从节点 1]
    Mongo3-slave2[从节点 2]
  end

  MongoS --> Mongo1
  MongoS --> Mongo2
  MongoS --> Mongo3

MongoS 是路由节点,提供集群的统一入口,将业务层请求分发到合适数据节点中操作,并将各个数据节点的响应结果合并。

由于是入口文件,为了防止集群崩溃,一般至少准备一台备用 MongoS

Config 是配置节点,提供汲取元数据存储、分片数据分布的映射等,提供比如数据存储路径、不同 shared 管理哪些范围的数据等等,一般是复制集架构即可。

Shard 为数据节点,以复制集为单位,可以横向扩展,数据节点最多 1024 个,所有数据节点之间的数据不重复。

分片方式

MongoDB 提供三种数据分布方式:

  • 基于范围
  • 基于 Hash
  • 基于 zone / tag

基于范围

挑选部分字段,按照字段取值进行分片

  • 优点:
    • 查询性能好
    • 查询相近数据基本在同一节点,速度快,比如按时间分片,按时间段查询。
  • 缺点:
    • 数据分布不均匀
    • 容易有热点(某个分片频繁读写,其他分片闲置)

基于 Hash

挑选部分字段,按照字段值进行 Hash ,按照 Hash 结果进行分片

  • 优点:
    • 数据分布均匀
    • 写数据可以分散到多个分片,效率高
  • 缺点:
    • 范围查询效率较低

基于 zone / tag

按照节点地域、节点标签分片

总结

分片架构可以有效解决性能问题与扩容问题,但是有大量额外资源开销,管理复杂,非必要不使用( MongoDB 官方统计数据,约 10% 会使用分片集群 )。

分片架构设计

分片基础标准:

  • 数据量不要超过 3TB ,尽可能保持在 2TB 一个片
  • 索引必须能放到内存中

分片估算:

  • 存储: 所有的存储总量 / 单服务器负载容量
  • 内存: 工作集大小 / (单服务器内存容量 * 0.6)
  • 并发: 总并发数 / (单服务器并发数 * 0.7)

三者取最大值作为分片数量

分片概念

各种概念由小到大:

  • 片键 shard key: 文档中的一个字段,用于决定数据存储到哪个分片中
  • 文档 doc: 包含 shard key 的一行数据
  • 块 Chunk: 包含 n 个文档;
  • 分片 Shard: 包含 n 个 chunk
  • 集群 Cluster: 包含 n 个分片

影响分片性能的主要因素

分片字段:

  • 分片字段取值范围较大: 过小会导致数据集中,难以扩展
  • 分片字段取值均匀: 尽量让不同分片的数据均匀分布,避免单独分片压力大
  • 使用片键作为查询条件: 帮助 MongoS 快速定位需要查询哪个分片数据,否则 MongoS 需要查询每个分片数据

总结: 一般单独片键很难兼顾上述三个条件,所以会选用组合片键,比如 user_id + time 作为分片字段

读写:

  • 分散写,集中读: 写尽量分散到不同分片中,避免复制集 master 过载;读尽量在同一分片中,减少数据读取、数据合并带来的开销

资源:

  • mongos 与 config 可以使用较低配置,但是 Shard 需要使用较高配置
  • 由于扩容也需要时间,比如数据迁移,重新分片等等,最好在资源负载 60% 就先开启扩容,避免影响业务

分片命令

创建分片复制集集

需要在同一分片中的设备都执行一次

# 监听 IP
mongod --bind_ip 0.0.0.0 \
  # 分片集名称
  --replSet shard1 \
  # 分片集数据存储路径
  --dbpath /data/shard1 \
  # 分片集日志存储路径
  --logpath /data/shard1/mongod.log \
  # 监听的端口
  --port 27010 \
  # 后台运行
  --fork \
  # 表明为分片集节点
  --shardsvr \
  # 指定 MongoDB 缓存大小,按需配置
  --wiredTigerCacheSizeGB 1

初始化分片集中的复制集

通过 mongosh 连接到复制集任意节点,执行初始化即可,初始化后会自动进行选举

rs.initiate({
  _id: 'shard1',
  members: [
    {
      _id: 0,
      host: 'member.he110.site:4395'
    },
    {
      _id: 1,
      host: 'member.he110.site:4396'
    },
    {
      _id: 2,
      host: 'member.he110.site:4397'
    },
  ]
})

创建 Config

需要在同一分片中的设备都执行一次

# 监听 IP
mongod --bind_ip 0.0.0.0 \
  # 分片集名称
  --replSet config \
  # 分片集数据存储路径
  --dbpath /data/config \
  # 分片集日志存储路径
  --logpath /data/config/mongod.log \
  # 监听的端口
  --port 27019 \
  # 后台运行
  --fork \
  # 表明为分片集配置节点
  configsvr \
  # 指定 MongoDB 缓存大小,按需配置
  --wiredTigerCacheSizeGB 1

初始化 Config 中的复制集

通过 mongosh 连接到 Config 任意节点,执行初始化即可,初始化后会自动进行选举

rs.initiate({
  _id: 'config',
  members: [
    {
      _id: 0,
      host: 'config.he110.site:4395'
    },
    {
      _id: 1,
      host: 'config.he110.site:4396'
    },
    {
      _id: 2,
      host: 'config.he110.site:4397'
    },
  ]
})

创建 MongoS

需要在同一 MongoS 集群中的设备都执行一次

# 监听 IP
mongos --bind_ip 0.0.0.0 \
  # 分片集日志存储路径
  --logpath /data/shard1/mongod.log \
  # 监听的端口
  --port 27010 \
  # 后台运行
  --fork \
  # 指定配置节点
  --configdb config/config.he110.site:4395,config.he110.site:4396,config.he110.site:4397

添加分片

通过 mongosh 连接到 MongoS 主节点,执行以下命令

sh.addShard("shard1/member.he110.site:4395,member.he110.site:4396,member.he110.site:4397")

创建分片集合

通过 mongosh 连接到 MongoS 主节点,执行以下命令

// 开启分片功能
sh.enableSharding('${db}')
// 创建分片集
sh.shardCollection(
  // 哪个集合需要开启分片
  '${db}.${collection}',
  // 创建片键,支持多个字段
  {
    // 使用 id 作为片段,分片方式为 hash
    _id: 'hashed',
  },
)

扩容

创建分片与前面一样,不需要再创建 Config 与 MongoS

创建完成后,连接到 MongoS ,将分片 2 通过 sh.addShard 添加进去即可, MongoS 会自动完成数据迁移

运维

性能监控

监控工具

进行性能监控常见工具有:

  • MongoDB Ops Manager: 官方监控,企业版收费
  • Percona
  • 通用的服务器监控平台
  • 程序脚本

监控数据来源

  • db.serverStatus(): 开机到现在的累计数据,主要的监控数据来源
  • db.isMaster(): 次要数据来源
  • mongostat: 命令行工具,只能查到一部分信息

serverStatus() 主要关注以下信息:

  • connections: 连接数信息
  • locks: 使用锁的情况
  • network: 网络 I/O 情况
  • opcounters: CURD 执行次数统计
  • repl: 复制集的配置信息
  • wiredTiger: 包含 WirdTiger 执行情况信息
    • block-manager: WT 数据块的读写情况
    • session: session 使用数量
    • concurrentTransactions: Ticket 使用情况
  • mem: 内存使用情况
  • mertrics: 性能指标统计信息

告警指标

指标意义获取方法
opcounters
(操作计数器)
统计查询、更新、插入、删除、 getmore 等命令的数量db.serverStatus().opcounters
tickets
(令牌)
WiredTiger 存储引擎的读/写令牌数量,表示可以进入存储引擎的并发操作数db.serverStatus().wiredTiger.concurrentTransactions
replication lag
(复制延迟)
写操作到达从结点所需的最小时间。过高的延迟会降低从结点价值,且不利于配置了写关注( w > 1 )的操作db.adminCommand({'replSetGetStatus': 1})
​​oplog window
(复制时间窗)​​
代表 oplog 可容纳写操作的时间范围,从结点离线后仍能追上主节点的最大时长(建议保持 24 小时以上)db.oplog.rs.find().sort({$natural:-1}).limit(1).next().ts - db.oplog.rs.find().sort({$natural:1}).limit(1).next().ts
​​connections
(连接数)​​
监控数据库连接数,每个连接消耗资源,需统计低峰/高峰时段的连接数并设置报警阈值db.serverStatus().connections
​​Query targeting
(查询专注度)​​
计算每秒扫描的 索引键/文档数量 与 返回文档数 的比值,值高表示查询效率低(可能缺少索引或索引不当)var status = db.serverStatus()
status.metrics.queryExecutor.scanned / status.metrics.document.returned
status.metrics.queryExecutor.scannedObjects / status.metrics.document.returned
​Scan and Order
(扫描和排序)​​
每秒内存排序操作平均比例,内存排序可能消耗大量资源,通过索引可避免var status = db.serverStatus();
status.metrics.queryExecutor.scanAndOrder / status.opcounters.query
​​节点状态​​监控各节点状态( PRIMARY / SECONDARY / ARBITER ),非正常状态或命令执行失败时报警db.runCommand("isMaster")db.isMaster()
​dataSize
(数据大小)​​
实例数据总量(压缩前原始大小)逐个数据库执行:db.stats().dataSize
​StorageSize
(磁盘空间大小)​​
已用磁盘空间占总空间的百分比,需监控存储压力通过文件系统工具计算,或使用 db.stats().storageSize 结合磁盘总容量推算

备份与恢复

备份的目的

  • 防止硬件故障导致数据丢失
  • 防止认为误删数据
  • 时间回溯
  • 监管要求

备份机制

  • 延迟节点备份: 设置子节点 slaveDelay 并且不参与选举,将节点作为备份节点
  • 全量备份 + oplog 增量: 初始数据全量备份,通过 oplog 可以回放数据操作,实现任意时间点的数据还原。为了防止 oplog 过大,可以定期更正初始数据为较新状态,裁剪过期 oplog

全量备份方式

  • 复制数据文件
    • 必须先关闭节点,才能复制,否则复制的文件无效
    • 可以通过 db.fsyncLock() 锁定节点备份,通过 db.fsyncUnlock() 解锁
    • 在从节点完成备份
    • 注意节点被锁定后,投票节点总数需要保持奇数
  • 文件系统快照
    • MongoDB 支持使用快照获取某一时刻的镜像
    • 快照过程可以不用停机
    • 数据文件和 journal 需要在同一个卷上
    • 尽快复制并删除快照,避免磁盘占用打满
  • mongodump
    • 备份速度最慢
    • 只能备份某个时间段的数据
    • oplog 需要从开始备份的时候记录,处理不停机备份导致的脏数据问题(备份过程中数据被修改)

mongodump 命令

# 开始备份,并记录 oplog
mongodump --host localhost:27017 --oplog

# 恢复数据,并通过 oplog 回放保证数据一致性
mongorestore --host localhost:27017 --oplogReplay

mongodump 备份的目录结构

  • dump: 备份文件目录
    • db: 备份的数据库
      • collection.bson: 备份的集合数据
      • collection.metadata.bson: 备份的集合元数据
    • oplog.bson: 备份期间的 oplog

安全架构

认证

用户认证:

认证方式描述备注
​用户名 + 密码​默认认证方式,使用 SCRAM-SHA-1 哈希算法,用户信息存于 MongoDB 本地数据库
​证书方式​基于 X.509 标准: 服务端需提供证书文件启动 、 客户端需证书文件连接 、 支持内部或外部 CA 颁发的证书
​LDAP外部认证​连接到外部 LDAP 服务器进行认证企业版功能
​Kerberos外部认证​连接到外部 Kerberos 服务器进行认证企业版功能

集群节点认证:

  • Keyfile: 将同一 Keyfile 拷贝到不同的节点
  • X.509: 基于证书认证,推荐不同节点使用不同证书

创建管理员账号:

// 跳转到 admin 集合
use admin;

// 创建用户 admin ,密码为 123456 ,拥有管理所有用户、数据库、读写权限
db.createUser({
  user: "admin",
  pwd: "123456",
  roles: [
    { role: "userAdminAnyDatabase", db: "admin" },
    { role: "dbAdminAnyDatabase", db: "admin" },
    { role: "readWriteAnyDatabase", db: "admin" }
  ]
})

使用账号登录

mongosh \
  # 用户名,可以简写为 -u
  --username admin \
  # 密码,可以简写为 -p
  --password abc123456 \
  # 需要操作的数据库
  --authenticationDatabase admin

鉴权

基于角色的权限控制,通过 db.getRole('角色名') 获取角色信息,通过 db.getRole('角色名', { showPrivileges: true }) 获取角色以及权限信息,权限保存在 privileges.actions 字段中

graph TD
  classDef app fill:#ffd6e7,stroke:#ff69b4
  classDef db fill:#e6f4ff,stroke:#1890ff
  classDef backup fill:#b3e0ff,stroke:#096dd9
  classDef root fill:#f0f0f0,stroke:#666

  %% 权限关系图
  root("root<br>(超级用户角色)")

  subgraph 全局管理
    readAnyDB["readAnyDatabase"]
    writeAnyDB["readWriteAnyDatabase"]
    adminAnyDB["dbAdminAnyDatabase"]
    userAnyDB["userAdminAnyDatabase"]
  end

  root --> readAnyDB["readAnyDatabase"]
  root --> writeAnyDB["readWriteAnyDatabase"]
  root --> adminAnyDB["dbAdminAnyDatabase"]
  root --> userAnyDB["userAdminAnyDatabase"]

  subgraph 备份恢复
    backup
    restore
  end
  root --> restore

  subgraph 数据库管理
    dbOwner --> dbAdmin
    dbOwner --> userAdmin
  end

  subgraph 集群管理
    ClusterAdmin --> hostManager
    ClusterAdmin --> clusterManager
    ClusterAdmin --> clusterMonitor
  end
  root --> ClusterAdmin

  subgraph 应用程序用户
    readWrite --> read
  end

  %% 权限继承关系
  readAnyDB --> read
  writeAnyDB --> readWrite
  adminAnyDB --> dbAdmin
  userAnyDB --> userAdmin

  %% 样式绑定
  class root root
  class GlobalAdmin,readAnyDB,writeAnyDB,adminAnyDB,userAnyDB db
  class BackupRestore,backup,restore backup
  class DBAdmin,dbAdmin,userAdmin,dbOwner db
  class ClusterAdmin,hostManager,clusterManager,clusterMonitor db
  class AppUser,read,readWrite app

创建角色:

db.createRole({
  role: 'sampleRole',
  // 声明角色具有的权限
  privileges: [
    {
      // 允许操作 test.sample
      resource: {
        db: 'test',
        collection: 'sample'
      },
      // 允许进行更新
      actions: ['update']
    },
  ],
  // 继承其他角色的权限
  roles: [
    // 继承 read 角色的读权限,允许读取 test 数据库
    {
      role: 'read',
      db: 'test',
    },
  ],
})

加密

  • 通信加密: MongoDB 允许通过 TLS/SSL 加密所有网络传输,包括客户端与服务端、内部复制集之间
  • 存储加密: 企业版 MongoDB 支持在持久化到磁盘时,对数据进行加密,具体流程为如下
    • 生成 master key ,用来生成每一个数据库的 key
    • 每一个数据库的 key ,用来加密数据库
    • 基于生成的数据库 key ,加密数据库中的数据
    • 管理时只需要关注 master key 即可,数据库 key 保存在数据库内部
  • 字段加密: 单独字段可以通过自身密钥加密,数据库中只能看到密文,全程由 MongoDB 自动操作,具体流程如下
    1. 请求 MongoDB 驱动加密字段
    2. MongoDB 驱动查询密钥管理器
    3. 密钥管理器返回密钥
    4. MongoDB 驱动查询数据库
    5. 数据库返回加密后的数据
    6. 使用密钥解密数据,返回给用户

审计

审计功能可以针对特定的行为进行记录,必须购买企业版才支持

  • 格式: json
  • 存储: 本地文件或者 syslog
  • 内容:
    • Schema change ( DDL )
    • CURD (DML)
    • 用户认证

# 监听 IP
mongod --bind_ip 0.0.0.0 \
  # 分片集数据存储路径
  --dbpath /data/config \
  # 分片集日志存储路径
  --logpath /data/config/mongod.log \
  # 监听的端口
  --port 27019 \
  # 后台运行
  --fork \
  # 审计日志记录到 syslog 另一个可选值为 file
  --auditDestination syslog \
  # 存储的审计文件格式,配置为 syslog 后不需要配置
  --auditFormat JSON \
  # 配置审计文件存放路径,配置为 syslog 后不需要配置
  --auditPath /data/audit \
  # 审计文件内容,这里是审计创建集合与删除集合
  --auditFilter '{ atype: { $in: [ "createCollection", "dropCollection" ] } }'

最佳实践

默认情况下 MongoDB 没有安全设置,也没有鉴权,需要手动创建用户并且关闭

  • 启用身份认证
  • 严格权限控制
  • 加密传输、加密数据、活动审计
  • 内网部署服务器,并部署防火墙、关闭 http 访问、关闭 restful 访问、绑定 IP 、替换默认端口等等
  • 遵循当地合规需求

索引机制

术语表

  • Index: 索引。通常为某条记录的字段。

  • Key: 键名。每条记录所有的字段都是键名。

  • Data Page: 数据页。存储记录的地方。

  • Covered Query: 查询覆盖。指查询的所有字段都在索引中,不需要从数据页中加载数据。

  • FETCH: 抓取。指按照索引,在数据页中查询对应的记录。

  • IXSCAN: 索引扫描。检索索引。

  • COLLSCAN: 集合扫描。检索数据页。

  • Query Shape: 查询条件的数据结构。不同的查询条件对查询性能也有影响。

  • Index Prefix: 索引前缀。指多个键名索引时,所有的查询前缀都会被索引,不需要再添加索引了,比如 { a: 1, b: 1, c: 1 } ,会自动添加 { a: 1, b: 1 } 已经 { a: 1 } 的索引

  • Selectivity: 过滤性。优先使用能过滤绝大多数数据的条件进行查询。

索引存储结构

索引存储使用 B- 树 , 一般 B 树是二叉树, B- 树可以是多叉树

索引的执行

查询条件命中多个索引时,会通过多线程查询几条记录,哪个查的快用哪个,所以可能会使用慢速索引(索引对应的数据较多)

graph TB
  classDef orange fill:#FFA500,stroke:#FF4500,color:white
  classDef green fill:#98FB98,stroke:#32CD32
  classDef diamond fill:#FFD700,stroke:#DAA520

  %% 流程节点
  start[("查询语句")]:::orange --> match{匹配计划缓存}

  %% 匹配成功分支
  match -- Y --> evaluate{评估计划性能}:::diamond
  evaluate -- Y --> execute["按执行计划执行"]:::green
  execute --> result["返回结果"]:::green

  %% 评估不成功分支
  evaluate -- N --> evict1["驱逐计划缓存"]:::green
  evict1 --> generate1["生成候选计划"]:::green
  generate1 --> assess1["评估候选计划"]:::green
  assess1 --> select1["选择最优计划"]:::green
  select1 --> create1["创建计划缓存"]:::green
  create1 --> execute

  %% 匹配失败分支
  match -- N --> generate2["生成候选计划"]:::green
  generate2 --> assess2["评估候选计划"]:::green
  assess2 --> select2["选择最优计划"]:::green
  select2 --> create2["创建计划缓存"]:::green
  create2 --> execute

查看执行过程

可以在查询语句后面追加 explain ,要求查看检索过程: db.col.find({ name: '张三' }).explain(true)

重点关注:

  • executionStages.stage: 是 COLLSCAN 还是 IXSCAN ,也就是有没有用到索引
  • totalDocsExaminednReturned: 查询了 totalDocsExamined 条文档能返回 nReturned 条数据
  • totalKeysExamined: 查询了多少 key
  • executuionTimeMillis: 执行了多少毫秒

查询条件应该遵循 ESR 原则,使用: 精确匹配 > 排序条件 > 范围匹配 的顺序排列索引字段,如果字段无索引,放到最后面

索引类型

  • 单键索引
  • 组合索引
  • 多值索引
  • 地理位置索引
  • 全文索引
  • TTL 索引
  • 部分索引
  • 哈希索引

读写性能

驱动层

整体驱动工作流程图如下:

MongoDB 驱动工作流程

选择节点

对于复制集读操作,选择哪些节点是由 readPreference 决定的,如果不希望选中远距离节点,应该做到以下其中一条:

  • 设置为隐藏节点
  • 通过 Tag 控制候选节点
  • 使用 nearest 就近选择节点

排队等待

总连接数大于最大连接数 ( maxPoolSize ) 就会发生排队等待,也就是阻塞情况。

可以采用以下方式解决:

  • 加大最大连接数(受限于服务器性能,不一定有用)
  • 优化操作性能,缩短操作时间

连接与认证

认证比不认证需要多消耗一些时间,但是认证才能保证安全,为了优化连接速度,可以采用以下方式:

  • 设置最小连接数 ( minPoolSize ) ,在连接池中保持一定数量的空闲连接,可以直接复用不需要额外创建
  • 在业务处理时,避免突发的大量请求

数据库层

整体数据库层工作流程图如下:

MongoDB 数据库工作流程

排队等待

ticket 不足导致数据库查询排队,一般是其他操作性能迟缓的问题,解决方案如下:

  • 通过 性能监控 找到操作迟缓的命令,使用 explain 排查问题,并优化操作性能
  • zlib 压缩本身也会耗时,可能是由于开启了压缩导致的性能迟缓

读请求

读请求迟缓的主要原因是由于索引导致的,可以参考 索引的执行 章节提到的索引工作过程,结合 explain 排查问题,优化索引。

写操作

写操作也需要利用索引找到需要修改的位置,如果写操作迟缓,也可以通过 explain 定位问题点,看是不是索引问题导致的。

写数据需要利用 WT 缓存, WT 缓存在写入磁盘后失效,如果磁盘 I/O 性能低,缓存来不及被消费,也会导致写操作迟缓。

分片结果合并

  • 避免在 MongoDB 中进行排序操作,减少数据库查询时间
  • 尽可能使用带片键的查询条件,明确查询哪个分片,而不是都查一遍

网络传输

  • 网络延迟
  • 集群间开启 TLS/SSL 也影响传输性能

性能诊断

mongostat

统计同时发生了哪些操作,需要重点关注以下字段:

  • dirty: 内存更新了,但是没有持久化的数据,占比多少。低于 5% 时正常,超过 5% MongoDB 会加速写入, 超过 20% 时会阻塞新请求 ,全力写入磁盘。
  • used: 分配给 MongoDB 的内存占用了多少。低于 80% 时正常,超过 80% MongoDB 会加速清理内存, 超过 95% 时会阻塞新请求 ,全力清理内存空间。
  • qrw / arw: 排队的请求。
  • conn: 连接数。

mongotop

了解集合压力状态的工具。

可以查询每个集合的 总时间 / 读时间 / 写时间。

mongod 日志

mongod 日志会记录执行超过 100ms 的查询以及 查询计划

mtools

开源 的 Python 工具,通过 pip install mtools 安装,常见命令如下:

常见数据库集群设计方案

两地三中心集群设计

容灾级别

等级灾难恢复策略RPO(恢复点目标)RTO(恢复时间目标)实现方式与说明
L0无备源中心24小时4小时仅本地数据备份,无灾难恢复能力
L1本地备份+异地保存24小时8小时本地备份关键数据后送异地保存,需按预定程序恢复系统和数据
L2双中心主备模式秒级数分钟到半小时异地建立热备份点,通过网络备份数据;灾难时备份站点接替主站点业务
L3双中心双活秒级秒级两地建立相互备份的数据中心,任一中心故障时另一中心接管工作
L4双中心双活+异地热备(两地三中心)秒级分钟级同城双活中心+异地热备,当同城两中心同时不可用时快速切换至异地中心

常规两地三中心方案

网络层设计

DNS 服务器需要使用 GSLB 全局负载均衡(带 DNS 功能以及健康检查功能),在中心失活后 GSLB 自动切换为另一个中心。

应用层设计

  • 使用负载均衡、虚拟 IP
  • 使用同一个 Session
  • 使用同一套设计

数据库设计

主要考虑跨中心同步问题

  • DBMS 采用基于日志的同步方案
  • 文件系统采用基于存储镜像的同步方案

MongoDB 两地三中心方案

三中心即为:同城双中心 + 异地中心

同城双中心都需要有双节点,异地中心单节点,也就是至少 5 节点。

主数据中心优先级最高,其次是同城备用中心,再次是跨城中心。

writeConcern 需要使用 majority

同城双中心需要使用低延迟专线,让数据同步时延降低,避免丢失数据。

完整的搭建步骤如下:

  • 配置域名解析
  • 安装 MongoDB
  • 配置复制集
  • 配置优先级
// 获取复制集配置
cfg = rs.conf()
// 修改第一个节点的优先级,默认为 1
cfg.members[0].priority = 10
// 让复制集更新配置信息
rs.reconfig(cfg)

全球多写集群( global cluster )

业务需求:跨国企业跨地区读写时延高,时延接近秒级

解决方案如下:

使用多个分片集群,每个分片集群中,本地中心存在两台复制集节点,异地中心各有一台复制集节点。

本地数据读写都在本地中心,异地数据只能读,写需要通过异地数据中心才能写。

完整的搭建步骤如下:

  • 针对要分片的数据集合,增加区域区分字段,只要能区分数据即可
  • 给集群的每个分片加上区域标签
  • 给每个区域指定属于这个区域的分片块范围
// 给分片 shard0 打标签
sh.addShardTag('shard0', 'Asia')

// 给分片指定数据范围
sh.addTagRange(
  // 数据库.集合
  'crm.orders',
  // 分片的范围
  { locationCode: 'CN', order_id: 'min' },
  { locationCode: 'CN', order_id: 'max' },
  // 给数据添加哪个标签
  'Asia'
)

通过 tag 可以帮助 MongoS 快速选择分片,减少跨中心读写。

上线准备

上线前

  • 性能测试: 压测各项指标,比如 CURD 数、连接数等,设置好监控范围、调整硬件资源
  • 环境检查: MongoDB 提供了一套上线前的环境检查 checklist ,可以按照上述内容进行检查

上线后

  • 性能监控
  • 定期巡检

版本升级

单机升级

单机升级完整升级流程如下:

复制集升级

复制集升级一般是逐个节点升级,升级中的节点可以视为宕机,但是复制集还能继续工作,参考升级步骤如下:

降级

如果升级无论因何种原因失败,则需要降级到原有旧版本。在降级过程中:

  • 滚动降级过程中集群可以保持在线,仅在切换节点时会产生一定的不可写时间
  • 降级前应先去除已经用到的新版本特性。例如用到了 NumberDecimal 则应把所有使用 NumberDecimal 的文档先去除该字段
  • 通过设置 FCV ( Feature CompatibilityVersion ) 可以在功能上降到与l旧版本兼容
  • FCV 设置完成后再滚动替换为旧版本