MongoDB数据文件内部结构
- MongoDB在数据存储上按命名空间划分,一个
collection
是一个命名空间,一个索引也是一个命名空间。 - 同一个命名空间的数据被分成很多个Extent,Extent之间使用双向链表连接。
- 在每个Extent中保存了具体每行数据,这些数据也是通过双向链表连接的。
- 每行数据存储空间不仅包括数据占用空间,还可能包含一部分附加空间,这使得数据
update
变大后可不移动位置。 - 索引以 BTree 结构实现
MongoDB实现事务
MongoDB仅支持对单行记录的原子性修改,并不支持对多行数据的原子操作。
- 第1步
先记录一条事务记录,将要修改的多行记录的修改值写入其中,并设置其状态为init
,若此时操作中断那么在重启时会判断是否处于init
状态,从而将其保存的多行修改操作应用到具体的行上。 - 第2步
更新具体要修改的行,将刚才写的事务记录的标识写到它的tran
字段中。 - 第3步
将事务记录的状态从init
转变为pending
,若此时操作中断,重启时会判断到它的状态是pending
,此时查看其所对应的多条要修改的记录,若它的tran
有值就向下进行第4步,若无值则说明第4步已执行,直接将其状态从pending
转变为committed
即可。 - 第4步
将需要修改的多条记录的相应值修改,并unset
掉之前的tran
字段。 - 第5步
将事务记录那一条的状态从pending
转变为committed
,事务完成。
在支持事务的RDBMS中,其事务原子性提交的保证大多与上面类似,其实事务记录的 tran
那条记录类似于 DBMS 中的 redolog
一样。
MongoDB 数据同步
- 红色箭头 写操作写到 Primary上,然后异步同步到多个Secondary上。
- 蓝色箭头 读操作可从Primary或 Secondary任意一个上读取。
- 各 Primary 与 Secondary 之间一直保持心跳同步检测,用于判断 Replica Sets 的状态。
数据同步与读写分离
MongoDB分片机制
- MongoDB的分片指定一个分片
key
来进行,数据按范围分为不同的chunk
,每个chunk
大小有限制。 - 多个分片节点保存这些
chunk
,每个节点保存一部分的chunk
。 - 每个分片节点都是一个 Replica Sets, 这样保证数据的安全性。
- 当一个
chunk
超过限制的最大体积时,会分裂为两个小的chunk
。 - 当
chunk
在分片节点中分布不均衡时,会引发chunk
迁移操作。
分片时几种节点角色
- 客户端访问路由节点 mongos 来进行数据读写
- config服务器保存了两个映射关系,一个是 key 值得区间对应哪个chunk,一个是chunk存在哪一个分片节点。
- 路由节点通过 config 服务器获取数据信息,通过这些信息,找到真正存放数据的分片节点进行对应操作。
- 路由节点还会在写操作时判断当前 chunk 是否超出限定大小,若超出就分裂成两个 chunk 。
- 对于按分片 key 进行的查询和 update 操作来说,路由节点会查找具体的 chunk 然后再进行相关工作。
- 对于不按分片 key 进行的查询 和 update 操作来说, mongos 会对所有下属节点发送请求然后再对返回结果进行合并。
MongoDB数据建模与表结构设计
- 优先考虑内嵌,除非有什么迫不得已的原因。
- 若需单独访问一个对象,那它就适合被内嵌到其他对象中。
- 数组不应该无限制增长
- 不要太过担心应用层级别的JOIN
- 在进行反范式设计时先认真考量业务逻辑
MongoDB支持内嵌对象和数组类型,其建模方式有两种
- 内嵌(Embed)
- 子文档较小
- 数据不会定期更改
- 最终数据一致即可
- 文档数据小额增加
- 数据通常需要执行二次查询
- 快速读取
- 连接(Link)
- 子文档较大
- 数据经常变化
- 中间阶段数据也必须一致
- 文档数据大幅增加
- 数据通常不包含在查询结果中
- 快速写入
什么时候使用内嵌,什么时候用连接呢?那得看两个实体之间的关系是什么类型的。
1. 内嵌建模
将相关的数据包括在一个单个的结构或文档下,此模式也叫做非规范化模式,它充分利用了MongoDB的灵活文档格式功能。
内嵌式一种反范式化的设计,指的是将每个文档所需的数据都嵌入到文档内部。例如:用户和账户的关系,在驱动领域设计中,用户是一个聚合根,每个用户对应一个账户,是一对一的关系,在关系型数据库设计中,大部分会将此两者严格区分。但在MongoDB中,可直接选择将用户需要的账户数据内嵌到用户文档中,以便于增删改查。
> db.userinfo.insert({
username:"junchow",
contact:{
phone:"15523423212",
email:"junchow520@gmail.com"
},
access:{
level:3,
group:"dev"
}
})
内嵌数据可让应用把相关的数据保存在同一条数据库记录中,应用即可发送较少的请求非MongoDB来完成常用查询及更新请求。
一般而言下列情况建议使用内嵌数据
- 数据对象之间有 “contains” 包含关系
- 数据之间存在一对多的关系,多个或子文档会经常和父文档一起被显示和查看。
内嵌数据会对读操作有比较好的性能提高,也可使用应用在一个单个操作就可以完成对数据的读取。同时内嵌数据也对更新相关数据提供了一个原子性的写操作。
内嵌数据到同一个文档的缺陷是会导致文档的增长,文档增长会影响写性能并导致数据碎片化问题。MongoDB文档大小必须小于16M,超过此大小可考虑使用GridFS。
- 优点 仅需一次查询即可获取数据
- 缺点 数据重复、不可作为单独对象、修改、大小
2. 连接建模
连接建模即规范化数据建模,是指通过使用引用来表达对象之间关系。
一般而言,下列情况下可使用规范化建模
- 当内嵌数据导致很多数据的重复,并且读性能的优势又不足以盖过数据重复的弊端时。
- 需表达比较复杂的多对多的关系
- 大型多层次结构数据集
引用比内嵌要更加灵活,但客户端应用必须使用二次查询来解析文档内包含的引用。换言之,对同样的操作而言,规范化模式会导致更多的网络请求发送到数据库服务端。
- 优点 可作为单独的对象
- 缺点 需二次查询
文档结构建模
当设计一个MongoDB数据库结构时,你需要问下自己一个在使用关系型数据库不会考虑的问题:
这个关系中集合的大小是什么样的规模呢?
你需要意识到一对很少、一对许多、一对非常多这些细微的区别,不同的情况下建模也将不同。
1. 一对较少(Basics: Modeling One-to-Few,内嵌)
针对个人需要保存多个地址进行建模的场景使用内嵌文档是很合适的。
场景:个人地址
> db.persons.insert({
name:"junchow",
ssn:"123-456-789",
addr:{
privince:"Hubei",
city:"Wuhan"
}
})
> db.persons.find()
/* 1 */
{
"_id" : ObjectId("5a09b8cf61bf6b35a74b0faa"),
"name" : "junchow",
"ssn" : "123-456-789",
"addr" : {
"privince" : "Hubei",
"city" : "Wuhan"
}
}
场景:考试成绩
> db.scores.insert({
name:"junchow",
grades:[
{project:"english",grade:90},
{project:"math", grade:90}
]
})
> db.scores.find()
/* 1 */
{
"_id" : ObjectId("5a09b95461bf6b35a74b0fab"),
"name" : "junchow",
"grades" : [
{
"project" : "english",
"grade" : 90.0
},
{
"project" : "math",
"grade" : 90.0
}
]
}
- 优点
无需单独执行一条语句去获取内嵌内容 - 缺点
无法把内嵌文档当做单独的实体去访问
例如:对一个任务跟踪系统进行建模,每个用户将会分配若干任务,内嵌这些任务到用户文档,在遇到“查询昨天所有的任务”时将非常困难。
2. 一对较多(Basics:One-to-Many,子引用)
场景1:产品零件订货系统
每个商品有数百个可替换的零件,但不会超过数千个。
此用例很适合使用间接引用 - 将零件的objectId作为数组存放在商品文档中,每个零件都将有它们自己的文档对象。
> db.parts.insert({
partno:'123-aff-23a',
name:'#4 grommet',
qty:90,
cost:0.98,
price:3.99
})
> db.parts.find()
/* 1 */
{
"_id" : ObjectId("5a09bb7461bf6b35a74b0fac"),
"partno" : "123-aff-23a",
"name" : "#4 grommet",
"qty" : 90.0,
"cost" : 0.98,
"price" : 3.99
}
每个产品的文档对象中 parts 数组将会存放多个零件的ObjectId
> db.products.insert({
name:'left-handed smoke shifter',
manufacturer:'Acme Corp',
catalog_number:121,
parts:[ObjectId('5a09bb7461bf6b35a74b0fac')]
})
> db.products.find()
/* 1 */
{
"_id" : ObjectId("5a09bc2561bf6b35a74b0fad"),
"name" : "left-handed smoke shifter",
"manufacturer" : "Acme Corp",
"catalog_number" : 121.0,
"parts" : [
ObjectId("5a09bb7461bf6b35a74b0fac")
]
}
在获取特定产品中所有零件,需一个应用层级别的join。
为了能快速的执行查询,必须确保 products.catalog_number
有索引,由于零件中 parts._id
一定是由索引的,所以这样会很高效。
此种引用的方式是对内嵌优缺点的补充,每个零件是单独的文档,很容易独立去搜索和更新。需一条单独的语句去获取零件的具体内容是使用此种建模方式需要考虑的一个问题。
此种建模方式中的零件部分可被多个产品使用,所以在多对多时无需一张单独的连接表。
场景2:游戏库中每个人所获卡牌可能有几百个,将卡牌ID作为数组存在用户信息中,此外每个卡牌又有自身的文档对象。
反范式在实际应用中,大多与玩家相关。例如卡牌等级,觉醒系数等。加入反范式后的结构看看!
3. 一对很多(Basics:One-to-Squillions,父引用)
场景1:机器日志
由于每个MongoDB文档有16M的大小限制,所以即使你存储ObjectId也不够的。可使用经典的处理方式“父级引用”,即用一个文档存储主机,在每个日志文档中保存主机的ObjectId。
> db.hosts.insert({
name:"goofy.example.com",
ipaddr:"127.88.12.12"
})
> db.hosts.find()
/* 1 */
{
"_id" : ObjectId("5a09be3161bf6b35a74b0fae"),
"name" : "goofy.example.com",
"ipaddr" : "127.88.12.12"
}
> db.logs.insert({
created_at:Date(),
host:ObjectId('5a09be3161bf6b35a74b0fae'),
message:'cpu is on fire'
})
> db.logs.find()
/* 1 */
{
"_id" : ObjectId("5a09be9261bf6b35a74b0faf"),
"created_at" : "Mon Nov 13 2017 23:47:30 GMT+0800",
"host" : ObjectId("5a09be3161bf6b35a74b0fae"),
"message" : "cpu is on fire"
}
查找某台主机最近5000条日志
> host = db.hosts.findOne({ipaddr:'127.88.12.12'})
> messages = db.logs.find({host:host._id}).sort({created_at: -1}).limit(5000).toArray()
注意MongoDB和RDBMS建模不同之处在于
Will the entities on the 'N' side of One-to-N ever need to stand alone?
一对多中的多是否需要一个单独的实体呢?
What is the cardinality of the relationshio : is it one-to-few; one-to-mand; or one-to-squillions?
关系中集合的规模是一对很少,一对很多,还是非常多呢?
Based on these factors, you can pick one of the three basic One-to-N schema designs.
基于以上因素来决定采取三种建模方式
- 一对很少且无需单独访问内嵌内容的情况下可使用内嵌多的一方
- 一对多且多的一段内容因各种理由需单独存在的情况,可通过数组方式引用多的一方。
- 一对非常多的情况,请将一的那端引用嵌入进多的一端对象中。
场景:日志收集系统存放游戏各服务器的游戏日志,使用父级引用更为合理。查询时仅需取出某台机器的_id然后去查询。
高级主题
1. 双向关联
让引用的 one 端 和 many 端,同时保存对象的引用。
场景:任务跟踪系统
有persons和tasks两个集合,one-to-n的关系是从persons端到tasks端,在需要获取persons所有的tasks场景下需要在persons这个对象中保存tasks的id数组。
场景:游戏任务系统,拥有人物person
和任务task
两个集合。
若任务系统有共享人物就会涉及人物所有者,可在task任务集合中增加一个所有者owner。