MongoDB 设计误区
文档模型的设计不像第三范式一样有科学的规则,这可能趋于经验之谈。
文档模型的设计原则:性能(高并发和低延迟的读写)和开发易用。
一:基础建模
第一步:建立基础文档模型。
根据概念模型或业务需求推导出逻辑模型:找到对象(具体对应的集合)
列出实体之间的关系:明确关系(集合间的关系,例如 1-1,1-N,N-N)
进行建模:套用逻辑设计原则来决定内嵌方式
主要来看三种建模关系。
1-1 关系建模
一对一关系原则上以内嵌为主,作为子文档的形式或者直接在顶级,不涉及到数据冗余。
比如每个账号就只有1个头像,那么就直接把该头像(子文档)作为一个字段放在账号(母文档)里面。
{
name: "xiongbw",
portrait: "https://gravatar.loli.net/avatar/"
}
MongoDB 也是支持直接把二进制内容放到 JSON 文档里,不需要额外的处理。
例外情况:内嵌后导致文档大小超过 16MB(MongoDB 一个文档不能超过 16MB)。
1-N 关系建模
一对多关系同样以内嵌为主,用数组的形式来表示一对多,不涉及到数据冗余。
比如一个账号可能有多个收货地址,那么就可以将收货地址(子文档)作为一个字段放在账号(母文档)里面。
这个字段就可以是以一个 JSON 数组的形式进行存储。
{
name: "xiongbw",
portrait: "https://gravatar.loli.net/avatar/",
addresses: [
{type: "home", city: "Guangzhou"},
{type: "work", street: "Shenzhen"}
]
}
例外情况:内嵌后导致文档大小超过 16MB、数组长度不确定或太大(上万甚至更多)。
N-N 关系建模
多对多关系不需要像传统方式一样建立映射表的方式来表示两个实体之间的联系。
一般用内嵌数组来表示 1-N,再通过冗余来实现 N-N。
比如联系人的分组,你属于我的朋友、同事分组,我在你的同学、同事分组下,可以通过内嵌数组的方式存在一个文档里。
{
name: "xiongbw",
portrait: "https://gravatar.loli.net/avatar/",
addresses: [
{type: "home", city: "Guangzhou"},
{type: "work", street: "Shenzhen"}
],
groups: [
{name: "colleague"},
{name: "friend"}
]
}
这种方式确实会出现很多冗余,但在 MongoDB 里面冗余并非不提倡,甚至鼓励做的(当然还是要具体情况具体分析,在设计之初是可以先把模型构建好数组转json,以后再分析实际业务对其作进一步优化)。
例外情况:内嵌后导致文档大小超过 16MB、数组长度不确定或太大(上万甚至更多)。
小结
到这主要是基础建模的方法,其实就是对关系(1-1、1-N、N-N)的各种表达,在 MongoDB 里面基本都是可以用内嵌的方式来完成。
二:工况细化
第二步:用些技术场景(读写)的细化来对模型做些修饰。
了解业务使用数据的方式,比如是以个人在用还是以报表式的方式使用
最常用的查询参数,比如时间范围近几天
最频繁的数据写入模式
读写操作比例,比如 99:1
数据量大小
……
基于第一步内嵌的基础文档模型,根据业务需求,使用引用来避免性能瓶颈,使用冗余来优化访问性能。
以上面基础建模 N-N 关系的分组例子来说,比如现在有这么一个场景:
我们数据量很大,一个分组下实际有百万级的联系人,我现在需要修改分组信息,比如分组名称,那么分组信息被冗余到每一个成员里的话就会一次导致百万级的磁盘写操作。
解决方案:分组信息使用单独的集合。
{
name: "xiongbw",
portrait: "https://gravatar.loli.net/avatar/",
addresses: [
{type: "home", city: "Guangzhou"},
{type: "work", street: "Shenzhen"}
],
group_ids: [1, 2]
}
使用 group_id 进行关联
{
group_id: 1,
name: "colleague"
}
{
group_id: 2,
name: "friend"
}
引用模式下的关联查询
使用聚合运算 aggregate() 的方式,完成一些复杂的数据处理,包括现在的关联。
MongoDB 3.2 版本以上使用 $lookup 来提供一次查询多表的能力(类似于关系型数据库的关联 join)
db.contact.aggregate([
{
$lookup: {
from: "group",
localField: "group_ids",
foreignField: "group_id",
as: "groups_info"
}
}
])
操作符 $lookup 基本的属性字段:
pretty() 只是让输出的结果格式化更易读了~
小结什么时候该使用引用模式
内嵌文档太大,数 MB 甚至超过 16MB;
内嵌文档或数组会被频繁修改;
内嵌数据元素会持续增长且上不封顶。
引用模式设计的限制
并不是说什么都可以使用引用模式,容易又直接变成了关系模型设计。
MongoDB 对引用的集合之间并无主外键检查(完全需要我们应用自己去维护之间的关系)。虽然我们现在关系型数据库(MySQL, Oracle)也不一定使用主外键为表结构进行限制,但如果你想的话还是可以做到的。
使用 $lookup 来模仿关联查询,只支持 left outer join 的效果,只是把一些信息合并进来,太复杂的关联其实也做不到;
$lookup 的 from 字段所指定的目前表不能是分片表,只能是当前的基础表(主表)可以分片。
三:模式套用
第三步:套用设计模式
MongoDB 因为它没有范式规约,没有定型思维,比较灵活,所以有时候设计更需要一些经验之谈。
一个好的设计模式应该做到:
分桶模式
假设现在有个物联网下的场景,是需要实时或近实时的监控,对飞机的状况、运行的位置等指标进行时序数据向的统计。
{
_id: "1234567890ABCDEFG",
time: ISODate("2022-08-23T00:50:22.861Z"),
location: {
longitude: "113.28877",
latitude: "23.15439"
}
}
现在有10w架飞机,每分钟存1条,那么一年下来其实就有 100000 × 365 × 24 × 60 = 52,560,000,000,520 多亿条数据。
这数据量肯定是很庞大的。
那么现在对于这种时序数据,可以通过经典的“分桶”思想:把多条数据放到一个组里面。
拿当前例子来说,我们可以考虑把每分钟存一条改为每小时存一条,每小时存的那条数据里通过数组的形式,包含了过去每分钟的数据。
{
_id: "1234567890ABCDEFG",
time: ISODate("2022-08-23T00:50:22.861Z"),
locations: [
{longitude: "113.28877", latitude: "23.15439"},
{longitude: "114.28877", latitude: "24.15439"},
{longitude: "115.28877", latitude: "25.15439"},
……
]
}
现在的话,10w架飞机,每小时存1条,那么一年下来其实就有 100000 × 365 × 24 = 876,000,000,8亿多条数据。
这主要是通过省下一些公用字段的存储来达到提高读写效率和降低资源的目的。
列转行
比如现在需要查询一部电影的首映日期,每个国家的日期有可能不同。
{
name: "Xia Luo Te Fan Nao",
release_china: "2015-09-30",
release_usa: "2015-10-30",
release_britain: "2015-11-30",
release_italy: "2015-12-30"
}
如果需要知道在某个国家的首映日期,那么多国家一般还要加索引,一个国家一个索引,这往往是不现实的事情。
像这种字段属性的含义基本一样的话,可以考虑列转行的方式。
{
name: "Xia Luo Te Fan Nao",
releases: [
{country: "China", date: "2015-09-30"},
{country: "USA", date: "2015-10-30"},
{country: "Britain", date: "2015-11-30"},
{country: "Italy", date: "2015-12-30"}
]
}
现在如果需要添加索引的话,可以直接针对 releases 字段的子文档添加索引:db.movie.createIndex({“releases.country”: 1, “releases.date”: 1}),这样的方式不但大大减少了字段的个数,还减少了索引的建立,对写入性能也友好了许多。
版本字段
现在业务上通过版本的更新,从 v2.0 开始需要新加一个字段,可能随着版本的迭代升级,模型会更加灵活甚至导致失控,这时管理不同版本下的文档会很麻烦,你可能也不知道它是什么时候产生的,为什么长这样。
v1.0
{
name: "xiongbw",
age: 18
}
v2.0
{
name: "xiongbw",
age: 18,
from: "WeChat"
}
解决方案:增加一个版本字段。
{
name: "xiongbw",
age: 18,
from: "WeChat",
schema_version: "v2.0"
}
虽然 MongoDB 并没有一个显示的 schema,我们可以直接通过增加一个“版本”字段,这个字段并不是非要每个版本都存在,可以在需要的时候才出现,比如 v1.0 版本没有,那默认就是 v1.0。
意思就是从 v2.0 开始有了哪些新的字段来标识文档数据,好处就是可以根据不同版本来设计文档的检验规则。
比如通过 JSON Schema 给不同的版本(对应“版本”字段)建规则。
近似计算
假设现在有业务需要你统计公司页面的点击量,严格来说的话,每访问一次网页就要对数据库进行一次更新操作,次数+1,如果公司有比较大的用户量的话,这个写入操作是很频繁的。
但这种场景的特点就是,对数字的准确性并没有那么高的要求,其实一个近似值也能接受。
解决方案:近似计算
那刚刚的例子来说,从每访问一次计数+1变成每十次计数+1,这样写入的量差不多会减少10倍。
{$inc: {count: 10}}
预聚合
假设现在需要统计商品的日、周、月销量,或对游戏玩家进行排名整理出一个排行榜。这个时候我们用相似计算就显得不科学了。
传统方式就是定期做聚合计算,但数据量大的话这个计算就会相当耗时,可能比较吃系统资源。
解决方案:使用预聚合字段
原模型
{
name: "T-shirt",
price: 18,
quantity: 100
}
增加预聚合字段:日销、周销、月销
{
_id: 123,
name: "T-shirt",
price: 18,
quantity: 100,
daily_sales: 3,
weekly_sales: 6,
monthly_sales: 9
}
每次有人下单时,对商品数量-1的同时,对另外三个销量字段进行+1。
db.product.updateOne(
{_id: 123},
{$inc: {
quantity: -1,
daily_sales: 1,
weekly_sales: 1,
monthly_sales: 1,
}}
)
小结
场景痛点方案优点
时序数据:物联网、智慧城市……
数据点采集频繁,数据量多。
分桶:利用文档内嵌数组,将一个时间段的数据聚合到一个文档里。
大量减少文档数量
大量减少索引占用空间
多个属性含义一样的字段
文档中有许多类似的字段;
用户组合查询搜索,需要建立很多索引。
列转行:将相同含义的属性字段转化为用一个数组进行存储。
针对该数组的子文档可只建立一个索引。
任何有版本演变的数据库
文档模型格式多,无法知道其合理性;
升级的时候需要更新太多文档。
增加版本字段:根据版本字段表示该数据模型是从哪个版本起开始出现的。
快速过滤掉不需要升级的文档;
升级的时候对不同版本的文档做不同处理。
网页计数或各种不需要准确数据的排名。
写入频繁数组转json,消耗系统资源。
近似计算:把写入操作周期改为每10次或100次。
大量减少写入所需要消耗的资源。
准确的排名。
统计计算耗时。
增加统计字段:直接在模型中增加统计字段,业务触发更新数据的同时更新统计值。
避免后期的统计计算消耗大量时间。
参考资料
【MongoDB 高手课】14 | JSON文档设计模型特点:
【MongoDB 高手课】15 | 文档模型之一:基础设计:
【MongoDB 高手课】16 | 文档模型之二:工况细化:
【MongoDB 高手课】17 | 文档模型之三:模式套用:
下期预告
限时特惠:本站每日持续更新海量设计资源,一年会员只需29.9元,全站资源免费下载
站长微信:ziyuanshu688