BSON 协议与数据类型

MongoDB 为什么会使用 BSON?

JSON 是当今非常通用的一种跨语言 Web 数据交互格式,属 ECMAScript 标准规范的一个子集。JSON (JavaScript Object Notation,JS 对象简谱)即 JavaScript 对象表示法,它是 JavaScript 对象的一种文本表现形式。
作为一种轻量级的数据交换格式,JSON 的可读性非常好,而且非常便于系统生成和解析,这些优势也让它逐渐取代了 XML 标准在 Web 领域的地位,当今许多流行的 Web 应用开发框架,如 SpringBoot 都选择了 JSON 作为默认的数据编/解码格式。
JSON 只定义了 6 种数据类型:

  • string: 字符串
  • number : 数值
  • object: JS 的对象形式,用 {key:value} 表示,可嵌套
  • array: 数组,JS 的表示方式 [value],可嵌套
  • true/false: 布尔类型
  • null: 空值

大多数情况下,使用 JSON 作为数据交互格式已经是理想的选择,但是 JSON 基于文本的解析效率并不是最好的,在某些场景下往往会考虑选择更合适的编/解码格式,一些做法如:

  • 在微服务架构中,使用 gRPC(基于 Google 的 Protobuf)可以获得更好的网络利用率。
  • 分布式中间件、数据库,使用私有定制的 TCP 数据包格式来提供高性能、低延时的计算能力。

BSON 由 10gen 团队设计并开源,目前主要用于 MongoDB 数据库。BSON(Binary JSON)是二进制版本的 JSON,其在性能方面有更优的表现。BSON 在许多方面和 JSON 保持一致,其同样也支持内嵌的文档对象和数组结构。二者最大的区别在于 JSON 是基于文本的,而 BSON 则是二进制(字节流)编/解码的形式。在空间的使用上,BSON 相比 JSON 并没有明显的优势。
MongoDB 在文档存储、命令协议上都采用了 BSON 作为编/解码格式,主要具有如下优势:

  • 类 JSON 的轻量级语义,支持简单清晰的嵌套、数组层次结构,可以实现模式灵活的文档结构。
  • 更高效的遍历,BSON 在编码时会记录每个元素的长度,可以直接通过 seek 操作进行元素的内容读取,相对 JSON 解析来说,遍历速度更快。
  • 更丰富的数据类型,除了 JSON 的基本数据类型,BSON 还提供了 MongoDB 所需的一些扩展类型,比如日期、二进制数据等,这更加方便数据的表示和操作。

BSON 的数据类型

MongoDB 中,一个 BSON 文档最大大小为 16M,文档嵌套的级别不超过100。

https://www.mongodb.com/docs/v6.0/reference/bson-types/

TypeNumberAliasNotes
Double1“double”
String2“string”
Object3“object”
Array4“array”
Binary data5“binData”二进制数据
Undefined6“undefined”Deprecated.
ObjectId7“objectId”对象ID,用于创建文档ID
Boolean8“bool”
Date9“date”
Null10“null”
Regular Expression11“regex”正则表达式
DBPointer12“dbPointer”Deprecated.
JavaScript13“javascript”
Symbol14“symbol”Deprecated.
JavaScript code with scope15“javascriptWithScope”Deprecated in MongoDB 4.4.
32-bit integer16“int”
Timestamp17“timestamp”
64-bit integer18“long”
Decimal12819“decimal”New in version 3.4.
Min key-1“minKey”表示一个最小值
Max key127“maxKey”表示一个最大值

t y p e 操作符: type 操作符:
type操作符:<br/>
type 操作符基于 BSON 类型来检索集合中匹配的数据类型,并返回结果。

db.books.find({"title" : {$type : 2}})// 或者db.books.find({"title" : {$type : "string"}})

日期类型

MongoDB 的日期类型使用 UTC(Coordinated Universal Time,即世界协调时)进行存储,也就是 +0 时区的时间。

db.dates.insertMany([{data1:Date()},{data2:new Date()},{data3:ISODate()}])db.dates.find().pretty()

使用 new Date 与 ISODate 最终都会生成 ISODate 类型的字段(对应于 UTC 时间)。

ObjectId 生成器

MongoDB 集合中所有的文档都有一个唯一的 _id 字段,作为集合的主键。在默认情况下,_id 字段使用 ObjectId 类型,采用 16 进制编码形式,共 12 个字节。

为了避免文档的 _id 字段出现重复,ObjectId 被定义为 3 个部分:

  • 4 字节表示 Unix 时间戳(秒)。
  • 5 字节表示随机数(机器号+进程号唯一)。
  • 3 字节表示计数器(初始化时随机)。

大多数客户端驱动都会自行生成这个字段,比如 MongoDB Java Driver 会根据插入的文档是否包含 _id 字段来自动补充 ObjectId 对象。这样做不但提高了离散性,还可以降低 MongoDB 服务器端的计算压力。在 ObjectId 的组成中,5 字节的随机数并没有明确定义,客户端可以采用机器号、进程号来实现:

属性/方法描述
str返回对象的十六进制字符串表示。
ObjectId.getTimestamp()将对象的时间戳部分作为日期返回。
ObjectId.toString()以字符串文字“”的形式返回 JavaScript 表示ObjectId(…)。
ObjectId.valueOf()将对象的表示形式返回为十六进制字符串。返回的字符串是 str 属性。

生成一个新的 ObjectId:

x = ObjectId()

内嵌文档和数组

内嵌文档

一个文档中可以包含作者的信息,包括作者名称、性别、家乡所在地,一个显著的优点是,当我们查询 book 文档的信息时,作者的信息也会一并返回。

db.books.insert({title: "撒哈拉的故事",author: {name:"三毛",gender:"女",hometown:"重庆"}})

查询三毛的作品:

db.books.find({"author.name":"三毛"})

修改三毛的家乡所在地:

db.books.updateOne({"author.name":"三毛"},{$set:{"author.hometown":"重庆/台湾"}})

数组

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

db.books.updateOne({"author.name":"三毛"},{$set:{tags:["旅行","随笔","散文","爱情","文学"]}})

查询数组元素:

# 会查询到所有的tagsdb.books.find({"author.name":"三毛"},{title:1,tags:1})# 利用$slice获取最后一个tagdb.books.find({"author.name":"三毛"},{title:1,tags:{$slice:-1}})

$silice 是一个查询操作符,用于指定数组的切片方式

数组末尾追加元素,可以使用 $push 操作符:

db.books.updateOne({"author.name":"三毛"},{$push:{tags:"猎奇"}})

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

db.books.updateOne({"author.name":"三毛"},{$push:{tags:{$each:["伤感","想象力"]}}})

如果加上 $slice 操作符,那么只会保留经过切片后的元素:

db.books.updateOne({"author.name":"三毛"},{$push:{tags:{$each:["伤感","想象力"],$slice:-3}}})

根据元素查询:

# 会查出所有包含伤感的文档db.books.find({tags:"伤感"})# 会查出所有同时包含"伤感","想象力"的文档db.books.find({tags:{$all:["伤感","想象力"]}})

嵌套型的数组

数组元素可以是基本类型,也可以是内嵌的文档结构

{tags:[{tagKey:xxx,tagValue:xxxx},{tagKey:xxx,tagValue:xxxx}]}

这种结构非常灵活,一个很适合的场景就是商品的多属性表示。

一个商品可以同时包含多个维度的属性,比如尺码、颜色、风格等,使用文档可以表示为:

db.goods.insertMany([{name:"羽绒服",tags:[{tagKey:"size",tagValue:["M","L","XL","XXL","XXXL"]},{tagKey:"color",tagValue:["黑色","宝蓝"]},{tagKey:"style",tagValue:"韩风"}]},{name:"羊毛衫",tags:[{tagKey:"size",tagValue:["L","XL","XXL"]},{tagKey:"color",tagValue:["蓝色","杏色"]},{tagKey:"style",tagValue:"韩风"}]}])

以上的设计是一种常见的多值属性的做法,当我们需要根据属性进行检索时,需要用到 $elementMatch 操作符:

# 筛选出color=黑色的商品信息db.goods.find({tags:{$elemMatch:{tagKey:"color",tagValue:"黑色"}}})

如果进行组合式的条件检索,则可以使用多个 $elemMatch 操作符:

# 筛选出color=蓝色,并且size=XL的商品信息db.goods.find({tags:{$all:[{$elemMatch:{tagKey:"color",tagValue:"黑色"}},{$elemMatch:{tagKey:"size",tagValue:"XL"}}]}})

固定(封顶)集合

https://www.mongodb.com/docs/manual/core/capped-collections/

固定集合(capped collection)是一种限定大小的集合,其中 capped 是覆盖、限额的意思。跟普通的集合相比,数据在写入这种集合时遵循 FIFO 原则。可以将这种集合想象为一个环状的队列,新文档在写入时会被插入队列的末尾,如果队列已满,那么之前的文档就会被新写入的文档所覆盖。通过固定集合的大小,我们可以保证数据库只会存储“限额”的数据,超过该限额的旧数据都会被丢弃。

使用示例

创建固定集合:

db.createCollection("logs",{capped:true,size:4096,max:10})

max:指集合的文档数量最大值,这里是 10 条
size:指集合的空间占用最大值,这里是 4096 字节(4 KB)

这两个参数会同时对集合的上限产生影响。也就是说,只要任一条件达到阈值都会认为集合已经写满。其中 size 是必选的,而 max 则是可选的。
可以使用collection.stats命令查看文档的占用空间:

db.logs.stats()


将普通集合转换为固定集合:

db.runCommand({"convertToCapped": "mycoll", size: 100000})

测试:
尝试在这个集合中插入 15 条数据,再查询会发现,由于文档数量上限被设定为 10 条,前面插入的 5 条数据已经被覆盖了。

for(var i=0;i<15;i++){db.logs.insert({t:"row-"+i})}

适用场景

固定集合很适合用来存储一些“临时态”的数据。“临时态”意味着数据在一定程度上可以被丢弃。同时,用户还应该更关注最新的数据,随着时间的推移,数据的重要性逐渐降低,直至被淘汰处理。
一些适用的场景如下:

  • 系统日志

这非常符合固定集合的特征,而日志系统通常也只需要一个固定的空间来存放日志。在 MongoDB 内部,副本集的同步日志(oplog)就使用了固定集合。

  • 存储少量文档

如最新发布的 TopN 条文章信息。得益于内部缓存的作用,对于这种少量文档的查询是非常高效的。