1. 简介

MongoDB 是一款开源的文档数据库,具有灵活的数据模型、高性能和易扩展性,常用于处理非结构化或半结构化数据的应用开发,适合需要快速迭代、数据格式多样化、追求水平扩展的项目。

官网地址:https://www.mongodb.com/

源码仓库:https://github.com/mongodb/mongo

MongoDB 的技术特性有:

  • 模式灵活,无需提前定义表结构,可以随时为文档添加或删除字段,适合快速迭代和数据结构灵活多变的开发模式;
  • 支持索引,可以在任何嵌套字段上创建索引,以提高查询速度;
  • 支持副本,提供自动故障转移和数据容易,提高可用性;
  • 支持分片,提高大数据量和高吞吐量使用场景;
  • 具有强大的查询语言和聚合框架,能实现关系行数据库单表查询的大部份功能,聚合管道可以对数据进行复杂的查询操作;
  • 支持多种存储引擎,默认为 WiredTiger;

MongoDB 适合的应用场景有:

  • 用户数据画像:存储用户的属性、偏好、行为等,不同用户的属性差异较大;
  • Web 和移动应用:存储用户生成内容、社交数据等,通过灵活的数据模型来适应变化的需求;
  • 内容管理系统:存储文章、评论、标签等结构多变的内容数据;

MongoDB 不适用的场景:

  • 依赖复杂事务和强一致性的系统,如金融系统;
  • 需要复杂 SQL 查询和多表连接查询的场景,如 ERP、财务系统、报表系统;
  • 传统商业智能 BI 和数据仓库;
  • 数据模型稳定且结构规范的业务,关系行数据库的优势更大;

2. 使用

2.1 服务端

启动 MongoDB 服务端:

mongod

2.2 客户端

MongoDB 自带 JavaScript shell,可以使用命令行与服务端进行交互,它是一个功能齐全的 JavaScript 解释器,可以运行任意 JavaScript 程序。

如果有需要启动 mongo shell 时自动加载的脚本,可以添加到 .mongorc.js 文件中。

连接服务端

运行 shell,默认连接到本地的服务端 mongod,或者指定主机、端口、数据库:

mongosh
mongosh 192.168.1.1:30000/video

也可以不连接任何 mongod,启动后再连接:

mongosh --nodb

conn = new Mongo("192.168.1.1:30000")

db = conn.getDB("video")

操作

查看当前使用的数据库:

db

选择数据库 video:

use video

使用 db 变量作为当前数据库,访问集合 movie:

db.movie

一些基本的文档操作:

# 创建文档
mv = {"title":"Star Wars","year":1977}
db.movie.insertOne(mv)

# 查询文档
db.movie.findOne()

# 更新文档
db.movie.updateOne({"title":"Star Wars"},
  {$set: {view: 100}})

# 删除文档
db.movie.deleteOne({"title":"Star Wars"})

执行脚本

mongo shell 可以执行指定的 JavaScript 脚本文件。

登陆后执行某个脚本然后退出:

mongosh 192.168.1.1:30000/video --quiet script1.js script2.js

可以在 mongo shell 内执行脚本:

load("script.js")

mongo shell 中还可以执行命令行程序:

run("ls", "-l")

3. 基础概念

MongoDB 中分为实例、数据库、集合、文档四层概念,前一个包含下一个概念。

3.1 文档

MongoDB 的基本数据单元是文档(Document),相当于关系型数据库中的行,它的数据格式为 BSON(Binary JSON),文档中支持嵌套其他的文档和数组,数据表示能力非常强大。

每个文档都有一个特殊的键 _id,不存在时会自动生成,默认类型为 ObjectId,也可以使用其他类型。每个文档的 _id 在所属集合中必须是唯一的,ObjectId 使得 MondoDB 能够在分片环境中生成唯一的标识符。

文档是一组有序键值的集合,可以包含多个键值对,例如:

{"greeting": "hello"}

{"greeting": "hello", "view": 3}

其中的键是字符串类型,区分大小写,文档中不能包含重复的键。

值可以是布尔、数值、字符串、数组、内嵌文档等类型。

3.2 集合

集合(Collection)是一组文档,相当于具有动态模式的表,一个集合中的文档可以拥有完全不同的字段。

集合的命名习惯通过 . 分隔不同命名空间的子集合,如 blog、blog.post 和 blog.author,但只是集合命名的管理方式,而不是真正的父子集合关系。

3.3 数据库

一个 MongoDB 实例可以拥有多个独立的数据库,每个数据库都拥有自己的集合。

通过数据库名称和集合名称连接起来,得到限定的集合名称,成为命名空间,如 db.blog。

有一些数据库名称是系统保留的:

  • admin:用于身份验证和授权;
  • local:单个服务器的数据存储,副本集中用于存储复制过程使用的数据;
  • config:分片集群用来存储分片信息;

3.4 数据类型

MongoDB 在 JSON 基本键值对特性的基础上,增加了一些额外的数据类型支持。

null

null 类型表示空值或不存在的字段。

{"x":null}

布尔

布尔类型的值为 true 或 false。

{"x":true}

数值

数值类型默认使用 64 位浮点数。

{"x":3.14}
{"x":3}

使用 NumberInt 或 NumberLong 类来表示 4 字节和 8 字节有符号整数。

{"x":NumberInt("3")}
{"x":NumberLong("3")}

字符串

字符串类型是任何 UTF-8 字符串。

{"x":"foobar"}

日期

日期类型存储为 64 位整数,表示自 1970 年以来的毫秒数。

日期本身不会保存时区信息,会按本地时区来显示,可以用另一个键来保存时区信息。

{"x":new Date()}

正则表达式

查询时可以用正则表达式,语法和 JavaScript相同。

{"x":/foobar/i}

数组

数组是一组值,可以包含不同数据类型的元素。

MongoDB 可以深入数组内部对内容执行操作,也可以对数组内容进行更新、查询和创建索引。

{"x":["a","b","c"]}
{"x":[3.14,"pi",true]}

内嵌文档

文档内可以嵌套其他文档,被嵌套的文档就是父文档的值。

MongoDB 可以深入内嵌文档内部对内容执行操作,也可以对数组内容进行更新、查询和创建索引。

{"x":{"foo":"bar"}}

ObjectId

ObjectId 是一个 12 字节的 ID,是文档的唯一标识 _id 的默认类型。

它包含 4 字节的时间戳,5 字节的随机值,3字节的计数器(起始值随机)。时间戳和随机值保证 ObjectId 在一秒内跨机器和进程的唯一行,计数器保证单个进程 1 秒内的唯一性。

{"x":ObjectId()}

二进制数据

二进制数据是任意字节的字符串,不能通过 shell 操作,可以用来保存非 UTF-8 字符串。

代码

文档中可以存储任意的 JavaScript 代码。

{"x":function(){/* ... */}}

4. 文档操作

4.1 插入

MongoDB 对于插入操作会先执行检查:

  • 如果没有 _id 字段,则会自动添加;
  • 文档大小必须小于 16MB;
  • 无效数据校验:包含非 UTF-8 字符串,类型无法识别等;

insertOne 插入单个文档:

db.movie.insertOne({"title":"Star War"})

insertMany 插入多个文档:

db.movie.insertMany([
  {"title":"Star War1"},
  {"title":"Star War2"},
  {"title":"Star War3"}
])

MongoDB 3.0 之前的插入方法为 insert,新版本仍然支持,但不建议继续使用。

4.2 更新

更新操作是原子操作,如果两个更新同时发生,先执行先到达服务器的更新,再执行下一个更新。

replaceOne 将一个文档进行替换,可以在 shell 中创建另一个文档,然后替换之前的文档:

var d1 = db.user.findOne({"name": "joe"})
d1.age = 30
db.movie.replaceOne({"name":"joe"}, d1)

当匹配条件命中多个文档时,replaceOne 只会对其中的一个进行操作,有可能替换了错误的文档或者发生 _id 冲突,更建议使用 _id 作为匹配条件,查询也会更高效。

updateOne 更新一个文档,通过更新运算符来完成不同的更新操作,不过 _id 是不能改变的:

# $set 增加或修改键
db.movie.updateOne({"title":"Star War"},
  {"$set":{"year":1989}})

# $unset 删除键
db.movie.updateOne({"title":"Star War"},
  {"$unset":{"year":1}})

# $inc 修改键的值,不存在时创建,用于整型和浮点数
db.movie.updateOne({"title":"Star War"},
  {"$inc":{"view":100}})

# $push 将元素添加到数组末尾,数组不存在时创建
db.movie.updateOne({"title":"Star War"},
  {"$push":{"actor":"Mike"}})

# $addToSet 仅当数组不存在时才插入元素
db.movie.updateOne({"title":"Star War"},
  {"$addToSet":{"actor":"Mike"}})

# $pop 从数组一端删除元素,key=1从末尾删除,key=-1从头部删除
db.movie.updateOne({"title":"Star War"},
  {"$pop":{"key":1}})

# $pull 从数组删除匹配条件的所有元素
db.movie.updateOne({"title":"Star War"},
  {"$pull":{"actor":"Tom"}})

updateMany 更新多个文档,用法和 updateOne 相同:

db.movie.updateMany({"title":"Star War"},
  {"$set":{"year":1989}})

upsert 更新方式为插入更新,当文档不存在时插入,存在时更新。

db.movie.updateOne({"title":"Star War"},
  {"$inc":{"view":100}},
  {"upsert": true})

save 函数用于在文档不存在时插入,存在则更新。

var x = db.movie.findOne()
x.like = 10
db.movie.save(x)

4.3 删除

删除可以指定匹配条件来删除文档,也可以不指定条件。

数据删除是永久性的,无法撤回或恢复,除非从之前的备份中恢复。

deleteOne 删除一个文档:

db.movie.deleteOne({"title":"Star War","year":1983})

# 不指定条件,删除一个文档
db.movie.deleteOne({})

deleteMany 删除多个文档:

db.movie.deleteMany({"year":1984})

# 不指定条件,删除集合所有文档
db.movie.deleteMany({})

MongoDB 3.0 之前的删除方法为 remove,新版本仍然支持,但不建议继续使用。

删除集合,可以用于清空整个集合后重建集合,重建后索引会更快。

db.movie.drop()

4.4 查询

find 查询并返回文档,传递的查询值必须是常量。

# 查询所有文档
db.movie.find()

# 指定查询条件
db.movie.find({"year":1988})

指定要返回的键:

# 就算不指定 _id 也默认会返回
db.movie.find({"year":1988},{"title":1,"year":1})

# 指定不返回 _id
db.movie.find({"year":1988},{"title":1,"year":1,"_id":0})

运算符

比较运算符有 $lt $lte $gt $gte $eq $ne,用于比较范围和是否等于。

db.movie.find({"view":{"$gte":100,"$lt":200}})
db.movie.find({"year":{"$ne":1950}})

$in $nin 匹配一个键属于或排除多个值。

db.movie.find({"year":{"$in":[1988,1989]}})

一般多个条件是 AND 的关系,OR 关系通过 $or 来实现。

db.movie.find({"$or":[{"year":1900},{"title":"Star War"}]})

$not 用于排除条件。

db.movie.find({"year":{"$not":1900}})

$mod 用于计算余数,如果和第一个值取余数等于第二个值,则匹配成功。

db.movie.find({"year":{"$mod":[5,0]}})

null 可以匹配值为 null 或者键不存在,$exist 匹配键是否存在,组合起来可以匹配 null 值。

db.movie.find({"author":null})
db.movie.find({"author":{"$exists":true}})
db.movie.find({"author":{"$eq":null,"$exists":true}})

$regex 使用正则表达式来查询,后面的 i 表示不区分大小写。

db.movie.find({"title":{"$regex":/war/}})
db.movie.find({"title":{"$regex":/war/i}})
db.movie.find({"title":{"$regex":/^war/i}}) # 前缀匹配,可以利用上索引

数组

查询一个值时如果数组包含该值,也会返回。

db.food.insertOne({"fruit":["apple","banana","peach"]})
db.food.find({"fruit":"banana"})

查询使用数组的值,必须整个数组按顺序精确匹配。

db.food.find({"fruit":["apple","banana","peach"]}) # 匹配
db.food.find({"fruit":["apple","peach","banana"]}) # 不匹配

$all 匹配多个查询值都包含在数组,顺序无关紧要。

db.food.find({"fruit":{"$all":["apple","banana"]}})

可以匹配指定下标的值,下标从 0 开始。

db.food.find({"fruit.2":"banana"}) 

$size 匹配数组长度。

db.food.find({"fruit":{"$size":3}}) 

$slize 返回数组的部份切片。

db.blog.post.findOne(criteria,{"comment":{"$slice":10}}) # 前10条
db.blog.post.findOne(criteria,{"comment":{"$slice":-20}}) # 后20条
db.blog.post.findOne(criteria,{"comment":{"$slice":[100,5]}}) # 指定偏移量和数量

内嵌文档

对于以下的文档:

{
  "name" :{
    "first":"Donald",
    "last":"Trump"
  },
  "age": 70
}

如果需要查询姓名:

db.people.find({"name":{"first":"Donald","last":"Trump"}})
db.people.find({"name.first":"Donald","name.last":"Trump"})

JavaScript 代码

通过 $where 来执行 JavaScript 代码,这将无法使用索引。

db.movie.find({"$where":function(){...}})

游标

使用游标来限制结果数量,或者跳过一些结果。

var cursor = db.collaction.find();

while (cursor.hasNext()) {
  obj = cursor.next();
  // do sth.
}

排序和分页

limit 函数用于限制结果数量。

db.movie.find().limit(10)

skip 函数用于跳过前面部份结果,如果 skip 跳过大量结果,将会导致性能问题。

db.movie.find().skip(100)

sort 函数用于给结果排序,对于键的值,1 表示升序排序,-1 表示降序排序。

db.movie.find().sort({"title":1,"year":-1})

5. 索引

未使用索引时会对集合进行扫描,使用索引可以加速集合的查询,为了提高查询速度,应该给必要的查询模式创建对应索引。

索引也会带来一定代价,对索引字段的写操作也需要同时更新索引,带来一些性能消耗。

5.1 创建索引

在指定字段创建索引,键对应的 1 表示索引在组织数据时保存为升序,-1 表示降序,升序和降序只需要创建一个即可。

db.movie.createIndex({"title":1})

在多个字段创建复合索引,查询匹配到复合索引的前缀时才能使用该索引。

db.movie.createIndex({"year":1,"title":1})

可以对对象和数组创建索引,复合索引中只能有一个字段是来自数组的,避免组合爆炸。

# 对子字段创建索引
db.movie.createIndex({"loc.city":1})

# 对数组创建索引,当数组长度较大时索引压力会较大
db.movie.createIndex({"comment.date":1})

# 对数组某一下标内部创建索引
db.movie.createIndex({"comment.10.vote":1})

唯一索引确保每个值只会在索引出现一次,_id 本身就有一个唯一索引。

当字段不存在时,索引中会存储 null,需要注意集合中多个文档在唯一索引中的字段不存在时,会因为出现多个 null 而唯一性冲突。

db.movie.createIndex({"title":1},
  {"unique":true})

部份索引是只在集合的部份数据创建索引。

唯一索引和部份索引可以同时声明。

db.movie.createIndex({"title":1},
  {"partialFilterExpression":{"email":{"$exists":true}}})

设计复合索引时,应该考虑到常用的查询方式,将等值过滤的键放在最前面,排序的键放在多值过滤字段前,多值过滤字段放在后面。

创建索引尽量选择值的区分度较高的字段。

每个索引都会有一个默认名字:

db.movie.createIndex({"year":1,"title":1})
# 索引名称: year_1_title_1

创建时指定索引名称:

db.movie.createIndex({"year":1,"title":1},
  {"name":"movie_year_title"})

TTL 索引允许为每个文档设置一个超时时间,一个文档过期后会被删除,TTL 索引不能是复合索引。

MongoDB 每分钟扫描一次 TTL 索引删除过期文档。

db.movie.createIndex({"lastupdate":1},
  {"expireAfterSeconds":60*60*24})

5.2 选择索引

MongoDB 在查询操作,会根据查询的字段,来从多个索引选择最优的。

当有多个索引被标识为该查询的候选索引,就会创建多个查询计划,并行运行这些查询,最快返回结果的一个作为选择,同样字段的查询后续也会复用该索引。

服务器会维护查询计划的缓存,以备后面执行相同的查询。时间推移、集合和索引的变化、mongod 重启都可能将查询计划从缓存清除。

查询只需要索引中的字段时,称为覆盖查询,就不再需要去加载实际文档了,无必要可以避免返回 _id 字段。

范围查询可以使用索引,$ne 查询可以使用索引,但必须扫描整个索引,$not $nin 常常退化为全表扫描,OR 查询的每个子句都可以使用索引,然后进行结果集合并去重。

查询结果集在集合中占的比例越大,索引就越低效,因为索引需要先执行一次查找索引项,再执行一次根据索引指针查找文档,而全表扫描只需要查找文档。

5.3 explain

explain 可以输出查询操作的详细信息、查询的方式、使用的索引。

db.movie.find().explain('executionStats')

返回的信息:

  • stage:是否使用索引,COLLSCAN 表示集合扫描,IXSCAN 表示使用索引;
  • indexName:使用的索引;
  • totalKeysExamined:扫描的索引项数量;
  • totalDocsExamined:扫描的文档数量;
  • executionTimeMillis:查询执行的毫秒数;
  • nReturned:查询返回的文档数量;
  • indexBounds:索引的使用方式和遍历范围;

5.4 索引管理

数据库中所有的索引都记录在 system.indexes 集合中,可以查看已有索引的元信息。

也可以查看某个集合中的已有索引:

db.movie.getIndexes()

删除索引:

db.movie.dropIndex("year_1_title_1")

5.5 地理空间索引

GeoJSON 格式可以表示点、线和多边形。

# 点由精度和纬度组成
{
  "name":"City1",
  "loc": {
    "type":"Point",
    "coordinates": [50, 20]
  }
}

# 线由点的数组表示
{
  "name":"River1",
  "loc": {
    "type":"LineString",
    "coordinates": [[0,1],[0,3],[4,4]]
  }
}

# 多边形由点的数组表示
{
  "name":"Country1",
  "loc": {
    "type":"Polygon",
    "coordinates": [[0,1],[0,3],[4,4],[5,2]]
  }
}

2dsphere 索引基于地球球面几何模型,考虑到地球形状,距离处理更准确。

db.map.createIndex({"loc":"2dsphere"})

2d 索引表示一个平面上的坐标,用于游戏地图、时间序列数据等。

db.map.createIndex({"loc":"2d"})

可以通过 $near $geoNear $geoWithin 等对地理空间索引进行查询。

# 矩形方框范围
db.map.find({"loc":{"$geoWithin":{"$box":[10,10],[20,20]}}})

# 圆形范围
db.map.find({"loc":{"$geoWithin":{"$center":[[40,40],5]}}})

# 多边形范围
db.map.find({"loc":{"$geoWithin":{"$polygon":[[10,10],[20,20],[10,20]]}}})

5.6 全文搜索索引

MongoDB 支持对字符串字段创建全文搜索索引,但其会消耗大量系统资源,比单字段索引、复合索引、多键索引的写操作开销更大。

创建文本索引:

db.movie.createIndex({"title":"text","body":"text"})

# 设置权重
db.movie.createIndex({"title":"text","body":"text"},
  {"wweights":{"title":5,"body":2}})

# 给所有字符串字段创建全文本索引
db.movie.createIndex({"$**":"text"})

文本查询:

db.movie.find({"$text":{"$search":"hello world"}})

# 按相关性分数排序
db.movie.find({"$text":{"$search":"hello world"}},
  {"title":1,"score":{"$meta":"textScore"}}).
  sort("score":{"$meta":"textScore"}).limit(10)

5.7 固定集合

固定集合是固定大小的集合,插入新的文档如果满了会淘汰最旧的文档,该集合无法删除文档,无法进行导致文档大小增长的更新。

可以用于记录日志。

创建固定集合:

db.createCollection("collection",{"capped":true,"size":10000})

6. 聚合

聚合框架基于管道的概念,从集合获取输入,传递到一到多个阶段,每个阶段执行不同的操作,最后输出结果。

匹配:筛选结果

db.movie.aggregate([
  {"$match": {"year":{"$gte":1900}}}
])

投射:指定输出字段

db.movie.aggregate([
  {"$project": {"_id":0,"title":1,"year":1}}
])

排序

db.movie.aggregate([
  {"$sort": {"view":1}}
])

跳过

db.movie.aggregate([
  {"$skip": 100}
])

限制

db.movie.aggregate([
  {"$limit": 10}
])

将他们组合起来:

db.movie.aggregate([
  {"$match": {"year":{"$gte":1900}}},
  {"$sort": {"view":1}},
  {"$skip": 100},
  {"$limit": 10},
  {"$project": {"_id":0,"title":1,"year":1}}
])

7. 事务

MongoDB 支持跨操作、集合、数据库、文档、分片的 ACID 事务。

核心API

使用类似关系型数据库执行开启、提交事务的语句,不为大多数错误提供重试逻辑,要求开发者为操作、事务提交、错误处理编写代码。

session.start_transaction(...)
session.commit_transaction()
with client.start_serssion() as session:
  try:
    run_with_retry(...,session)
  except Exception as exc:
    # 错误处理
    raise

回调API

推荐使用这种方式。

def callback(session):
   ...
with client.start_session() as session:
session.with_transaction(callback,...)

多文档事务只能对一存在的集合或数据库执行读写操作,不能在事务中创建集合、删除集合、进行索引操作。

7. 复制

在 MongoDB 中,创建副本集(replica set)后就可以使用复制功能了,副本集是一组服务器,其中一个是用于处理写操作的主节点(primary),还有多个保存主节点数据副本的从节点(secondary)。

当主节点崩溃了,从节点中会选取出一个新的主节点。

创建副本集,创建成功后会有 3 个 mongod 进程。

mkdir -p ~/data/rs1
mkdir -p ~/data/rs2
mkdir -p ~/data/rs3

mongod --replSet mdbDefGuide --dbpath ~/data/rs1 --port 27017 --smallfiles --oplogSize 200
mongod --replSet mdbDefGuide --dbpath ~/data/rs2 --port 27018 --smallfiles --oplogSize 200
mongod --replSet mdbDefGuide --dbpath ~/data/rs3 --port 27019 --smallfiles --oplogSize 200

在任一成员中启动副本集,它们会选举出一个主节点。

mongosh --port 27017

rsconf = {
  "_id":"mdbDefGuide",
  "members": [
    {"_id":0,"host":"localhost:27017"},
    {"_id":1,"host":"localhost:27018"},
    {"_id":2,"host":"localhost:27019"}
  ]
}
rs.initiate(rsconf)

副本集选区主节点需要得到大多数(超过一半)节点的支持。

MongoDB 通过操作日志(oplog)在多台服务器进行数据同步,oplog 保存了主节点的每个写操作。每个从节点也都维护着自己的 oplog,也就可以被其他成员用作同步源。

初始化同步时,会讲所有数据从副本集的一个成员复制到另一个成员,完成初始化同步后,会持续地从同步源复制 oplog,并在一个异步进程中应用操作。

所有成员每隔两秒会想副本集的其他成员发送心跳请求,用来检查每个成员的状态。

8. 分片

MongoDB 分片机制允许创建一个由多个机器组成的集群,将集合中的数据分散在集群中,每个分片拥有一个子集数据。

需要在分片的前面运行一到多个路由进程 mongos,mongos 负责客户端请求的转发和合并,配置服务器(Config Server)负责存储集群分片的元数据。应用程序的请求到达 mongos 后,mongos 负责将请求转发到对应的分片,再收集响应合并后发送回应用程序。

首先需要对数据库启用分片:

sh.enableSharding("video")

对集合进行分片,需要选择一个片键(shard key),片键用来拆分数据的一个或几个字段。

首先创建一个索引:

db.movie.createIndex({"title":1})

对集合进行分片:

sh.shardCollection("video.movie",{"title":1})

查询在包含片键时,发送到部份的分片称为定向查询,必须发送到所有分片的查询称为分散-收集查询/广播查询。

不应该过早分片,这回增加部署的复杂度,需要在数据量超载前进行分片处理。

分片的作用有:

  • 增加可用内存;
  • 增加可用磁盘空间;
  • 减少服务器负载;
  • 处理单个 mongod 无法承受的吞吐量;

9. 参考