MongoDB数据模型设计

3,519 阅读8分钟

思考:NoSQL是否需要数据建模?

场景1:假设现在需要保存一个企业的员工信息、部门信息,我们应该选择用一张表嵌入完整部门信息还是只嵌入主键ID?

场景2:假设现在有一批传感器每分钟都会上报监测的温度、湿度信息,后端需要提供
一些聚合查询场景,我们应该怎么建模?

一、数据库三范式

1.三范式的定义

(1)第一范式(1NF):数据库表的每一列都是不可分割的原子项。

如下就不符合第一范式,地址这一列可以拆分为省、市、区、详细地址四个字段。

编号姓名性别年龄地址
1张三18四川省成都市双流区万顺路一段
2李四17四川省德阳市旌阳区XXX路

第一范式要求将列尽可能分割到最小的粒度,希望消除利用某个列存储多值的行为,而且每个列都可以独立进行查询。

对于这一条要根据实际需求来,比如你每次查询返回的就是整个地址则没必要再拆分。

(2)第二范式(2NF):每个表必须有且仅有一个主键(primary key),其他属性需完全依赖于主键。这里除了主键,还定义了不允许存在对主键的部分依赖。

比如要设计一个订单信息表,因为订单中可能会有多种商品,所以要将订单编号和商品编号作为数据库表的联合主键,如下表所示。

订单编号商品编号商品名称数量单位价格商品类别物流信息
0011洗面奶120洗护中通快递
0012牙膏215洗护韵达快递
0023电风扇1200电器京东物流

对于商品类别这个属性,我们认为其仅仅与商品编号有关,也就是仅依赖于主键的一部分,因此这是违反第二范式的。改善的做法应该是将商品类别存放于商品信息表中。

(3)第三范式(3NF):数据表中的每一列都和主键直接相关,而不能间接相关。

比如上面的图,商品名称、数量、单位、类别和商品编号直接相关和订单编号间接相关,所以就不满足第三范式。应该把这些信息都放在商品信息表中。

2.范式的优缺点

(1)范式设计消除了冗余,因此需要的空间更少。而且,范式化的表更容易进行更新,有利于保证数据的一致性。但是,其缺点在于关联查询较慢,一些查询需要在数据库中执行多次查找,如果只考虑磁盘操作,则相当于增加了磁盘的随机I/O,这是比较昂贵的。

(2)反范式的设计一般可以优化读取的性能,MongoDB很少会使用数据库的关联查询,因此通过嵌套式设计的方式还能减少客户端与数据库之间的调用次数(网络I/O)。此外,使用嵌入还能获得写入数据的原子性保证,即要么完全成功,要么完全失败。

二、模型设计

数据建模设计时,我们要考虑多方面的因素,如使用场景中是读多还是写多、数据查询方式、数据库本身的性能等,在各个因素中做 tradeoff,找到最适合自己业务场景的设计。

1.嵌入设计

将相关数据嵌入到单个结构或文档中。MongoDB实际上鼓励尽量使用嵌入设计。在 MongoDB 中,写入操作在单个文档级别是原子的,即使该操作修改了单个文档中的多个嵌入文档。当单个写操作修改多个文档时(例如db.collection.updateMany()),每个文档的修改是原子的,但操作整体上不是原子的。

image.png

通常,在以下情况下使用嵌入式数据模型:

  • 在实体之间具有“包含”关系。(一对一)

  • 实体之间存在一对多关系。在这些关系中,“多个”或子文档始终与“一个”或父文档一起出现或在其上下文中查看。(一对多)

image.png

一般来说,嵌入为读取操作提供了更好的性能,以及在单个数据库操作中请求和检索相关数据的能力。嵌入式数据模型保证了单个文档更新的原子性。

(1)内嵌文档

对于一对多的关系可以使用内嵌文档,如下:

{
   "_id": "joe",
   "name": "Joe Bookreader",
   "addresses": [
       {
         "street": "123 Fake Street",
         "city": "Faketon",
         "state": "MA",
         "zip": "12345"
       },
       {
         "street": "1 Some Other Street",
         "city": "Boston",
         "state": "MA",
         "zip": "12345"
       }
   ]
 }

嵌入设计提升了查询性能,一次查询就可以获取到想要信息。但是也有一定限制:

  • 嵌入的文档不能是无限增加

  • 单个文档的大小不能操作16M(节省内存、带宽)

详见: docs.mongodb.com/manual/refe…

(2)内嵌引用

内嵌引用是内嵌文档的另一种形式,它只会在内嵌文档中保留子文档ID,而不是全部字段。如下:

// 用户信息
{
    "_id": "joe",
    "name": "Joe Bookreader",
    "addresses": [
        1,
        2
    ]
}

// 地址信息
[
    {
        "id": 1,
        "street": "123 Fake Street",
        "city": "Faketon",
        "state": "MA",
        "zip": "12345"
    },
    {
        "id": 2,
        "street": "1 Some Other Street",
        "city": "Boston",
        "state": "MA",
        "zip": "12345"
    }
]

内嵌引用查询关联文档时需要查询两次,如上第一次查询主要拿到地址 id 集合,然后到地址表用 $in 查询全部地址信息,这样查询性能也不差。

使用内嵌文档的主要原因:

  • 内嵌文档体积很大,可能无限增长或超出16M限制

  • 内嵌文档频繁更新,内嵌引用后不会因为子文档更新而更新所有相关的文档

(3)引用模式

引用模式类似于外键,一般用某个字段作为引用关联另一个表。

如下表示一对一关系的引用:

// 学生表
{
  "id":1,
  "name":"张三",
  "class":1
}

// 班级表
{
  "id":1,
  "name":"一年级一班",
  "teacher":"张老师"
}

如下多对多的关系表示:

// 教师表
[
  {
    "id":1,
    "name":"张老师"
	},
  {
    "id":2,
    "name":"李老师"
  }
]

// 科目表
[
  {
    "id":1,
    "语文"
  }{
    "id":2,
    "数学"
  },
 {
    "id":3,
    "英语"
  }
]

// 教师-科目关系表
[
  {
    "techer_id":1,
    "subject_id":1
  },
  {
    "techer_id":1,
    "subject_id":2
  }
]

image.png

选择引用模式的原因:

  • 关联文档非常多或者增长不受限制。例如微博的评论列表。
  • 业务实体关系层级复杂
  • 多对多优先采用引用关系
  • 数据一致性要求高,避免冗余数据场景
(4)子集模式

如果我们查询数据大部分时候不需要嵌入文档的全部数据,这些不必要的数据可能会给服务器带来额外的负载并减慢读取操作的速度。此时,你可以使用子集模式来检索在单个数据库调用中最常访问的数据子集。

如下一个显示电影信息的应用程序:

{
  "_id": 1,
  "title": "The Arrival of a Train",
  "year": 1896,
  "runtime": 1,
  "released": ISODate("01-25-1896"),
  "poster": "http://ia.media-imdb.com/images/M/MV5BMjEyNDk5MDYzOV5BMl5BanBnXkFtZTgwNjIxMTEwMzE@._V1_SX300.jpg",
  "plot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, ...",
  "fullplot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, the line dissolves. The doors of the railway-cars open, and people on the platform help passengers to get off.",
  "lastupdated": ISODate("2015-08-15T10:06:53"),
  "type": "movie",
  "directors": [ "Auguste Lumière", "Louis Lumière" ],
  "imdb": {
    "rating": 7.3,
    "votes": 5043,
    "id": 12
  },
  "countries": [ "France" ],
  "genres": [ "Documentary", "Short" ],
  "tomatoes": {
    "viewer": {
      "rating": 3.7,
      "numReviews": 59
    },
    "lastUpdated": ISODate("2020-01-09T00:02:53")
  }
}

目前,电影集合包含应用程序不需要显示电影简单概述的几个字段,例如 fullplotrating。您可以将集合拆分为两个集合,而不是将所有电影数据存储在单个集合中:

电影集合包含电影的基本信息。这是应用程序默认加载的数据:

// movie collection

{
  "_id": 1,
  "title": "The Arrival of a Train",
  "year": 1896,
  "runtime": 1,
  "released": ISODate("1896-01-25"),
  "type": "movie",
  "directors": [ "Auguste Lumière", "Louis Lumière" ],
  "countries": [ "France" ],
  "genres": [ "Documentary", "Short" ],
}

movie_details 集合包含每部电影的额外的、访问频率较低的数据:

// movie_details collection

{
  "_id": 156,
  "movie_id": 1, // reference to the movie collection
  "poster": "http://ia.media-imdb.com/images/M/MV5BMjEyNDk5MDYzOV5BMl5BanBnXkFtZTgwNjIxMTEwMzE@._V1_SX300.jpg",
  "plot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, ...",
  "fullplot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, the line dissolves. The doors of the railway-cars open, and people on the platform help passengers to get off.",
  "lastupdated": ISODate("2015-08-15T10:06:53"),
  "imdb": {
    "rating": 7.3,
    "votes": 5043,
    "id": 12
  },
  "tomatoes": {
    "viewer": {
      "rating": 3.7,
      "numReviews": 59
    },
    "lastUpdated": ISODate("2020-01-29T00:02:53")
  }
}

这种方法提高了读的性能,因为程序只需要读取更少的数据来满足常见请求。如果需要,再进行额外的数据库查询以获取访问频率较低的数据。

提示:

在设计时,我们应当把最常访问的数据放在主文档中。但如果每次都是需要全部数据则此模式就不太适合了。关键还是要看使用场景。

2.树形结构设计

(1)父引用模型树结构

父引用模式将每一个树节点存储在一个文档中。除了树节点,文档中还存储了该节点的父节点 id。

image.png 请看下面的例子:

db.categories.insertMany( [
   { _id: "MongoDB", parent: "Databases" },
   { _id: "dbm", parent: "Databases" },
   { _id: "Databases", parent: "Programming" },
   { _id: "Languages", parent: "Programming" },
   { _id: "Programming", parent: "Books" },
   { _id: "Books", parent: null }
] )

查询节点父节点:

db.categories.findOne( { _id: "MongoDB" } ).parent

为 parent 字段创建索引:

db.categories.createIndex( { parent: 1 } )

查询父节点的子节点:

db.categories.find( { parent: "Databases" } )
(2)子引用模型树结构

子引用模式模式将每个树节点存储在一个文档中。除了树节点,文档中还要用一个数组保存节点的子节点。

image.png

请看例子:

db.categories.insertMany( [
   { _id: "MongoDB", children: [] },
   { _id: "dbm", children: [] },
   { _id: "Databases", children: [ "MongoDB", "dbm" ] },
   { _id: "Languages", children: [] },
   { _id: "Programming", children: [ "Databases", "Languages" ] },
   { _id: "Books", children: [ "Programming" ] }
] )

查询节点的子节点:

db.categories.findOne( { _id: "Databases" } ).children

为 children 字段创建索引:

db.categories.createIndex( { children: 1 } )

查询子节点的父节点:

db.categories.find( { children: "MongoDB" } )

只要不对子树进行操作,就可以用子引用模式。这种模式还可以为存储节点可能有多个父节点的图形结构提供合适的解决方案。

(3)具有祖先数组的模型树结构

祖先数组模式将每个树节点存储在一个文档中。除了树节点,文档还要将节点的祖先或路径的id存储在一个数组中。

image.png

以下示例使用祖先数组对树进行建模。除了祖先字段之外,这些文档还在父字段中存储了对直接父类别的引用:

db.categories.insertMany( [
  { _id: "MongoDB", ancestors: [ "Books", "Programming", "Databases" ], parent: "Databases" },
  { _id: "dbm", ancestors: [ "Books", "Programming", "Databases" ], parent: "Databases" },
  { _id: "Databases", ancestors: [ "Books", "Programming" ], parent: "Programming" },
  { _id: "Languages", ancestors: [ "Books", "Programming" ], parent: "Programming" },
  { _id: "Programming", ancestors: [ "Books" ], parent: "Books" },
  { _id: "Books", ancestors: [ ], parent: null }
] )

查询节点的祖先节点或路径节点:

db.categories.findOne( { _id: "MongoDB" } ).ancestors

为 ancestors 字段创建索引:

db.categories.createIndex( { ancestors: 1 } )

查找祖先节点的所有后代:

db.categories.find( { ancestors: "Programming" } )

祖先数组模式通过在 ancestors 字段的元素上创建索引来查找节点的后代和祖先。该模式可以快速的查找子树。

(4)实体化路径模型树结构

实体化路径模式将每个树节点存储在一个文档中。除了树节点,文档还将节点的祖先或路径的 id 存储为字符串。尽管物化路径模式需要额外的处理字符串和正则表达式的步骤,但该模式为处理路径提供了更大的灵活性,例如通过部分路径查找节点。

image.png

例子:

db.categories.insertMany( [
   { _id: "Books", path: null },
   { _id: "Programming", path: ",Books," },
   { _id: "Databases", path: ",Books,Programming," },
   { _id: "Languages", path: ",Books,Programming," },
   { _id: "MongoDB", path: ",Books,Programming,Databases," },
   { _id: "dbm", path: ",Books,Programming,Databases," }
] )

查询检索整棵树,按字段路径排序:

db.categories.find().sort( { path: 1 } )

使用正则表达式查询 Programming 的后代:

db.categories.find( { path: /,Programming,/ } )

查询 Books 的后代:

db.categories.find( { path: /^,Books,/ } )

path 字段上创建索引:

db.categories.createIndex( { path: 1 } )

此索引还是得满足最左匹配原则,从 根节点开始的 /,Books,//,Books,Programming,/ 可以提高查询性能,其他情况可能不太有效。

(5)嵌套集模型树结构

该模式不太常见,具体 点击

3.桶模式

桶模式就是根据某个维度因子(通常是时间),将多个具有一定关系的文档聚合放到一个文档内的方式,具体实现时可以采用 MongoDB 内嵌文档或是数组。

桶模式非常适合用于物联网(IoT)、实时分析以及时间序列数据的场景。

例子:假设我们现在需要采集传感器的数据,传感器能收集温度、湿度,每分钟上报一次。后端负责存储,并且提供查询和一些统计功能。

(1)传统方式

常规情况,我们很容易想到下面的数据结构:

> db.sensor.find().pretty()
{
	"_id" : ObjectId("60e319d4803e0e77e67b5e9e"),
	"sensor_od" : "SENSOR-1",
	"temperature" : 20.36,
	"humidity" : 0.67,
	"created_time" : "2021-07-05 10:01:00"
}
{
	"_id" : ObjectId("60e319d4803e0e77e67b5e9f"),
	"sensor_od" : "SENSOR-2",
	"temperature" : 21.36,
	"humidity" : 0.87,
	"created_time" : "2021-07-05 10:01:00"
}

每次数据上报,会执行数据写入操作:

> db.sensor.insertOne({"sensor_od":"SENSOR-3","temperature":21.36,"humidity":0.67,"created_time":"2021-07-05 10:01:00"})

我们如果要查询某个传感器低端时间的温度、湿度:

> db.sensor.find({"sensor_od":"SENSOR-1","created_time":{$gte:"2021-07-05 10:00:00",$lt:"2021-07-05 11:00:00"}})


{ "_id" : ObjectId("60e319d4803e0e77e67b5e9e"), "sensor_od" : "SENSOR-1", "temperature" : 20.36, "humidity" : 0.67, "created_time" : "2021-07-05 10:01:00" }
{ "_id" : ObjectId("60e31b15803e0e77e67b5ea1"), "sensor_od" : "SENSOR-1", "temperature" : 23.36, "humidity" : 0.57, "created_time" : "2021-07-05 10:02:00" }
{ "_id" : ObjectId("60e31b20803e0e77e67b5ea2"), "sensor_od" : "SENSOR-1", "temperature" : 25.36, "humidity" : 0.67, "created_time" : "2021-07-05 10:03:00" }

为了加快查询速度,我们要创建联合索引:

db.sensor.createIndex({"sensor_id":1,"created_time":1})

这种方式读写简单,但是随着时间变化,写入的文档数量会非常多。假设我们有100个传感器,1个月后 sensor 的文档就会达到432万条,如果是10000个传感器数据数据将超过4亿条。

(2)按时间分桶

其实我们经过思考可以发现,大部分时候我们是很少会查看单次数据上报的值,我们更多关注的是均值以及一段时间内数值的趋势变化。

我们现在才去按天、小时的分桶方式:

  • 以1天单位,每小时被表示为 "0,1,2...23",共24个刻度。

  • 以1小时为单位,每分钟被表示为 "0,1,2...59",共60个刻度。

最终文档变成了这样:

{
    "sensor_id": "SENSOR-1",
    "data":{
      // 1点
    	"0":{
        	// 00:00	
        	"0":{
            	"temperature": 21.36,
    					"humidity": 0.67
            },
            "1":{
            	"temperature": 22.36,
    					"humidity": 0.57
            },
        		// 00:02
            "2":{
            	"temperature": 23.36,
    					"humidity": 0.37
            }, 
            "59":{
            	"temperature": 25.36,
    					"humidity": 0.37
            },     
        },
        "1":{
        	"0":{
            	"temperature": 21.36,
    					"humidity": 0.67
            },
            "1":{
            	"temperature": 22.36,
    					"humidity": 0.57
            },
            "2":{
            	"temperature": 23.36,
    					"humidity": 0.37
            }    
        },
      // 23点
        "23":{
        	"0":{
            	"temperature": 21.36,
    					"humidity": 0.67
            },
            "1":{
            	"temperature": 22.36,
    					"humidity": 0.57
            },
            "2":{
            	"temperature": 23.36,
    					"humidity": 0.37
            }    
        }
    },
  	// 时间按天取整
    "created_time":"2021-07-05 00:00:00"  
}

这样一个传感器在一天内的数据被放在了一个文档内,因此查询某一天的数据就是这样:

db.sensor_bucket.find({"sensor_id":"SENSOR-1","created_time":"2021-07-05 00:00:00"})

如果需要查询一天内某个时刻的数据:

> db.sensor_bucket.find({"sensor_id":"SENSOR-1","created_time":"2021-07-05 00:00:00"},{"sensor_id":1,"created_time":1,"data.23.1":1}).pretty()


{
	"_id" : ObjectId("60e31fd6803e0e77e67b5ea3"),
	"sensor_id" : "SENSOR-1",
	"data" : {
		"23" : {
			"1" : {
				"temperature" : 22.36,
				"humidity" : 0.57
			}
		}
	},
	"created_time" : "2021-07-05 00:00:00"
}

如果要查询一天内一段时间内的数据:

> db.sensor_bucket.find({"sensor_id":"SENSOR-1","created_time":"2021-07-05 00:00:00"},{"sensor_id":1,"created_time":1,"data.0.0":1,"data.0.1":1,"data.0.2":1}).pretty()

{
	"_id" : ObjectId("60e31fd6803e0e77e67b5ea3"),
	"sensor_id" : "SENSOR-1",
	"data" : {
		"0" : {
			"0" : {
				"temperature" : 21.36,
				"humidity" : 0.67
			},
			"1" : {
				"temperature" : 22.36,
				"humidity" : 0.57
			},
			"2" : {
				"temperature" : 23.36,
				"humidity" : 0.37
			}
		}
	},
	"created_time" : "2021-07-05 00:00:00"
}

对于数据的写入变成了文档更新:

db.sensor_bucket.updateOne({
	{
    "sensor_id": "SENSOR-1",
    "created_time": "2021-07-05 00:00:00"
	},
	{
    "$set": {
      "data.0.3": {
        "temperature": 23.56,
        "humidity": 0.67
     	}
  	}
  },
  {"upsert": true}
})
(3)预聚合

由于我们存储方式的变化,那么我们读取数据的方式也相应发生了变化。如果我们希望进行某些时段的聚合操作,比如 15:00~16:00 的平均温度,我们可以有多种方式:

  • 将文档中的时段数据查询出来,在程序中进行统计。

  • 使用预聚合的方式,提前写入预计算的结果。比如新建一个统计表或者直接写入当前文档。

预聚合非常适合频繁统计的场景,数据只需要计算一遍就可以满足后续的查询。

(4)对比

对于两种方式,我们可以编写测试程序,按照100个传感器写入一个月的数据,然后进行对比:

对比项传统方式分桶
文档数量432万个3000个
文档总大小432M198M
文档平均大小104K65K
索引大小172M91M

我们可以看出,分桶优化后文档的数量和索引大小缩减幅度还是非常客观的。但是我们要注意,单个文档不能超过16M,相比insert,update 或 upsert 性能有一定下降。

4.数据分页

(1)传统的分页模式

比如文章列表分页(UI展示上一页、下一页、1、2、3、4.......、跳转到第几页)。传统方式是通过页码(当前第几页)、页大小(每页显示条数)来进行。如我们现在要查询第二页数据、每页显示20条,这时查询语句是这样的:

db.test.find().limit(20).skip(20)

随着页数的增加,我们的 skip 数值会变的特别大,我们显示来看看该语句的性能分析:

db.test.find().limit(20).skip(2500000).explain("executionStats")

结果如下:

{
	...
	"executionStats" : {
		"executionSuccess" : true,
		"nReturned" : 20,
		"executionTimeMillis" : 733,
		"totalKeysExamined" : 0,
		"totalDocsExamined" : 2500020,
		"executionStages" : {
			"stage" : "LIMIT",
			"nReturned" : 20,
			"executionTimeMillisEstimate" : 8,
			"works" : 2500022,
			"advanced" : 20,
			"needTime" : 2500001,
			"needYield" : 0,
			"saveState" : 19531,
			"restoreState" : 19531,
			"isEOF" : 1,
			"limitAmount" : 20,
			"inputStage" : {
				"stage" : "SKIP",
				"nReturned" : 20,
				"executionTimeMillisEstimate" : 7,
				"works" : 2500021,
				"advanced" : 20,
				"needTime" : 2500001,
				"needYield" : 0,
				"saveState" : 19531,
				"restoreState" : 19531,
				"isEOF" : 0,
				"skipAmount" : 0,
				"inputStage" : {
					"stage" : "COLLSCAN",
					"nReturned" : 2500020,
					"executionTimeMillisEstimate" : 6,
					"works" : 2500021,
					"advanced" : 2500020,
					"needTime" : 1,
					"needYield" : 0,
					"saveState" : 19531,
					"restoreState" : 19531,
					"isEOF" : 0,
					"direction" : "forward",
					"docsExamined" : 2500020
				}
			}
		}
	},
}

这里虽然我们只查询20条数据,但是 MongoDB 还是会扫描 skip 的250W条记录,而这个操作是通过 cursor 迭代器来实现的,对 CPU 消耗比较高,当数据达到千万级以上时,响应也会非常慢。

(2)游标分页

这个方案比较实用于 App ,只需要上一页、下一页的场景。

具体做法是,参数传递时通过两个参数: cursor(当前游标值)、direction(向上、向下翻页),此时查询语句变成了这样:

db.test.find({"id":{$gt:2999981}}).limit(20)

cursor 一般选择主键,这样查询效率是相当高的,不需要遍历不需要的数据,通过索引直接定位到游标值。

(3)折中处理

类似 google 搜索这样,实现页码分页、上下翻页,但是无法已有选择某个页码。由于数据量较大时页码很多,我们不可能全部展示,我们对页码进行分组(如10页一组),界面上永远展示一组分页。

image.png

假设我们现在每页展示10条数据,当前在第10页,我们想要查看第13页的数据,此时的查询语句是:

db.test.find({"_id":{$gt:100}}).skip(10*2).limit(10)

这里 $gt 后的值是第第十页的最后一条记录的id(游标),skip 计算公式:page_size * 跳几页(这里每页10跳,从10到13,跳了2页)。这里虽然也有 skip,但是由于限定了 _id 的起始点,同时固定分页组为10页,所以最多 skip 10*10 条记录,速度也是非常快的。