MongoDB 4.x数据模型
1、BSON协议与类型
1.1、JSON标准
JSON是当今非常通用的一种跨语言Web数据交互格式,属于ECMAScript标准规范的一个子集。JSON(JavaScript Object Notation, JS对象简谱)即JavaScript对象表示法。顾名思义,JSON与JavaScript语言是分不开的,它是JavaScript对象的一种文本表现形式。
作为一种轻量级的数据交换格式,JSON的可读性非常好,而且非常便于系统生成和解析,这些优势也让它逐渐取代了XML标准在Web领域的地位,当今许多流行的Web应用开发框架,如SpringBoot都选择了JSON作为默认的数据编/解码格式。
以JSON格式定义的数据形式如下:
js
{
"name": "李小龙",
"age": 25,
"address": {
"acode": 538817,
"street": "广东省深圳市龙华区民治街道"
},
"favorites": ["武术", "篮球", "围棋"]
}
总的来说,JSON由两种基本结构组成:
- 键值对的集合,等同于我们所说的对象、字典、哈希表(hash table)等数据结构,比如一个用户会同时拥有名称(name)、年龄(age)等字段信息;这个结构还可以支持嵌套,如用户的地址信息(address)作为子对象,地址中又可以包含邮政编码(zcode)、详细街道地址(street)等。
- 有序的数据列表,通常对应于数组形式,如上述例子中的faviorites字段,表示一个用户可以有多种偏好的标签信息。
JSON只定义了6种数据类型,如图所示。

1.2、BSON和JSON
大多数情况下,使用JSON作为数据交互格式已经是理想的选择,但是JSON基于文本的解析效率并不是最好的,在某些场景下往往会考虑选择更合适的编/解码格式,一些做法如:
- 在微服务架构中,使用gRPC(基于Google的Protobuf)可以获得更好的网络利用率。
- 分布式中间件、数据库,使用私有定制的TCP数据包格式来提供高性能、低延时的计算能力。
BSON(Binary JSON)是二进制版本的JSON,其在性能方面有更优的表现。BSON在许多方面和JSON保持一致,其同样也支持内嵌的文档对象和数组结构。二者最大的区别在于JSON是基于文本的,而BSON则是二进制(字节流)编/解码的形式。除此之外,BSON还提供了一些扩展的数据类型,比如日期、二进制数据等。
为了更浅显地表示二者的不同,可以看看下面这个简单的JSON对象:
js
{"hello": "world"}
其对应的BSON的格式编码为:
js
\x16\x00\x00\x00 //total document size
\x02 //0x02 = type String
hello\x00 //field name
\x06\x00\x00\x00world\x00 // field value
\x00 // 0x00 = type EOO ('end of object')
BSON由10gen团队设计并开源,目前主要用于MongoDB数据库。
MongoDB在文档存储、命令协议上都采用了BSON作为编/解码格式,主要具有如下优势:
- 类JSON的轻量级语义,支持简单清晰的嵌套、数组层次结构,可以实现无模式(模式灵活)的文档结构。
- 更高效的遍历,BSON在编码时会记录每个元素的长度,可以直接通过seek操作进行元素的内容读取,相对JSON解析来说,遍历速度更快。
- 更丰富的数据类型,除了JSON的基本数据类型,BSON还提供了MongoDB所需的一些扩展类型,这更加方便数据的表示和操作。
在空间的使用上,BSON相比JSON并没有明显的优势。
1.3、BSON的数据类型
BSON的数据类型见下表。


总结:
- JSON是通用的、轻量级的Web数据交换格式,支持嵌套的对象和数组结构,其本身也是无模式的。
- BSON是JSON的二进制版本,除了具备更高的性能,还提供了一些扩展的数据类型。
- MongoDB内部使用BSON数据格式,但由于BSON与JSON非常相近,许多情况下称MongoDB是基于JSON的说法也是合理的。
2、使用日期
MongoDB中的日期使用Date类型表示,在其内部实现中采用了一个64位长的整数,该整数代表的是自1970年1月1日零点时刻(UTC)以来所经过的毫秒数。Date类型的数值范围非常大,可以表示上下2.9亿年的时间范围,负值则表示1970年之前的时间。
这种方式比较常见,比如Java中的System.currentTimeMillis方法也是这么计算的。
在使用日期类型时,通常需要注意时区的问题。MongoDB的日期类型使用UTC(Coordinated UniversalTime)进行存储,也就是+0时区的时间。一般客户端会根据本地时区自动转换为UTC时间,代码如下:
js
> new Date()
ISODate("2026-07-19T13:19:36.998Z")
在这里,ISODate是对于UTC时间的包装类。
下面,再看一个稍微复杂的例子,代码如下:
js
var date1 = Date();
var date2 = new Date();
var date3 = ISODate();
var dateObject = {
d1: date1,
d2: date3,
d3: date3
}
db.dates.insert(dateObject);
db.dates.find().pretty()
执行上述代码,将会看到输出如下:
js
{
"_id" : ObjectId("6a5cd42bfcf45abb4d8df3b9"),
"d1" : "Sun Jul 19 2026 21:42:03 GMT+0800 (CST)",
"d2" : ISODate("2026-07-19T13:42:03.778Z"),
"d3" : ISODate("2026-07-19T13:42:03.778Z")
}
可以看到,使用new Date与ISODate的语义是相同的,两者最终都会生成ISODate类型的字段(对应于UTC时间)。而Date与两者都不同,它会以字符串形式返回当前的系统时间。由于当前正处于+8时区(北京标准时间),因此输出的时间值比ISODate多8个小时。
通过typeof操作符可以看到其中的不同,代码如下:
js
> print(typeof(Date()))
string
> print(typeof(new Date()))
object
> print(typeof(ISODate()))
object
3、ObjectId生成器
MongoDB集合中所有的文档都有一个唯一的_id字段,作为集合的主键。在默认情况下,_id字段使用ObjectId类型。如下面的代码:
js
> db.foo.insert({})
> db.foo.find()
{ "_id" : ObjectId("6a5cd526fcf45abb4d8df3ba") }
这里的_id是自动生成的,其中"6a5cd526fcf45abb4d8df3ba"是ObjectId的16进制编码形式,该字段总共为12个字节。
为了避免文档的_id字段出现重复,ObjectId被定义为3个部分:
- 4字节表示Unix时间戳(秒)。
- 5字节表示随机数。
- 3字节表示计数器(初始化时随机)。
由此可见,经过多个字段随机组合后,出现重复的概率是极低的。
对于新插入集合中的文档,如果没有包含_id字段,则数据库服务器会自动生成一个新的ObjectId。但实际上,大多数客户端驱动都会自行生成这个字段,比如MongoDBJava Driver会根据插入的文档是否包含_id字段来自动补充ObjectId对象。这样做不但提高了离散性,还可以降低MongoDB服务器端的计算压力。另外,在ObjectId的组成中,5字节的随机数并没有明确定义,客户端可以采用机器号、进程号来实现,如图所示。

ObjectId具体如何生成,可以参考下面的代码:



以上代码来自MongoDB Java Driver(3.6.2版本)。当然,具体应用也可以使用自动生成的_id,但必须保证_id的唯一性。
4、数组、内嵌
支持灵活的数据结构,是MongoDB这种文档数据库的一大优势。在面向对象的编程方式中,对象的成员可以是多种形式的,包括数组、子对象等。但是当我们希望将对象中的数据持久化到传统的关系型数据库中时,却发现没有很好的匹配模式。常见的一些做法如:
- 使用平铺式的多列式结构,如用tag1、tag2、tag3...表示数组中的若干个元素。
- 使用序列化的单列进行收敛,比如将数组或子对象转换为JSON字符串后存储到某个列,在读取时再进行解析。
无论哪一种方式,都是存在一些弊端的。平铺式的结构会导致列的数量膨胀,关系型数据库需要提前设计好Schema,但数组往往是动态的,无法满足快速变化的需求;单列序列化的方式带来了应用上的复杂性,数据库无法理解该列的内部结构,所能提供的操作只有"整存整取"。
MongoDB的文档模型充分理解了数组、内嵌式文档的数据结构,除了可以方便地对数组内的元素、内嵌文档的字段进行操作,还可以对这些"内嵌式"的字段进行索引以满足快速的查询。它们在使用方式上和普通的字段并没有什么大的不同,这是文档型数据库的一种强大的表现力。
值得注意的是,一些关系型数据库如MySQL、PostgreSQL在后来也支持数组和内嵌对象的类型,充分说明了该能力的重要性及普适性。
4.1、内嵌文档
让我们再回到前面的例子,一个book文档中可以包含作者的信息,包括作者名称、性别、家乡所在地等,代码如下:
js
{
title: "撒哈拉的故事",
...
author: {
name: "三毛",
gender: "女",
hometown: "重庆"
}
}
一个显著的优点是,当我们查询book文档的信息时,作者的信息也会一并返回。如果只希望返回作者的名称,则可以指定author.name,代码如下:

也可以将author.name作为查询条件,代码如下:
js
> db.book.find({"author.name": "三毛"})
如果作者信息需要修改,则可以指定其中的某个字段,比如修改作者的家乡所在地,代码如下:

4.2、数组
除了作者信息,book文档中还包含了若干个标签,这些标签可以用来表示book文档所包含的一些特征,如豆瓣读书中的标签(tag),如图所示。

我们用文档结构来表示,代码如下:

4.2.1、查询元素
在查询文档时,数组中的标签会被一起返回,如果只想获得最后一个标签元素,则可以用如下命令查询:

这里的$silice是一个查询操作符,用于指定数组的切片方式,与JavaScript中的用法类似。
4.2.2、修改元素
如果希望在标签中的这个数组末尾添加一个元素,则可以使用$push操作符,代码如下:

p u s h 操作符可以配合其他操作符,一起实现不同的数组修改操作,比如和 push操作符可以配合其他操作符,一起实现不同的数组修改操作,比如和 push操作符可以配合其他操作符,一起实现不同的数组修改操作,比如和each操作符配合可以用于添加多个元素,代码如下:

上述代码除了添加多个标签,最终只会保留最后的3个元素,即经过$slice操作后的结果。
4.2.3、根据元素查询
标签的一个重要作用就是用于查询,可以根据标签中的元素进行book文档的查找,代码如下:

上述代码会将所有标签数组中包含"伤感"一词的book文档都查找出来。如果希望查询同时存在多个标签的文档,则可以使用$all操作符,代码如下:

4.3、嵌套型的数组
数组元素可以是基本类型,也可以是内嵌的文档结构,我们尝试将标签的概念扩充一下,一个标签由tagKey和tagValue所组成,文档结构如下:
js
{
tags: [
{tagKey: xxx, tagValue: xxx},
{tagKey: xxx, tagValue: xxx},
...
]
}
这种结构非常灵活,一个很适合的场景就是商品的多属性表示,如图所示。

一个商品可以同时包含多个维度的属性,比如尺码、颜色、风格等,使用文档可以表示为:
js
> db.goods.insert({
name: "羊毛衫",
tags: [
{tagKey: "size", tagValue: "大码"},
{tagKey: "color", tagValue: "蓝色"},
{tagKey: "style", tagValue: "韩风"}
]
})
以上的设计是一种常见的多值属性的做法,当我们需要根据属性进行检索时,需要用到$elementMatch操作符,代码如下:
js
> db.goods.find({
tags: {
$elemMatch: {tagKey: "color", tagValue: "蓝色"}
}
})
{
"_id" : ObjectId("6a5cdb94fcf45abb4d8df3bb"),
"name" : "羊毛衫",
"tags" : [
{
"tagKey" : "size",
"tagValue" : "大码"
},
{
"tagKey" : "color",
"tagValue" : "蓝色"
},
{
"tagKey" : "style",
"tagValue" : "韩风"
}
]
}
当然,如果进行组合式的条件检索,则可以使用多个$elemMatch操作符,代码如下:
js
> db.goods.find({
tags:{
$all: [
{$elemMatch: {tagKey: "color", tagValue: "蓝色"}},
{$elemMatch: {tagKey: "size", tagValue: "大码"}}
]
}
})
{
"_id" : ObjectId("6a5cdb94fcf45abb4d8df3bb"),
"name" : "羊毛衫",
"tags" : [
{
"tagKey" : "size",
"tagValue" : "大码"
},
{
"tagKey" : "color",
"tagValue" : "蓝色"
},
{
"tagKey" : "style",
"tagValue" : "韩风"
}
]
}
上述代码可以筛选出color=蓝色,并且size=大码的商品信息。
5、固定集合
5.1、固定集合简介
固定集合(capped collection)是一种限定大小的集合,其中capped是覆盖、限额的意思。跟普通的集合相比,数据在写入这种集合时遵循FIFO原则。可以将这种集合想象为一个环状的队列,新文档在写入时会被插入队列的末尾,如果队列已满,那么之前的文档就会被新写入的文档所覆盖,如图所示。

这是一个很有意思的设计,通过固定集合的大小,我们可以保证数据库只会存储"限额"的数据,超过该限额的旧数据都会被丢弃。
对于普通的集合来说,如果想实现对于大小的限定,即我们需要做的事情如下:
- 设定集合的大小上限,比如文档的数量或是该集合所占用的存储空间大小,作为阈值。
- 开始向该集合中写入数据。
- 在写入数据后判断集合大小是否超过限额,通过collection.count命令可以获得文档的数量,而存储空间大小则可以使用collection.stat命令获得。
- 如果超过了限额,那么找出最早的一条数据将其删除。
上述方案有很多缺点,比如每次写入都需要进行一次判断和数据删除操作,对性能的影响是不小的,同时应用在代码实现上也比较复杂。相比之下,固定集合(capped collection)是在数据库层面进行处理,除了更加方便,可靠性也更有保证。
5.2、使用示例
通过下面的语句可以声明一个固定集合:
js
db.createCollection("logs", {capped: true, size: 4096, max: 10})
这里指定了两个参数。
max:指集合的文档数量最大值,这里是10条。size:指集合的空间占用最大值,这里是4096字节(4KB)。
这两个参数会同时对集合的上限产生影响。也就是说,只要任一条件达到阈值都会认为集合已经写满。其中size是必选的,而max则是可选的。
我们尝试在这个集合中插入15条数据,代码如下:
js
for(var i = 0; i < 15; i++){
db.logs.insert({t: "row-" + i})
}
接着尝试查询集合里的数据,可以看到,由于文档数量上限被设定为10条,前面插入的5条数据已经被覆盖了,结果如下:
js
> db.logs.find()
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c1"), "t" : "row-5" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c2"), "t" : "row-6" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c3"), "t" : "row-7" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c4"), "t" : "row-8" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c5"), "t" : "row-9" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c6"), "t" : "row-10" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c7"), "t" : "row-11" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c8"), "t" : "row-12" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3c9"), "t" : "row-13" }
{ "_id" : ObjectId("6a5cde02fcf45abb4d8df3ca"), "t" : "row-14" }
当然,如果我们不指定max参数,那么当文档的总大小(空间占用)超过size限额时,也会产生覆盖的情况:
js
db.createCollection("logs", {capped: true, size: 4096})
可以使用collection.stats命令查看文档的占用空间,代码如下:

其中,"size":530表示文档总大小为530字节,而"maxSize":4096则是文档总大小的上限。
需要注意,maxSize的值必须是2的n 次方。如果设定值不符合条件,则会被自动对齐,比如创建固定集合时指定"size":500,那么最终的maxSize就是512字节。
5.3、特征与限制
固定集合在底层使用的是顺序I/O操作,而普通集合使用的是随机I/O。众所周知,顺序I/O在磁盘操作上由于寻道次数少而比随机I/O要高效得多,因此固定集合的写入性能是很高的。此外,如果按写入顺序进行数据读取,也会获得非常好的性能表现。
但它也存在一些限制,主要有如下5个方面:
(1)无法动态修改存储的上限,如果需要修改max或size,则只能先执行collection.drop命令,将集合删除后再重新创建。
(2)无法删除已有的数据,对固定集合中的数据进行删除将会得到如下错误:

(3)对已有数据进行修改,新文档大小必须与原来的文档大小一致,否则不允许更新:

(4)默认情况下,固定集合只有一个_id索引,而且最好是按数据写入的顺序进行读取。当然,也可以添加新的索引,但这会降低数据写入的性能。
(5)固定集合不支持分片,同时,在MongoDB 4.2版本中规定了事务中也无法对固定集合执行写操作。
5.4、适用场景
固定集合很适合用来存储一些"临时态"的数据。"临时态"意味着数据在一定程度上可以被丢弃。同时,用户还应该更关注最新的数据,随着时间的推移,数据的重要性逐渐降低,直至被淘汰处理。
一些适用的场景如下:
- 系统日志,这非常符合固定集合的特征,而日志系统通常也只需要一个固定的空间来存放日志。在MongoDB内部,副本集的同步日志(oplog)就使用了固定集合。
- 存储少量文档,如最新发布的TopN条文章信息。
得益于内部缓存的作用,对于这种少量文档的查询是非常高效的。
6、使用固定集合实现FIFO队列
在股票实时系统中,大家往往最关心股票价格的变动。而应用系统中也需要根据这些实时的变化数据来分析当前的行情。
倘若将股票的价格变化看作是一个事件,而股票交易所则是价格变动事件的"发布者",股票APP、应用系统则是事件的"消费者"。这样,我们就可以将股票价格的发布、通知抽象为一种数据的消费行为,此时往往需要一个消息队列来实现该需求。
而基于前面的介绍,我们知道这种类型的集合拥有固定的大小,同时满足高性能FIFO 读写能力。因此,可以利用固定集合来实现股票系统中的消息队列。
首先,需要在数据库中声明固定集合,通size来指定该消息队列的容量,代码如下:
js
var db = db.getSiblingDB("appdb");
db.createCollection("stock_queue", {capped: true, size: 10485760})
这样,我们就拥有了stock_queue消息队列,其可以容纳10MB的数据。每一条消息的格式可以定义为如下形示。
js
{
timestamp: new Date(),
stock: "MongoDB Inc",
price: 30.31
}
- timestamp指股票动态消息的产生时间。
- stock指股票的名称。
- price指股票的价格,是一个Double类型的字段。
其中,为了能支持按时间条件进行快速的检索,比如查询某个时间点之后的数据,可以为timestamp添加索引,代码如下:
js
> db.stock_queue.createIndex({timestamp: 1})
{
"numIndexesBefore" : 1,
"numIndexesAfter" : 2,
"createdCollectionAutomatically" : false,
"ok" : 1
}
6.1、发布股票动态
为了模拟股票的实时变动,我们实现如下函数:
js
function pushEvent() {
while(true) {
db.stock_queue.insert({
timestamp: new Date(),
stock: "MongoDB Inc",
price: 100 * Math.random(1000)
});
print("publish stock changed.")
sleep(1000);
}
}
执行pushEvent函数,此时客户端会每隔1秒向stock_queue中写入一条股票信息,结果如下:
js
> pushEvent()
publish stock changed.
publish stock changed.
publish stock changed.
publish stock changed.
...
6.2、监听股票动态
对于股票动态的消费方来说,更关心的是最新数据,同时还应该保持持续进行"拉取",以便知晓实时发生的变化。根据这样的逻辑,可以实现一个listen函数,代码如下:
js
function listen() {
var cursor = db.stock_queue.find({
timestamp: {$gte: new Date()}
}).tailable()
while(true){
if(cursor.hasNext()){
print(JSON.stringify(cursor.next(), null, 2))
}
sleep(1000);
}
}
上述代码中,find操作的查询条件被指定为仅查询比当前时间更新的数据,而由于采用了读取游标的方式,因此游标在获取不到数据时并不会被关闭,这种行为非常类似于Linux中的tail-f命令。
接下来,在一个循环中会定时检查是否有新的数据产生,一旦发现新的数据(cursor.hasNext()=true),则直接将数据打印到控制台。
执行这个监听函数,就可以看到实时发布的股票信息,代码如下:
