MongoDB数据模型和WiredTiger读写模型

MongoDB数据模型

思考:MongoDB为什么会使用BSON?

BSON协议与数据类型

JSON

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

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://docs.mongodb.com/v4.4/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 操作符 < b r / > type操作符<br /> type操作符<br/>type操作符基于BSON类型来检索集合中匹配的数据类型,并返回结果。

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

日期类型

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

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

ObjectId生成器

MongoDB集合中所有的文档都有一个唯一的_id字段,作为集合的主键。在默认情况下,_id字段使用ObjectId类型,采用16进制编码形式,共12个字节。
image.png
为了避免文档的_id字段出现重复,ObjectId被定义为3个部分:

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

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

属性/方法描述
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)
image.png
增加tags标签

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

image.png
查询数组元素

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

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

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

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

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

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

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

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

image.png
根据元素查询

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

image.png

嵌套型的数组

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

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

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

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"}}
        ]  
    }
})

固定集合

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

创建固定集合

db.createCollection("logs",{capped:true,size:4096,max:10})
  • max:指集合的文档数量最大值,这里是10条
  • size:指集合的空间占用最大值,这里是4096字节(4KB)

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

db.logs.stats() 

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

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

image.png

优势与限制

固定集合在底层使用的是顺序I/O操作,而普通集合使用的是随机I/O。顺序I/O在磁盘操作上由于寻道次数少而比随机I/O要高效得多,因此固定集合的写入性能是很高的。此外,如果按写入顺序进行数据读取,也会获得非常好的性能表现。
但它也存在一些限制,主要有如下5个方面:

  1. 无法动态修改存储的上限,如果需要修改max或size,则只能先执行collection.drop命令,将集合删除后再重新创建。
  2. 无法删除已有的数据,对固定集合中的数据进行删除将会得到如下错误:

image.png

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

image.png

  1. 默认情况下,固定集合只有一个_id索引,而且最好是按数据写入的顺序进行读取。当然,也可以添加新的索引,但这会降低数据写入的性能。
  2. 固定集合不支持分片,同时,在MongoDB 4.2版本中规定了事务中也无法对固定集合执行写操作。

适用场景

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

  • 系统日志,这非常符合固定集合的特征,而日志系统通常也只需要一个固定的空间来存放日志。在MongoDB内部,副本集的同步日志(oplog)就使用了固定集合。
  • 存储少量文档,如最新发布的TopN条文章信息。得益于内部缓存的作用,对于这种少量文档的查询是非常高效的。

使用固定集合实现FIFO队列

在股票实时系统中,大家往往最关心股票价格的变动。而应用系统中也需要根据这些实时的变化数据来分析当前的行情。倘若将股票的价格变化看作是一个事件,而股票交易所则是价格变动事件的“发布者”,股票APP、应用系统则是事件的“消费者”。这样,我们就可以将股票价格的发布、通知抽象为一种数据的消费行为,此时往往需要一个消息队列来实现该需求。
结合业务场景: 利用固定集合实现存储股票价格变动信息的消息队列

1. 创建stock_queue消息队列,其可以容纳10MB的数据

db.createCollection("stock_queue",{capped:true,size:10485760}) 
  1. 定义消息格式
db.createCollection("stock_queue",{capped:true,size:10485760}) 
  • timestamp指股票动态消息的产生时间。
  • stock指股票的名称。
  • price指股票的价格,是一个Double类型的字段。

为了能支持按时间条件进行快速的检索,比如查询某个时间点之后的数据,可以为timestamp添加索引

db.stock_queue.createIndex({timestamped:1}) 

image.png
3. 构建生产者,发布股票动态
模拟股票的实时变动

function pushEvent(){
    while(true){
        db.stock_queue.insert({
            timestamped:new Date(),
            stock: "MongoDB Inc",
            price: 100*Math.random(1000)
        });
        print("publish stock changed");
        sleep(1000);
    }
}

执行pushEvent函数,此时客户端会每隔1秒向stock_queue中写入一条股票信息

pushEvent() 

image.png
4. 构建消费者,监听股票动态
对于消费方来说,更关心的是最新数据,同时还应该保持持续进行“拉取”,以便知晓实时发生的变化。根据这样的逻辑,可以实现一个listen函数

function listen(){
    var cursor = db.stock_queue.find({timestamped:{$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),则直接将数据打印到控制台。

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

listen()

image.png

WiredTiger读写模型详解

WiredTiger介绍

MongoDB从3.0开始引入可插拔存储引擎的概念。目前主要有MMAPV1、WiredTiger存储引擎可供选择。在3.22源的消耗,节省约60%以上的硬盘资源;

5WiredTiger读写模型

读缓存

理想情况下,MongoDB可以提供近似内存式的读写性能。WiredTiger引擎实现了数据的二级缓存,第一层是操作系统的页面缓存,第二层则是引擎提供的内部缓存。
image.png
读取数据时的流程如下:

  • 数据库发起Buffer I/O读操作,由操作系统将磁盘数据页加载到文件系统的页缓存区。
  • 引擎层读取页缓存区的数据,进行解压后存放到内部缓存区。
  • 在内存中完成匹配查询,将结果返回给应用。

MongoDB为了尽可能保证业务查询的“热数据”能快速被访问,其内部缓存的默认大小达到了内存的一半,该值由wiredTigerCacheSize参数指定,其默认的计算公式如下:

 wiredTigerCacheSize=Math.max(0.5*(RAM-1GB),256MB) 

写缓冲

当数据发生写入时,MongoDB并不会立即持久化到磁盘上,而是先在内存中记录这些变更,之后通过CheckPoint机制将变化的数据写入磁盘。为什么要这么处理?主要有以下两个原因:

  • 如果每次写入都触发一次磁盘I/O,那么开销太大,而且响应时延会比较大。
  • 多个变更的写入可以尽可能进行I/O合并,降低资源负荷。

思考:MongoDB会丢数据吗?
MongoDB单机下保证数据可靠性的机制包括以下两个部分:

  1. CheckPoint(检查点)机制
    快照(snapshot)描述了某一时刻(point-in-time)数据在内存中的一致性视图,而这种数据的一致性是WiredTiger通过MVCC(多版本并发控制)实现的。当建立CheckPoint时,WiredTiger会在内存中建立所有数据的一致性快照,并将该快照覆盖的所有数据变化一并进行持久化(fsync)。成功之后,内存中数据的修改才得以真正保存。默认情况下,MongoDB每60s建立一次CheckPoint,在检查点写入过程中,上一个检查点仍然是可用的。这样可以保证一旦出错,MongoDB仍然能恢复到上一个检查点。
  2. Journal日志
    Journal是一种预写式日志(write ahead log)机制,主要用来弥补CheckPoint机制的不足。如果开启了Journal日志,那么WiredTiger会将每个写操作的redo日志写入Journal缓冲区,该缓冲区会频繁地将日志持久化到磁盘上。默认情况下,Journal缓冲区每100ms执行一次持久化。此外,Journal日志达到100MB,或是应用程序指定journal:true,写操作都会触发日志的持久化。一旦MongoDB发生宕机,重启程序时会先恢复到上一个检查点,然后根据Journal日志恢复增量的变化。由于Journal日志持久化的间隔非常短,数据能得到更高的保障,如果按照当前版本的默认配置,则其在断电情况下最多会丢失100ms的写入数据。
    image.png
    WiredTiger写入数据的流程:
  • 应用向MongoDB写入数据(插入、修改或删除)。
  • 数据库从内部缓存中获取当前记录所在的页块,如果不存在则会从磁盘中加载(Buffer I/O)
  • WiredTiger开始执行写事务,修改的数据写入页块的一个更新记录表,此时原来的记录仍然保持不变。
  • 如果开启了Journal日志,则在写数据的同时会写入一条Journal日志(Redo Log)。该日志在最长不超过100ms之后写入磁盘
  • 数据库每隔60s执行一次CheckPoint操作,此时内存中的修改会真正刷入磁盘。

Journal日志的刷新周期可以通过参数storage.journal.commitIntervalMs指定,MongoDB 3.4及以下版本的默认值是50ms,而3.6版本之后调整到了100ms。由于Journal日志采用的是顺序I/O写操作,频繁地写入对磁盘的影响并不是很大。

CheckPoint的刷新周期可以调整storage.syncPeriodSecs参数(默认值60s),在MongoDB 3.4及以下版本中,当Journal日志达到2GB时同样会触发CheckPoint行为。如果应用存在大量随机写入,则CheckPoint可能会造成磁盘I/O的抖动。在磁盘性能不足的情况下,问题会更加显著,此时适当缩短CheckPoint周期可以让写入平滑一些。

  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值