MongoDB 4.x——数据模型

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)​,则直接将数据打印到控制台。

执行这个监听函数,就可以看到实时发布的股票信息,代码如下:

相关推荐
鸽芷咕25 分钟前
MySQL/PostgreSQL 迁移金仓 KES:LEFT JOIN 丢数据排查与避坑指南
数据库·mysql·postgresql
2401_8414956410 小时前
【数据结构】B+树
数据结构·数据库·c++·b+树·概念·结构·操作原理
Hardworking66612 小时前
第7章 软硬件系统集成
数据库·软硬件系统集成
名字还没想好☜12 小时前
Go 的 time.Ticker 陷阱:定时任务里被忽略的内存泄漏与正确关闭
java·数据库·golang·go·定时器
熊猫钓鱼>_>13 小时前
Redis 突发缓存穿透:一次完整的定位复盘
数据库·人工智能·redis·缓存·ai·agent·智能
wenb1n13 小时前
MySQL诊断系列(3/6):索引分析——5个SQL揪出“僵尸索引”
数据库·人工智能·编程语言
段一凡-华北理工大学14 小时前
向量数据库实战:选型、调优与落地~系列文章03:向量相似度算法全解:余弦、欧氏、内积,到底该用哪个?
大数据·数据库·人工智能·算法·机器学习·向量相似度·高炉炼铁
ClouGence15 小时前
CloudDM 数据库管理平台,全新 UI,更清晰、更高效!
数据库·开源
花生了什么事o17 小时前
DDD:领域驱动设计的初步认识
java·数据库
BGK11235817 小时前
基于qemu_v8+optee 4.00 平台构建 ca/ta
java·大数据·数据库