MongoDB 4.x——数据模型
MongoDB 4.x数据模型1、BSON协议与类型1.1、JSON标准1.2、BSON和JSON1.3、BSON的数据类型2、使用日期3、ObjectId生成器4、数组、内嵌4.1、内嵌文档4.2、数组4.2.1、查询元素4.2.2、修改元素4.2.3、根据元素查询4.3、嵌套型的数组5、固定集合5.1、固定集合简介5.2、使用示例5.3、特征与限制5.4、适用场景6、使用固定集合实现FIFO队列6.1、发布股票动态6.2、监听股票动态1、BSON协议与类型1.1、JSON标准JSON是当今非常通用的一种跨语言Web数据交互格式属于ECMAScript标准规范的一个子集。JSONJavaScript Object Notation, JS对象简谱即JavaScript对象表示法。顾名思义JSON与JavaScript语言是分不开的它是JavaScript对象的一种文本表现形式。作为一种轻量级的数据交换格式JSON的可读性非常好而且非常便于系统生成和解析这些优势也让它逐渐取代了XML标准在Web领域的地位当今许多流行的Web应用开发框架如SpringBoot都选择了JSON作为默认的数据编/解码格式。以JSON格式定义的数据形式如下{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数据包格式来提供高性能、低延时的计算能力。BSONBinary JSON是二进制版本的JSON其在性能方面有更优的表现。BSON在许多方面和JSON保持一致其同样也支持内嵌的文档对象和数组结构。二者最大的区别在于JSON是基于文本的而BSON则是二进制字节流编/解码的形式。除此之外BSON还提供了一些扩展的数据类型比如日期、二进制数据等。为了更浅显地表示二者的不同可以看看下面这个简单的JSON对象{hello:world}其对应的BSON的格式编码为\x16\x00\x00\x00//total document size\x02//0x02 type Stringhello\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的日期类型使用UTCCoordinated UniversalTime进行存储也就是0时区的时间。一般客户端会根据本地时区自动转换为UTC时间代码如下newDate()ISODate(2026-07-19T13:19:36.998Z)在这里ISODate是对于UTC时间的包装类。下面再看一个稍微复杂的例子代码如下vardate1Date();vardate2newDate();vardate3ISODate();vardateObject{d1:date1,d2:date3,d3:date3}db.dates.insert(dateObject);db.dates.find().pretty()执行上述代码将会看到输出如下{_id:ObjectId(6a5cd42bfcf45abb4d8df3b9),d1:Sun Jul 19 2026 21:42:03 GMT0800 (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操作符可以看到其中的不同代码如下print(typeof(Date()))stringprint(typeof(newDate()))objectprint(typeof(ISODate()))object3、ObjectId生成器MongoDB集合中所有的文档都有一个唯一的_id字段作为集合的主键。在默认情况下_id字段使用ObjectId类型。如下面的代码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 Driver3.6.2版本。当然具体应用也可以使用自动生成的_id但必须保证_id的唯一性。4、数组、内嵌支持灵活的数据结构是MongoDB这种文档数据库的一大优势。在面向对象的编程方式中对象的成员可以是多种形式的包括数组、子对象等。但是当我们希望将对象中的数据持久化到传统的关系型数据库中时却发现没有很好的匹配模式。常见的一些做法如使用平铺式的多列式结构如用tag1、tag2、tag3…表示数组中的若干个元素。使用序列化的单列进行收敛比如将数组或子对象转换为JSON字符串后存储到某个列在读取时再进行解析。无论哪一种方式都是存在一些弊端的。平铺式的结构会导致列的数量膨胀关系型数据库需要提前设计好Schema但数组往往是动态的无法满足快速变化的需求单列序列化的方式带来了应用上的复杂性数据库无法理解该列的内部结构所能提供的操作只有“整存整取”。MongoDB的文档模型充分理解了数组、内嵌式文档的数据结构除了可以方便地对数组内的元素、内嵌文档的字段进行操作还可以对这些“内嵌式”的字段进行索引以满足快速的查询。它们在使用方式上和普通的字段并没有什么大的不同这是文档型数据库的一种强大的表现力。值得注意的是一些关系型数据库如MySQL、PostgreSQL在后来也支持数组和内嵌对象的类型充分说明了该能力的重要性及普适性。4.1、内嵌文档让我们再回到前面的例子一个book文档中可以包含作者的信息包括作者名称、性别、家乡所在地等代码如下{title:撒哈拉的故事,...author:{name:三毛,gender:女,hometown:重庆}}一个显著的优点是当我们查询book文档的信息时作者的信息也会一并返回。如果只希望返回作者的名称则可以指定author.name代码如下也可以将author.name作为查询条件代码如下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所组成文档结构如下{tags:[{tagKey:xxx,tagValue:xxx},{tagKey:xxx,tagValue:xxx},...]}这种结构非常灵活一个很适合的场景就是商品的多属性表示如图所示。一个商品可以同时包含多个维度的属性比如尺码、颜色、风格等使用文档可以表示为db.goods.insert({name:羊毛衫,tags:[{tagKey:size,tagValue:大码},{tagKey:color,tagValue:蓝色},{tagKey:style,tagValue:韩风}]})以上的设计是一种常见的多值属性的做法当我们需要根据属性进行检索时需要用到$elementMatch操作符代码如下db.goods.find({tags:{$elemMatch:{tagKey:color,tagValue:蓝色}}}){_id:ObjectId(6a5cdb94fcf45abb4d8df3bb),name:羊毛衫,tags:[{tagKey:size,tagValue:大码},{tagKey:color,tagValue:蓝色},{tagKey:style,tagValue:韩风}]}当然如果进行组合式的条件检索则可以使用多个$elemMatch操作符代码如下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、使用示例通过下面的语句可以声明一个固定集合db.createCollection(logs,{capped:true,size:4096,max:10})这里指定了两个参数。max指集合的文档数量最大值这里是10条。size指集合的空间占用最大值这里是4096字节4KB。这两个参数会同时对集合的上限产生影响。也就是说只要任一条件达到阈值都会认为集合已经写满。其中size是必选的而max则是可选的。我们尝试在这个集合中插入15条数据代码如下for(vari0;i15;i){db.logs.insert({t:row-i})}接着尝试查询集合里的数据可以看到由于文档数量上限被设定为10条前面插入的5条数据已经被覆盖了结果如下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限额时也会产生覆盖的情况db.createCollection(logs,{capped:true,size:4096})可以使用collection.stats命令查看文档的占用空间代码如下其中“size”530表示文档总大小为530字节而maxSize4096则是文档总大小的上限。需要注意maxSize的值必须是2的n 次方。如果设定值不符合条件则会被自动对齐比如创建固定集合时指定size500那么最终的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来指定该消息队列的容量代码如下vardbdb.getSiblingDB(appdb);db.createCollection(stock_queue,{capped:true,size:10485760})这样我们就拥有了stock_queue消息队列其可以容纳10MB的数据。每一条消息的格式可以定义为如下形示。{timestamp:newDate(),stock:MongoDB Inc,price:30.31}timestamp指股票动态消息的产生时间。stock指股票的名称。price指股票的价格是一个Double类型的字段。其中为了能支持按时间条件进行快速的检索比如查询某个时间点之后的数据可以为timestamp添加索引代码如下db.stock_queue.createIndex({timestamp:1}){numIndexesBefore:1,numIndexesAfter:2,createdCollectionAutomatically:false,ok:1}6.1、发布股票动态为了模拟股票的实时变动我们实现如下函数functionpushEvent(){while(true){db.stock_queue.insert({timestamp:newDate(),stock:MongoDB Inc,price:100*Math.random(1000)});print(publish stock changed.)sleep(1000);}}执行pushEvent函数此时客户端会每隔1秒向stock_queue中写入一条股票信息结果如下pushEvent()publish stock changed.publish stock changed.publish stock changed.publish stock changed....6.2、监听股票动态对于股票动态的消费方来说更关心的是最新数据同时还应该保持持续进行“拉取”以便知晓实时发生的变化。根据这样的逻辑可以实现一个listen函数代码如下functionlisten(){varcursordb.stock_queue.find({timestamp:{$gte:newDate()}}).tailable()while(true){if(cursor.hasNext()){print(JSON.stringify(cursor.next(),null,2))}sleep(1000);}}上述代码中find操作的查询条件被指定为仅查询比当前时间更新的数据而由于采用了读取游标的方式因此游标在获取不到数据时并不会被关闭这种行为非常类似于Linux中的tail-f命令。接下来在一个循环中会定时检查是否有新的数据产生一旦发现新的数据cursor.hasNext()true则直接将数据打印到控制台。执行这个监听函数就可以看到实时发布的股票信息代码如下